generated from coulomb/repo-seed
Compare commits
27 Commits
847abcba73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd69f0374 | |||
| afc01456a5 | |||
| d076e7ee7b | |||
| c4f281a376 | |||
| bee021735c | |||
| c9838a4811 | |||
| 593b5af8dc | |||
| d6d41dd84f | |||
| 06d20c3379 | |||
| 937cb39de6 | |||
| 56d279a8e6 | |||
| 1d68639225 | |||
| 7e22fcf3c7 | |||
| 393abf3e0e | |||
| f45784f951 | |||
| 465a778c1f | |||
| 10868739a8 | |||
| a626dd5d4e | |||
| 926adfb3aa | |||
| cfa12e978d | |||
| a6af43b332 | |||
| 18dbad68ed | |||
| 7822ba0703 | |||
| 393ef3ca76 | |||
| 303663e48b | |||
| 80bf79de46 | |||
| ece58bc363 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Kaizen Agents
|
||||||
|
|
||||||
|
Specialized agent personas available on demand via the state-hub MCP.
|
||||||
|
|
||||||
|
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||||
|
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||||
|
|
||||||
|
Common agents:
|
||||||
|
|
||||||
|
| Agent | Category | When to use |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||||
|
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||||
|
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||||
|
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||||
|
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||||
|
| `project-management` | process | Track status, determine next steps |
|
||||||
|
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||||
|
|
||||||
|
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
<!-- TODO: Describe the key design decisions and component structure.
|
||||||
|
Key modules, data flows, external integrations, state machines, etc. -->
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=key-cape` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
## First Session Protocol
|
||||||
|
|
||||||
|
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||||
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
|
**Step 1 — Read, don't write**
|
||||||
|
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||||
|
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||||
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
|
**Step 2 — Survey in-progress work**
|
||||||
|
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||||
|
|
||||||
|
**Step 3 — Propose workstreams to Bernd**
|
||||||
|
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||||
|
roadmap phase. **Wait for approval before creating.**
|
||||||
|
|
||||||
|
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||||
|
```
|
||||||
|
workplans/KEY-WP-NNNN-<slug>.md ← write this first
|
||||||
|
```
|
||||||
|
Then register in the hub:
|
||||||
|
```
|
||||||
|
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
|
||||||
|
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5 — Record the setup**
|
||||||
|
```
|
||||||
|
add_progress_event(
|
||||||
|
summary="First session: structured infotech into N workstreams, M tasks",
|
||||||
|
event_type="milestone",
|
||||||
|
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||||
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Delete or archive this file once past first session -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Repo boundary
|
||||||
|
|
||||||
|
This repo owns **KeyCape** only. It does not own:
|
||||||
|
|
||||||
|
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||||
|
- SSH key management → railiance-infra/
|
||||||
|
- State hub code → state-hub/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** key-cape
|
||||||
|
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
|
**Step 1 — Orient**
|
||||||
|
|
||||||
|
Read the offline-safe brief first — it works without a live hub connection:
|
||||||
|
```bash
|
||||||
|
cat .custodian-brief.md
|
||||||
|
```
|
||||||
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
|
```
|
||||||
|
get_domain_summary("infotech")
|
||||||
|
```
|
||||||
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
If the hub is offline: `cd ~/state-hub && make api`
|
||||||
|
|
||||||
|
**Step 2 — Check inbox**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
get_messages(to_agent="key-cape", unread_only=True)
|
||||||
|
```
|
||||||
|
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||||
|
requests before proceeding.
|
||||||
|
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=key-cape&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Scan workplans**
|
||||||
|
```bash
|
||||||
|
ls workplans/
|
||||||
|
```
|
||||||
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||||
|
2. **Pending tasks** from `workplans/` + any `[repo:key-cape]` hub tasks
|
||||||
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
|
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||||
|
4. **Suggested next action** — highest-priority open item
|
||||||
|
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||||
|
|
||||||
|
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
|
|
||||||
|
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||||
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
|
|
||||||
|
**Session close:**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
|
||||||
|
```
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||||
|
```
|
||||||
|
If workplan files were modified, ensure the local copy is up to date first:
|
||||||
|
```bash
|
||||||
|
git -C <repo_path> pull --ff-only
|
||||||
|
cd ~/state-hub && make fix-consistency REPO=key-cape
|
||||||
|
```
|
||||||
|
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||||
|
use the combined target which pulls before fixing:
|
||||||
|
```bash
|
||||||
|
cd ~/state-hub && make fix-consistency-remote REPO=key-cape
|
||||||
|
```
|
||||||
|
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||||
|
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||||
|
until you pull — intentional to prevent clobbering remote progress.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Stack
|
||||||
|
|
||||||
|
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||||
|
- **Language:**
|
||||||
|
- **Key deps:**
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TODO: Fill in the standard commands for this repo
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
|
||||||
|
# Lint / type check
|
||||||
|
|
||||||
|
# Build / package (if applicable)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
File location: `workplans/KEY-WP-NNNN-<slug>.md`
|
||||||
|
ID prefix: `KEY-WP-`
|
||||||
|
|
||||||
|
Work items originate as files in this repo **before** being registered in the hub.
|
||||||
|
|
||||||
|
Canonical workplan/workstream frontmatter statuses are:
|
||||||
|
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||||
|
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||||
|
repo state, and `finished` when implementation is complete. `stalled` and
|
||||||
|
`needs_review` are derived health labels, not stored statuses.
|
||||||
|
|
||||||
|
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||||
|
prefix: `YYMMDD-KEY-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||||
|
unchanged; the prefix is only for quick visual reference.
|
||||||
|
|
||||||
|
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||||
|
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||||
|
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||||
|
multiple planned phases into a normal workplan.
|
||||||
|
|
||||||
|
Ecosystem todos from other agents arrive as `[repo:key-cape]` hub tasks —
|
||||||
|
visible at session start. Pick one up by creating the workplan file, then registering
|
||||||
|
the workstream.
|
||||||
|
|
||||||
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||||
|
blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||||
18
.custodian-brief.md
Normal file
18
.custodian-brief.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||||
|
# Custodian Brief — key-cape
|
||||||
|
|
||||||
|
**Domain:** communication
|
||||||
|
**Last synced:** 2026-06-22 16:02 UTC
|
||||||
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
|
## Active Workstreams
|
||||||
|
|
||||||
|
*(none — repo may need first-session setup)*
|
||||||
|
|
||||||
|
---
|
||||||
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
|
If the state-hub MCP server is reachable, call:
|
||||||
|
`get_domain_summary("communication")`
|
||||||
|
This provides richer cross-domain context.
|
||||||
|
If the MCP call fails, use this file as your orientation source.
|
||||||
51
.gitea/workflows/image.yaml
Normal file
51
.gitea/workflows/image.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Build and Publish Container Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: 92.205.130.254:32166
|
||||||
|
IMAGE_NAME: coulomb/key-cape
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: act_runner
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,prefix=main-,format=short,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
26
.repo-classification.yaml
Normal file
26
.repo-classification.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-22'
|
||||||
|
classified_by: human
|
||||||
|
category: product
|
||||||
|
domain: infotech
|
||||||
|
secondary_domains:
|
||||||
|
- communication
|
||||||
|
capability_tags:
|
||||||
|
- identity
|
||||||
|
- access-control
|
||||||
|
- security
|
||||||
|
- platform
|
||||||
|
- operations
|
||||||
|
business_stake:
|
||||||
|
- technology
|
||||||
|
- operations
|
||||||
|
- legal
|
||||||
|
- product
|
||||||
|
business_mechanics:
|
||||||
|
- control
|
||||||
|
- operation
|
||||||
|
- adaptation
|
||||||
|
notes: NetKingdom IAM Profile lightweight mode (Authelia/LLDAP/privacyIDEA); human
|
||||||
|
corrected domain from communication→infotech.
|
||||||
219
AGENTS.md
Normal file
219
AGENTS.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# KeyCape — Agent Instructions
|
||||||
|
|
||||||
|
## Repo Identity
|
||||||
|
|
||||||
|
**Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** key-cape
|
||||||
|
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||||
|
**Workplan prefix:** `KEY-WP-`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Hub Integration
|
||||||
|
|
||||||
|
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||||
|
there is no MCP server for Codex agents.
|
||||||
|
|
||||||
|
| Context | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Local workstation | `http://127.0.0.1:8000` |
|
||||||
|
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||||
|
|
||||||
|
### Orient at session start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Offline brief — works without hub connection
|
||||||
|
cat .custodian-brief.md
|
||||||
|
|
||||||
|
# Active workstreams for this domain
|
||||||
|
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
|
||||||
|
# Check inbox
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=key-cape&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Mark a message read:
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log progress (required at session close)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"summary": "what was done",
|
||||||
|
"event_type": "note",
|
||||||
|
"author": "codex",
|
||||||
|
"workstream_id": "<uuid>",
|
||||||
|
"task_id": "<uuid>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `workstream_id` / `task_id` when not applicable.
|
||||||
|
|
||||||
|
### Update task status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status": "progress"}'
|
||||||
|
# values: wait | todo | progress | done | cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flag a task for human review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
**Start:**
|
||||||
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
|
2. Check inbox: `GET /messages/?to_agent=key-cape&unread_only=true`; mark read
|
||||||
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
|
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
|
**During work:**
|
||||||
|
- Update task statuses in workplan files as tasks progress
|
||||||
|
- Record significant decisions via `POST /decisions/`
|
||||||
|
|
||||||
|
**Close:**
|
||||||
|
1. Update workplan file task statuses to reflect progress
|
||||||
|
2. Log: `POST /progress/` with a summary of what changed
|
||||||
|
3. Note for the custodian operator: after workplan file changes, run from
|
||||||
|
`~/state-hub`:
|
||||||
|
```bash
|
||||||
|
make fix-consistency REPO=key-cape
|
||||||
|
```
|
||||||
|
This syncs task status from files into the hub DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=key-cape` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
Work items originate as files in this repo — not in the hub. The hub is a
|
||||||
|
read/cache/index layer that rebuilds from files.
|
||||||
|
|
||||||
|
**File location:** `workplans/KEY-WP-NNNN-<slug>.md`
|
||||||
|
|
||||||
|
**Archived location:** finished workplans may move to
|
||||||
|
`workplans/archived/YYMMDD-KEY-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
|
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||||
|
this only for low-risk work completed directly; create a normal workplan for
|
||||||
|
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||||
|
|
||||||
|
**Frontmatter:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: KEY-WP-NNNN
|
||||||
|
type: workplan
|
||||||
|
title: "..."
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
|
owner: codex
|
||||||
|
topic_slug: ...
|
||||||
|
created: "YYYY-MM-DD"
|
||||||
|
updated: "YYYY-MM-DD"
|
||||||
|
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
|
```
|
||||||
|
## Task Title
|
||||||
|
|
||||||
|
` ` `task
|
||||||
|
id: KEY-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
` ` `
|
||||||
|
|
||||||
|
Task description text.
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
To create a new workplan:
|
||||||
|
1. Write the file following the format above
|
||||||
|
2. Notify the custodian operator to run `make fix-consistency REPO=key-cape`
|
||||||
|
(or send a message to the hub agent via `POST /messages/`)
|
||||||
144
CLAUDE.md
144
CLAUDE.md
@@ -1,136 +1,12 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
# KeyCape — Claude Code Instructions
|
# KeyCape — Claude Code Instructions
|
||||||
|
|
||||||
## What This Repo Is
|
@SCOPE.md
|
||||||
|
@.claude/rules/repo-identity.md
|
||||||
**KeyCape** is the lightweight IAM component of NetKingdom.
|
@.claude/rules/session-protocol.md
|
||||||
|
@.claude/rules/first-session.md
|
||||||
> *"Prepare for Keycloak without Keycloak"*
|
@.claude/rules/workplan-convention.md
|
||||||
|
@.claude/rules/stack-and-commands.md
|
||||||
KeyCape implements the **NetKingdom IAM Profile** — a versioned OIDC/PKCE contract
|
@.claude/rules/architecture.md
|
||||||
that NetKingdom applications integrate against. It orchestrates:
|
@.claude/rules/repo-boundary.md
|
||||||
|
@.claude/rules/credential-routing.md
|
||||||
| Component | Role |
|
@.claude/rules/agents.md
|
||||||
|--------------|-------------------------------|
|
|
||||||
| Authelia | OIDC provider / session / tokens |
|
|
||||||
| LLDAP | Lightweight identity directory |
|
|
||||||
| privacyIDEA | MFA authority |
|
|
||||||
|
|
||||||
Keycape is intentionally replaceable by **Keycloak** in expanded mode. All apps
|
|
||||||
must target the profile, not Keycape or Keycloak incidentals.
|
|
||||||
|
|
||||||
## Custodian State Hub Integration
|
|
||||||
|
|
||||||
- **Domain:** `netkingdom`
|
|
||||||
- **Repo ID:** `8a99bb74-1ec0-4478-ac70-35a7cddb0e3c`
|
|
||||||
- **State Hub API:** `http://127.0.0.1:8000` (run `cd ~/the-custodian/state-hub && make api` if offline)
|
|
||||||
|
|
||||||
### Session Protocol
|
|
||||||
|
|
||||||
**Start of every session:**
|
|
||||||
```
|
|
||||||
get_domain_summary("netkingdom")
|
|
||||||
```
|
|
||||||
This gives the full picture of active workstreams, blocking decisions, and recent
|
|
||||||
progress for the NetKingdom domain at ~10% of the cost of `get_state_summary()`.
|
|
||||||
|
|
||||||
**During work:**
|
|
||||||
- `record_decision()` for any architectural choice (profile extensions, backend selection, etc.)
|
|
||||||
- `add_progress_event()` for milestones, blockers, discoveries
|
|
||||||
- `resolve_decision()` once a decision is closed
|
|
||||||
|
|
||||||
**End of every session:**
|
|
||||||
```
|
|
||||||
add_progress_event(summary="...", event_type="...", workstream_id="<active ws id>")
|
|
||||||
```
|
|
||||||
|
|
||||||
After modifying workplan files, run:
|
|
||||||
```
|
|
||||||
cd ~/the-custodian/state-hub && make fix-consistency REPO=key-cape
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Documents
|
|
||||||
|
|
||||||
| Document | Path | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| Keycape Specification v0.1 | `wiki/KeyCapeSpecification_v0.1.md` | Architecture, design intent, objectives |
|
|
||||||
| Normative Specification Pack v0.1 | `wiki/KeyCapeSpecificationPack_v0.1.md` | Normative spec for implementation agents: identity model, LDAP schema, error taxonomy, telemetry, migration contract, acceptance test matrix |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
key-cape/
|
|
||||||
wiki/ # Specifications (read before implementing)
|
|
||||||
workplans/ # Implementation workplans (ADR-001 convention)
|
|
||||||
src/ # Implementation (to be created)
|
|
||||||
tests/ # Test suite (to be created)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lightweight mode stack
|
|
||||||
|
|
||||||
```
|
|
||||||
Application ──→ NetKingdom IAM Profile
|
|
||||||
│
|
|
||||||
KeyCape ←── config translation, claim normalization
|
|
||||||
/ | \
|
|
||||||
Authelia LLDAP privacyIDEA
|
|
||||||
```
|
|
||||||
|
|
||||||
### Expanded mode stack (Keycape → Keycloak)
|
|
||||||
|
|
||||||
```
|
|
||||||
Application ──→ NetKingdom IAM Profile
|
|
||||||
│
|
|
||||||
Keycloak (same profile, different runtime)
|
|
||||||
/ \
|
|
||||||
LDAP privacyIDEA
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Priorities (from spec)
|
|
||||||
|
|
||||||
1. **Profile endpoints** — OIDC discovery, authorization, token, JWKS, userinfo
|
|
||||||
2. **Canonical identity model** — product-neutral user/group/client schema
|
|
||||||
3. **Claim normalization** — stable claim set regardless of backend quirks
|
|
||||||
4. **Unsupported-feature enforcement** — structured errors, never silent emulation
|
|
||||||
5. **Telemetry** — demand visibility for unsupported features and auth events
|
|
||||||
6. **Migration tooling** — export/validate for LLDAP → Keycloak path
|
|
||||||
|
|
||||||
## Normative Constraints (from spec — binding on implementation)
|
|
||||||
|
|
||||||
**Never silently emulate unsupported features.** Any request outside the profile MUST fail with a structured error from this taxonomy:
|
|
||||||
- `feature_not_supported_by_profile` — outside the NetKingdom IAM Profile entirely
|
|
||||||
- `available_in_keycloak_mode_only` — exists in expanded mode, absent here by design
|
|
||||||
- `rejected_for_profile_safety` — would weaken profile guarantees or security discipline
|
|
||||||
- `invalid_profile_usage` — supported endpoint/feature used incorrectly
|
|
||||||
|
|
||||||
**Security hard rules:** No handwritten cryptography. No handwritten password hashing. Use established protocol and crypto libraries. Strict redirect URI validation. Strict issuer consistency.
|
|
||||||
|
|
||||||
**Canonical identity model** is the source of truth for test fixtures, provisioning, migration, and validation — not any backend's native schema.
|
|
||||||
|
|
||||||
**Spec Pack structure** (`wiki/KeyCapeSpecificationPack_v0.1.md`) contains 7 normative components agents must read before implementing:
|
|
||||||
1. Normative Specification — OIDC/PKCE contract, endpoints, scopes, claims, client model, MFA
|
|
||||||
2. Canonical Identity Schema — User, Group, Membership, Client, Role, MFAEnrollmentReference, etc.
|
|
||||||
3. Canonical LDAP Schema + Validator Rules — restricted LDAP expression of identity model
|
|
||||||
4. Error Taxonomy — machine-readable/human-readable/loggable structured errors
|
|
||||||
5. Telemetry Schema — event types, required fields (timestamp, env, client_id, endpoint, feature_category, correlation_id, …)
|
|
||||||
6. Migration Contract — LLDAP → full LDAP, KeyCape → Keycloak migration paths
|
|
||||||
7. Acceptance Test Matrix — lightweight baseline, IAM replacement, full expansion, negative profile tests
|
|
||||||
|
|
||||||
## Workplan Convention (ADR-001)
|
|
||||||
|
|
||||||
Workplans live in `workplans/<id>-<slug>.md` with YAML frontmatter:
|
|
||||||
```yaml
|
|
||||||
id: KEY-WP-0001
|
|
||||||
type: workplan
|
|
||||||
title: "..."
|
|
||||||
domain: netkingdom
|
|
||||||
repo: key-cape
|
|
||||||
status: todo|active|done
|
|
||||||
owner: Bernd
|
|
||||||
topic_slug: netkingdom
|
|
||||||
```
|
|
||||||
|
|
||||||
Tasks are embedded as `## Task Title\n```task\nid: ...\nstatus: todo\n```\n` blocks.
|
|
||||||
|
|||||||
96
INTENT.md
Normal file
96
INTENT.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# INTENT
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This repository exists to provide a **lightweight, profile-conformant identity and access management (IAM) system**.
|
||||||
|
|
||||||
|
It ensures that applications can rely on a **stable, versioned authentication contract** independent of the underlying IAM implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primary Utility
|
||||||
|
|
||||||
|
The repository provides an implementation of a **versioned IAM profile** that:
|
||||||
|
|
||||||
|
* Delivers OIDC/PKCE-based authentication with strong security constraints
|
||||||
|
* Normalizes identity data across heterogeneous backend systems
|
||||||
|
* Enforces strict adherence to the defined IAM contract
|
||||||
|
* Enables seamless migration between lightweight and expanded IAM modes
|
||||||
|
|
||||||
|
It transforms IAM from a system dependency into a **replaceable, contract-driven capability**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intended Users
|
||||||
|
|
||||||
|
* Application developers integrating against the IAM profile
|
||||||
|
* Infrastructure operators (`adm`) deploying IAM in constrained environments
|
||||||
|
* Automation systems (`atm`) managing identity, migration, and validation workflows
|
||||||
|
* LLM agents (`agt`) interacting with authenticated services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Role in the System
|
||||||
|
|
||||||
|
This repository serves as the **lightweight IAM layer**:
|
||||||
|
|
||||||
|
* It provides a **resource-efficient implementation** of the IAM profile for environments with limited resources
|
||||||
|
* It anchors IAM around a **profile contract rather than a specific implementation**
|
||||||
|
* It enables a **two-mode architecture**:
|
||||||
|
|
||||||
|
* Lightweight mode (this implementation)
|
||||||
|
* Expanded mode (a heavier, full-featured implementation)
|
||||||
|
|
||||||
|
The profile ensures that both modes are **interchangeable without application changes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Boundaries
|
||||||
|
|
||||||
|
This repository is **not** intended to:
|
||||||
|
|
||||||
|
* Become a full-featured, general-purpose IAM platform
|
||||||
|
* Extend beyond the defined IAM profile
|
||||||
|
* Support features that weaken security guarantees (e.g., implicit flow, wildcard redirects)
|
||||||
|
* Replace or wrap the heavier expanded-mode implementation
|
||||||
|
|
||||||
|
Its responsibility is limited to **strict, secure, and transparent profile implementation**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
* **Contract over implementation**
|
||||||
|
Applications depend on the IAM profile, not on KeyCape internals
|
||||||
|
|
||||||
|
* **Security through constraint**
|
||||||
|
Only explicitly allowed features are supported; unsafe patterns are rejected
|
||||||
|
|
||||||
|
* **Explicitness over convenience**
|
||||||
|
Unsupported features must fail clearly and predictably
|
||||||
|
|
||||||
|
* **Replaceability by design**
|
||||||
|
The system must be swappable with a heavier profile implementation without breaking integrations
|
||||||
|
|
||||||
|
* **Canonical identity model**
|
||||||
|
Identity data must be normalized and consistent across all backends
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maturity Target
|
||||||
|
|
||||||
|
A mature version of this repository should:
|
||||||
|
|
||||||
|
* Fully implement and enforce the **IAM profile** with zero ambiguity
|
||||||
|
* Provide **complete migration pathways** between lightweight and expanded modes
|
||||||
|
* Offer **deterministic and testable behavior** across all supported scenarios
|
||||||
|
* Act as a **reference implementation** of the IAM profile
|
||||||
|
* Enable IAM deployments that are **minimal, secure, and operationally efficient**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stability Note
|
||||||
|
|
||||||
|
Changes to this file represent a **deliberate shift in the IAM contract, scope, or architectural role** of this repository.
|
||||||
|
|
||||||
|
Such changes must be made with explicit intent, as they directly affect all dependent applications.
|
||||||
16
Makefile
16
Makefile
@@ -1,4 +1,9 @@
|
|||||||
.PHONY: dev seed build test lint
|
IMAGE_REGISTRY ?= 92.205.130.254:32166
|
||||||
|
IMAGE_REPO ?= coulomb/key-cape
|
||||||
|
IMAGE_TAG ?= latest
|
||||||
|
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||||
|
|
||||||
|
.PHONY: dev seed build test lint image push image-tag
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
docker compose -f docker-compose.dev.yml up
|
docker compose -f docker-compose.dev.yml up
|
||||||
@@ -14,3 +19,12 @@ test:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
cd src && go vet ./...
|
cd src && go vet ./...
|
||||||
|
|
||||||
|
image:
|
||||||
|
docker build -t $(IMAGE) .
|
||||||
|
|
||||||
|
push: image
|
||||||
|
docker push $(IMAGE)
|
||||||
|
|
||||||
|
image-tag:
|
||||||
|
docker tag $(IMAGE) $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||||
|
|||||||
92
README.md
92
README.md
@@ -3,9 +3,11 @@
|
|||||||
*Prepare for Keycloak without Keycloak*
|
*Prepare for Keycloak without Keycloak*
|
||||||
|
|
||||||
KeyCape is the lightweight IAM component of [NetKingdom](../net-kingdom/). It
|
KeyCape is the lightweight IAM component of [NetKingdom](../net-kingdom/). It
|
||||||
implements the **NetKingdom IAM Profile** — a versioned OIDC/PKCE contract —
|
implements lightweight mode for the **NetKingdom IAM Profile** — a versioned
|
||||||
by orchestrating Authelia, LLDAP, and privacyIDEA. The same profile is
|
OIDC/PKCE contract whose canonical core is now
|
||||||
implemented by Keycloak in expanded-mode deployments.
|
`../net-kingdom/canon/standards/iam-profile_v0.2.md` — by orchestrating
|
||||||
|
Authelia, LLDAP, and privacyIDEA. The same profile is implemented by Keycloak
|
||||||
|
in expanded-mode deployments.
|
||||||
|
|
||||||
Applications integrate against the profile, not against Keycape internals. This
|
Applications integrate against the profile, not against Keycape internals. This
|
||||||
makes the lightweight → expanded migration a tested, automated operation rather
|
makes the lightweight → expanded migration a tested, automated operation rather
|
||||||
@@ -20,7 +22,7 @@ than a rewrite.
|
|||||||
|
|
||||||
```
|
```
|
||||||
Application
|
Application
|
||||||
│ (NetKingdom IAM Profile)
|
│ (NetKingdom IAM Profile v0.2)
|
||||||
▼
|
▼
|
||||||
KeyCape ←── profile enforcement, claim normalization, telemetry
|
KeyCape ←── profile enforcement, claim normalization, telemetry
|
||||||
/ | \
|
/ | \
|
||||||
@@ -28,7 +30,8 @@ Auth LLDAP privacyIDEA
|
|||||||
elia
|
elia
|
||||||
```
|
```
|
||||||
|
|
||||||
**Expanded mode:** Replace KeyCape with Keycloak. Same profile, same tests pass.
|
**Expanded mode:** Replace KeyCape with Keycloak. Same profile contract, same
|
||||||
|
conformance suite in `../net-kingdom/tools/iam-profile-conformance/`.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ lldap:
|
|||||||
baseDN: "dc=netkingdom,dc=local"
|
baseDN: "dc=netkingdom,dc=local"
|
||||||
|
|
||||||
authelia:
|
authelia:
|
||||||
baseURL: "https://authelia.local"
|
baseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
browserBaseURL: "https://authelia.local"
|
||||||
|
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||||
clientId: "keycape"
|
clientId: "keycape"
|
||||||
clientSecret: "secret"
|
clientSecret: "secret"
|
||||||
redirectURI: "https://auth.netkingdom.local/authorize/callback"
|
redirectURI: "https://auth.netkingdom.local/authorize/callback"
|
||||||
@@ -78,10 +83,22 @@ clients:
|
|||||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
grantTypes: ["authorization_code"]
|
grantTypes: ["authorization_code"]
|
||||||
clientType: "public"
|
clientType: "public"
|
||||||
|
- clientId: "netkingdom-bootstrap-console"
|
||||||
|
displayName: "NetKingdom Bootstrap Console"
|
||||||
|
redirectUris:
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
- "http://localhost:8876/oidc/callback"
|
||||||
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
|
grantTypes: ["authorization_code"]
|
||||||
|
clientType: "public"
|
||||||
```
|
```
|
||||||
|
|
||||||
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
|
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
|
||||||
|
|
||||||
|
`browserBaseURL` is used only for the human browser redirect to Authelia.
|
||||||
|
`tokenBaseURL` is used for server-side code exchange. If either is omitted,
|
||||||
|
KeyCape falls back to `baseURL`.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
@@ -90,6 +107,7 @@ Config is validated at startup — the server exits 1 with validation errors if
|
|||||||
| `GET /jwks` | RS256 public key in JWK Set format |
|
| `GET /jwks` | RS256 public key in JWK Set format |
|
||||||
| `GET /authorize` | Authorization endpoint (PKCE required) |
|
| `GET /authorize` | Authorization endpoint (PKCE required) |
|
||||||
| `GET /authorize/callback` | Authelia callback handler |
|
| `GET /authorize/callback` | Authelia callback handler |
|
||||||
|
| `POST /authorize/callback` | privacyIDEA MFA challenge submission |
|
||||||
| `POST /token` | Token exchange (authorization_code only) |
|
| `POST /token` | Token exchange (authorization_code only) |
|
||||||
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
|
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
|
||||||
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |
|
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |
|
||||||
@@ -105,8 +123,10 @@ KeyCape enforces the NetKingdom IAM Profile. Violations return structured errors
|
|||||||
| `rejected_for_profile_safety` | Would weaken security guarantees |
|
| `rejected_for_profile_safety` | Would weaken security guarantees |
|
||||||
| `invalid_profile_usage` | Supported feature used incorrectly |
|
| `invalid_profile_usage` | Supported feature used incorrectly |
|
||||||
|
|
||||||
Enforced boundaries: no implicit flow, no wildcard redirect URIs, no dynamic client
|
Enforced boundaries: no implicit flow, no wildcard redirect URIs, no dynamic
|
||||||
registration, no identity brokering, PKCE S256 required.
|
client registration, no identity brokering, PKCE S256 required. Profile v0.2
|
||||||
|
also requires normalized tenant, principal type, groups, roles, scopes, and
|
||||||
|
assurance evidence in tokens consumed by applications and flex-auth.
|
||||||
|
|
||||||
## Migration Tools
|
## Migration Tools
|
||||||
|
|
||||||
@@ -177,6 +197,62 @@ wiki/ # Specifications
|
|||||||
- `wiki/KeyCapeSpecificationPack_v0.1.md` — Normative implementation spec
|
- `wiki/KeyCapeSpecificationPack_v0.1.md` — Normative implementation spec
|
||||||
- `docs/adr/ADR-0001-choose-go-for-keycape.md` — Language decision (Go vs Rust)
|
- `docs/adr/ADR-0001-choose-go-for-keycape.md` — Language decision (Go vs Rust)
|
||||||
|
|
||||||
|
## Container Image
|
||||||
|
|
||||||
|
The KeyCape image is published to the Gitea OCI registry on CoulombCore.
|
||||||
|
|
||||||
|
**Registry:** `92.205.130.254:32166`
|
||||||
|
**Image:** `92.205.130.254:32166/coulomb/key-cape`
|
||||||
|
|
||||||
|
### Pull
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull 92.205.130.254:32166/coulomb/key-cape:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The registry runs over plain HTTP. Configure Docker to allow it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// /etc/docker/daemon.json
|
||||||
|
{ "insecure-registries": ["92.205.130.254:32166"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and push locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with default tag (latest)
|
||||||
|
make image
|
||||||
|
|
||||||
|
# Build with a specific tag
|
||||||
|
IMAGE_TAG=dev make image
|
||||||
|
|
||||||
|
# Push to registry (requires prior docker login)
|
||||||
|
docker login 92.205.130.254:32166
|
||||||
|
make push
|
||||||
|
|
||||||
|
# Push with a specific tag
|
||||||
|
IMAGE_TAG=v1.0.0 make push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
| Trigger | Tags |
|
||||||
|
|---------|------|
|
||||||
|
| Push to `main` | `latest`, `main-<short-sha>` |
|
||||||
|
| Tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
|
||||||
|
|
||||||
|
### CI (Gitea Actions)
|
||||||
|
|
||||||
|
The workflow at `.gitea/workflows/image.yaml` builds and publishes automatically
|
||||||
|
on every push to `main` and on semver tags (`v*`).
|
||||||
|
|
||||||
|
Required Gitea Actions secrets on the `key-cape` repo:
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `REGISTRY_USER` | Gitea username or machine account (e.g. `ci-netkingdom`) |
|
||||||
|
| `REGISTRY_TOKEN` | Gitea personal access token with `write:packages` scope |
|
||||||
|
|
||||||
## Domain
|
## Domain
|
||||||
|
|
||||||
Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under
|
Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under
|
||||||
|
|||||||
115
SCOPE.md
Normal file
115
SCOPE.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# SCOPE
|
||||||
|
|
||||||
|
> This file helps you quickly understand what this repository is about,
|
||||||
|
> when it is relevant, and when it is not.
|
||||||
|
> It is intentionally lightweight and may be incomplete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-liner
|
||||||
|
|
||||||
|
Lightweight IAM implementation of the NetKingdom IAM Profile — orchestrates Authelia, LLDAP, and privacyIDEA to provide OIDC/PKCE authentication as a drop-in Keycloak alternative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Idea
|
||||||
|
|
||||||
|
NetKingdom applications target the "NetKingdom IAM Profile" — a versioned OIDC/PKCE contract. KeyCape implements that profile in lightweight mode (Authelia + LLDAP + privacyIDEA) with intentional constraints: no implicit flow, no wildcard redirects, no dynamic client registration. The same profile is implemented in expanded mode by Keycloak, so applications can migrate between modes without code changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In Scope
|
||||||
|
|
||||||
|
- OIDC profile endpoints (discovery, authorization, token, JWKS, userinfo) per NetKingdom IAM Profile
|
||||||
|
- Canonical identity model: users, groups, clients, MFA
|
||||||
|
- Claim normalization across Authelia/LLDAP/privacyIDEA backend quirks
|
||||||
|
- Profile enforcement with structured error taxonomy (no silent emulation of unsupported features)
|
||||||
|
- Telemetry for unsupported-feature requests
|
||||||
|
- Migration tooling: LLDAP export, Keycloak import, LDIF generation
|
||||||
|
- LDAP schema validation
|
||||||
|
- Full acceptance test suite (profile baseline, migration scenarios, negative tests)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- General-purpose IAM (profile-specific only; no out-of-profile extensions)
|
||||||
|
- Dynamic client registration
|
||||||
|
- Implicit flow
|
||||||
|
- Wildcard redirect URIs
|
||||||
|
- Identity brokering beyond OIDC
|
||||||
|
- Keycloak operations (KeyCape is the lightweight alternative, not a Keycloak wrapper)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevant When
|
||||||
|
|
||||||
|
- Deploying NetKingdom IAM in lightweight mode (no Keycloak license/resources needed)
|
||||||
|
- Applications need OIDC authentication with MFA in a constrained environment
|
||||||
|
- Migrating from lightweight (KeyCape) to expanded (Keycloak) mode
|
||||||
|
- Validating LDAP schema or generating migration artifacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not Relevant When
|
||||||
|
|
||||||
|
- Expanded-mode Keycloak is already running (applications use the same profile; no code changes needed)
|
||||||
|
- Need out-of-profile IAM features (dynamic client registration, implicit flow, etc.)
|
||||||
|
- Non-NetKingdom OIDC deployments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- Status: stable (v0.1 complete)
|
||||||
|
- Implementation: complete — all 23 workplan tasks implemented and tested
|
||||||
|
- Stability: high — profile-constrained; no silent failures; acceptance tests passing
|
||||||
|
- Usage: internal NetKingdom stack; replaces Keycloak in lightweight deployments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Fits
|
||||||
|
|
||||||
|
- Upstream dependencies: Authelia (OIDC provider/sessions), LLDAP (identity directory), privacyIDEA (MFA)
|
||||||
|
- Downstream consumers: all NetKingdom applications; net-kingdom (parent domain)
|
||||||
|
- Often used with: net-kingdom (SSO/MFA workplan), railiance (deployed on Railiance infrastructure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
- Preferred terms: NetKingdom IAM Profile, lightweight mode, expanded mode, profile enforcement, canonical model
|
||||||
|
- Also known as: "KeyCape", "key-cape"
|
||||||
|
- Potentially confusing terms: "lightweight mode" = KeyCape stack; "expanded mode" = Keycloak stack; both implement the same OIDC profile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related / Overlapping
|
||||||
|
|
||||||
|
- `net-kingdom` — parent platform domain; KeyCape is the lightweight IAM implementation of its IAM Profile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provided Capabilities
|
||||||
|
|
||||||
|
```capability
|
||||||
|
type: security
|
||||||
|
title: OIDC/PKCE authentication (lightweight mode)
|
||||||
|
description: Provides OIDC/PKCE endpoints conforming to the NetKingdom IAM Profile via Authelia + LLDAP + privacyIDEA — a drop-in Keycloak alternative for constrained environments.
|
||||||
|
keywords: [oidc, pkce, authentication, iam, sso, authelia, lldap, mfa, identity]
|
||||||
|
```
|
||||||
|
|
||||||
|
```capability
|
||||||
|
type: security
|
||||||
|
title: Identity migration tooling
|
||||||
|
description: Migrate identities between lightweight (KeyCape) and expanded (Keycloak) IAM modes — LLDAP export, Keycloak import, LDIF generation.
|
||||||
|
keywords: [migration, identity, lldap, keycloak, ldif, iam]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Oriented
|
||||||
|
|
||||||
|
- Start with: `wiki/KeyCapeSpecification_v0.1.md` (architecture and design intent)
|
||||||
|
- Key files / directories: `wiki/KeyCapeSpecificationPack_v0.1.md` (normative spec), `src/cmd/` (binary entrypoints), `src/internal/` (implementation), `tests/` (acceptance suite)
|
||||||
|
- Entry points: `keycape server` binary; `keycape migrate` for migration tooling
|
||||||
@@ -10,6 +10,8 @@ lldap:
|
|||||||
baseDN: "dc=netkingdom,dc=local"
|
baseDN: "dc=netkingdom,dc=local"
|
||||||
authelia:
|
authelia:
|
||||||
baseURL: "http://authelia:9091"
|
baseURL: "http://authelia:9091"
|
||||||
|
browserBaseURL: "http://localhost:9091"
|
||||||
|
tokenBaseURL: "http://authelia:9091"
|
||||||
clientId: "keycape"
|
clientId: "keycape"
|
||||||
clientSecret: "changeme"
|
clientSecret: "changeme"
|
||||||
redirectURI: "http://localhost:8080/authorize/callback"
|
redirectURI: "http://localhost:8080/authorize/callback"
|
||||||
@@ -22,6 +24,16 @@ clients:
|
|||||||
displayName: "Demo Application"
|
displayName: "Demo Application"
|
||||||
redirectUris:
|
redirectUris:
|
||||||
- "http://localhost:3000/callback"
|
- "http://localhost:3000/callback"
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
- "http://localhost:8876/oidc/callback"
|
||||||
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
|
grantTypes: ["authorization_code"]
|
||||||
|
clientType: "public"
|
||||||
|
- clientId: "netkingdom-bootstrap-console"
|
||||||
|
displayName: "NetKingdom Bootstrap Console"
|
||||||
|
redirectUris:
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
- "http://localhost:8876/oidc/callback"
|
||||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
grantTypes: ["authorization_code"]
|
grantTypes: ["authorization_code"]
|
||||||
clientType: "public"
|
clientType: "public"
|
||||||
|
|||||||
12
registry/README.md
Normal file
12
registry/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Capability Registry
|
||||||
|
|
||||||
|
Markdown-first capability index for federation and reuse planning.
|
||||||
|
|
||||||
|
## Authoring
|
||||||
|
|
||||||
|
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
|
||||||
|
2. Add the row to `indexes/capabilities.yaml`.
|
||||||
|
3. Run `reuse-surface validate` from a checkout with the CLI installed.
|
||||||
|
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
|
||||||
|
|
||||||
|
Federation contract: reuse-surface `docs/RegistryFederation.md`.
|
||||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
4
registry/indexes/capabilities.yaml
Normal file
4
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
version: 1
|
||||||
|
updated: '2026-06-16'
|
||||||
|
domain: helix_forge
|
||||||
|
capabilities: []
|
||||||
@@ -37,26 +37,20 @@ func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
|
|||||||
|
|
||||||
// AuthorizeURL builds the Authelia OIDC authorization URL to which the user
|
// AuthorizeURL builds the Authelia OIDC authorization URL to which the user
|
||||||
// should be redirected.
|
// should be redirected.
|
||||||
|
//
|
||||||
|
// KeyCape is a confidential OIDC client to Authelia. The adapter always uses
|
||||||
|
// its own registered client_id and redirect_uri — NOT the downstream client's
|
||||||
|
// values — and requests the full fixed scope set. PKCE is omitted because
|
||||||
|
// the confidential client_secret authenticates the token exchange instead.
|
||||||
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
|
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
|
||||||
base := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/authorization"
|
base := strings.TrimRight(a.authorizeBaseURL(), "/") + "/api/oidc/authorization"
|
||||||
|
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("client_id", req.ClientID)
|
q.Set("client_id", a.cfg.ClientID)
|
||||||
q.Set("redirect_uri", req.RedirectURI)
|
q.Set("redirect_uri", a.cfg.RedirectURI)
|
||||||
q.Set("response_type", "code")
|
q.Set("response_type", "code")
|
||||||
q.Set("state", req.State)
|
q.Set("state", req.State)
|
||||||
if req.Nonce != "" {
|
q.Set("scope", "openid profile email groups")
|
||||||
q.Set("nonce", req.Nonce)
|
|
||||||
}
|
|
||||||
if len(req.Scopes) > 0 {
|
|
||||||
q.Set("scope", strings.Join(req.Scopes, " "))
|
|
||||||
} else {
|
|
||||||
q.Set("scope", "openid profile")
|
|
||||||
}
|
|
||||||
if req.PKCEChallenge != "" {
|
|
||||||
q.Set("code_challenge", req.PKCEChallenge)
|
|
||||||
q.Set("code_challenge_method", req.PKCEChallengeMethod)
|
|
||||||
}
|
|
||||||
|
|
||||||
return base + "?" + q.Encode(), nil
|
return base + "?" + q.Encode(), nil
|
||||||
}
|
}
|
||||||
@@ -142,20 +136,20 @@ type tokenResponse struct {
|
|||||||
// exchangeCode sends a POST to Authelia's token endpoint and returns the
|
// exchangeCode sends a POST to Authelia's token endpoint and returns the
|
||||||
// parsed token response. On any HTTP or status error it returns a non-nil error.
|
// parsed token response. On any HTTP or status error it returns a non-nil error.
|
||||||
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
|
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
|
||||||
tokenURL := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/token"
|
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
|
||||||
|
|
||||||
body := url.Values{}
|
body := url.Values{}
|
||||||
body.Set("grant_type", "authorization_code")
|
body.Set("grant_type", "authorization_code")
|
||||||
body.Set("code", code)
|
body.Set("code", code)
|
||||||
body.Set("redirect_uri", a.cfg.RedirectURI)
|
body.Set("redirect_uri", a.cfg.RedirectURI)
|
||||||
body.Set("client_id", a.cfg.ClientID)
|
body.Set("client_id", a.cfg.ClientID)
|
||||||
body.Set("client_secret", a.cfg.ClientSecret)
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
|
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("authelia: build token request: %w", err)
|
return nil, fmt.Errorf("authelia: build token request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,6 +173,20 @@ func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenRe
|
|||||||
return &tr, nil
|
return &tr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AutheliaAdapter) authorizeBaseURL() string {
|
||||||
|
if a.cfg.BrowserBaseURL != "" {
|
||||||
|
return a.cfg.BrowserBaseURL
|
||||||
|
}
|
||||||
|
return a.cfg.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutheliaAdapter) tokenBaseURL() string {
|
||||||
|
if a.cfg.TokenBaseURL != "" {
|
||||||
|
return a.cfg.TokenBaseURL
|
||||||
|
}
|
||||||
|
return a.cfg.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
// parseIDTokenClaims extracts the JWT payload claims without verifying the
|
// parseIDTokenClaims extracts the JWT payload claims without verifying the
|
||||||
// signature. This is intentional — the token is received directly from the
|
// signature. This is intentional — the token is received directly from the
|
||||||
// upstream OIDC provider over a server-to-server TLS connection.
|
// upstream OIDC provider over a server-to-server TLS connection.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ func jsonResponse(body string) *http.Response {
|
|||||||
|
|
||||||
func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
|
func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
|
||||||
adapter := authelia.New(testConfig(), &mockHTTPClient{})
|
adapter := authelia.New(testConfig(), &mockHTTPClient{})
|
||||||
|
// Downstream client values — must NOT appear in the Authelia URL.
|
||||||
req := domain.AuthRequest{
|
req := domain.AuthRequest{
|
||||||
ClientID: "myapp",
|
ClientID: "myapp",
|
||||||
RedirectURI: "https://myapp.local/cb",
|
RedirectURI: "https://myapp.local/cb",
|
||||||
@@ -90,22 +92,29 @@ func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := []string{
|
// Must use adapter's own client_id and redirect_uri, not the downstream client's.
|
||||||
"client_id=myapp",
|
required := []string{
|
||||||
|
"client_id=keycape",
|
||||||
"redirect_uri=",
|
"redirect_uri=",
|
||||||
"response_type=code",
|
"response_type=code",
|
||||||
"state=state-abc",
|
"state=state-abc",
|
||||||
"nonce=nonce-xyz",
|
|
||||||
"code_challenge=challenge123",
|
|
||||||
"code_challenge_method=S256",
|
|
||||||
"scope=",
|
"scope=",
|
||||||
"openid",
|
"openid",
|
||||||
}
|
}
|
||||||
for _, want := range checks {
|
for _, want := range required {
|
||||||
if !strings.Contains(u, want) {
|
if !strings.Contains(u, want) {
|
||||||
t.Errorf("AuthorizeURL missing %q in: %s", want, u)
|
t.Errorf("AuthorizeURL missing %q in: %s", want, u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Downstream client_id must NOT be forwarded to Authelia.
|
||||||
|
if strings.Contains(u, "client_id=myapp") {
|
||||||
|
t.Errorf("AuthorizeURL must not forward downstream client_id to Authelia, got: %s", u)
|
||||||
|
}
|
||||||
|
// PKCE must NOT be forwarded — confidential client uses client_secret instead.
|
||||||
|
if strings.Contains(u, "code_challenge") {
|
||||||
|
t.Errorf("AuthorizeURL must not include PKCE params for confidential client, got: %s", u)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
|
func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
|
||||||
@@ -128,6 +137,33 @@ func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeURL_UsesBrowserBaseURLWhenConfigured(t *testing.T) {
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.BaseURL = "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
cfg.BrowserBaseURL = "https://auth.coulomb.social"
|
||||||
|
|
||||||
|
adapter := authelia.New(cfg, &mockHTTPClient{})
|
||||||
|
req := domain.AuthRequest{
|
||||||
|
ClientID: "app",
|
||||||
|
RedirectURI: "https://app.local/cb",
|
||||||
|
State: "s",
|
||||||
|
PKCEChallenge: "c",
|
||||||
|
PKCEChallengeMethod: "S256",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := adapter.AuthorizeURL(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(u, "https://auth.coulomb.social") {
|
||||||
|
t.Errorf("expected URL to start with BrowserBaseURL, got: %s", u)
|
||||||
|
}
|
||||||
|
if strings.Contains(u, "authelia.sso.svc.cluster.local") {
|
||||||
|
t.Errorf("browser redirect must not use internal service URL, got: %s", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HandleCallback — successful token exchange
|
// HandleCallback — successful token exchange
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -144,6 +180,27 @@ func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
|
|||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
t.Errorf("expected POST, got %s", req.Method)
|
t.Errorf("expected POST, got %s", req.Method)
|
||||||
}
|
}
|
||||||
|
gotID, gotSecret, ok := req.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected client_secret_basic authentication")
|
||||||
|
}
|
||||||
|
if gotID != "keycape" || gotSecret != "test-secret" {
|
||||||
|
t.Errorf("unexpected basic auth credentials for client %q", gotID)
|
||||||
|
}
|
||||||
|
rawBody, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read request body: %v", err)
|
||||||
|
}
|
||||||
|
form, err := url.ParseQuery(string(rawBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse request body: %v", err)
|
||||||
|
}
|
||||||
|
if form.Get("client_secret") != "" {
|
||||||
|
t.Error("client_secret must not be sent in the form body")
|
||||||
|
}
|
||||||
|
if form.Get("client_id") != "keycape" {
|
||||||
|
t.Errorf("client_id: want keycape, got %q", form.Get("client_id"))
|
||||||
|
}
|
||||||
return jsonResponse(tokenBody), nil
|
return jsonResponse(tokenBody), nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -164,6 +221,32 @@ func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleCallback_UsesTokenBaseURLWhenConfigured(t *testing.T) {
|
||||||
|
tokenBody := buildTokenResponse(map[string]interface{}{
|
||||||
|
"sub": "user-uuid-123",
|
||||||
|
"preferred_username": "alice",
|
||||||
|
})
|
||||||
|
var tokenURL string
|
||||||
|
client := &mockHTTPClient{
|
||||||
|
doFn: func(req *http.Request) (*http.Response, error) {
|
||||||
|
tokenURL = req.URL.String()
|
||||||
|
return jsonResponse(tokenBody), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.BaseURL = "https://auth.coulomb.social"
|
||||||
|
cfg.TokenBaseURL = "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
|
||||||
|
adapter := authelia.New(cfg, client)
|
||||||
|
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(tokenURL, "http://authelia.sso.svc.cluster.local:9091") {
|
||||||
|
t.Errorf("expected token exchange to use TokenBaseURL, got: %s", tokenURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
|
func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
|
||||||
tokenBody := buildTokenResponse(map[string]interface{}{
|
tokenBody := buildTokenResponse(map[string]interface{}{
|
||||||
"sub": "user-uuid-456",
|
"sub": "user-uuid-456",
|
||||||
|
|||||||
@@ -8,17 +8,25 @@ import "net/http"
|
|||||||
// Config holds all connection parameters for the Authelia adapter.
|
// Config holds all connection parameters for the Authelia adapter.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
|
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
|
||||||
BaseURL string
|
BaseURL string `yaml:"baseURL"`
|
||||||
|
|
||||||
|
// BrowserBaseURL is the public Authelia URL used for browser redirects.
|
||||||
|
// If empty, BaseURL is used.
|
||||||
|
BrowserBaseURL string `yaml:"browserBaseURL,omitempty"`
|
||||||
|
|
||||||
|
// TokenBaseURL is the server-side Authelia URL used for token exchange.
|
||||||
|
// If empty, BaseURL is used.
|
||||||
|
TokenBaseURL string `yaml:"tokenBaseURL,omitempty"`
|
||||||
|
|
||||||
// ClientID is the client ID registered in Authelia for KeyCape.
|
// ClientID is the client ID registered in Authelia for KeyCape.
|
||||||
ClientID string
|
ClientID string `yaml:"clientId"`
|
||||||
|
|
||||||
// ClientSecret is the client secret for the KeyCape client registration.
|
// ClientSecret is the client secret for the KeyCape client registration.
|
||||||
ClientSecret string
|
ClientSecret string `yaml:"clientSecret"`
|
||||||
|
|
||||||
// RedirectURI is the callback URL registered in Authelia that points back
|
// RedirectURI is the callback URL registered in Authelia that points back
|
||||||
// to KeyCape's callback handler.
|
// to KeyCape's callback handler.
|
||||||
RedirectURI string
|
RedirectURI string `yaml:"redirectURI"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPClient is a minimal interface over net/http.Client for test injection.
|
// HTTPClient is a minimal interface over net/http.Client for test injection.
|
||||||
|
|||||||
@@ -125,11 +125,16 @@ func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.
|
|||||||
entry := result.Entries[0]
|
entry := result.Entries[0]
|
||||||
user := mapEntryToUser(entry)
|
user := mapEntryToUser(entry)
|
||||||
|
|
||||||
// Run the canonical LDAP schema validator.
|
// Runtime login should not fail because a live directory entry is missing
|
||||||
|
// provisioning metadata such as cn/sn. Keep the warning visible for
|
||||||
|
// diagnostics, but return the resolved user so token issuance can proceed.
|
||||||
snap := validator.Snapshot{Users: []domain.User{user}}
|
snap := validator.Snapshot{Users: []domain.User{user}}
|
||||||
report := validator.Validate(snap, validator.ModeProvisioning)
|
report := validator.Validate(snap, validator.ModeProvisioning)
|
||||||
if !report.Passed {
|
if !report.Passed {
|
||||||
return nil, fmt.Errorf("lldap: validation failed for user %q: %s", username, validationSummary(report))
|
if user.LDAPAttributes == nil {
|
||||||
|
user.LDAPAttributes = make(map[string]string)
|
||||||
|
}
|
||||||
|
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &user, nil
|
return &user, nil
|
||||||
|
|||||||
@@ -154,16 +154,20 @@ func TestLookupUser_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLookupUser_ValidationFailure(t *testing.T) {
|
func TestLookupUser_ValidationWarningDoesNotBlockRuntimeLogin(t *testing.T) {
|
||||||
// Return an entry with an empty DisplayName and empty sn — will fail validator.
|
// Return an entry with an empty DisplayName and empty sn. Runtime login
|
||||||
dn := "uid=broken,ou=users,dc=netkingdom,dc=local"
|
// should still resolve the user; provisioning validators report the warning.
|
||||||
|
dn := "uid=platform-root,ou=people,dc=netkingdom,dc=local"
|
||||||
conn := &mockConn{
|
conn := &mockConn{
|
||||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
if req.BaseDN != "ou=people,dc=netkingdom,dc=local" {
|
||||||
|
t.Fatalf("BaseDN: want ou=people,dc=netkingdom,dc=local, got %q", req.BaseDN)
|
||||||
|
}
|
||||||
attrs := []*ldap.EntryAttribute{
|
attrs := []*ldap.EntryAttribute{
|
||||||
{Name: "uid", Values: []string{"broken"}},
|
{Name: "uid", Values: []string{"platform-root"}},
|
||||||
{Name: "cn", Values: []string{""}},
|
{Name: "cn", Values: []string{""}},
|
||||||
{Name: "sn", Values: []string{""}},
|
{Name: "sn", Values: []string{""}},
|
||||||
{Name: "mail", Values: []string{"broken@example.com"}},
|
{Name: "mail", Values: []string{"bernd.worsch@gmail.com"}},
|
||||||
}
|
}
|
||||||
return &ldap.SearchResult{
|
return &ldap.SearchResult{
|
||||||
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
|
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
|
||||||
@@ -171,10 +175,21 @@ func TestLookupUser_ValidationFailure(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter := makeAdapter(testConfig(), conn)
|
cfg := testConfig()
|
||||||
_, err := adapter.LookupUser(context.Background(), "broken")
|
cfg.UserOU = "ou=people"
|
||||||
if err == nil {
|
adapter := makeAdapter(cfg, conn)
|
||||||
t.Fatal("expected validation error, got nil")
|
user, err := adapter.LookupUser(context.Background(), "platform-root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if user.ID != dn {
|
||||||
|
t.Errorf("ID: want %q, got %q", dn, user.ID)
|
||||||
|
}
|
||||||
|
if user.Username != "platform-root" {
|
||||||
|
t.Errorf("Username: want platform-root, got %q", user.Username)
|
||||||
|
}
|
||||||
|
if user.LDAPAttributes["_validation_warning"] == "" {
|
||||||
|
t.Error("expected validation warning for missing displayName")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,26 +6,26 @@ package lldap
|
|||||||
// Config holds all connection parameters for the LLDAP adapter.
|
// Config holds all connection parameters for the LLDAP adapter.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636".
|
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636".
|
||||||
URL string
|
URL string `yaml:"url"`
|
||||||
|
|
||||||
// BindDN is the distinguished name used for the service account bind,
|
// BindDN is the distinguished name used for the service account bind,
|
||||||
// e.g. "cn=admin,dc=netkingdom,dc=local".
|
// e.g. "cn=admin,dc=netkingdom,dc=local".
|
||||||
BindDN string
|
BindDN string `yaml:"bindDN"`
|
||||||
|
|
||||||
// BindPW is the service account password.
|
// BindPW is the service account password.
|
||||||
BindPW string
|
BindPW string `yaml:"bindPW"`
|
||||||
|
|
||||||
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
|
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
|
||||||
BaseDN string
|
BaseDN string `yaml:"baseDN"`
|
||||||
|
|
||||||
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
|
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
|
||||||
UserOU string
|
UserOU string `yaml:"userOU,omitempty"`
|
||||||
|
|
||||||
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
|
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
|
||||||
GroupOU string
|
GroupOU string `yaml:"groupOU,omitempty"`
|
||||||
|
|
||||||
// TLSSkipVerify disables TLS certificate verification. For development only.
|
// TLSSkipVerify disables TLS certificate verification. For development only.
|
||||||
TLSSkipVerify bool
|
TLSSkipVerify bool `yaml:"tlsSkipVerify,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// userOU returns the effective UserOU, falling back to the default.
|
// userOU returns the effective UserOU, falling back to the default.
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter {
|
|||||||
// registered in privacyIDEA. Fails closed: any infrastructure error returns
|
// registered in privacyIDEA. Fails closed: any infrastructure error returns
|
||||||
// (false, err) so callers cannot bypass the check.
|
// (false, err) so callers cannot bypass the check.
|
||||||
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
|
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
|
||||||
|
if a.cfg.RequireForAll {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
||||||
|
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
|
|||||||
@@ -101,6 +101,27 @@ func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckMFARequired_RequireForAll_ReturnsTrueWithoutTokenList(t *testing.T) {
|
||||||
|
client := &mockHTTPClient{
|
||||||
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("token-list endpoint must not be called when RequireForAll is enabled")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.RequireForAll = true
|
||||||
|
adapter := privacyidea.New(cfg, client)
|
||||||
|
|
||||||
|
required, err := adapter.CheckMFARequired(context.Background(), "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !required {
|
||||||
|
t.Error("expected MFA required=true when RequireForAll is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
|
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
|
||||||
client := &mockHTTPClient{
|
client := &mockHTTPClient{
|
||||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ import "net/http"
|
|||||||
// Config holds all connection parameters for the privacyIDEA adapter.
|
// Config holds all connection parameters for the privacyIDEA adapter.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
|
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
|
||||||
BaseURL string
|
BaseURL string `yaml:"baseURL"`
|
||||||
|
|
||||||
// AdminToken is the service-account JWT used to authenticate requests to the
|
// AdminToken is the service-account JWT used to authenticate requests to the
|
||||||
// privacyIDEA admin API.
|
// privacyIDEA admin API.
|
||||||
AdminToken string
|
AdminToken string `yaml:"adminToken"`
|
||||||
|
|
||||||
// Realm is the privacyIDEA realm to scope token and validate requests.
|
// Realm is the privacyIDEA realm to scope token and validate requests.
|
||||||
// Defaults to "netkingdom" when empty.
|
// Defaults to "netkingdom" when empty.
|
||||||
Realm string
|
Realm string `yaml:"realm"`
|
||||||
|
|
||||||
|
// RequireForAll skips privacyIDEA token-list discovery and requires MFA for
|
||||||
|
// every authenticated upstream user. This is useful during bootstrap when
|
||||||
|
// token-list admin credentials may not be durable yet.
|
||||||
|
RequireForAll bool `yaml:"requireForAll,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// realm returns the effective realm, falling back to "netkingdom".
|
// realm returns the effective realm, falling back to "netkingdom".
|
||||||
|
|||||||
@@ -81,6 +81,122 @@ clients:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_AutheliaSplitURLs(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "placeholder-key")
|
||||||
|
yaml := `
|
||||||
|
issuer: "https://kc.example.com"
|
||||||
|
port: 8080
|
||||||
|
tokenLifetime: "15m"
|
||||||
|
privateKeyPem: "` + keyPath + `"
|
||||||
|
environment: "dev"
|
||||||
|
authelia:
|
||||||
|
baseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
browserBaseURL: "https://auth.example.com"
|
||||||
|
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
clientId: "keycape"
|
||||||
|
clientSecret: "secret"
|
||||||
|
redirectURI: "https://kc.example.com/authorize/callback"
|
||||||
|
clients:
|
||||||
|
- clientId: "netkingdom-bootstrap-console"
|
||||||
|
displayName: "NetKingdom Bootstrap Console"
|
||||||
|
redirectUris:
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
- "http://localhost:8876/oidc/callback"
|
||||||
|
clientType: "public"
|
||||||
|
`
|
||||||
|
cfgPath := writeTempFile(t, yaml)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Authelia.BaseURL != "http://authelia.sso.svc.cluster.local:9091" {
|
||||||
|
t.Errorf("Authelia.BaseURL: got %q", cfg.Authelia.BaseURL)
|
||||||
|
}
|
||||||
|
if cfg.Authelia.BrowserBaseURL != "https://auth.example.com" {
|
||||||
|
t.Errorf("Authelia.BrowserBaseURL: got %q", cfg.Authelia.BrowserBaseURL)
|
||||||
|
}
|
||||||
|
if cfg.Authelia.TokenBaseURL != "http://authelia.sso.svc.cluster.local:9091" {
|
||||||
|
t.Errorf("Authelia.TokenBaseURL: got %q", cfg.Authelia.TokenBaseURL)
|
||||||
|
}
|
||||||
|
if len(cfg.Clients) != 1 || cfg.Clients[0].ClientID != "netkingdom-bootstrap-console" {
|
||||||
|
t.Fatalf("bootstrap client not loaded: %+v", cfg.Clients)
|
||||||
|
}
|
||||||
|
if got := cfg.Clients[0].RedirectURIs; len(got) != 2 || got[0] != "http://127.0.0.1:8876/oidc/callback" {
|
||||||
|
t.Errorf("bootstrap redirect URIs not loaded: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "placeholder-key")
|
||||||
|
yaml := `
|
||||||
|
issuer: "https://kc.example.com"
|
||||||
|
port: 8080
|
||||||
|
tokenLifetime: "15m"
|
||||||
|
privateKeyPem: "` + keyPath + `"
|
||||||
|
environment: "dev"
|
||||||
|
privacyidea:
|
||||||
|
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
|
||||||
|
adminToken: "service-token"
|
||||||
|
realm: "coulomb"
|
||||||
|
requireForAll: true
|
||||||
|
clients:
|
||||||
|
- clientId: "netkingdom-bootstrap-console"
|
||||||
|
displayName: "NetKingdom Bootstrap Console"
|
||||||
|
redirectUris:
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
clientType: "public"
|
||||||
|
`
|
||||||
|
cfgPath := writeTempFile(t, yaml)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.PrivacyIDEA.Realm != "coulomb" {
|
||||||
|
t.Errorf("PrivacyIDEA.Realm: got %q", cfg.PrivacyIDEA.Realm)
|
||||||
|
}
|
||||||
|
if !cfg.PrivacyIDEA.RequireForAll {
|
||||||
|
t.Error("PrivacyIDEA.RequireForAll should load from YAML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_LLDAPOrganisationalUnits(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "placeholder-key")
|
||||||
|
yaml := `
|
||||||
|
issuer: "https://kc.example.com"
|
||||||
|
port: 8080
|
||||||
|
tokenLifetime: "15m"
|
||||||
|
privateKeyPem: "` + keyPath + `"
|
||||||
|
environment: "dev"
|
||||||
|
lldap:
|
||||||
|
url: "ldap://lldap.sso.svc.cluster.local:3890"
|
||||||
|
bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local"
|
||||||
|
bindPW: "secret"
|
||||||
|
baseDN: "dc=netkingdom,dc=local"
|
||||||
|
userOU: "ou=people"
|
||||||
|
groupOU: "ou=groups"
|
||||||
|
clients:
|
||||||
|
- clientId: "netkingdom-bootstrap-console"
|
||||||
|
displayName: "NetKingdom Bootstrap Console"
|
||||||
|
redirectUris:
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
clientType: "public"
|
||||||
|
`
|
||||||
|
cfgPath := writeTempFile(t, yaml)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.LLDAP.UserOU != "ou=people" {
|
||||||
|
t.Errorf("LLDAP.UserOU: got %q", cfg.LLDAP.UserOU)
|
||||||
|
}
|
||||||
|
if cfg.LLDAP.GroupOU != "ou=groups" {
|
||||||
|
t.Errorf("LLDAP.GroupOU: got %q", cfg.LLDAP.GroupOU)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_FileNotFound(t *testing.T) {
|
func TestLoad_FileNotFound(t *testing.T) {
|
||||||
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package oidc
|
package oidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,8 +23,10 @@ type PendingState struct {
|
|||||||
PKCEChallenge string
|
PKCEChallenge string
|
||||||
PKCEChallengeMethod string
|
PKCEChallengeMethod string
|
||||||
State string
|
State string
|
||||||
|
Nonce string
|
||||||
Scopes []string
|
Scopes []string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
|
AuthenticatedUser string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
// pendingStateStore is a thread-safe map of state → PendingState.
|
||||||
@@ -99,6 +104,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||||||
responseType := q.Get("response_type")
|
responseType := q.Get("response_type")
|
||||||
scope := q.Get("scope")
|
scope := q.Get("scope")
|
||||||
state := q.Get("state")
|
state := q.Get("state")
|
||||||
|
nonce := q.Get("nonce")
|
||||||
codeChallenge := q.Get("code_challenge")
|
codeChallenge := q.Get("code_challenge")
|
||||||
codeChallengeMethod := q.Get("code_challenge_method")
|
codeChallengeMethod := q.Get("code_challenge_method")
|
||||||
|
|
||||||
@@ -187,6 +193,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||||||
PKCEChallenge: codeChallenge,
|
PKCEChallenge: codeChallenge,
|
||||||
PKCEChallengeMethod: codeChallengeMethod,
|
PKCEChallengeMethod: codeChallengeMethod,
|
||||||
State: state,
|
State: state,
|
||||||
|
Nonce: nonce,
|
||||||
Scopes: strings.Fields(scope),
|
Scopes: strings.Fields(scope),
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||||
})
|
})
|
||||||
@@ -212,6 +219,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||||||
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
h.init()
|
h.init()
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
h.serveMFASubmission(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
|
|
||||||
state := q.Get("state")
|
state := q.Get("state")
|
||||||
@@ -229,7 +247,6 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||||||
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.pending.Delete(state)
|
|
||||||
|
|
||||||
// Handle upstream callback.
|
// Handle upstream callback.
|
||||||
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
|
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
|
||||||
@@ -248,42 +265,110 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if result == nil || result.Username == "" {
|
||||||
|
h.pending.Delete(state)
|
||||||
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventType: telemetry.EventAuthFailure,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Endpoint: "/authorize/callback",
|
||||||
|
Result: "failure",
|
||||||
|
ErrorType: "auth_failed",
|
||||||
|
})
|
||||||
|
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check MFA requirement.
|
// Check MFA requirement.
|
||||||
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventType: telemetry.EventAuthFailure,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Endpoint: "/authorize/callback",
|
||||||
|
Result: "failure",
|
||||||
|
ErrorType: "mfa_check_error",
|
||||||
|
})
|
||||||
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mfaRequired {
|
if mfaRequired {
|
||||||
|
if mfaToken == "" {
|
||||||
|
ps.AuthenticatedUser = result.Username
|
||||||
|
h.pending.Store(state, ps)
|
||||||
|
h.renderMFAChallenge(w, ps, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.pending.Delete(state)
|
||||||
Timestamp: time.Now(),
|
h.emitMFAFailure(ctx, ps.ClientID)
|
||||||
EventType: telemetry.EventAuthFailure,
|
|
||||||
ClientID: ps.ClientID,
|
|
||||||
Endpoint: "/authorize/callback",
|
|
||||||
Result: "failure",
|
|
||||||
ErrorType: "mfa_failed",
|
|
||||||
})
|
|
||||||
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.pending.Delete(state)
|
||||||
|
h.completeAuthorization(w, r, ps, result.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := r.Form.Get("state")
|
||||||
|
mfaToken := r.Form.Get("mfa_token")
|
||||||
|
|
||||||
|
ps, ok := h.pending.Load(state)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unknown or expired state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(ps.ExpiresAt) {
|
||||||
|
h.pending.Delete(state)
|
||||||
|
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ps.AuthenticatedUser == "" {
|
||||||
|
h.pending.Delete(state)
|
||||||
|
http.Error(w, "mfa challenge not active", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(mfaToken) == "" {
|
||||||
|
h.renderMFAChallenge(w, ps, "Enter the one-time code.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
|
||||||
|
h.pending.Delete(state)
|
||||||
|
h.emitMFAFailure(ctx, ps.ClientID)
|
||||||
|
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.pending.Delete(state)
|
||||||
|
h.completeAuthorization(w, r, ps, ps.AuthenticatedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string) {
|
||||||
// Generate authorization code and store PKCE session.
|
// Generate authorization code and store PKCE session.
|
||||||
sess := &PKCESession{
|
sess := &PKCESession{
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
RedirectURI: ps.RedirectURI,
|
RedirectURI: ps.RedirectURI,
|
||||||
PKCEChallenge: ps.PKCEChallenge,
|
PKCEChallenge: ps.PKCEChallenge,
|
||||||
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
||||||
State: state,
|
State: ps.State,
|
||||||
Username: result.Username,
|
Nonce: ps.Nonce,
|
||||||
|
Username: username,
|
||||||
Scopes: ps.Scopes,
|
Scopes: ps.Scopes,
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||||
}
|
}
|
||||||
authCode := h.Sessions.Create(sess)
|
authCode := h.Sessions.Create(sess)
|
||||||
|
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.Emitter.Emit(r.Context(), telemetry.Event{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
EventType: telemetry.EventAuthSuccess,
|
EventType: telemetry.EventAuthSuccess,
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
@@ -293,14 +378,94 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to client with code and state.
|
// Redirect to client with code and state.
|
||||||
redirectTo := ps.RedirectURI + "?code=" + authCode + "&state=" + state
|
redirectTo, err := url.Parse(ps.RedirectURI)
|
||||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
if err != nil {
|
||||||
|
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := redirectTo.Query()
|
||||||
|
q.Set("code", authCode)
|
||||||
|
q.Set("state", ps.State)
|
||||||
|
redirectTo.RawQuery = q.Encode()
|
||||||
|
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
|
||||||
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventType: telemetry.EventAuthFailure,
|
||||||
|
ClientID: clientID,
|
||||||
|
Endpoint: "/authorize/callback",
|
||||||
|
Result: "failure",
|
||||||
|
ErrorType: "mfa_failed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthorizeHandler) renderMFAChallenge(w http.ResponseWriter, ps *PendingState, errorMessage string) {
|
||||||
|
clientName := ps.ClientID
|
||||||
|
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" {
|
||||||
|
clientName = client.DisplayName
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
if errorMessage != "" {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = mfaChallengeTemplate.Execute(w, struct {
|
||||||
|
State string
|
||||||
|
Username string
|
||||||
|
ClientName string
|
||||||
|
ErrorMessage string
|
||||||
|
}{
|
||||||
|
State: ps.State,
|
||||||
|
Username: ps.AuthenticatedUser,
|
||||||
|
ClientName: clientName,
|
||||||
|
ErrorMessage: errorMessage,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeyCape MFA</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f7f9; color: #17202a; }
|
||||||
|
main { width: min(420px, calc(100vw - 32px)); background: #fff; border: 1px solid #dfe4ea; border-radius: 8px; padding: 28px; box-shadow: 0 18px 45px rgba(23, 32, 42, .08); }
|
||||||
|
h1 { margin: 0 0 6px; font-size: 22px; font-weight: 650; letter-spacing: 0; }
|
||||||
|
p { margin: 0 0 20px; color: #52606d; line-height: 1.45; }
|
||||||
|
label { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 650; color: #344054; }
|
||||||
|
input[type="text"] { width: 100%; box-sizing: border-box; height: 44px; border: 1px solid #c9d3df; border-radius: 6px; padding: 0 12px; font: inherit; background: #fff; }
|
||||||
|
input[type="text"]:focus { outline: 2px solid #2f80ed; outline-offset: 2px; border-color: #2f80ed; }
|
||||||
|
button { width: 100%; height: 44px; border: 0; border-radius: 6px; margin-top: 16px; background: #17324d; color: #fff; font: inherit; font-weight: 650; cursor: pointer; }
|
||||||
|
button:focus { outline: 2px solid #2f80ed; outline-offset: 2px; }
|
||||||
|
.meta { font-size: 13px; color: #667085; }
|
||||||
|
.error { margin: 0 0 12px; color: #b42318; font-size: 13px; font-weight: 650; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Verify sign-in</h1>
|
||||||
|
<p class="meta">{{.Username}} for {{.ClientName}}</p>
|
||||||
|
{{if .ErrorMessage}}<p class="error">{{.ErrorMessage}}</p>{{end}}
|
||||||
|
<form method="post" action="/authorize/callback" autocomplete="off">
|
||||||
|
<input type="hidden" name="state" value="{{.State}}">
|
||||||
|
<label for="mfa_token">One-time code</label>
|
||||||
|
<input id="mfa_token" name="mfa_token" type="text" inputmode="numeric" autocomplete="one-time-code" required autofocus>
|
||||||
|
<button type="submit">Verify</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
func uriRegistered(registered []string, target string) bool {
|
func uriRegistered(registered []string, target string) bool {
|
||||||
for _, u := range registered {
|
for _, u := range registered {
|
||||||
if u == target {
|
if u == target {
|
||||||
|
|||||||
@@ -45,14 +45,20 @@ type mockMFAProvider struct {
|
|||||||
required bool
|
required bool
|
||||||
requiredErr error
|
requiredErr error
|
||||||
|
|
||||||
validateErr error
|
validateErr error
|
||||||
|
validateCalls int
|
||||||
|
validatedUser string
|
||||||
|
validatedToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
||||||
return m.required, m.requiredErr
|
return m.required, m.requiredErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, _, _ string) error {
|
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
|
||||||
|
m.validateCalls++
|
||||||
|
m.validatedUser = user
|
||||||
|
m.validatedToken = token
|
||||||
return m.validateErr
|
return m.validateErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +86,21 @@ func testClient() map[string]*domain.Client {
|
|||||||
return map[string]*domain.Client{
|
return map[string]*domain.Client{
|
||||||
"test-client": {
|
"test-client": {
|
||||||
ClientID: "test-client",
|
ClientID: "test-client",
|
||||||
|
DisplayName: "Test Client",
|
||||||
RedirectURIs: []string{"https://app.example.com/callback"},
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
||||||
AllowedScopes: []string{"openid", "profile", "email"},
|
AllowedScopes: []string{"openid", "profile", "email"},
|
||||||
ClientType: "public",
|
ClientType: "public",
|
||||||
},
|
},
|
||||||
|
"netkingdom-bootstrap-console": {
|
||||||
|
ClientID: "netkingdom-bootstrap-console",
|
||||||
|
DisplayName: "NetKingdom Bootstrap Console",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"http://127.0.0.1:8876/oidc/callback",
|
||||||
|
"http://localhost:8876/oidc/callback",
|
||||||
|
},
|
||||||
|
AllowedScopes: []string{"openid", "profile", "email", "groups"},
|
||||||
|
ClientType: "public",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +163,28 @@ func TestAuthorizeHandler_ValidRequest_RedirectsToAuthelia(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeHandler_BootstrapConsoleRedirectURI_RedirectsToAuthelia(t *testing.T) {
|
||||||
|
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth?state=bootstrap"}
|
||||||
|
mfa := &mockMFAProvider{}
|
||||||
|
emitter := &captureEmitter{}
|
||||||
|
|
||||||
|
h := newAuthorizeHandler(auth, mfa, emitter)
|
||||||
|
params := validAuthorizeParams()
|
||||||
|
params.Set("client_id", "netkingdom-bootstrap-console")
|
||||||
|
params.Set("redirect_uri", "http://127.0.0.1:8876/oidc/callback")
|
||||||
|
|
||||||
|
req := authorizeRequest(params)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Errorf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if loc := w.Header().Get("Location"); loc != "https://authelia.example.com/auth?state=bootstrap" {
|
||||||
|
t.Errorf("expected Authelia redirect, got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthorizeHandler_EmitsAuthStart(t *testing.T) {
|
func TestAuthorizeHandler_EmitsAuthStart(t *testing.T) {
|
||||||
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
|
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
|
||||||
mfa := &mockMFAProvider{}
|
mfa := &mockMFAProvider{}
|
||||||
@@ -449,6 +488,164 @@ func TestAuthorizeCallback_MFAFailed_AuthFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeCallback_MFARequired_RendersChallengeWithoutToken(t *testing.T) {
|
||||||
|
auth := &mockAuthProvider{
|
||||||
|
callbackResult: &domain.AuthResult{Username: "alice"},
|
||||||
|
}
|
||||||
|
mfa := &mockMFAProvider{required: true}
|
||||||
|
emitter := &captureEmitter{}
|
||||||
|
|
||||||
|
sessions := oidc.NewSessionStore()
|
||||||
|
h := &oidc.AuthorizeHandler{
|
||||||
|
ClientConfig: testClient(),
|
||||||
|
Auth: auth,
|
||||||
|
MFA: mfa,
|
||||||
|
Sessions: sessions,
|
||||||
|
Emitter: emitter,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.PendingStates().Store("random-state", &oidc.PendingState{
|
||||||
|
ClientID: "test-client",
|
||||||
|
RedirectURI: "https://app.example.com/callback",
|
||||||
|
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
PKCEChallengeMethod: "S256",
|
||||||
|
State: "random-state",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/authorize/callback?code=authelia-code&state=random-state", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 challenge page, got %d (body: %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Verify sign-in", "alice", "Test Client", `name="mfa_token"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("challenge page missing %q in body: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mfa.validateCalls != 0 {
|
||||||
|
t.Errorf("MFA token should not be validated until form submission, got %d calls", mfa.validateCalls)
|
||||||
|
}
|
||||||
|
ps, ok := h.PendingStates().Load("random-state")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected pending state to remain for MFA form submission")
|
||||||
|
}
|
||||||
|
if ps.AuthenticatedUser != "alice" {
|
||||||
|
t.Errorf("AuthenticatedUser: want alice, got %q", ps.AuthenticatedUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeCallback_MFASubmission_ValidToken_RedirectsWithCode(t *testing.T) {
|
||||||
|
auth := &mockAuthProvider{}
|
||||||
|
mfa := &mockMFAProvider{required: true}
|
||||||
|
emitter := &captureEmitter{}
|
||||||
|
|
||||||
|
sessions := oidc.NewSessionStore()
|
||||||
|
h := &oidc.AuthorizeHandler{
|
||||||
|
ClientConfig: testClient(),
|
||||||
|
Auth: auth,
|
||||||
|
MFA: mfa,
|
||||||
|
Sessions: sessions,
|
||||||
|
Emitter: emitter,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.PendingStates().Store("random-state", &oidc.PendingState{
|
||||||
|
ClientID: "test-client",
|
||||||
|
RedirectURI: "https://app.example.com/callback?from=bootstrap",
|
||||||
|
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
PKCEChallengeMethod: "S256",
|
||||||
|
State: "random-state",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
AuthenticatedUser: "alice",
|
||||||
|
})
|
||||||
|
|
||||||
|
form := url.Values{"state": {"random-state"}, "mfa_token": {"123456"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Errorf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mfa.validatedUser != "alice" || mfa.validatedToken != "123456" {
|
||||||
|
t.Errorf("validated MFA: want alice/123456, got %q/%q", mfa.validatedUser, mfa.validatedToken)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
parsed, err := url.Parse(loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalid Location header: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Query().Get("from") != "bootstrap" {
|
||||||
|
t.Errorf("expected original redirect query to be preserved, got %q", loc)
|
||||||
|
}
|
||||||
|
if parsed.Query().Get("code") == "" {
|
||||||
|
t.Error("expected code param in redirect, got empty")
|
||||||
|
}
|
||||||
|
if parsed.Query().Get("state") != "random-state" {
|
||||||
|
t.Errorf("expected state=random-state, got %q", parsed.Query().Get("state"))
|
||||||
|
}
|
||||||
|
if _, ok := h.PendingStates().Load("random-state"); ok {
|
||||||
|
t.Error("expected pending MFA state to be deleted after successful submission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeCallback_MFASubmission_InvalidToken_AuthFailure(t *testing.T) {
|
||||||
|
auth := &mockAuthProvider{}
|
||||||
|
mfa := &mockMFAProvider{
|
||||||
|
required: true,
|
||||||
|
validateErr: domain.ErrMFAFailed,
|
||||||
|
}
|
||||||
|
emitter := &captureEmitter{}
|
||||||
|
|
||||||
|
h := &oidc.AuthorizeHandler{
|
||||||
|
ClientConfig: testClient(),
|
||||||
|
Auth: auth,
|
||||||
|
MFA: mfa,
|
||||||
|
Sessions: oidc.NewSessionStore(),
|
||||||
|
Emitter: emitter,
|
||||||
|
}
|
||||||
|
h.PendingStates().Store("random-state", &oidc.PendingState{
|
||||||
|
ClientID: "test-client",
|
||||||
|
RedirectURI: "https://app.example.com/callback",
|
||||||
|
PKCEChallenge: "abc",
|
||||||
|
PKCEChallengeMethod: "S256",
|
||||||
|
State: "random-state",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
AuthenticatedUser: "alice",
|
||||||
|
})
|
||||||
|
|
||||||
|
form := url.Values{"state": {"random-state"}, "mfa_token": {"wrong"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if _, ok := h.PendingStates().Load("random-state"); ok {
|
||||||
|
t.Error("expected pending MFA state to be deleted after invalid submission")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, ev := range emitter.events {
|
||||||
|
if ev.EventType == telemetry.EventAuthFailure && ev.ErrorType == "mfa_failed" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected mfa_failed auth_failure telemetry event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthorizeCallback_AuthProviderFailed_AuthFailure(t *testing.T) {
|
func TestAuthorizeCallback_AuthProviderFailed_AuthFailure(t *testing.T) {
|
||||||
auth := &mockAuthProvider{
|
auth := &mockAuthProvider{
|
||||||
callbackErr: domain.ErrAuthFailed,
|
callbackErr: domain.ErrAuthFailed,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type PKCESession struct {
|
|||||||
PKCEChallenge string // S256 challenge
|
PKCEChallenge string // S256 challenge
|
||||||
PKCEChallengeMethod string // always "S256"
|
PKCEChallengeMethod string // always "S256"
|
||||||
State string
|
State string
|
||||||
|
Nonce string
|
||||||
Username string // set after auth
|
Username string // set after auth
|
||||||
Scopes []string
|
Scopes []string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
"exp": exp.Unix(),
|
"exp": exp.Unix(),
|
||||||
"iat": now.Unix(),
|
"iat": now.Unix(),
|
||||||
}
|
}
|
||||||
|
if sess.Nonce != "" {
|
||||||
|
claims["nonce"] = sess.Nonce
|
||||||
|
}
|
||||||
|
|
||||||
scopeSet := make(map[string]bool)
|
scopeSet := make(map[string]bool)
|
||||||
for _, s := range sess.Scopes {
|
for _, s := range sess.Scopes {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func seededSession(sessions *oidc.SessionStore, verifier string) (code string) {
|
|||||||
PKCEChallenge: challenge,
|
PKCEChallenge: challenge,
|
||||||
PKCEChallengeMethod: "S256",
|
PKCEChallengeMethod: "S256",
|
||||||
State: "state1",
|
State: "state1",
|
||||||
|
Nonce: "nonce1",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
Scopes: []string{"openid", "profile", "email", "groups"},
|
Scopes: []string{"openid", "profile", "email", "groups"},
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||||
@@ -323,6 +324,9 @@ func TestTokenHandler_JWTClaims_CorrectSubAndIssuer(t *testing.T) {
|
|||||||
if claims["aud"] != "test-client" {
|
if claims["aud"] != "test-client" {
|
||||||
t.Errorf("aud: expected test-client, got %v", claims["aud"])
|
t.Errorf("aud: expected test-client, got %v", claims["aud"])
|
||||||
}
|
}
|
||||||
|
if claims["nonce"] != "nonce1" {
|
||||||
|
t.Errorf("nonce: expected nonce1, got %v", claims["nonce"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) {
|
func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) {
|
||||||
|
|||||||
@@ -224,9 +224,13 @@ The lightweight stack shall be considered valid production infrastructure where
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. NetKingdom IAM Profile v0.1
|
## 8. NetKingdom IAM Profile
|
||||||
|
|
||||||
This section defines the initial minimum profile to be supported.
|
This section defines the initial minimum profile supported by the KeyCape v0.1
|
||||||
|
specification. The canonical NetKingdom profile has since moved to
|
||||||
|
`net-kingdom/canon/standards/iam-profile_v0.2.md`; KeyCape conformance should
|
||||||
|
be measured against that profile and the executable suite in
|
||||||
|
`net-kingdom/tools/iam-profile-conformance/`.
|
||||||
|
|
||||||
## 8.1 Supported authentication model
|
## 8.1 Supported authentication model
|
||||||
|
|
||||||
@@ -282,11 +286,15 @@ Initial standard claims may include:
|
|||||||
* `email` if present
|
* `email` if present
|
||||||
* `name` if present
|
* `name` if present
|
||||||
|
|
||||||
Optional NetKingdom-specific claims may include:
|
NetKingdom profile v0.2 requires these normalized claims before applications
|
||||||
|
or flex-auth consume a token:
|
||||||
|
|
||||||
* groups
|
* `tenant`
|
||||||
* roles
|
* `principal_type`
|
||||||
* tenant or environment markers if explicitly defined
|
* `groups`
|
||||||
|
* `roles`
|
||||||
|
* `scope` or `scp`
|
||||||
|
* `assurance`
|
||||||
|
|
||||||
Claim names, types, and semantics must be fixed by the profile and validated in tests.
|
Claim names, types, and semantics must be fixed by the profile and validated in tests.
|
||||||
|
|
||||||
@@ -786,9 +794,11 @@ Canonical fixtures conform if they pass canonical model and LDAP schema validati
|
|||||||
|
|
||||||
The following implementation artifacts should be created next:
|
The following implementation artifacts should be created next:
|
||||||
|
|
||||||
### 21.1 NetKingdom IAM Profile v0.1
|
### 21.1 NetKingdom IAM Profile
|
||||||
|
|
||||||
A more formal profile document with endpoint-by-endpoint detail.
|
A formal canonical profile document now exists in net-kingdom as
|
||||||
|
`canon/standards/iam-profile_v0.2.md`, with endpoint-by-endpoint detail,
|
||||||
|
tenant/principal/assurance claims, and executable conformance checks.
|
||||||
|
|
||||||
### 21.2 Canonical identity model schema
|
### 21.2 Canonical identity model schema
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
id: KEY-WP-0001
|
id: KEY-WP-0001
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "KeyCape Implementation — Lightweight IAM Profile"
|
title: "KeyCape Implementation — Lightweight IAM Profile"
|
||||||
domain: netkingdom
|
domain: infotech
|
||||||
repo: key-cape
|
repo: key-cape
|
||||||
status: active
|
status: done
|
||||||
owner: Bernd
|
owner: Bernd
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
workstream_id: 2c9caad8-2ced-492d-9d63-376387b4b9b0
|
|
||||||
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e
|
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e
|
||||||
repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
|
repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
|
||||||
created: 2026-03-13
|
created: 2026-03-13
|
||||||
|
updated: 2026-03-13
|
||||||
spec_refs:
|
spec_refs:
|
||||||
- wiki/KeyCapeSpecification_v0.1.md
|
- wiki/KeyCapeSpecification_v0.1.md
|
||||||
- wiki/KeyCapeSpecificationPack_v0.1.md
|
- wiki/KeyCapeSpecificationPack_v0.1.md
|
||||||
@@ -20,6 +20,7 @@ decisions:
|
|||||||
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
|
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
|
||||||
status: accepted
|
status: accepted
|
||||||
ref: docs/adr/ADR-0001-choose-go-for-keycape.md
|
ref: docs/adr/ADR-0001-choose-go-for-keycape.md
|
||||||
|
state_hub_workstream_id: "0d34dfc1-7ccb-4bd5-b872-5c7379b9adce"
|
||||||
---
|
---
|
||||||
|
|
||||||
# KEY-WP-0001 — KeyCape Implementation
|
# KEY-WP-0001 — KeyCape Implementation
|
||||||
@@ -95,7 +96,8 @@ T01 (project setup)
|
|||||||
id: KEY-WP-0001-T01
|
id: KEY-WP-0001-T01
|
||||||
hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
|
hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
|
state_hub_task_id: "38822bc0-4189-4909-874e-ea40e5771250"
|
||||||
```
|
```
|
||||||
|
|
||||||
Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
|
Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
|
||||||
@@ -108,8 +110,9 @@ no application code. **Agent must call `record_decision()` with chosen language
|
|||||||
id: KEY-WP-0001-T02
|
id: KEY-WP-0001-T02
|
||||||
hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
|
hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "940c118b-c1e6-4dda-bd4c-4fac105822be"
|
||||||
```
|
```
|
||||||
|
|
||||||
Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership,
|
Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership,
|
||||||
@@ -122,8 +125,9 @@ validation. This file is the **source of truth** — all other code derives from
|
|||||||
id: KEY-WP-0001-T03
|
id: KEY-WP-0001-T03
|
||||||
hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
|
hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01, T02]
|
depends_on: [T01, T02]
|
||||||
|
state_hub_task_id: "c1715d70-f10f-45e9-b73a-b54a3d360342"
|
||||||
```
|
```
|
||||||
|
|
||||||
Write `spec/ldap-schema.yaml`: tree layout (`ou=users`, `ou=groups`, `ou=clients` under
|
Write `spec/ldap-schema.yaml`: tree layout (`ou=users`, `ou=groups`, `ou=clients` under
|
||||||
@@ -139,8 +143,9 @@ machine-readable report.
|
|||||||
id: KEY-WP-0001-T04
|
id: KEY-WP-0001-T04
|
||||||
hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
|
hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "6e3b6b97-ac77-44c5-959e-be12751f1b63"
|
||||||
```
|
```
|
||||||
|
|
||||||
Implement four error types (spec §5):
|
Implement four error types (spec §5):
|
||||||
@@ -162,8 +167,9 @@ all handler errors. Error type strings are stable and test-assertable.
|
|||||||
id: KEY-WP-0001-T05
|
id: KEY-WP-0001-T05
|
||||||
hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
|
hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T04]
|
depends_on: [T04]
|
||||||
|
state_hub_task_id: "0dbc08e3-c465-4c37-a219-832a580bedfd"
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
|
`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
|
||||||
@@ -176,8 +182,9 @@ implicit flow. Issuer configurable. Cacheable response.
|
|||||||
id: KEY-WP-0001-T06
|
id: KEY-WP-0001-T06
|
||||||
hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
|
hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T04, T11, T12, T13, T14]
|
depends_on: [T04, T11, T12, T13, T14]
|
||||||
|
state_hub_task_id: "cdb4b06d-3d54-49dd-ac05-ca9ed6d7322f"
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET/POST /authorize`. Validate: `client_id` (static config), `redirect_uri` (exact match —
|
`GET/POST /authorize`. Validate: `client_id` (static config), `redirect_uri` (exact match —
|
||||||
@@ -191,8 +198,9 @@ Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybri
|
|||||||
id: KEY-WP-0001-T07
|
id: KEY-WP-0001-T07
|
||||||
hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
|
hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T06, T08, T10]
|
depends_on: [T06, T08, T10]
|
||||||
|
state_hub_task_id: "534d8616-90de-4d32-961c-c2ef719642e4"
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /token`. Validate PKCE `code_verifier`. Issue RS256 JWT via standard library (no custom
|
`POST /token`. Validate PKCE `code_verifier`. Issue RS256 JWT via standard library (no custom
|
||||||
@@ -207,8 +215,9 @@ claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (grou
|
|||||||
id: KEY-WP-0001-T08
|
id: KEY-WP-0001-T08
|
||||||
hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
|
hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "7e2167be-bcc7-49c2-8681-e518abd5bc0c"
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /jwks`. RS256 public key in JWK Set format. Key loaded from config. Key rotation: serve
|
`GET /jwks`. RS256 public key in JWK Set format. Key loaded from config. Key rotation: serve
|
||||||
@@ -220,8 +229,9 @@ multiple keys during rotation window, keyed by `kid`. Standard library key gener
|
|||||||
id: KEY-WP-0001-T09
|
id: KEY-WP-0001-T09
|
||||||
hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
|
hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T07, T10]
|
depends_on: [T07, T10]
|
||||||
|
state_hub_task_id: "78094ca5-a831-4443-9ccf-fc476ff87b91"
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /userinfo`. Optional per spec — implement if any registered client requires it. Validate
|
`GET /userinfo`. Optional per spec — implement if any registered client requires it. Validate
|
||||||
@@ -239,8 +249,9 @@ identical to ID token for same scopes. If no client needs it: stub returning
|
|||||||
id: KEY-WP-0001-T10
|
id: KEY-WP-0001-T10
|
||||||
hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
|
hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T02, T03]
|
depends_on: [T02, T03]
|
||||||
|
state_hub_task_id: "97d19662-f482-4ea5-84fd-9fccb84ff317"
|
||||||
```
|
```
|
||||||
|
|
||||||
`adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical
|
`adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical
|
||||||
@@ -254,8 +265,9 @@ validator on every read. No LDAP internals exposed to `server/`.
|
|||||||
id: KEY-WP-0001-T11
|
id: KEY-WP-0001-T11
|
||||||
hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
|
hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T04, T13]
|
depends_on: [T04, T13]
|
||||||
|
state_hub_task_id: "6461865b-f57c-4591-9cf3-68c79af22723"
|
||||||
```
|
```
|
||||||
|
|
||||||
`adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated
|
`adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated
|
||||||
@@ -268,8 +280,9 @@ profile layer. Unavailable Authelia → fail closed (`auth_failure` event).
|
|||||||
id: KEY-WP-0001-T12
|
id: KEY-WP-0001-T12
|
||||||
hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
|
hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T02, T13]
|
depends_on: [T02, T13]
|
||||||
|
state_hub_task_id: "e403a783-c856-4d6d-b859-a9cad7545fe1"
|
||||||
```
|
```
|
||||||
|
|
||||||
`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
|
`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
|
||||||
@@ -287,8 +300,9 @@ privacyIDEA remains stable across lightweight → expanded migration.
|
|||||||
id: KEY-WP-0001-T13
|
id: KEY-WP-0001-T13
|
||||||
hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
|
hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "4df7bda1-5b84-4b4c-9b16-bcb1d3cca096"
|
||||||
```
|
```
|
||||||
|
|
||||||
`server/telemetry`. Event types (spec §6.1): `auth_start`, `auth_success`, `auth_failure`,
|
`server/telemetry`. Event types (spec §6.1): `auth_start`, `auth_success`, `auth_failure`,
|
||||||
@@ -303,8 +317,9 @@ metrics endpoint. Every auth and error path emits an event — **no silent paths
|
|||||||
id: KEY-WP-0001-T14
|
id: KEY-WP-0001-T14
|
||||||
hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
|
hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T04, T13]
|
depends_on: [T04, T13]
|
||||||
|
state_hub_task_id: "ae16fba9-5bb4-4780-ac77-558e3ed7e1dd"
|
||||||
```
|
```
|
||||||
|
|
||||||
`server/errors` enforcement middleware. Intercept any parameter, grant type, scope, or client
|
`server/errors` enforcement middleware. Intercept any parameter, grant type, scope, or client
|
||||||
@@ -323,8 +338,9 @@ Every registry entry must have a corresponding test in T21.
|
|||||||
id: KEY-WP-0001-T15
|
id: KEY-WP-0001-T15
|
||||||
hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
|
hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T02, T03, T10]
|
depends_on: [T02, T03, T10]
|
||||||
|
state_hub_task_id: "f7549cd7-33f0-4407-a656-ab8f5a184e64"
|
||||||
```
|
```
|
||||||
|
|
||||||
`migration/lldap-export` tool. Read all users, groups, memberships, attributes from LLDAP. Map
|
`migration/lldap-export` tool. Read all users, groups, memberships, attributes from LLDAP. Map
|
||||||
@@ -338,8 +354,9 @@ report for unmappable LLDAP data.
|
|||||||
id: KEY-WP-0001-T16
|
id: KEY-WP-0001-T16
|
||||||
hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
|
hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T15]
|
depends_on: [T15]
|
||||||
|
state_hub_task_id: "96486c41-9f33-42a5-b7b6-ad0a9eb2bdee"
|
||||||
```
|
```
|
||||||
|
|
||||||
`migration/keycape-to-keycloak` tool. Read canonical export (T15). Transform to Keycloak realm
|
`migration/keycape-to-keycloak` tool. Read canonical export (T15). Transform to Keycloak realm
|
||||||
@@ -353,8 +370,9 @@ Include round-trip validation report.
|
|||||||
id: KEY-WP-0001-T17
|
id: KEY-WP-0001-T17
|
||||||
hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
|
hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T15]
|
depends_on: [T15]
|
||||||
|
state_hub_task_id: "1ec335a2-80ca-4c34-b08e-211f537e4214"
|
||||||
```
|
```
|
||||||
|
|
||||||
`migration/lldap-to-ldap` tool. Export via T15 canonical export. Generate LDIF for target
|
`migration/lldap-to-ldap` tool. Export via T15 canonical export. Generate LDIF for target
|
||||||
@@ -372,8 +390,9 @@ migration dimensions are independent (spec §14.1).
|
|||||||
id: KEY-WP-0001-T18
|
id: KEY-WP-0001-T18
|
||||||
hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
|
hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T05, T06, T07, T08, T09, T22]
|
depends_on: [T05, T06, T07, T08, T09, T22]
|
||||||
|
state_hub_task_id: "1b0e9f26-d441-42b8-b532-1eb713fb355d"
|
||||||
```
|
```
|
||||||
|
|
||||||
`tests/profile`. Provision canonical fixtures into LLDAP + Authelia + KeyCape. Test categories
|
`tests/profile`. Provision canonical fixtures into LLDAP + Authelia + KeyCape. Test categories
|
||||||
@@ -387,8 +406,9 @@ redirect validation, client config, MFA policy, logout (if implemented). Tests a
|
|||||||
id: KEY-WP-0001-T19
|
id: KEY-WP-0001-T19
|
||||||
hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
|
hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T18, T16]
|
depends_on: [T18, T16]
|
||||||
|
state_hub_task_id: "a02d24e7-32de-4be6-935c-896c10dde020"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run T18 suite against Keycloak + LLDAP (configured from T16 canonical export). **No test code
|
Run T18 suite against Keycloak + LLDAP (configured from T16 canonical export). **No test code
|
||||||
@@ -401,8 +421,9 @@ without directory migration.
|
|||||||
id: KEY-WP-0001-T20
|
id: KEY-WP-0001-T20
|
||||||
hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
|
hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T19, T17]
|
depends_on: [T19, T17]
|
||||||
|
state_hub_task_id: "545f319f-053d-48bd-8d94-c8c05cd56736"
|
||||||
```
|
```
|
||||||
|
|
||||||
Apply T17 LLDAP→OpenLDAP migration, then T16 Keycloak import. Run T18 suite. Migration successful
|
Apply T17 LLDAP→OpenLDAP migration, then T16 Keycloak import. Run T18 suite. Migration successful
|
||||||
@@ -414,8 +435,9 @@ only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment req
|
|||||||
id: KEY-WP-0001-T21
|
id: KEY-WP-0001-T21
|
||||||
hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
|
hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
|
||||||
priority: high
|
priority: high
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T14]
|
depends_on: [T14]
|
||||||
|
state_hub_task_id: "5856afe0-2a9e-4489-b057-35e59f86c359"
|
||||||
```
|
```
|
||||||
|
|
||||||
`tests/negative`. For every entry in T14 unsupported-feature registry: attempt usage, assert
|
`tests/negative`. For every entry in T14 unsupported-feature registry: attempt usage, assert
|
||||||
@@ -434,8 +456,9 @@ is complete.
|
|||||||
id: KEY-WP-0001-T22
|
id: KEY-WP-0001-T22
|
||||||
hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
|
hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
|
||||||
priority: medium
|
priority: medium
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T01]
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "b98f2671-a20a-4438-99c9-fbe0e5324534"
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.dev.yml`: KeyCape, LLDAP, Authelia, privacyIDEA (or stub). Pre-seeded with
|
`docker-compose.dev.yml`: KeyCape, LLDAP, Authelia, privacyIDEA (or stub). Pre-seeded with
|
||||||
@@ -449,8 +472,9 @@ Test environment for T18 and T21.
|
|||||||
id: KEY-WP-0001-T23
|
id: KEY-WP-0001-T23
|
||||||
hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
|
hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
|
||||||
priority: low
|
priority: low
|
||||||
status: todo
|
status: done
|
||||||
depends_on: [T18, T21]
|
depends_on: [T18, T21]
|
||||||
|
state_hub_task_id: "8c1752c2-7fb3-4da5-aab3-6b7acf12ea64"
|
||||||
```
|
```
|
||||||
|
|
||||||
Single stateless binary. Declarative YAML config: profile version, client definitions, backend
|
Single stateless binary. Declarative YAML config: profile version, client definitions, backend
|
||||||
|
|||||||
245
workplans/KEY-WP-0002-container-image-gitea.md
Normal file
245
workplans/KEY-WP-0002-container-image-gitea.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
---
|
||||||
|
id: KEY-WP-0002
|
||||||
|
type: workplan
|
||||||
|
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: done
|
||||||
|
owner: netkingdom
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-03-22"
|
||||||
|
updated: "2026-03-21"
|
||||||
|
capability_request_id: ""
|
||||||
|
state_hub_workstream_id: "c8843c7a-460a-47a2-b45a-b8d3940f9aa2"
|
||||||
|
---
|
||||||
|
|
||||||
|
# KEY-WP-0002 — KeyCape Container Image — Build & Publish to Gitea OCI Registry
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
KeyCape has a `Dockerfile` but no automated build pipeline and no published
|
||||||
|
image. Other services (k3s deployments, local dev) that need to run KeyCape
|
||||||
|
must build locally from source. There is no versioned artefact to reference
|
||||||
|
in Helm charts or manifests.
|
||||||
|
|
||||||
|
The capability request for this work was originally misrouted to railiance.
|
||||||
|
It belongs here: KeyCape owns its own image.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Produce a versioned OCI image for KeyCape, published to the Gitea container
|
||||||
|
registry on CoulombCore (`92.205.130.254:32166`), triggered automatically on
|
||||||
|
every merge to `main` and on semver tags (`v*`).
|
||||||
|
|
||||||
|
**Gitea OCI registry endpoint:** `92.205.130.254:32166`
|
||||||
|
**Image name:** `92.205.130.254:32166/coulomb/key-cape`
|
||||||
|
|
||||||
|
> **Why Gitea, not GHCR?**
|
||||||
|
> The net-kingdom cluster is self-hosted. Keeping images in Gitea (also
|
||||||
|
> self-hosted on CoulombCore) avoids any external registry dependency and
|
||||||
|
> keeps image pulls within the cluster network. GHCR is a future option
|
||||||
|
> once public distribution is needed.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Image naming & tagging
|
||||||
|
|
||||||
|
| Trigger | Tags applied |
|
||||||
|
|---------|-------------|
|
||||||
|
| push to `main` | `latest`, `main-<short-sha>` |
|
||||||
|
| tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
Multi-stage Dockerfile already present — no changes needed to the build
|
||||||
|
itself. The image builds to a distroless static binary (~10 MB).
|
||||||
|
|
||||||
|
### Registry auth
|
||||||
|
|
||||||
|
Gitea issues a personal access token (or machine account token) with
|
||||||
|
`write:packages` scope. Stored as Gitea Actions secret `REGISTRY_TOKEN`;
|
||||||
|
username stored as `REGISTRY_USER`.
|
||||||
|
|
||||||
|
For local `make push`, credentials are passed via `docker login` before
|
||||||
|
the push target runs.
|
||||||
|
|
||||||
|
### Makefile targets
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
IMAGE_REGISTRY ?= 92.205.130.254:32166
|
||||||
|
IMAGE_REPO ?= coulomb/key-cape
|
||||||
|
IMAGE_TAG ?= latest
|
||||||
|
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||||
|
|
||||||
|
image:
|
||||||
|
docker build -t $(IMAGE) .
|
||||||
|
|
||||||
|
push: image
|
||||||
|
docker push $(IMAGE)
|
||||||
|
|
||||||
|
image-tag:
|
||||||
|
docker tag $(IMAGE) $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea Actions workflow
|
||||||
|
|
||||||
|
`.gitea/workflows/image.yaml` — triggers on push to `main` and on `v*` tags:
|
||||||
|
- Checkout
|
||||||
|
- Set up Docker Buildx
|
||||||
|
- Login to `92.205.130.254:32166` using secrets
|
||||||
|
- Build and push with metadata-action tags
|
||||||
|
- (Optional) sign with cosign if available
|
||||||
|
|
||||||
|
### k3s insecure registry
|
||||||
|
|
||||||
|
Gitea runs over plain HTTP on port 32166 (NodePort). k3s must be configured
|
||||||
|
to treat this endpoint as an insecure registry so image pulls work from
|
||||||
|
within the cluster:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/rancher/k3s/registries.yaml (on CoulombCore)
|
||||||
|
mirrors:
|
||||||
|
"92.205.130.254:32166":
|
||||||
|
endpoint:
|
||||||
|
- "http://92.205.130.254:32166"
|
||||||
|
```
|
||||||
|
|
||||||
|
k3s picks this up on restart (or SIGHUP). Worker nodes (if any) need the
|
||||||
|
same file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### T01 — Makefile: image, push, image-tag targets
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0002-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "749472fc-edb9-4948-9ebc-58d5f38327ee"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `image`, `push`, and `image-tag` targets to `Makefile` with
|
||||||
|
`IMAGE_REGISTRY`, `IMAGE_REPO`, `IMAGE_TAG` variables defaulting to the
|
||||||
|
Gitea endpoint and `coulomb/key-cape:latest`.
|
||||||
|
|
||||||
|
Gate: `make image` builds successfully locally; `IMAGE_TAG=dev make image`
|
||||||
|
produces a differently-tagged image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T02 — Gitea Actions workflow
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0002-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "8ecf18cc-a3bb-4ede-a09c-fcd0d26d7f9d"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `.gitea/workflows/image.yaml`:
|
||||||
|
- Trigger: `push` to `main`, `push` tags matching `v*`
|
||||||
|
- Runner: `act_runner` label (or `ubuntu-latest` if configured)
|
||||||
|
- Steps: checkout → docker buildx → login → build+push
|
||||||
|
- Tags via `docker/metadata-action`: `latest` on main, semver on tags
|
||||||
|
|
||||||
|
Secrets required (document in README.md under "CI"):
|
||||||
|
- `REGISTRY_USER` — Gitea username or machine account
|
||||||
|
- `REGISTRY_TOKEN` — Gitea personal access token with `write:packages`
|
||||||
|
|
||||||
|
Gate: workflow file is syntactically valid; documented in README.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T03 — k3s insecure registry config on CoulombCore
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0002-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "2dde67f9-944f-418d-a2e9-7367bc556425"
|
||||||
|
```
|
||||||
|
|
||||||
|
On CoulombCore, create/update `/etc/rancher/k3s/registries.yaml` to add
|
||||||
|
the Gitea NodePort as an HTTP mirror. Restart k3s (or send SIGHUP) and
|
||||||
|
verify `crictl pull 92.205.130.254:32166/coulomb/key-cape:latest` works.
|
||||||
|
|
||||||
|
Gate: image pull from within the cluster succeeds without TLS errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T04 — Create Gitea machine account & token
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0002-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "25775e10-3164-4adb-9c41-835c86fde5f8"
|
||||||
|
```
|
||||||
|
|
||||||
|
In Gitea (http://92.205.130.254:32166), create a machine account
|
||||||
|
`ci-netkingdom` (or reuse an existing service account) with access to
|
||||||
|
the `netkingdom` organisation. Generate a token with `write:packages`
|
||||||
|
scope and store it in:
|
||||||
|
- Gitea Actions secrets on the `key-cape` repo: `REGISTRY_USER`, `REGISTRY_TOKEN`
|
||||||
|
- The net-kingdom credential store (SOPS-encrypted) under
|
||||||
|
`credentials/gitea-ci-token.enc.yaml`
|
||||||
|
|
||||||
|
Gate: `docker login 92.205.130.254:32166` succeeds with the token;
|
||||||
|
secret is in the credential store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T05 — Smoke test: push and pull a dev image
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0002-T05
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "0f6ab38f-6d34-41af-9180-f19c687947b5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Manually trigger a build-and-push:
|
||||||
|
```bash
|
||||||
|
docker login 92.205.130.254:32166
|
||||||
|
IMAGE_TAG=dev make push
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify the image is pullable from CoulombCore:
|
||||||
|
```bash
|
||||||
|
# on CoulombCore
|
||||||
|
crictl pull 92.205.130.254:32166/coulomb/key-cape:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate: pull succeeds; image is listed in Gitea -> Packages -> coulomb/key-cape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T06 — Update README with registry & CI docs
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0002-T06
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "946cd34d-94da-4fa9-a781-ed36f6c827a3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a "Container Image" section to `README.md` documenting:
|
||||||
|
- Registry URL and image name
|
||||||
|
- How to pull (`docker pull 92.205.130.254:32166/coulomb/key-cape:latest`)
|
||||||
|
- How to build and push locally (Makefile targets)
|
||||||
|
- CI secrets required for the Actions workflow
|
||||||
|
|
||||||
|
Gate: README section present and accurate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done Criteria
|
||||||
|
|
||||||
|
- [ ] `make image` and `make push` work locally
|
||||||
|
- [ ] `.gitea/workflows/image.yaml` present and documented
|
||||||
|
- [ ] k3s can pull the image from Gitea without TLS errors
|
||||||
|
- [ ] Machine account token stored in credential store
|
||||||
|
- [ ] Smoke test: `dev` image pushed and pulled successfully
|
||||||
|
- [ ] README updated
|
||||||
200
workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md
Normal file
200
workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
---
|
||||||
|
id: KEY-WP-0003
|
||||||
|
type: workplan
|
||||||
|
title: "Bootstrap Console OIDC Login and MFA Verification"
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-05-24"
|
||||||
|
updated: "2026-05-24"
|
||||||
|
state_hub_workstream_id: "02990009-a2b3-44f6-a579-487fbacae41a"
|
||||||
|
---
|
||||||
|
|
||||||
|
# KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The NetKingdom security bootstrap console now acts as a local OIDC client
|
||||||
|
callback so the operator can verify the dedicated platform-root login before
|
||||||
|
approving custody mode. The current live KeyCape deployment rejects that flow
|
||||||
|
with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "invalid_profile_usage",
|
||||||
|
"description": "redirect_uri does not match any registered URI",
|
||||||
|
"feature": "redirect_uri"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That error is correct profile enforcement: KeyCape only accepts exact
|
||||||
|
registered redirect URIs. The live `demo-app` registration has not yet been
|
||||||
|
updated to allow the local bootstrap console callback:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:8876/oidc/callback`
|
||||||
|
- `http://localhost:8876/oidc/callback`
|
||||||
|
|
||||||
|
After that is fixed, there is a second usability/security gap. KeyCape checks
|
||||||
|
privacyIDEA MFA after the Authelia callback, but the browser flow currently
|
||||||
|
expects an `mfa_token` query parameter instead of presenting a proper OTP
|
||||||
|
challenge page to the human operator.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the bootstrap console's "Start demo OIDC login" button a real
|
||||||
|
end-to-end verification path for the current lightweight IAM stack:
|
||||||
|
|
||||||
|
1. KeyCape accepts the bootstrap console callback URI by exact registration.
|
||||||
|
2. The browser leaves KeyCape for the public Authelia login URL.
|
||||||
|
3. After password login, KeyCape presents a minimal MFA challenge when
|
||||||
|
privacyIDEA requires one.
|
||||||
|
4. KeyCape issues an OIDC authorization code to the bootstrap console callback.
|
||||||
|
5. The console can exchange the code and let the operator mark
|
||||||
|
`OIDC login verified` without exposing tokens or secrets.
|
||||||
|
|
||||||
|
This keeps KeyCape's security posture intact: no wildcard redirect URIs, no
|
||||||
|
dynamic client registration, no token display, and no storage of OTP material.
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Prefer a dedicated public client named `netkingdom-bootstrap-console` for
|
||||||
|
long-lived clarity. Reusing `demo-app` is acceptable for the immediate
|
||||||
|
unblock only if the deployment/runbook clearly labels it as a bootstrap test
|
||||||
|
client.
|
||||||
|
- The bootstrap callback is local-only and operator-attended. It must be an
|
||||||
|
exact URI in config, not a wildcard or dynamic registration exception.
|
||||||
|
- Browser-facing Authelia redirects must use the public Authelia base URL
|
||||||
|
(`https://auth.coulomb.social`) so the human login page opens correctly.
|
||||||
|
- KeyCape may still need an internal service URL for back-channel token
|
||||||
|
exchange. If so, split the current single Authelia URL into browser-facing
|
||||||
|
authorize URL and internal token URL instead of making the browser use an
|
||||||
|
in-cluster hostname.
|
||||||
|
- The MFA prompt should collect only a one-time code, post it back to KeyCape,
|
||||||
|
validate with privacyIDEA, and then continue the normal OIDC code flow.
|
||||||
|
- This work unblocks the NetKingdom custody gate in
|
||||||
|
`NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**2026-05-24:** Implemented in source:
|
||||||
|
|
||||||
|
- added `netkingdom-bootstrap-console` as a public OIDC client in the sample
|
||||||
|
KeyCape config, while keeping the local callback registered on `demo-app`
|
||||||
|
for compatibility,
|
||||||
|
- split Authelia browser redirects from server-side token exchange via
|
||||||
|
`browserBaseURL` and `tokenBaseURL`,
|
||||||
|
- added a browser MFA challenge page at `POST /authorize/callback` that
|
||||||
|
validates the one-time code with privacyIDEA before issuing the downstream
|
||||||
|
OIDC authorization code,
|
||||||
|
- updated NetKingdom's `keycape-config` generation template and bootstrap
|
||||||
|
console to use the dedicated client,
|
||||||
|
- added regression tests for callback registration, split Authelia URLs, MFA
|
||||||
|
challenge rendering, valid OTP continuation, and invalid OTP failure.
|
||||||
|
|
||||||
|
Live use still requires deployment: build/publish the updated KeyCape image,
|
||||||
|
refresh the live `keycape-config` Secret through the custodian age-key unlock
|
||||||
|
ceremony, and restart the KeyCape deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T01 - Register the bootstrap console callback client
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0003-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "b396c99f-d711-475a-9cba-4f03a1db561d"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a KeyCape client registration for the bootstrap console. Either create a
|
||||||
|
dedicated `netkingdom-bootstrap-console` public client or update `demo-app`
|
||||||
|
temporarily with these exact redirect URIs:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:8876/oidc/callback`
|
||||||
|
- `http://localhost:8876/oidc/callback`
|
||||||
|
|
||||||
|
Update the sample config, tests, and deployment/runbook references so the
|
||||||
|
registered client is reproducible and not just a live-cluster patch.
|
||||||
|
|
||||||
|
Gate: an authorize request using the local callback no longer returns
|
||||||
|
`invalid_profile_usage` for `redirect_uri`.
|
||||||
|
|
||||||
|
## T02 - Separate browser-facing and internal Authelia URLs if needed
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0003-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "46172e6d-3e11-493c-b223-79c2fc321aec"
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm whether the current `authelia.baseURL` is safe to use for both browser
|
||||||
|
redirects and server-side token exchange. If not, add explicit configuration
|
||||||
|
for the browser authorize base URL and internal token/userinfo base URL.
|
||||||
|
|
||||||
|
Gate: the first browser redirect leaves `https://kc.coulomb.social` for
|
||||||
|
`https://auth.coulomb.social/...`; server-side token exchange still works from
|
||||||
|
inside the deployment.
|
||||||
|
|
||||||
|
## T03 - Add a browser MFA challenge step
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0003-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "92fca4d0-6215-4ea6-9f80-9178ae183acb"
|
||||||
|
```
|
||||||
|
|
||||||
|
When `CheckMFARequired` returns true after the Authelia callback, render a
|
||||||
|
minimal KeyCape MFA challenge page instead of requiring `mfa_token` in the
|
||||||
|
callback query string. The page should:
|
||||||
|
|
||||||
|
- show the authenticated username and client display name,
|
||||||
|
- collect only the OTP code,
|
||||||
|
- preserve the pending OIDC state server-side,
|
||||||
|
- validate with privacyIDEA,
|
||||||
|
- continue to issue the normal authorization code on success,
|
||||||
|
- fail closed with the existing telemetry on invalid MFA.
|
||||||
|
|
||||||
|
Gate: a user enrolled in privacyIDEA can complete password + OTP in the
|
||||||
|
browser and is returned to the registered downstream callback.
|
||||||
|
|
||||||
|
## T04 - Add end-to-end profile tests for the bootstrap login path
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0003-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "079a5929-1864-4461-a64c-746cebca469d"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add tests that cover:
|
||||||
|
|
||||||
|
- local bootstrap callback registration,
|
||||||
|
- rejection of unregistered callbacks remains intact,
|
||||||
|
- Authelia browser redirect uses the expected public URL,
|
||||||
|
- MFA-required login presents a challenge instead of immediate failure,
|
||||||
|
- invalid OTP fails closed,
|
||||||
|
- valid OTP produces an authorization code bound to the original PKCE session.
|
||||||
|
|
||||||
|
Gate: `make test` passes and the negative redirect URI tests remain green.
|
||||||
|
|
||||||
|
## T05 - Document the live rollout ceremony
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0003-T05
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "1d67225d-a20b-4e36-9b2e-20836be2f439"
|
||||||
|
```
|
||||||
|
|
||||||
|
Document the deployment path for updating live KeyCape config without
|
||||||
|
regenerating unrelated secrets. The runbook must fit the NetKingdom custodian
|
||||||
|
age-key model: decrypt or unlock only during an attended ceremony, apply the
|
||||||
|
updated client registration/config, restart KeyCape, and remove plaintext
|
||||||
|
secret material afterward.
|
||||||
|
|
||||||
|
Gate: an operator can update the live `keycape-config` Secret and verify the
|
||||||
|
bootstrap console OIDC login without printing or committing secrets.
|
||||||
Reference in New Issue
Block a user