Compare commits

...

38 Commits

Author SHA1 Message Date
2fd69f0374 Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:27 +02:00
afc01456a5 Fixed workplan frontmatter
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-22 18:40:55 +02:00
d076e7ee7b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for key-cape
2026-06-22 18:02:26 +02:00
c4f281a376 Human-review .repo-classification.yaml (CUST-WP-0050 follow-up)
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-22 17:56:17 +02:00
bee021735c Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:37 +02:00
c9838a4811 Add credential routing instructions for all agent runtimes
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Propagate shared credential-routing section (Codex, Claude, Grok, llm-connect)
from state-hub template via scripts/propagate_credential_routing.py.
2026-06-18 22:48:38 +02:00
593b5af8dc Add capability registry scaffold (REUSE-WP-0014-T05 B03)
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-16 01:53:59 +02:00
d6d41dd84f Fix OpenBao OIDC token exchange compatibility
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-01 21:20:54 +02:00
06d20c3379 Load LLDAP organizational unit config
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-25 00:28:33 +02:00
937cb39de6 Require MFA during bootstrap mode
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-25 00:09:40 +02:00
56d279a8e6 Use basic auth for Authelia token exchange
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-24 18:04:28 +02:00
1d68639225 Align KeyCape image namespace with deployment
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-24 17:17:37 +02:00
7e22fcf3c7 bootrapping support
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-24 17:03:01 +02:00
393abf3e0e Reference IAM Profile v0.2
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-22 14:35:29 +02:00
f45784f951 Make INTENT.md self-coherent
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Remove external reference points so the intent stands on its own at the
abstract, stable level. The IAM profile this repo implements is described
as a versioned profile contract rather than attributed to an external
owner, and the heavier comparison mode is described generically instead of
by product name. All of KeyCape's own substance is preserved — purpose,
primary utility, intended users, strategic role and boundaries, design
principles, maturity target, and stability note.

Relationships to other systems belong in interface contracts and the
orchestration responsibility map, not in intent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:50:08 +02:00
465a778c1f Refresh agent instruction files
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-18 16:55:43 +02:00
10868739a8 Added INTENT.md file
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-03 17:37:45 +02:00
a626dd5d4e Scope update from repo-scoping refactor
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-01 12:26:34 +02:00
926adfb3aa chore(session): read .custodian-brief.md before MCP call in session init
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:48:52 +01:00
cfa12e978d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-26:
  - update .custodian-brief.md for key-cape
2026-03-26 17:47:47 +01:00
a6af43b332 fix(authelia): use adapter's own client_id/redirect_uri in AuthorizeURL
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
The adapter was forwarding the downstream client's client_id and
redirect_uri to Authelia, which would always be rejected — Authelia
only recognises client_id=keycape and its registered callback URI.
Also removed downstream PKCE forwarding: KeyCape is a confidential
OIDC client to Authelia and authenticates via client_secret instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:15:36 +00:00
18dbad68ed feat(close): mark KEY-WP-0002 done — all 6 tasks complete
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:33:28 +00:00
7822ba0703 feat(image): KEY-WP-0002 T01/T02/T06 — Makefile image targets, Gitea Actions workflow, README CI docs
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
- Makefile: add IMAGE_REGISTRY/IMAGE_REPO/IMAGE_TAG vars + image, push, image-tag targets
- .gitea/workflows/image.yaml: build+push on main push and v* tags via metadata-action
- README: Container Image section with pull/build/push/CI secret docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:27:39 +00:00
393ef3ca76 feat(workplan): KEY-WP-0002 — build & publish KeyCape image to Gitea OCI registry
Some checks failed
CI / Build and Test (push) Has been cancelled
Adds workplan for containerising KeyCape and publishing to the self-hosted
Gitea registry on CoulombCore (92.205.130.254:32166) instead of GHCR. Covers
Makefile targets, Gitea Actions workflow, k3s insecure registry config, machine
account/token management, and a smoke test round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:18:12 +01:00
303663e48b Enhanced scope with provided capabilities
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-03-19 21:41:24 +01:00
80bf79de46 docs: add SCOPE.md for rapid orientation
Some checks failed
CI / Build and Test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:10:44 +01:00
ece58bc363 feat(close): mark KEY-WP-0001 done — all 23 tasks complete, tests passing
Some checks failed
CI / Build and Test (push) Has been cancelled
All implementation phases complete: OIDC server (Authorization Code + PKCE),
canonical identity model + LDAP validator, backend adapters (Authelia/LLDAP/
privacyIDEA), telemetry, enforcement middleware, migration tooling, and all
four replacement test scenarios (A–D). Tests pass with Go 1.23.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:49:13 +01:00
847abcba73 feat: implement T19, T20 — Scenario B/C replacement tests; complete workplan
Some checks failed
CI / Build and Test (push) Has been cancelled
- T19: Scenario B tests — IAM swap correctness (7 tests: profile safety, client mapping, user/group preservation)
- T20: Scenario C tests — full expansion correctness (6 tests: LDIF round-trip, target differences, MFA orthogonality)
- CI scripts: test-scenario-b.sh, test-scenario-c.sh
- README: complete documentation with quick start, endpoints, migration guide
- Workplan: all acceptance criteria checked off

All 23 tasks done. 15 test packages, all green. go vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:36:29 +01:00
c18adb6441 feat: implement T22, T18, T23 — dev stack, profile tests, server binary
- T22: docker-compose.dev.yml dev stack, Dockerfile, root Makefile
- T18: Profile test suite (Scenario A) — 8 integration tests with real handlers
- T23: Server binary wiring all components, config validation, /healthz
- Config: ValidateConfig with startup validation

14 test packages pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:18:36 +01:00
fa27adbc77 feat: implement T16, T17 — Keycloak realm import transformer, LDIF generator
- T16: canonical → Keycloak realm JSON (profile-safe: no identity brokering, implicit flow always false)
- T17: canonical → LDIF for openldap/389ds/ad targets with pre-validation

27 migration tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:13:04 +01:00
3ee8090a98 feat: implement T09, T15, T21 — userinfo endpoint, LLDAP export, negative tests
- T09: /userinfo with RS256 JWT validation, scope-filtered claims
- T15: LLDAP→canonical export tool with validation, migration_event telemetry
- T21: Negative test suite (Scenario D) — all 7 unsupported features verified

All go tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:08:03 +01:00
4097a7de8b feat: implement T06, T07 — authorization endpoint, token endpoint
- T06: /authorize with full PKCE validation, Authelia delegation, MFA check
- T07: /token with RS256 JWT issuance (stdlib only), PKCE verification, scope-filtered claims

50 OIDC tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:56:57 +01:00
d05c73dc19 feat: implement T11, T12 — Authelia adapter, privacyIDEA adapter
- T11: AutheliaAdapter delegating login UI and session; Authelia tokens never leak to profile layer
- T12: PrivacyIDEAAdapter delegating MFA 100% — no MFA logic in KeyCape

21 adapter tests pass, vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:50:31 +01:00
b0adbc5daa feat: implement T14, T10 — enforcement middleware, LLDAP adapter
- T14: Unsupported feature registry with 7 pre-registered profile boundaries
- T10: LLDAP adapter implementing UserRepository; validator-gated reads

24 tests pass, go vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:45:21 +01:00
22f7a7dc50 feat: implement T05, T08, T13 — OIDC discovery, JWKS, telemetry pipeline
- T05: /.well-known/openid-configuration — profile-only features advertised
- T08: /jwks — RS256 JWK Set, stdlib crypto only, key rotation support
- T13: Structured telemetry — Event types, LogEmitter/NoopEmitter/MultiEmitter, context helpers

38 server tests pass, go vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:35:34 +01:00
329e996619 feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers

28 tests pass, go vet clean, go build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
f3b1cdcba4 chore: track specification documents
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:30:46 +01:00
3780190456 feat: prime repo — CLAUDE.md + README, register in state-hub
- CLAUDE.md: session protocol, architecture overview, spec pointers,
  workplan convention, state-hub repo ID (8a99bb74, netkingdom domain)
- README.md: replace repo-seed placeholder with KeyCape description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 00:23:19 +01:00
91 changed files with 15352 additions and 2 deletions

20
.claude/rules/agents.md Normal file
View 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.

View 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

View 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`

View 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 13 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 -->

View 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/
-->

View 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

View 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.

View 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)
```

View 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
View 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.

View 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

43
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build-and-test:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Verify dependencies
working-directory: src
run: go mod verify
- name: Vet
working-directory: src
run: go vet ./...
- name: Test
working-directory: src
run: go test -v -race ./...
- name: Build all binaries
working-directory: src
run: |
mkdir -p ../bin
go build -o ../bin/keycape ./cmd/keycape/
go build -o ../bin/validator ./cmd/validator/
go build -o ../bin/lldap-export ./cmd/lldap-export/
go build -o ../bin/keycape-to-keycloak ./cmd/keycape-to-keycloak/
go build -o ../bin/lldap-to-ldap ./cmd/lldap-to-ldap/

26
.repo-classification.yaml Normal file
View 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
View 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/`)

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# KeyCape — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY src/go.mod src/go.sum ./
RUN go mod download
COPY src/ .
RUN CGO_ENABLED=0 go build -o keycape ./cmd/keycape
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/keycape /keycape
EXPOSE 8080
ENTRYPOINT ["/keycape"]

96
INTENT.md Normal file
View 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.

30
Makefile Normal file
View File

@@ -0,0 +1,30 @@
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:
docker compose -f docker-compose.dev.yml up
seed:
docker compose -f docker-compose.dev.yml exec lldap /scripts/seed.sh
build:
cd src && go build ./...
test:
cd src && go test ./...
lint:
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)

262
README.md
View File

@@ -1,3 +1,261 @@
# repo-seed
# KeyCape
A git repository template to bootstrap coulomb projects from.
*Prepare for Keycloak without Keycloak*
KeyCape is the lightweight IAM component of [NetKingdom](../net-kingdom/). It
implements lightweight mode for the **NetKingdom IAM Profile** — a versioned
OIDC/PKCE contract whose canonical core is now
`../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
makes the lightweight → expanded migration a tested, automated operation rather
than a rewrite.
## Status
**Implementation complete (v0.1).** All 23 workplan tasks implemented and tested.
21 test packages, all green. See `workplans/KEY-WP-0001-keycape-implementation.md`.
## Architecture
```
Application
│ (NetKingdom IAM Profile v0.2)
KeyCape ←── profile enforcement, claim normalization, telemetry
/ | \
Auth LLDAP privacyIDEA
elia
```
**Expanded mode:** Replace KeyCape with Keycloak. Same profile contract, same
conformance suite in `../net-kingdom/tools/iam-profile-conformance/`.
## Quick Start
```bash
# Start the dev stack (KeyCape + LLDAP + Authelia + privacyIDEA)
make dev
# Build the server binary
make build
# Run all tests
make test
```
## Configuration
KeyCape uses a YAML config file. See `config/dev-config.yaml` for a full example.
```yaml
issuer: "https://auth.netkingdom.local"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "/etc/keycape/key.pem"
environment: "production"
lldap:
url: "ldap://lldap:389"
bindDN: "cn=admin,dc=netkingdom,dc=local"
bindPW: "secret"
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "http://authelia.sso.svc.cluster.local:9091"
browserBaseURL: "https://authelia.local"
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
clientId: "keycape"
clientSecret: "secret"
redirectURI: "https://auth.netkingdom.local/authorize/callback"
privacyidea:
baseURL: "https://privacyidea.local"
adminToken: "secret"
realm: "netkingdom"
clients:
- clientId: "my-app"
displayName: "My Application"
redirectUris: ["https://myapp.local/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"]
grantTypes: ["authorization_code"]
clientType: "public"
```
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
| Endpoint | Description |
|---|---|
| `GET /.well-known/openid-configuration` | OIDC discovery document |
| `GET /jwks` | RS256 public key in JWK Set format |
| `GET /authorize` | Authorization endpoint (PKCE required) |
| `GET /authorize/callback` | Authelia callback handler |
| `POST /authorize/callback` | privacyIDEA MFA challenge submission |
| `POST /token` | Token exchange (authorization_code only) |
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |
## Profile Constraints
KeyCape enforces the NetKingdom IAM Profile. Violations return structured errors:
| Error type | Meaning |
|---|---|
| `feature_not_supported_by_profile` | Feature is outside the profile entirely |
| `available_in_keycloak_mode_only` | Available in expanded mode, not lightweight |
| `rejected_for_profile_safety` | Would weaken security guarantees |
| `invalid_profile_usage` | Supported feature used incorrectly |
Enforced boundaries: no implicit flow, no wildcard redirect URIs, no dynamic
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
KeyCape ships migration tools for the two orthogonal migration dimensions:
**IAM migration (KeyCape → Keycloak):**
```bash
# Export canonical data from LLDAP
./lldap-export --url ldap://lldap:389 --bind-dn cn=admin,... --output canonical-export.yaml
# Transform to Keycloak realm import
./keycape-to-keycloak --input canonical-export.yaml --realm netkingdom --output keycloak-realm-import.json
```
**Directory migration (LLDAP → OpenLDAP / 389DS / AD):**
```bash
./lldap-to-ldap --input canonical-export.yaml --target openldap --base-dn dc=netkingdom,dc=local --output migration.ldif
```
Both migrations are independent. Perform either or both without affecting privacyIDEA MFA enrollment.
## LDAP Schema Validator
```bash
# Validate in CI mode (strict)
./validator --mode ci --input directory-snapshot.yaml
# Validate before provisioning
./validator --mode provisioning --input users.yaml
```
Validates: DN structure, required attributes, no unknown attributes, user references,
no cyclic groups, username uniqueness, email format.
## Repo Structure
```
src/
cmd/ # Binary entrypoints
keycape/ # Main server
validator/ # LDAP schema validator
lldap-export/ # Migration: LLDAP → canonical
keycape-to-keycloak/ # Migration: canonical → Keycloak
lldap-to-ldap/ # Migration: canonical → LDIF
internal/
config/ # Config loading and validation
domain/ # Canonical identity model (Go types)
errors/ # Profile error taxonomy
adapters/ # Backend adapters (Authelia, LLDAP, privacyIDEA)
server/ # OIDC handlers + telemetry + enforcement
migration/ # Migration logic
validator/ # LDAP schema validation
tests/
profile/ # Scenario A: lightweight baseline
negative/ # Scenario D: unsupported feature rejection
migration/ # Scenarios B & C: replacement tests
spec/
canonical-model.yaml # Source of truth for all identity data
ldap-schema.yaml # Canonical LDAP schema rules
docs/adr/ # Architecture Decision Records
workplans/ # Implementation workplans
wiki/ # Specifications
```
## Key Documents
- `wiki/KeyCapeSpecification_v0.1.md` — Architecture, design intent, objectives
- `wiki/KeyCapeSpecificationPack_v0.1.md` — Normative implementation spec
- `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
Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under
domain `netkingdom`, repo slug `key-cape`.
See `CLAUDE.md` for agent session protocol and workplan conventions.

115
SCOPE.md Normal file
View 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

39
config/dev-config.yaml Normal file
View File

@@ -0,0 +1,39 @@
issuer: "http://localhost:8080"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "/etc/keycape/key.pem"
environment: "dev"
lldap:
url: "ldap://lldap:3890"
bindDN: "cn=admin,ou=people,dc=netkingdom,dc=local"
bindPW: "adminpassword"
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "http://authelia:9091"
browserBaseURL: "http://localhost:9091"
tokenBaseURL: "http://authelia:9091"
clientId: "keycape"
clientSecret: "changeme"
redirectURI: "http://localhost:8080/authorize/callback"
privacyidea:
baseURL: "http://privacyidea:80"
adminToken: "changeme"
realm: "netkingdom"
clients:
- clientId: "demo-app"
displayName: "Demo Application"
redirectUris:
- "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"]
grantTypes: ["authorization_code"]
clientType: "public"

48
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,48 @@
version: "3.8"
services:
keycape:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
volumes:
- ./config/dev-config.yaml:/etc/keycape/config.yaml:ro
- ./config/dev-key.pem:/etc/keycape/key.pem:ro
environment:
- KEYCAPE_CONFIG=/etc/keycape/config.yaml
depends_on:
- lldap
- authelia
lldap:
image: lldap/lldap:stable
ports:
- "17170:17170"
- "3890:3890"
environment:
- LLDAP_JWT_SECRET=devjwtsecret
- LLDAP_LDAP_USER_PASS=adminpassword
- LLDAP_LDAP_BASE_DN=dc=netkingdom,dc=local
volumes:
- lldap_data:/data
authelia:
image: authelia/authelia:latest
ports:
- "9091:9091"
volumes:
- ./config/authelia:/config:ro
environment:
- AUTHELIA_JWT_SECRET=devsecret
privacyidea:
image: khalibre/privacyidea:latest
ports:
- "5000:80"
environment:
- PI_ADMIN_PASSWORD=adminpassword
volumes:
lldap_data:

View File

@@ -0,0 +1,119 @@
---
id: ADR-0001
title: "Implementation language for KeyCape: Go"
status: accepted
date: 2026-03-13
decided_by: Bernd
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
workstream: KEY-WP-0001 (keycape-implementation)
alternatives_considered: [Rust]
---
# ADR-0001 — Implementation Language: Go
## Status
Accepted
## Context
KeyCape must be implemented in a language that satisfies the requirements of spec §11:
> Keycape SHOULD be implemented in Go or Rust.
> Key requirements: stateless, small memory footprint, simple deployment, clear logging,
> structured telemetry.
Both Go and Rust are valid per spec. A decision was needed before T01 (project setup).
### What KeyCape actually does
KeyCape is an **orchestrating boundary service**, not a protocol or security engine:
- delegates authentication UI and session to **Authelia**
- delegates identity storage to **LLDAP**
- delegates MFA enforcement to **privacyIDEA**
- its own code is: HTTP endpoints, config loading, JWT signing (via library), JSON/JWKS handling,
structured telemetry, adapter glue, migration CLI tooling, LDAP schema validator
The main risks are **understandability and clean integration boundaries**, not memory safety in
hot loops or complex parser internals.
## Decision
**Go.**
## Rationale
### Why Go fits KeyCape
Go is especially strong for the actual implementation surface of KeyCape:
| What KeyCape does | Go fit |
|---|---|
| HTTP API server | excellent |
| Config loading and static validation | excellent |
| Adapter code to Authelia, LLDAP, privacyIDEA | excellent |
| JWT/JWKS/JSON handling | excellent |
| Structured logging and Prometheus metrics | excellent |
| CLI tooling (migration, validator, export) | excellent |
| Integration tests in containers | excellent |
Go also provides:
- faster iteration to a working, testable v1
- simpler dependency and build model
- easy static binaries and minimal container images
- low enough runtime overhead for the stated lightweight target
- straightforward output for coding agents across a growing infra codebase
### Why not Rust for this scope
Rust's advantages are real — stronger compile-time safety, better memory control, excellent for
security-critical infrastructure — but they pay back most clearly when:
- substantial protocol machinery is implemented internally
- complex async concurrency or parser-heavy code is required
- the service is intended as a long-lived, standalone security product for others
KeyCape's design **intentionally avoids** all of that. It stays narrow and delegates to existing
components. For a façade/orchestrator, developer friction matters more than theoretical maximal
correctness.
### Decision rule
> **Pick Go if KeyCape is primarily an orchestrating boundary service.**
> **Pick Rust if KeyCape starts becoming a real protocol/security engine.**
Based on the v0.1 spec, KeyCape is clearly the first.
## Consequences
### Positive
- fastest path to a working, testable, operationally simple implementation
- simple build, deploy, and CI story
- lower friction for coding agents producing coherent infra code
### Negative / risks
- weaker compiler-enforced invariants than Rust
- easier to write sloppy code if discipline lapses
- error handling and domain modeling can drift if not designed carefully
### Compensating guardrails (mandatory)
To recover the rigor that Rust would provide via the type system:
1. **Typed domain models** for the profile contract — no raw maps or untyped JSON in business logic
2. **Narrow adapter interfaces**`server/` layer never sees LDAP, Authelia, or privacyIDEA types directly
3. **Layered architecture** — protocol layer | domain layer | adapter layer | migration layer (hard boundaries)
4. **Strict schema/config validation** at startup and in CI
5. **Fuzz and property tests** around the LDAP schema validator, redirect URI checker, and claim mapping
6. **No cleverness** — small, deterministic functions
## Revisit trigger
Reconsider this decision if a subcomponent (e.g. LDAP schema validator, token normalization
engine, or a future high-throughput policy evaluator) demonstrably needs stronger guarantees.
That subcomponent could be redesigned in Rust without regretting the overall Go choice — Go and
Rust interop via CGo or separate binaries is feasible.

12
registry/README.md Normal file
View 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`.

View File

View File

@@ -0,0 +1,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []

44
scripts/test-scenario-b.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# test-scenario-b.sh — Scenario B: IAM swap (KeyCape → Keycloak, same LLDAP directory)
#
# This script verifies that after migrating to Keycloak (with the same LLDAP directory),
# all profile tests pass without modification.
#
# Prerequisites: docker, docker compose
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo "=== Scenario B: IAM Replacement Test ==="
# Step 1: Export canonical data from LLDAP
echo "--- Step 1: Export canonical data ---"
./src/bin/lldap-export \
--url "${LLDAP_URL:-ldap://localhost:3890}" \
--bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \
--bind-pw "${LLDAP_BIND_PW:-adminpassword}" \
--base-dn "dc=netkingdom,dc=local" \
--output /tmp/canonical-export.yaml
# Step 2: Transform to Keycloak realm
echo "--- Step 2: Transform to Keycloak realm ---"
./src/bin/keycape-to-keycloak \
--input /tmp/canonical-export.yaml \
--realm netkingdom \
--issuer "${ISSUER:-https://auth.netkingdom.local}" \
--output /tmp/keycloak-realm-import.json
# Step 3: Start Keycloak with the imported realm
echo "--- Step 3: Start Keycloak with imported realm ---"
docker compose -f docker-compose.scenario-b.yml up -d keycloak
echo "Waiting for Keycloak to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/netkingdom/.well-known/openid-configuration > /dev/null; do sleep 3; done'
# Step 4: Run profile tests against Keycloak
echo "--- Step 4: Run profile tests against Keycloak ---"
KEYCAPE_TEST_ISSUER="http://localhost:8080/realms/netkingdom" \
/home/worsch/go/bin/go test ./src/tests/profile/... -v -count=1
echo "=== Scenario B PASSED ==="

60
scripts/test-scenario-c.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# test-scenario-c.sh — Scenario C: Full expansion (LLDAP→OpenLDAP + KeyCape→Keycloak)
#
# This script verifies the full migration path:
# LLDAP → canonical → OpenLDAP (directory migration)
# KeyCape → canonical → Keycloak (IAM migration)
# privacyIDEA MFA remains stable (no re-enrollment)
#
# Prerequisites: docker, docker compose
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo "=== Scenario C: Full Expansion Test ==="
# Step 1: Export canonical data from LLDAP
echo "--- Step 1: Export canonical data ---"
./src/bin/lldap-export \
--url "${LLDAP_URL:-ldap://localhost:3890}" \
--bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \
--bind-pw "${LLDAP_BIND_PW:-adminpassword}" \
--base-dn "dc=netkingdom,dc=local" \
--output /tmp/canonical-export.yaml
# Step 2a: Generate LDIF for OpenLDAP
echo "--- Step 2a: Generate OpenLDAP LDIF ---"
./src/bin/lldap-to-ldap \
--input /tmp/canonical-export.yaml \
--target openldap \
--base-dn "dc=netkingdom,dc=local" \
--output /tmp/migration.ldif
# Step 2b: Transform to Keycloak realm
echo "--- Step 2b: Transform to Keycloak realm ---"
./src/bin/keycape-to-keycloak \
--input /tmp/canonical-export.yaml \
--realm netkingdom \
--issuer "${ISSUER:-https://auth.netkingdom.local}" \
--output /tmp/keycloak-realm-import.json
# Step 3: Start OpenLDAP + Keycloak
echo "--- Step 3: Start expanded stack ---"
docker compose -f docker-compose.scenario-c.yml up -d openldap keycloak
echo "Waiting for OpenLDAP..."
timeout 60 bash -c 'until ldapsearch -x -H ldap://localhost:389 -b dc=netkingdom,dc=local > /dev/null 2>&1; do sleep 3; done'
echo "Waiting for Keycloak..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/netkingdom/.well-known/openid-configuration > /dev/null; do sleep 3; done'
# Step 4: Import LDIF into OpenLDAP
echo "--- Step 4: Import LDIF ---"
ldapadd -x -H ldap://localhost:389 -D "cn=admin,dc=netkingdom,dc=local" -w adminpassword -f /tmp/migration.ldif
# Step 5: Run profile tests against Keycloak + OpenLDAP
echo "--- Step 5: Run profile tests ---"
KEYCAPE_TEST_ISSUER="http://localhost:8080/realms/netkingdom" \
/home/worsch/go/bin/go test ./src/tests/profile/... -v -count=1
echo "=== Scenario C PASSED ==="

174
spec/canonical-model.yaml Normal file
View File

@@ -0,0 +1,174 @@
version: "0.1"
description: >
Canonical Identity Model for KeyCape / NetKingdom IAM Profile.
This file is the source of truth for all identity entities.
All provisioning, tests, and migrations derive from these definitions.
entities:
User:
description: "A person or service account in the identity directory."
fields:
id:
type: string
required: true
description: "Stable internal identifier. Immutable after creation."
username:
type: string
required: true
description: "Unique login name. Maps to LDAP uid."
displayName:
type: string
required: true
description: "Human-readable full name. Maps to LDAP cn."
email:
type: string
required: false
format: email
description: "Primary email address. Maps to LDAP mail."
enabled:
type: boolean
required: true
description: "Whether the account is active."
groups:
type: array
items:
type: string
ref: Group.id
description: "Group memberships by group ID."
roles:
type: array
items:
type: string
ref: Role.id
description: "Role assignments by role ID."
mfaEnrollment:
type: object
ref: MFAEnrollment
nullable: true
description: "MFA enrollment record if the user has enrolled."
ldapAttributes:
type: object
additionalProperties: true
description: "Raw LDAP attributes not covered by the canonical model."
Group:
description: "A named collection of users."
fields:
id:
type: string
required: true
description: "Stable internal identifier."
name:
type: string
required: true
description: "Unique group name. Maps to LDAP cn."
description:
type: string
required: false
description: "Human-readable description."
members:
type: array
items:
type: string
ref: User.id
description: "User IDs belonging to this group."
Role:
description: "A named permission set assigned to users."
fields:
id:
type: string
required: true
description: "Stable internal identifier."
name:
type: string
required: true
description: "Unique role name."
description:
type: string
required: false
description: "Human-readable description."
Client:
description: "A registered OIDC client. Registration is static in v0.1."
fields:
clientId:
type: string
required: true
description: "OAuth2 client_id."
displayName:
type: string
required: true
description: "Human-readable client name."
redirectUris:
type: array
items:
type: string
format: uri
required: true
minItems: 1
description: "Allowed redirect URIs. Wildcards are NEVER permitted."
allowedScopes:
type: array
items:
type: string
required: true
description: "Scopes this client may request."
grantTypes:
type: array
items:
type: string
enum: [authorization_code]
required: true
description: "Allowed OAuth2 grant types. Only authorization_code in v0.1."
clientType:
type: string
enum: [confidential, public]
required: true
description: "confidential = server-side app; public = SPA or native."
secretRef:
type: string
nullable: true
description: "Reference to the client secret (confidential clients only)."
tokenProfile:
type: string
description: "Optional: token configuration profile name."
environments:
type: array
items:
type: string
description: "Environments this client is registered for (e.g. prod, staging)."
Membership:
description: "Explicit link between a user and a group."
fields:
userId:
type: string
required: true
ref: User.id
groupId:
type: string
required: true
ref: Group.id
MFAEnrollment:
description: "Records MFA enrollment state for a user via privacyIDEA."
fields:
userId:
type: string
required: true
ref: User.id
provider:
type: string
required: true
enum: [privacyidea]
description: "MFA provider. Only privacyidea is supported in v0.1."
state:
type: string
required: true
enum: [enabled, disabled, pending]
description: "Current enrollment state."
enrolledAt:
type: string
format: datetime
description: "ISO 8601 timestamp of enrollment."

91
spec/ldap-schema.yaml Normal file
View File

@@ -0,0 +1,91 @@
version: "0.1"
description: >
Canonical LDAP Schema for KeyCape / NetKingdom IAM Profile.
Expresses the canonical identity model in LDAP terms.
Portable across LLDAP, OpenLDAP, 389DS, and Active Directory.
base_dn: "dc=netkingdom,dc=local"
organization_units:
users:
dn: "ou=users,dc=netkingdom,dc=local"
description: "User accounts"
object_classes:
required:
- inetOrgPerson
- organizationalPerson
- person
- top
attributes:
required:
- uid # canonical: username
- cn # canonical: displayName
- sn # canonical: surname (may be set to displayName if absent)
optional:
- mail # canonical: email
- memberOf # back-reference to group membership
forbidden: []
naming_attr: uid
examples:
- dn: "uid=alice,ou=users,dc=netkingdom,dc=local"
uid: alice
cn: "Alice Example"
sn: Example
mail: alice@example.com
groups:
dn: "ou=groups,dc=netkingdom,dc=local"
description: "User groups"
object_classes:
required:
- groupOfNames
- top
attributes:
required:
- cn # canonical: name
- member # list of member DNs
optional:
- description
forbidden: []
naming_attr: cn
examples:
- dn: "cn=admins,ou=groups,dc=netkingdom,dc=local"
cn: admins
member:
- "uid=alice,ou=users,dc=netkingdom,dc=local"
clients:
dn: "ou=clients,dc=netkingdom,dc=local"
description: "OIDC client registrations"
object_classes:
required:
- inetOrgPerson
- top
attributes:
required:
- uid # canonical: clientId
- cn # canonical: displayName
optional:
- description
forbidden: []
naming_attr: uid
validation_rules:
structural:
- name: valid_dn_structure
description: "All DNs must conform to the base_dn and OU layout above."
- name: required_attributes_present
description: "Every entry must carry all required attributes for its OU."
- name: no_unknown_attributes
description: "No attributes outside the allowed set may appear."
- name: valid_group_memberships
description: "All member values must be non-empty valid DNs."
semantic:
- name: referenced_users_exist
description: "Every user ID referenced in group members must exist."
- name: no_cyclic_groups
description: "Groups may not contain other group IDs as members."
- name: usernames_unique
description: "The uid attribute must be unique across ou=users."
- name: email_format_valid
description: "mail, when present, must be a valid RFC 5322 address."

29
src/Makefile Normal file
View File

@@ -0,0 +1,29 @@
GOBIN ?= $(shell go env GOPATH)/bin
BINDIR = ../bin
.PHONY: all build test lint vet clean
all: vet lint test build
build:
go build -o $(BINDIR)/keycape ./cmd/keycape/
go build -o $(BINDIR)/validator ./cmd/validator/
go build -o $(BINDIR)/lldap-export ./cmd/lldap-export/
go build -o $(BINDIR)/keycape-to-keycloak ./cmd/keycape-to-keycloak/
go build -o $(BINDIR)/lldap-to-ldap ./cmd/lldap-to-ldap/
test:
go test ./...
vet:
go vet ./...
lint:
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run ./...; \
else \
echo "golangci-lint not installed, skipping (run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)"; \
fi
clean:
rm -rf $(BINDIR)

View File

@@ -0,0 +1,75 @@
// keycape-to-keycloak migrates a KeyCape canonical snapshot to a Keycloak
// realm export format. Part of the NetKingdom IAM migration contract.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/rs/zerolog"
"gopkg.in/yaml.v3"
"keycape/internal/migration/lldapexport"
"keycape/internal/migration/tokeycloak"
"keycape/internal/server/telemetry"
)
func main() {
inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)")
outputFile := flag.String("output", "keycloak-realm.json", "Path to write Keycloak realm import JSON")
realmName := flag.String("realm", "netkingdom", "Keycloak realm name")
issuer := flag.String("issuer", "", "OIDC issuer URL")
flag.Parse()
if *inputFile == "" {
fmt.Fprintln(os.Stderr, "keycape-to-keycloak: -input is required")
flag.Usage()
os.Exit(1)
}
data, err := os.ReadFile(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: read %q: %v\n", *inputFile, err)
os.Exit(1)
}
var export lldapexport.ExportResult
if err := yaml.Unmarshal(data, &export); err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: parse YAML: %v\n", err)
os.Exit(1)
}
log := zerolog.New(os.Stderr).With().Timestamp().Logger()
em := telemetry.NewLogEmitter(log)
tr := tokeycloak.New(tokeycloak.Config{
RealmName: *realmName,
Issuer: *issuer,
}, em)
realm, err := tr.Transform(&export)
if err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: transform: %v\n", err)
os.Exit(1)
}
// Print validation report to stderr.
report := tr.ValidationReport(&export, realm)
for _, issue := range report {
fmt.Fprintf(os.Stderr, "WARNING: %s\n", issue)
}
out, err := json.MarshalIndent(realm, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: marshal JSON: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(*outputFile, out, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: write %q: %v\n", *outputFile, err)
os.Exit(1)
}
fmt.Printf("keycape-to-keycloak: wrote %s (%d bytes)\n", *outputFile, len(out))
}

271
src/cmd/keycape/main.go Normal file
View File

@@ -0,0 +1,271 @@
// keycape is the main server binary for the KeyCape IAM profile service.
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
package main
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/config"
"keycape/internal/domain"
servererrors "keycape/internal/server/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
const version = "0.1.0"
func main() {
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
// -----------------------------------------------------------------
// 1. Parse flags and load config.
// -----------------------------------------------------------------
var cfgPath string
flag.StringVar(&cfgPath, "config", "", "path to YAML config file (env: KEYCAPE_CONFIG)")
flag.Parse()
cfg, err := config.Load(cfgPath)
if err != nil {
log.Error().Err(err).Msg("failed to load config")
os.Exit(1)
}
// -----------------------------------------------------------------
// 2. Validate config.
// -----------------------------------------------------------------
errs := config.ValidateConfig(cfg)
if len(errs) > 0 {
log.Error().Strs("errors", errs).Msg("config validation failed")
os.Exit(1)
}
// -----------------------------------------------------------------
// 3. Load RSA private key.
// -----------------------------------------------------------------
privateKey, err := loadPrivateKey(cfg.PrivateKeyPEM)
if err != nil {
log.Error().Err(err).Str("path", cfg.PrivateKeyPEM).Msg("failed to load private key")
os.Exit(1)
}
// -----------------------------------------------------------------
// 4. Build JWKS from public key.
// -----------------------------------------------------------------
ks := oidc.NewKeySet()
ks.AddKey("key-1", &privateKey.PublicKey)
// -----------------------------------------------------------------
// 5. Build client registry.
// -----------------------------------------------------------------
clients := buildClientRegistry(cfg.Clients)
// -----------------------------------------------------------------
// 6. Create adapters.
// -----------------------------------------------------------------
lldapAdapter := lldap.New(cfg.LLDAP)
autheliaAdapter := authelia.New(cfg.Authelia, nil)
privacyIDEAAdapter := privacyidea.New(cfg.PrivacyIDEA, nil)
// -----------------------------------------------------------------
// 7. Create telemetry emitter.
// -----------------------------------------------------------------
emitter := telemetry.NewLogEmitter(log)
// -----------------------------------------------------------------
// 8. Create enforcement registry.
// -----------------------------------------------------------------
enforcement := servererrors.DefaultRegistry()
// -----------------------------------------------------------------
// 9. Create session store.
// -----------------------------------------------------------------
sessions := oidc.NewSessionStore()
// -----------------------------------------------------------------
// 10. Parse token lifetime.
// -----------------------------------------------------------------
tokenLifetime := 15 * time.Minute
if cfg.TokenLifetime != "" {
d, err := time.ParseDuration(cfg.TokenLifetime)
if err != nil {
log.Error().Err(err).Str("tokenLifetime", cfg.TokenLifetime).Msg("invalid tokenLifetime")
os.Exit(1)
}
tokenLifetime = d
}
// -----------------------------------------------------------------
// 11. Build issuer base URL.
// -----------------------------------------------------------------
issuer := strings.TrimRight(cfg.Issuer, "/")
// -----------------------------------------------------------------
// 12. Register HTTP handlers.
// -----------------------------------------------------------------
mux := http.NewServeMux()
// Discovery.
mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
JWKSUri: issuer + "/jwks",
UserinfoEndpoint: issuer + "/userinfo",
}))
// JWKS.
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
// Authorize handler (with enforcement middleware).
authorizeHandler := &oidc.AuthorizeHandler{
ClientConfig: clients,
Auth: autheliaAdapter,
MFA: privacyIDEAAdapter,
Sessions: sessions,
Emitter: emitter,
}
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler)
// Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{
ClientConfig: clients,
Sessions: sessions,
Users: lldapAdapter,
SigningKey: privateKey,
Issuer: issuer,
TokenLifetime: tokenLifetime,
Emitter: emitter,
}
mux.Handle("/token", enforcement.Middleware(tokenHandler))
// Userinfo handler.
mux.Handle("/userinfo", &oidc.UserinfoHandler{
Users: lldapAdapter,
SigningKey: &privateKey.PublicKey,
Issuer: issuer,
Emitter: emitter,
})
// Healthz.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": version,
})
})
// Inject emitter into request context.
handler := withEmitter(mux, emitter)
// -----------------------------------------------------------------
// 13. Start HTTP server.
// -----------------------------------------------------------------
addr := fmt.Sprintf(":%d", cfg.Port)
if cfg.Port == 0 {
addr = ":8080"
}
log.Info().
Str("issuer", issuer).
Str("addr", addr).
Str("environment", cfg.Environment).
Str("version", version).
Msg("starting keycape server")
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error().Err(err).Msg("server error")
os.Exit(1)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// loadPrivateKey reads a PEM file and parses the RSA private key.
// Supports both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM blocks.
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found in %q", path)
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS1 private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKCS8 private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not an RSA key")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unexpected PEM block type %q; expected RSA PRIVATE KEY or PRIVATE KEY", block.Type)
}
}
// buildClientRegistry converts []ClientConfig into the map used by handlers.
func buildClientRegistry(cfgClients []config.ClientConfig) map[string]*domain.Client {
m := make(map[string]*domain.Client, len(cfgClients))
for i := range cfgClients {
c := &cfgClients[i]
m[c.ClientID] = &domain.Client{
ClientID: c.ClientID,
DisplayName: c.DisplayName,
RedirectURIs: c.RedirectURIs,
AllowedScopes: c.AllowedScopes,
GrantTypes: c.GrantTypes,
ClientType: c.ClientType,
SecretRef: c.SecretRef,
}
}
return m
}
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
func withEmitter(next http.Handler, e telemetry.Emitter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := telemetry.WithEmitter(context.Background(), e)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,65 @@
// lldap-export exports the LLDAP directory as a canonical YAML snapshot
// for use with the validator and migration tools.
package main
import (
"context"
"flag"
"fmt"
"os"
"keycape/internal/adapters/lldap"
"keycape/internal/migration/lldapexport"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
"github.com/rs/zerolog"
)
func main() {
// Flags.
url := flag.String("url", "ldap://localhost:389", "LLDAP server URL (ldap:// or ldaps://)")
bindDN := flag.String("bind-dn", "", "Service account bind DN (required)")
bindPW := flag.String("bind-pw", "", "Service account password (required)")
baseDN := flag.String("base-dn", "", "LDAP search base DN (required)")
output := flag.String("output", "canonical-export.yaml", "Output file path")
tlsSkip := flag.Bool("tls-skip-verify", false, "Skip TLS certificate verification (dev only)")
flag.Parse()
if *bindDN == "" || *baseDN == "" {
fmt.Fprintln(os.Stderr, "lldap-export: --bind-dn and --base-dn are required")
flag.Usage()
os.Exit(1)
}
log := zerolog.New(os.Stderr).With().Timestamp().Logger()
emitter := telemetry.NewLogEmitter(log)
cfg := lldap.Config{
URL: *url,
BindDN: *bindDN,
BindPW: *bindPW,
BaseDN: *baseDN,
TLSSkipVerify: *tlsSkip,
}
repo := lldap.New(cfg)
exp := lldapexport.New(repo, validator.ModeProvisioning, emitter)
result, err := exp.Export(context.Background(), *output)
if err != nil {
fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stdout, "Exported %d users, %d groups to %s\n",
len(result.Users), len(result.Groups), *output)
if len(result.IncompatibilityReport) > 0 {
fmt.Fprintln(os.Stderr, "Incompatibility report:")
for _, item := range result.IncompatibilityReport {
fmt.Fprintln(os.Stderr, " -", item)
}
os.Exit(2) // partial success: exported with warnings
}
}

View File

@@ -0,0 +1,81 @@
// lldap-to-ldap migrates LLDAP directory data to standard LDAP (LDIF format).
// Part of the NetKingdom IAM migration contract.
package main
import (
"flag"
"fmt"
"os"
"github.com/rs/zerolog"
"gopkg.in/yaml.v3"
"keycape/internal/migration/lldapexport"
"keycape/internal/migration/toldap"
"keycape/internal/server/telemetry"
)
func main() {
inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)")
outputFile := flag.String("output", "export.ldif", "Path to write LDIF output")
baseDN := flag.String("basedn", "dc=netkingdom,dc=local", "LDAP base DN")
targetStr := flag.String("target", "openldap", "LDAP target: openldap | 389ds | ad")
flag.Parse()
if *inputFile == "" {
fmt.Fprintln(os.Stderr, "lldap-to-ldap: -input is required")
flag.Usage()
os.Exit(1)
}
target, err := parseTarget(*targetStr)
if err != nil {
fmt.Fprintf(os.Stderr, "lldap-to-ldap: %v\n", err)
os.Exit(1)
}
data, err := os.ReadFile(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "lldap-to-ldap: read %q: %v\n", *inputFile, err)
os.Exit(1)
}
var export lldapexport.ExportResult
if err := yaml.Unmarshal(data, &export); err != nil {
fmt.Fprintf(os.Stderr, "lldap-to-ldap: parse YAML: %v\n", err)
os.Exit(1)
}
log := zerolog.New(os.Stderr).With().Timestamp().Logger()
em := telemetry.NewLogEmitter(log)
gen := toldap.New(toldap.Config{
BaseDN: *baseDN,
Target: target,
}, em)
ldif, err := gen.Generate(&export)
if err != nil {
fmt.Fprintf(os.Stderr, "lldap-to-ldap: generate: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(*outputFile, []byte(ldif), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "lldap-to-ldap: write %q: %v\n", *outputFile, err)
os.Exit(1)
}
fmt.Printf("lldap-to-ldap: wrote %s (%d bytes)\n", *outputFile, len(ldif))
}
func parseTarget(s string) (toldap.Target, error) {
switch s {
case "openldap":
return toldap.TargetOpenLDAP, nil
case "389ds":
return toldap.Target389DS, nil
case "ad":
return toldap.TargetAD, nil
default:
return "", fmt.Errorf("unknown target %q: must be one of openldap, 389ds, ad", s)
}
}

69
src/cmd/validator/main.go Normal file
View File

@@ -0,0 +1,69 @@
// validator is the CLI binary for the KeyCape canonical LDAP schema validator.
// It reads a YAML directory snapshot and emits a machine-readable JSON report.
//
// Usage:
//
// validator --mode=ci|provisioning|migration --input=<snapshot.yaml>
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"gopkg.in/yaml.v3"
"keycape/internal/domain"
"keycape/internal/validator"
)
func main() {
mode := flag.String("mode", "ci", "validation mode: ci, provisioning, or migration")
input := flag.String("input", "", "path to YAML directory snapshot (required)")
flag.Parse()
if *input == "" {
fmt.Fprintln(os.Stderr, "error: --input is required")
flag.Usage()
os.Exit(2)
}
m := validator.Mode(*mode)
switch m {
case validator.ModeCI, validator.ModeProvisioning, validator.ModeMigration:
// valid
default:
fmt.Fprintf(os.Stderr, "error: unknown mode %q (must be ci, provisioning, or migration)\n", *mode)
os.Exit(2)
}
data, err := os.ReadFile(*input)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading input: %v\n", err)
os.Exit(1)
}
var dir domain.Directory
if err := yaml.Unmarshal(data, &dir); err != nil {
fmt.Fprintf(os.Stderr, "error parsing YAML: %v\n", err)
os.Exit(1)
}
snap := validator.Snapshot{
Users: dir.Users,
Groups: dir.Groups,
}
report := validator.Validate(snap, m)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
fmt.Fprintf(os.Stderr, "error encoding report: %v\n", err)
os.Exit(1)
}
if !report.Passed {
os.Exit(1)
}
}

19
src/go.mod Normal file
View File

@@ -0,0 +1,19 @@
module keycape
go 1.23.0
require (
github.com/go-ldap/ldap/v3 v3.4.12
github.com/rs/zerolog v1.34.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/sys v0.31.0 // indirect
)

54
src/go.sum Normal file
View File

@@ -0,0 +1,54 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,223 @@
package authelia
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// AutheliaAdapter implements domain.AuthProvider by delegating to Authelia's
// OIDC endpoints. All Authelia tokens and cookies are confined to this package.
type AutheliaAdapter struct {
cfg Config
client HTTPClient
}
// New returns a production-ready AutheliaAdapter.
// If httpClient is nil the default net/http.Client is used.
func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
if httpClient == nil {
httpClient = defaultHTTPClient
}
return &AutheliaAdapter{cfg: cfg, client: httpClient}
}
// ---------------------------------------------------------------------------
// domain.AuthProvider implementation
// ---------------------------------------------------------------------------
// AuthorizeURL builds the Authelia OIDC authorization URL to which the user
// 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) {
base := strings.TrimRight(a.authorizeBaseURL(), "/") + "/api/oidc/authorization"
q := url.Values{}
q.Set("client_id", a.cfg.ClientID)
q.Set("redirect_uri", a.cfg.RedirectURI)
q.Set("response_type", "code")
q.Set("state", req.State)
q.Set("scope", "openid profile email groups")
return base + "?" + q.Encode(), nil
}
// HandleCallback exchanges the authorization code for tokens and extracts the
// authenticated user identity. Authelia tokens are never returned — only the
// normalized AuthResult is.
func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.CallbackParams) (*domain.AuthResult, error) {
emitter := telemetry.EmitterFromContext(ctx)
// Surface callback-level errors from Authelia immediately.
if params.Error != "" {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: params.Error,
})
return nil, domain.ErrAuthFailed
}
// Exchange the authorization code for tokens.
tokenResp, err := a.exchangeCode(ctx, params.Code)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "token_exchange_error",
})
return nil, domain.ErrAuthFailed
}
// Parse the ID token claims (no signature verification — internal service boundary).
claims, err := parseIDTokenClaims(tokenResp.IDToken)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "id_token_parse_error",
})
return nil, domain.ErrAuthFailed
}
// Extract username: prefer preferred_username, fall back to sub.
username := stringClaim(claims, "preferred_username")
if username == "" {
username = stringClaim(claims, "sub")
}
if username == "" {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "missing_username_claim",
})
return nil, domain.ErrAuthFailed
}
// Security boundary: only the ID token claims are forwarded.
// The access_token and refresh_token remain within this adapter.
return &domain.AuthResult{
Username: username,
Claims: claims,
}, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// tokenResponse is the subset of the Authelia token endpoint response that
// this adapter needs. Fields beyond IDToken are intentionally not forwarded.
type tokenResponse struct {
IDToken string `json:"id_token"`
}
// 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.
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", a.cfg.RedirectURI)
body.Set("client_id", a.cfg.ClientID)
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
if err != nil {
return nil, fmt.Errorf("authelia: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("authelia: token exchange: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authelia: token endpoint returned %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("authelia: read token response: %w", err)
}
var tr tokenResponse
if err := json.Unmarshal(raw, &tr); err != nil {
return nil, fmt.Errorf("authelia: decode token response: %w", err)
}
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
// signature. This is intentional — the token is received directly from the
// upstream OIDC provider over a server-to-server TLS connection.
func parseIDTokenClaims(idToken string) (map[string]interface{}, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("authelia: malformed id_token: expected 3 parts, got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("authelia: decode id_token payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("authelia: unmarshal id_token claims: %w", err)
}
return claims, nil
}
// stringClaim extracts a string value from a claims map, returning "" if
// the key is absent or the value is not a string.
func stringClaim(claims map[string]interface{}, key string) string {
v, ok := claims[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

View File

@@ -0,0 +1,385 @@
package authelia_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"keycape/internal/adapters/authelia"
"keycape/internal/domain"
)
// ---------------------------------------------------------------------------
// Mock HTTP client
// ---------------------------------------------------------------------------
// mockHTTPClient implements authelia.HTTPClient for test injection.
type mockHTTPClient struct {
doFn func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if m.doFn != nil {
return m.doFn(req)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("{}")),
}, nil
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// testConfig returns a minimal Config suitable for tests.
func testConfig() authelia.Config {
return authelia.Config{
BaseURL: "https://authelia.local",
ClientID: "keycape",
ClientSecret: "test-secret",
RedirectURI: "https://keycape.local/callback",
}
}
// buildTokenResponse builds a fake token endpoint JSON response.
// The ID token is a minimal unsigned JWT (header.claims.signature) with the given claims.
func buildTokenResponse(claims map[string]interface{}) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
claimsJSON, _ := json.Marshal(claims)
claimsEnc := base64.RawURLEncoding.EncodeToString(claimsJSON)
idToken := header + "." + claimsEnc + ".fakesig"
body := fmt.Sprintf(`{"access_token":"at","token_type":"Bearer","id_token":%q}`,
idToken)
return body
}
// jsonResponse returns a *http.Response with a JSON body and status 200.
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
// ---------------------------------------------------------------------------
// AuthorizeURL
// ---------------------------------------------------------------------------
func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
adapter := authelia.New(testConfig(), &mockHTTPClient{})
// Downstream client values — must NOT appear in the Authelia URL.
req := domain.AuthRequest{
ClientID: "myapp",
RedirectURI: "https://myapp.local/cb",
State: "state-abc",
Nonce: "nonce-xyz",
Scopes: []string{"openid", "profile"},
PKCEChallenge: "challenge123",
PKCEChallengeMethod: "S256",
}
u, err := adapter.AuthorizeURL(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Must use adapter's own client_id and redirect_uri, not the downstream client's.
required := []string{
"client_id=keycape",
"redirect_uri=",
"response_type=code",
"state=state-abc",
"scope=",
"openid",
}
for _, want := range required {
if !strings.Contains(u, want) {
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) {
adapter := authelia.New(testConfig(), &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://authelia.local") {
t.Errorf("expected URL to start with BaseURL, got: %s", u)
}
}
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
// ---------------------------------------------------------------------------
func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-123",
"preferred_username": "alice",
"email": "alice@example.com",
})
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
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
},
}
adapter := authelia.New(testConfig(), client)
result, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "auth-code-xyz",
State: "state-abc",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Username != "alice" {
t.Errorf("Username: want %q, got %q", "alice", result.Username)
}
if result.Claims == nil {
t.Error("expected non-nil Claims map")
}
}
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) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-456",
})
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenBody), nil
},
}
adapter := authelia.New(testConfig(), client)
result, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "code",
State: "state",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Username != "user-uuid-456" {
t.Errorf("Username fallback to sub: want %q, got %q", "user-uuid-456", result.Username)
}
}
// ---------------------------------------------------------------------------
// HandleCallback — error propagation
// ---------------------------------------------------------------------------
func TestHandleCallback_CallbackError_ReturnsErrAuthFailed(t *testing.T) {
adapter := authelia.New(testConfig(), &mockHTTPClient{})
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Error: "access_denied",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
func TestHandleCallback_HTTPError_ReturnsErrAuthFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader(`{"error":"invalid_client"}`)),
}, nil
},
}
adapter := authelia.New(testConfig(), client)
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "bad-code",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
func TestHandleCallback_MissingUsernameClaim_ReturnsErrAuthFailed(t *testing.T) {
// JWT with no sub or preferred_username.
tokenBody := buildTokenResponse(map[string]interface{}{
"email": "anon@example.com",
})
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenBody), nil
},
}
adapter := authelia.New(testConfig(), client)
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "code",
})
if err == nil {
t.Fatal("expected error for missing username claim, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
func TestHandleCallback_TokenExchangeNetworkError_ReturnsErrAuthFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
adapter := authelia.New(testConfig(), client)
_, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{
Code: "code",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err != domain.ErrAuthFailed {
t.Errorf("expected domain.ErrAuthFailed, got %v", err)
}
}
// ---------------------------------------------------------------------------
// Security: AuthResult must not contain raw tokens
// ---------------------------------------------------------------------------
func TestHandleCallback_AuthResultContainsNoRawTokens(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "uid",
"preferred_username": "bob",
})
// Include an access_token in the response to verify it is not forwarded.
fullBody := strings.Replace(tokenBody, `"id_token"`, `"access_token":"raw-at","id_token"`, 1)
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(fullBody), nil
},
}
adapter := authelia.New(testConfig(), client)
result, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Claims must come from the ID token payload, not from the outer token response.
// In particular, "access_token" must not appear as a claim key.
if _, ok := result.Claims["access_token"]; ok {
t.Error("AuthResult.Claims must not expose raw access_token — security boundary violation")
}
}

View File

@@ -0,0 +1,38 @@
// Package authelia implements the domain.AuthProvider interface using Authelia
// as an upstream OIDC provider. Authelia tokens and session cookies are fully
// contained within this package and are never exposed to the server/ layer.
package authelia
import "net/http"
// Config holds all connection parameters for the Authelia adapter.
type Config struct {
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
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 string `yaml:"clientId"`
// ClientSecret is the client secret for the KeyCape client registration.
ClientSecret string `yaml:"clientSecret"`
// RedirectURI is the callback URL registered in Authelia that points back
// to KeyCape's callback handler.
RedirectURI string `yaml:"redirectURI"`
}
// HTTPClient is a minimal interface over net/http.Client for test injection.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// defaultHTTPClient is the production HTTP client used when none is injected.
var defaultHTTPClient HTTPClient = &http.Client{}

View File

@@ -0,0 +1,342 @@
package lldap
import (
"context"
"crypto/tls"
"fmt"
"net/url"
"strings"
"github.com/go-ldap/ldap/v3"
"keycape/internal/domain"
"keycape/internal/validator"
)
// LDAPConn is a minimal interface over an LDAP connection, enabling test injection.
// Only the operations used by the adapter are included; no concrete LDAP types are
// exposed through return values or parameters visible outside this package.
type LDAPConn interface {
Bind(username, password string) error
Search(request *ldap.SearchRequest) (*ldap.SearchResult, error)
Close() error
}
// LDAPAdapter implements domain.UserRepository using an LLDAP backend.
// All LDAP types are confined to this package — the domain and server layers
// are not aware of any LDAP-specific constructs.
type LDAPAdapter struct {
cfg Config
dialFn func(addr string) (LDAPConn, error)
}
// New returns a production-ready LDAPAdapter that dials real LDAP connections.
func New(cfg Config) *LDAPAdapter {
return &LDAPAdapter{
cfg: cfg,
dialFn: defaultDialFn(cfg),
}
}
// NewForTest returns an LDAPAdapter with a custom dial function for test injection.
// Production code should use New instead.
func NewForTest(cfg Config, dialFn func(addr string) (LDAPConn, error)) *LDAPAdapter {
return &LDAPAdapter{cfg: cfg, dialFn: dialFn}
}
// defaultDialFn returns a dial function that establishes a real LDAP connection.
func defaultDialFn(cfg Config) func(addr string) (LDAPConn, error) {
return func(addr string) (LDAPConn, error) {
u, err := url.Parse(cfg.URL)
if err != nil {
return nil, fmt.Errorf("lldap: invalid URL %q: %w", cfg.URL, err)
}
if u.Scheme == "ldaps" {
conn, err := ldap.DialTLS("tcp", addr, &tls.Config{
InsecureSkipVerify: cfg.TLSSkipVerify, //nolint:gosec // dev flag, documented
})
if err != nil {
return nil, fmt.Errorf("lldap: TLS dial %q: %w", addr, err)
}
return conn, nil
}
conn, err := ldap.Dial("tcp", addr)
if err != nil {
return nil, fmt.Errorf("lldap: dial %q: %w", addr, err)
}
return conn, nil
}
}
// dial opens a new LDAP connection and performs the service-account bind.
func (a *LDAPAdapter) dial() (LDAPConn, error) {
u, err := url.Parse(a.cfg.URL)
if err != nil {
return nil, fmt.Errorf("lldap: invalid URL %q: %w", a.cfg.URL, err)
}
host := u.Host
if host == "" {
host = a.cfg.URL // fallback for bare addr passed in tests
}
conn, err := a.dialFn(host)
if err != nil {
return nil, err
}
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPW); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("lldap: service bind failed: %w", err)
}
return conn, nil
}
// ---------------------------------------------------------------------------
// domain.UserRepository implementation
// ---------------------------------------------------------------------------
// LookupUser retrieves the canonical User for the given username.
// Returns domain.ErrUserNotFound when no matching entry exists.
// After mapping LDAP attributes the result is run through the canonical
// LDAP schema validator; a validation failure is returned as an error.
func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.User, error) {
conn, err := a.dial()
if err != nil {
return nil, err
}
defer conn.Close()
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
req := ldap.NewSearchRequest(
a.cfg.userBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
filter,
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
nil,
)
result, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("lldap: search for user %q: %w", username, err)
}
if len(result.Entries) == 0 {
return nil, domain.ErrUserNotFound
}
entry := result.Entries[0]
user := mapEntryToUser(entry)
// 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}}
report := validator.Validate(snap, validator.ModeProvisioning)
if !report.Passed {
if user.LDAPAttributes == nil {
user.LDAPAttributes = make(map[string]string)
}
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
}
return &user, nil
}
// LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to.
func (a *LDAPAdapter) LookupGroups(ctx context.Context, userDN string) ([]domain.Group, error) {
conn, err := a.dial()
if err != nil {
return nil, err
}
defer conn.Close()
// Search for groups that list the user as a member.
filter := fmt.Sprintf("(member=%s)", ldap.EscapeFilter(userDN))
req := ldap.NewSearchRequest(
a.cfg.groupBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
filter,
[]string{"dn", "cn", "description"},
nil,
)
result, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("lldap: group search for DN %q: %w", userDN, err)
}
groups := make([]domain.Group, 0, len(result.Entries))
for _, entry := range result.Entries {
groups = append(groups, domain.Group{
ID: entry.DN,
Name: entry.GetAttributeValue("cn"),
Description: entry.GetAttributeValue("description"),
})
}
return groups, nil
}
// ListUsers returns all user records from the LLDAP directory.
// It performs an LDAP search with filter (objectClass=inetOrgPerson) to list every user,
// then validates each against the canonical LDAP schema.
func (a *LDAPAdapter) ListUsers(ctx context.Context) ([]domain.User, error) {
conn, err := a.dial()
if err != nil {
return nil, err
}
defer conn.Close()
req := ldap.NewSearchRequest(
a.cfg.userBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
"(objectClass=inetOrgPerson)",
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
nil,
)
result, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("lldap: list users search: %w", err)
}
users := make([]domain.User, 0, len(result.Entries))
for _, entry := range result.Entries {
user := mapEntryToUser(entry)
snap := validator.Snapshot{Users: []domain.User{user}}
report := validator.Validate(snap, validator.ModeProvisioning)
if !report.Passed {
// Non-fatal: return the user with a warning embedded in LDAPAttributes.
if user.LDAPAttributes == nil {
user.LDAPAttributes = make(map[string]string)
}
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
}
users = append(users, user)
}
return users, nil
}
// ValidatePassword returns true when the username and password are valid.
// It opens a second connection and attempts a user bind. Bind failure (wrong
// credentials) returns false, nil. Infrastructure errors return false, err.
func (a *LDAPAdapter) ValidatePassword(ctx context.Context, username, password string) (bool, error) {
// First resolve the user DN.
conn, err := a.dial()
if err != nil {
return false, err
}
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
req := ldap.NewSearchRequest(
a.cfg.userBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
filter,
[]string{"dn"},
nil,
)
result, err := conn.Search(req)
conn.Close()
if err != nil {
return false, fmt.Errorf("lldap: DN lookup for user %q: %w", username, err)
}
if len(result.Entries) == 0 {
return false, nil
}
userDN := result.Entries[0].DN
// Attempt a user bind with the provided password using a fresh connection.
host := ldapHost(a.cfg.URL)
userConn, err := a.dialFn(host)
if err != nil {
return false, err
}
defer userConn.Close()
if err := userConn.Bind(userDN, password); err != nil {
// Distinguish authentication failure from infrastructure error.
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
return false, nil
}
return false, fmt.Errorf("lldap: user bind for %q: %w", username, err)
}
return true, nil
}
// ---------------------------------------------------------------------------
// Attribute mapping helpers (LDAP → canonical domain model).
// ---------------------------------------------------------------------------
// mapEntryToUser converts an LDAP entry to a canonical domain.User.
// Attribute mapping per spec:
// - uid → Username
// - cn → DisplayName (sn as fallback)
// - sn → DisplayName fallback if cn is empty
// - mail → Email
// - memberOf → Groups (DNs parsed to group names)
// - dn → ID (stable identifier)
func mapEntryToUser(entry *ldap.Entry) domain.User {
displayName := entry.GetAttributeValue("cn")
if displayName == "" {
displayName = entry.GetAttributeValue("sn")
}
memberOfs := entry.GetAttributeValues("memberOf")
groups := make([]string, 0, len(memberOfs))
for _, dn := range memberOfs {
groups = append(groups, groupNameFromDN(dn))
}
return domain.User{
ID: entry.DN,
Username: entry.GetAttributeValue("uid"),
DisplayName: displayName,
Email: entry.GetAttributeValue("mail"),
Groups: groups,
Enabled: true, // LLDAP does not expose a disabled flag in base schema
}
}
// groupNameFromDN extracts the cn value from an LDAP DN such as
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
// If parsing fails the full DN is returned unchanged.
func groupNameFromDN(dn string) string {
parts := strings.SplitN(dn, ",", 2)
if len(parts) == 0 {
return dn
}
kv := strings.SplitN(parts[0], "=", 2)
if len(kv) == 2 {
return kv[1]
}
return dn
}
// ldapHost extracts host:port from a URL string; falls back to the raw value.
func ldapHost(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return rawURL
}
return u.Host
}
// validationSummary produces a short string summarising all failed rules.
func validationSummary(r validator.Report) string {
var msgs []string
for _, rule := range r.Structural {
if !rule.Passed {
msgs = append(msgs, rule.Message)
}
}
for _, rule := range r.Semantic {
if !rule.Passed {
msgs = append(msgs, rule.Message)
}
}
return strings.Join(msgs, "; ")
}

View File

@@ -0,0 +1,356 @@
package lldap_test
import (
"context"
"errors"
"testing"
"github.com/go-ldap/ldap/v3"
"keycape/internal/adapters/lldap"
"keycape/internal/domain"
)
// ---------------------------------------------------------------------------
// Mock LDAP connection
// ---------------------------------------------------------------------------
// mockConn implements lldap.LDAPConn for test injection.
type mockConn struct {
bindFn func(username, password string) error
searchFn func(req *ldap.SearchRequest) (*ldap.SearchResult, error)
closed bool
}
func (m *mockConn) Bind(username, password string) error {
if m.bindFn != nil {
return m.bindFn(username, password)
}
return nil
}
func (m *mockConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
if m.searchFn != nil {
return m.searchFn(req)
}
return &ldap.SearchResult{}, nil
}
func (m *mockConn) Close() error {
m.closed = true
return nil
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// testConfig returns a minimal Config suitable for tests.
func testConfig() lldap.Config {
return lldap.Config{
URL: "ldap://lldap:389",
BindDN: "cn=admin,dc=netkingdom,dc=local",
BindPW: "secret",
BaseDN: "dc=netkingdom,dc=local",
}
}
// singleEntryResult builds a SearchResult with one entry for LookupUser tests.
func singleEntryResult(dn, uid, cn, sn, mail string, memberOfs []string) *ldap.SearchResult {
attrs := []*ldap.EntryAttribute{
{Name: "uid", Values: []string{uid}},
{Name: "cn", Values: []string{cn}},
{Name: "sn", Values: []string{sn}},
{Name: "mail", Values: []string{mail}},
}
if len(memberOfs) > 0 {
attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: memberOfs})
}
return &ldap.SearchResult{
Entries: []*ldap.Entry{
{DN: dn, Attributes: attrs},
},
}
}
// makeAdapter returns an LDAPAdapter using the exported NewForTest constructor.
// We use the package-level helper exported for testing.
func makeAdapter(cfg lldap.Config, conn lldap.LDAPConn) *lldap.LDAPAdapter {
return lldap.NewForTest(cfg, func(_ string) (lldap.LDAPConn, error) {
return conn, nil
})
}
// ---------------------------------------------------------------------------
// LookupUser
// ---------------------------------------------------------------------------
func TestLookupUser_Success(t *testing.T) {
dn := "uid=alice,ou=users,dc=netkingdom,dc=local"
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
return singleEntryResult(
dn, "alice", "Alice Liddell", "Liddell", "alice@example.com",
[]string{"cn=admins,ou=groups,dc=netkingdom,dc=local"},
), nil
},
}
adapter := makeAdapter(testConfig(), conn)
user, err := adapter.LookupUser(context.Background(), "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Username != "alice" {
t.Errorf("Username: want %q, got %q", "alice", user.Username)
}
if user.DisplayName != "Alice Liddell" {
t.Errorf("DisplayName: want %q, got %q", "Alice Liddell", user.DisplayName)
}
if user.Email != "alice@example.com" {
t.Errorf("Email: want %q, got %q", "alice@example.com", user.Email)
}
if user.ID != dn {
t.Errorf("ID: want %q, got %q", dn, user.ID)
}
if len(user.Groups) != 1 || user.Groups[0] != "admins" {
t.Errorf("Groups: want [admins], got %v", user.Groups)
}
}
func TestLookupUser_DisplayName_FallsBackToSN(t *testing.T) {
dn := "uid=bob,ou=users,dc=netkingdom,dc=local"
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
return singleEntryResult(dn, "bob", "", "Builder", "bob@example.com", nil), nil
},
}
adapter := makeAdapter(testConfig(), conn)
user, err := adapter.LookupUser(context.Background(), "bob")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.DisplayName != "Builder" {
t.Errorf("DisplayName fallback: want %q, got %q", "Builder", user.DisplayName)
}
}
func TestLookupUser_NotFound(t *testing.T) {
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
return &ldap.SearchResult{}, nil // zero entries
},
}
adapter := makeAdapter(testConfig(), conn)
_, err := adapter.LookupUser(context.Background(), "ghost")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, domain.ErrUserNotFound) {
t.Errorf("expected domain.ErrUserNotFound, got %v", err)
}
}
func TestLookupUser_ValidationWarningDoesNotBlockRuntimeLogin(t *testing.T) {
// Return an entry with an empty DisplayName and empty sn. Runtime login
// should still resolve the user; provisioning validators report the warning.
dn := "uid=platform-root,ou=people,dc=netkingdom,dc=local"
conn := &mockConn{
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{
{Name: "uid", Values: []string{"platform-root"}},
{Name: "cn", Values: []string{""}},
{Name: "sn", Values: []string{""}},
{Name: "mail", Values: []string{"bernd.worsch@gmail.com"}},
}
return &ldap.SearchResult{
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
}, nil
},
}
cfg := testConfig()
cfg.UserOU = "ou=people"
adapter := makeAdapter(cfg, conn)
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")
}
}
// ---------------------------------------------------------------------------
// LookupGroups
// ---------------------------------------------------------------------------
func TestLookupGroups_Success(t *testing.T) {
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
return &ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "cn=admins,ou=groups,dc=netkingdom,dc=local",
Attributes: []*ldap.EntryAttribute{
{Name: "cn", Values: []string{"admins"}},
{Name: "description", Values: []string{"Admins group"}},
},
},
},
}, nil
},
}
adapter := makeAdapter(testConfig(), conn)
groups, err := adapter.LookupGroups(context.Background(), userDN)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(groups) != 1 {
t.Fatalf("want 1 group, got %d", len(groups))
}
if groups[0].Name != "admins" {
t.Errorf("Group name: want %q, got %q", "admins", groups[0].Name)
}
}
func TestLookupGroups_Empty(t *testing.T) {
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
return &ldap.SearchResult{}, nil
},
}
adapter := makeAdapter(testConfig(), conn)
groups, err := adapter.LookupGroups(context.Background(), "uid=nobody,ou=users,dc=test,dc=local")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(groups) != 0 {
t.Errorf("expected 0 groups, got %d", len(groups))
}
}
// ---------------------------------------------------------------------------
// ValidatePassword
// ---------------------------------------------------------------------------
func TestValidatePassword_Success(t *testing.T) {
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
callCount := 0
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}}
return &ldap.SearchResult{
Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}},
}, nil
},
bindFn: func(username, password string) error {
callCount++
// First call: service bind (BindDN); second call: user bind.
return nil
},
}
// Provide two connections: one for the DN lookup and one for the user bind.
connIdx := 0
conns := []*mockConn{conn, {bindFn: func(u, p string) error { return nil }}}
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
c := conns[connIdx]
connIdx++
return c, nil
})
ok, err := adapter.ValidatePassword(context.Background(), "alice", "correct")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Error("expected ValidatePassword to return true")
}
}
func TestValidatePassword_WrongPassword(t *testing.T) {
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
searchConn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}}
return &ldap.SearchResult{
Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}},
}, nil
},
}
userConn := &mockConn{
bindFn: func(username, password string) error {
return ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials"))
},
}
connIdx := 0
conns := []lldap.LDAPConn{searchConn, userConn}
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
c := conns[connIdx]
connIdx++
return c, nil
})
ok, err := adapter.ValidatePassword(context.Background(), "alice", "wrong")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Error("expected ValidatePassword to return false for wrong password")
}
}
func TestValidatePassword_BindFailure(t *testing.T) {
// Service bind fails — infrastructure error.
conn := &mockConn{
bindFn: func(username, password string) error {
return errors.New("connection refused")
},
}
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
return conn, nil
})
ok, err := adapter.ValidatePassword(context.Background(), "alice", "pass")
if err == nil {
t.Fatal("expected infrastructure error, got nil")
}
if ok {
t.Error("expected false on bind failure")
}
}
func TestValidatePassword_UserNotFound(t *testing.T) {
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
return &ldap.SearchResult{}, nil // no entries
},
}
adapter := makeAdapter(testConfig(), conn)
ok, err := adapter.ValidatePassword(context.Background(), "ghost", "pass")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Error("expected false for non-existent user")
}
}

View File

@@ -0,0 +1,55 @@
// Package lldap implements the UserRepository adapter for LLDAP (Lightweight LDAP).
// No LDAP types are exposed beyond this package — the domain and server layers
// interact exclusively through the domain.UserRepository interface.
package lldap
// Config holds all connection parameters for the LLDAP adapter.
type Config struct {
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636".
URL string `yaml:"url"`
// BindDN is the distinguished name used for the service account bind,
// e.g. "cn=admin,dc=netkingdom,dc=local".
BindDN string `yaml:"bindDN"`
// BindPW is the service account password.
BindPW string `yaml:"bindPW"`
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
BaseDN string `yaml:"baseDN"`
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
UserOU string `yaml:"userOU,omitempty"`
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
GroupOU string `yaml:"groupOU,omitempty"`
// TLSSkipVerify disables TLS certificate verification. For development only.
TLSSkipVerify bool `yaml:"tlsSkipVerify,omitempty"`
}
// userOU returns the effective UserOU, falling back to the default.
func (c Config) userOU() string {
if c.UserOU != "" {
return c.UserOU
}
return "ou=users"
}
// groupOU returns the effective GroupOU, falling back to the default.
func (c Config) groupOU() string {
if c.GroupOU != "" {
return c.GroupOU
}
return "ou=groups"
}
// userBaseDN returns the full DN for the user search base.
func (c Config) userBaseDN() string {
return c.userOU() + "," + c.BaseDN
}
// groupBaseDN returns the full DN for the group search base.
func (c Config) groupBaseDN() string {
return c.groupOU() + "," + c.BaseDN
}

View File

@@ -0,0 +1,157 @@
package privacyidea
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"keycape/internal/domain"
)
// PrivacyIDEAAdapter implements domain.MFAProvider by delegating to privacyIDEA's
// REST API. No MFA logic is implemented here — every decision is owned by
// privacyIDEA. The adapter fails closed: any infrastructure error is returned
// as a non-nil error so the caller cannot proceed without a definitive answer.
type PrivacyIDEAAdapter struct {
cfg Config
client HTTPClient
}
// New returns a production-ready PrivacyIDEAAdapter.
// If httpClient is nil the default net/http.Client is used.
func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter {
if httpClient == nil {
httpClient = defaultHTTPClient
}
return &PrivacyIDEAAdapter{cfg: cfg, client: httpClient}
}
// ---------------------------------------------------------------------------
// domain.MFAProvider implementation
// ---------------------------------------------------------------------------
// CheckMFARequired returns true if the user has at least one active MFA token
// registered in privacyIDEA. Fails closed: any infrastructure error returns
// (false, err) so callers cannot bypass the check.
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/"
q := url.Values{}
q.Set("user", userID)
q.Set("realm", a.cfg.realm())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil)
if err != nil {
return false, fmt.Errorf("privacyidea: build token list request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken)
resp, err := a.client.Do(req)
if err != nil {
return false, fmt.Errorf("privacyidea: token list request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("privacyidea: token list returned status %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("privacyidea: read token list response: %w", err)
}
var parsed tokenListResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return false, fmt.Errorf("privacyidea: decode token list response: %w", err)
}
for _, tok := range parsed.Result.Value.Tokens {
if tok.Active {
return true, nil
}
}
return false, nil
}
// ValidateMFAToken validates the given OTP token for the user via privacyIDEA's
// /validate/check endpoint. Returns nil on success, domain.ErrMFAFailed if the
// token is invalid, and a wrapped infrastructure error on any network/HTTP failure.
// Fails closed: infrastructure errors are surfaced, not swallowed.
func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token string) error {
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/validate/check"
form := url.Values{}
form.Set("user", userID)
form.Set("pass", token)
form.Set("realm", a.cfg.realm())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("privacyidea: build validate request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken)
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("privacyidea: validate request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("privacyidea: validate endpoint returned status %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("privacyidea: read validate response: %w", err)
}
var parsed validateResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return fmt.Errorf("privacyidea: decode validate response: %w", err)
}
if !parsed.Result.Value {
return domain.ErrMFAFailed
}
return nil
}
// ---------------------------------------------------------------------------
// JSON response types (internal to this package)
// ---------------------------------------------------------------------------
// tokenListResponse models the privacyIDEA GET /token/ response envelope.
type tokenListResponse struct {
Result struct {
Status bool `json:"status"`
Value struct {
Tokens []tokenEntry `json:"tokens"`
} `json:"value"`
} `json:"result"`
}
// tokenEntry represents a single token entry in the token list response.
type tokenEntry struct {
Serial string `json:"serial"`
Active bool `json:"active"`
}
// validateResponse models the privacyIDEA POST /validate/check response envelope.
type validateResponse struct {
Result struct {
Status bool `json:"status"`
Value bool `json:"value"`
} `json:"result"`
}

View File

@@ -0,0 +1,330 @@
package privacyidea_test
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"keycape/internal/adapters/privacyidea"
"keycape/internal/domain"
)
// ---------------------------------------------------------------------------
// Mock HTTP client
// ---------------------------------------------------------------------------
// mockHTTPClient implements privacyidea.HTTPClient for test injection.
type mockHTTPClient struct {
doFn func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if m.doFn != nil {
return m.doFn(req)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("{}")),
}, nil
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// testConfig returns a minimal Config suitable for tests.
func testConfig() privacyidea.Config {
return privacyidea.Config{
BaseURL: "https://privacyidea.local",
AdminToken: "service-jwt",
Realm: "netkingdom",
}
}
// jsonResponse returns a *http.Response with a JSON body and status 200.
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
// tokenListResponse builds a privacyIDEA /token/ JSON response.
func tokenListResponse(tokens []map[string]interface{}) string {
tokenJSON := "["
for i, t := range tokens {
if i > 0 {
tokenJSON += ","
}
active, _ := t["active"].(bool)
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active)
}
tokenJSON += "]"
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s}}}`, tokenJSON)
}
// validateResponse builds a privacyIDEA /validate/check JSON response.
func validateResponse(success bool) string {
return fmt.Sprintf(`{"result":{"status":true,"value":%v}}`, success)
}
// ---------------------------------------------------------------------------
// CheckMFARequired — tokens present
// ---------------------------------------------------------------------------
func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
t.Errorf("expected GET, got %s", req.Method)
}
if !strings.Contains(req.URL.String(), "alice") {
t.Errorf("expected user in URL, got: %s", req.URL)
}
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": true},
})), nil
},
}
adapter := privacyidea.New(testConfig(), 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 active token present")
}
}
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) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": false},
})), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "bob")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if required {
t.Error("expected MFA required=false when only inactive tokens present")
}
}
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "charlie")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if required {
t.Error("expected MFA required=false when no tokens")
}
}
// ---------------------------------------------------------------------------
// CheckMFARequired — error cases (fail closed)
// ---------------------------------------------------------------------------
func TestCheckMFARequired_HTTPError_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
adapter := privacyidea.New(testConfig(), client)
_, err := adapter.CheckMFARequired(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
}
}
func TestCheckMFARequired_Non200Status_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(`{"result":{"status":false}}`)),
}, nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, err := adapter.CheckMFARequired(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
}
}
// ---------------------------------------------------------------------------
// CheckMFARequired — Authorization header
// ---------------------------------------------------------------------------
func TestCheckMFARequired_SendsAdminToken(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
auth := req.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("expected Bearer token in Authorization, got %q", auth)
}
if !strings.Contains(auth, "service-jwt") {
t.Errorf("expected admin token in Authorization header, got %q", auth)
}
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — success
// ---------------------------------------------------------------------------
func TestValidateMFAToken_ValidOTP_ReturnsNil(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Errorf("expected POST, got %s", req.Method)
}
body, _ := io.ReadAll(req.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "alice") {
t.Errorf("expected user in POST body, got: %s", bodyStr)
}
if !strings.Contains(bodyStr, "123456") {
t.Errorf("expected OTP in POST body, got: %s", bodyStr)
}
return jsonResponse(validateResponse(true)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err != nil {
t.Errorf("expected nil error for valid OTP, got %v", err)
}
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — failure
// ---------------------------------------------------------------------------
func TestValidateMFAToken_InvalidOTP_ReturnsErrMFAFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(validateResponse(false)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "wrong")
if err == nil {
t.Fatal("expected ErrMFAFailed, got nil")
}
if err != domain.ErrMFAFailed {
t.Errorf("expected domain.ErrMFAFailed, got %v", err)
}
}
func TestValidateMFAToken_HTTPError_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("network failure")
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err == nil {
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
}
}
func TestValidateMFAToken_Non200Status_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader(`{}`)),
}, nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err == nil {
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
}
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — realm is included in request
// ---------------------------------------------------------------------------
func TestValidateMFAToken_IncludesRealm(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
if !strings.Contains(string(body), "netkingdom") {
t.Errorf("expected realm in POST body, got: %s", body)
}
return jsonResponse(validateResponse(true)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_ = adapter.ValidateMFAToken(context.Background(), "alice", "000000")
}
func TestCheckMFARequired_IncludesRealm(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.String(), "netkingdom") {
t.Errorf("expected realm in request URL, got: %s", req.URL)
}
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
}

View File

@@ -0,0 +1,41 @@
// Package privacyidea implements the domain.MFAProvider interface by delegating
// all MFA decisions to a privacyIDEA server. KeyCape contains no MFA logic —
// every check and validation call is forwarded verbatim to privacyIDEA.
package privacyidea
import "net/http"
// Config holds all connection parameters for the privacyIDEA adapter.
type Config struct {
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
BaseURL string `yaml:"baseURL"`
// AdminToken is the service-account JWT used to authenticate requests to the
// privacyIDEA admin API.
AdminToken string `yaml:"adminToken"`
// Realm is the privacyIDEA realm to scope token and validate requests.
// Defaults to "netkingdom" when empty.
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".
func (c Config) realm() string {
if c.Realm != "" {
return c.Realm
}
return "netkingdom"
}
// HTTPClient is a minimal interface over net/http.Client for test injection.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// defaultHTTPClient is the production HTTP client used when none is injected.
var defaultHTTPClient HTTPClient = &http.Client{}

View File

@@ -0,0 +1,63 @@
// Package config handles loading and validating the KeyCape server configuration
// from a YAML file. The config path is resolved from the --config flag or the
// KEYCAPE_CONFIG environment variable.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
)
// Config is the top-level server configuration.
type Config struct {
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
}
// ClientConfig is a static OIDC client registration.
type ClientConfig struct {
ClientID string `yaml:"clientId"`
DisplayName string `yaml:"displayName"`
RedirectURIs []string `yaml:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes"`
GrantTypes []string `yaml:"grantTypes"`
ClientType string `yaml:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty"`
}
// Load reads and parses the YAML config file at path.
// If path is empty, it falls back to the KEYCAPE_CONFIG environment variable.
// Returns an error if the file cannot be read or parsed.
func Load(path string) (*Config, error) {
if path == "" {
path = os.Getenv("KEYCAPE_CONFIG")
}
if path == "" {
return nil, fmt.Errorf("config: no config path specified (use --config or KEYCAPE_CONFIG)")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse %q: %w", path, err)
}
return &cfg, nil
}

View File

@@ -0,0 +1,341 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"keycape/internal/config"
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// writeTempFile creates a temporary file with the given content and returns its path.
func writeTempFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "keycape-test-*")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatalf("write temp file: %v", err)
}
f.Close()
return f.Name()
}
// validConfig returns a minimal valid Config for use in tests.
func validConfig(keyPath string) *config.Config {
return &config.Config{
Issuer: "https://auth.example.com",
Port: 8080,
TokenLifetime: "15m",
PrivateKeyPEM: keyPath,
Environment: "dev",
Clients: []config.ClientConfig{
{
ClientID: "test-app",
DisplayName: "Test App",
RedirectURIs: []string{"https://app.example.com/callback"},
ClientType: "public",
},
},
}
}
// ---------------------------------------------------------------------------
// Load tests
// ---------------------------------------------------------------------------
func TestLoad_ValidYAML(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://auth.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
clients:
- clientId: "demo"
displayName: "Demo"
redirectUris:
- "https://demo.example.com/cb"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if cfg.Issuer != "https://auth.example.com" {
t.Errorf("Issuer: want %q, got %q", "https://auth.example.com", cfg.Issuer)
}
if cfg.Port != 8080 {
t.Errorf("Port: want 8080, got %d", cfg.Port)
}
if len(cfg.Clients) != 1 {
t.Errorf("Clients: want 1, got %d", len(cfg.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) {
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
if err == nil {
t.Error("Load: expected error for missing file, got nil")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
bad := writeTempFile(t, "not: valid: yaml: [[[")
_, err := config.Load(bad)
if err == nil {
t.Error("Load: expected error for invalid YAML, got nil")
}
}
// ---------------------------------------------------------------------------
// Validate tests
// ---------------------------------------------------------------------------
func TestValidate_ValidConfig(t *testing.T) {
keyPath := writeTempFile(t, "key")
errs := config.ValidateConfig(validConfig(keyPath))
if len(errs) != 0 {
t.Errorf("ValidateConfig: expected no errors, got %v", errs)
}
}
func TestValidate_MissingIssuer(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Issuer = ""
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "issuer") {
t.Errorf("expected issuer error, got %v", errs)
}
}
func TestValidate_InvalidIssuerURL(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Issuer = "not a url"
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "issuer") {
t.Errorf("expected issuer URL error, got %v", errs)
}
}
func TestValidate_PortZero(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Port = 0
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "port") {
t.Errorf("expected port error, got %v", errs)
}
}
func TestValidate_PortTooHigh(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Port = 70000
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "port") {
t.Errorf("expected port error, got %v", errs)
}
}
func TestValidate_NoClients(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients = nil
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "client") {
t.Errorf("expected client error, got %v", errs)
}
}
func TestValidate_ClientMissingRedirectURI(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients[0].RedirectURIs = nil
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "redirect") {
t.Errorf("expected redirect_uri error, got %v", errs)
}
}
func TestValidate_MissingPrivateKeyPEM(t *testing.T) {
cfg := validConfig("")
cfg.PrivateKeyPEM = ""
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "privateKeyPem") {
t.Errorf("expected privateKeyPem error, got %v", errs)
}
}
// ---------------------------------------------------------------------------
// Env var loading test
// ---------------------------------------------------------------------------
func TestLoad_FromEnvVar(t *testing.T) {
keyPath := writeTempFile(t, "key")
yaml := `
issuer: "https://auth.example.com"
port: 9090
tokenLifetime: "30m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
clients:
- clientId: "env-app"
displayName: "Env App"
redirectUris:
- "https://env.example.com/cb"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
t.Setenv("KEYCAPE_CONFIG", cfgPath)
// Load with empty path triggers env var lookup.
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load with env var: %v", err)
}
if cfg.Port != 9090 {
t.Errorf("Port: want 9090, got %d", cfg.Port)
}
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
func containsErr(errs []string, substring string) bool {
for _, e := range errs {
for i := 0; i <= len(e)-len(substring); i++ {
if e[i:i+len(substring)] == substring {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,61 @@
package config
import (
"fmt"
"net/url"
"strings"
)
// ValidateConfig validates a loaded Config and returns a list of human-readable
// error messages. An empty slice means the config is valid.
// Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string {
var errs []string
// Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" {
errs = append(errs, "issuer: must not be empty")
} else {
u, err := url.Parse(cfg.Issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
errs = append(errs, fmt.Sprintf("issuer: %q is not a valid URL (must include scheme and host)", cfg.Issuer))
} else if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, fmt.Sprintf("issuer: scheme must be http or https, got %q", u.Scheme))
}
}
// Port must be in the valid TCP range.
if cfg.Port < 1 || cfg.Port > 65535 {
errs = append(errs, fmt.Sprintf("port: must be between 1 and 65535, got %d", cfg.Port))
}
// At least one client must be registered.
if len(cfg.Clients) == 0 {
errs = append(errs, "clients: at least one client must be defined")
}
// Each client must have at least one redirect URI and a non-empty clientId.
for i, c := range cfg.Clients {
prefix := fmt.Sprintf("clients[%d] (%s)", i, c.ClientID)
if c.ClientID == "" {
prefix = fmt.Sprintf("clients[%d]", i)
errs = append(errs, prefix+": clientId must not be empty")
}
if len(c.RedirectURIs) == 0 {
errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered")
}
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
for _, uri := range c.RedirectURIs {
if strings.ContainsAny(uri, "*?") {
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
}
}
}
// Private key PEM path must be provided (existence is checked at startup).
if cfg.PrivateKeyPEM == "" {
errs = append(errs, "privateKeyPem: path must not be empty")
}
return errs
}

View File

@@ -0,0 +1,45 @@
package domain
import (
"context"
"errors"
)
// AuthProvider handles login UI delegation and session management.
// The server layer uses only this interface — no Authelia types leak out.
type AuthProvider interface {
// AuthorizeURL returns the URL to redirect the user to for login.
AuthorizeURL(ctx context.Context, req AuthRequest) (string, error)
// HandleCallback extracts the authenticated user identity from a callback request.
// Returns ErrAuthFailed if authentication was not successful.
HandleCallback(ctx context.Context, callbackParams CallbackParams) (*AuthResult, error)
}
// AuthRequest contains the parameters for initiating an auth flow.
type AuthRequest struct {
ClientID string
RedirectURI string
State string
Nonce string
Scopes []string
PKCEChallenge string
PKCEChallengeMethod string
}
// CallbackParams are the query params received on the redirect callback.
type CallbackParams struct {
Code string
State string
Error string
}
// AuthResult is the normalized identity returned after successful authentication.
type AuthResult struct {
Username string
// Raw identity claims from the backend (not exposed to OIDC layer directly)
Claims map[string]interface{}
}
// ErrAuthFailed is returned by AuthProvider.HandleCallback when authentication was not successful.
var ErrAuthFailed = errors.New("authentication failed")

View File

@@ -0,0 +1,23 @@
package domain
import (
"context"
"errors"
)
// MFAProvider checks MFA requirements and validates MFA tokens.
// KeyCape must NOT implement MFA logic — it delegates entirely to this interface.
type MFAProvider interface {
// CheckMFARequired returns true if MFA is required for the given user.
CheckMFARequired(ctx context.Context, userID string) (bool, error)
// ValidateMFAToken validates the given OTP token for the user.
// Returns ErrMFAFailed if the token is invalid or expired.
ValidateMFAToken(ctx context.Context, userID, token string) error
}
// ErrMFAFailed is returned when the MFA token is invalid or expired.
var ErrMFAFailed = errors.New("mfa validation failed")
// ErrMFANotEnrolled is returned when the user has no MFA enrollment.
var ErrMFANotEnrolled = errors.New("user has no MFA enrollment")

View File

@@ -0,0 +1,68 @@
// Package domain contains the canonical identity model for KeyCape.
// This is the source of truth for all user, group, client, and MFA data.
// All provisioning, tests, and migrations derive from these types.
package domain
import "time"
// User is the canonical identity entity — source of truth for all user data.
type User struct {
ID string `yaml:"id" json:"id"`
Username string `yaml:"username" json:"username"`
DisplayName string `yaml:"displayName" json:"displayName"`
Email string `yaml:"email" json:"email"`
Enabled bool `yaml:"enabled" json:"enabled"`
Groups []string `yaml:"groups" json:"groups"`
Roles []string `yaml:"roles" json:"roles"`
MFAEnrollment *MFAEnrollment `yaml:"mfaEnrollment,omitempty" json:"mfaEnrollment,omitempty"`
LDAPAttributes map[string]string `yaml:"ldapAttributes,omitempty" json:"ldapAttributes,omitempty"`
}
// Group is a named collection of users.
type Group struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Members []string `yaml:"members" json:"members"`
}
// Role is a named permission set.
type Role struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
}
// Client is a registered OIDC client (static in v0.1 — no dynamic registration).
type Client struct {
ClientID string `yaml:"clientId" json:"clientId"`
DisplayName string `yaml:"displayName" json:"displayName"`
RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"`
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
}
// Membership links a user to a group.
type Membership struct {
UserID string `yaml:"userId" json:"userId"`
GroupID string `yaml:"groupId" json:"groupId"`
}
// MFAEnrollment records that a user has enrolled MFA via privacyIDEA.
type MFAEnrollment struct {
UserID string `yaml:"userId" json:"userId"`
Provider string `yaml:"provider" json:"provider"` // "privacyidea"
State string `yaml:"state" json:"state"` // "enabled" | "disabled" | "pending"
EnrolledAt time.Time `yaml:"enrolledAt,omitempty" json:"enrolledAt,omitempty"`
}
// Directory is the full canonical identity directory snapshot.
// Used for provisioning, validation, and migration operations.
type Directory struct {
Users []User `yaml:"users" json:"users"`
Groups []Group `yaml:"groups" json:"groups"`
Roles []Role `yaml:"roles" json:"roles"`
Clients []Client `yaml:"clients" json:"clients"`
}

View File

@@ -0,0 +1,31 @@
package domain
import "context"
// UserRepository is the adapter interface between the OIDC layer and the identity directory.
// The server/ layer sees ONLY this interface — no LDAP types leak through.
type UserRepository interface {
// LookupUser retrieves the canonical User record for the given username.
// Returns an error wrapping ErrUserNotFound when the user does not exist.
LookupUser(ctx context.Context, username string) (*User, error)
// LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to.
LookupGroups(ctx context.Context, userDN string) ([]Group, error)
// ValidatePassword returns true when the username and password are correct.
// Returns false (not an error) for wrong credentials; errors indicate
// infrastructure failures (network, config, etc.).
ValidatePassword(ctx context.Context, username, password string) (bool, error)
// ListUsers returns all user records from the directory.
// Used by migration and export tooling; not required for the OIDC flow.
ListUsers(ctx context.Context) ([]User, error)
}
// ErrUserNotFound is returned by UserRepository.LookupUser when the
// requested user does not exist in the directory.
const ErrUserNotFound = userNotFound("user not found")
type userNotFound string
func (e userNotFound) Error() string { return string(e) }

View File

@@ -0,0 +1,85 @@
// Package errors implements the KeyCape error taxonomy from spec §5.
// All profile errors are structured and machine-readable.
// Errors MUST NOT be silent — every unsupported or misused feature returns a typed error.
package errors
import (
"encoding/json"
"net/http"
)
// ErrorType is a stable string identifier for profile error categories.
type ErrorType string
const (
// ErrFeatureNotSupported is returned when a feature is outside the NetKingdom IAM Profile.
ErrFeatureNotSupported ErrorType = "feature_not_supported_by_profile"
// ErrKeycloakModeOnly is returned when a feature exists only in expanded (Keycloak) mode.
ErrKeycloakModeOnly ErrorType = "available_in_keycloak_mode_only"
// ErrRejectedForSafety is returned when a feature is intentionally blocked for security reasons.
ErrRejectedForSafety ErrorType = "rejected_for_profile_safety"
// ErrInvalidProfileUsage is returned when a supported endpoint/feature is used incorrectly.
ErrInvalidProfileUsage ErrorType = "invalid_profile_usage"
)
// ProfileError is a structured error per spec §5.2.
// JSON format: {"error": "...", "description": "...", "feature": "..."}
type ProfileError struct {
Error ErrorType `json:"error"`
Description string `json:"description"`
Feature string `json:"feature,omitempty"`
}
// Write writes the error as JSON with the given HTTP status code.
func (e *ProfileError) Write(w http.ResponseWriter, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(e)
}
// GoError implements the standard error interface.
func (e *ProfileError) GoError() string {
if e.Feature != "" {
return string(e.Error) + ": " + e.Description + " [feature=" + e.Feature + "]"
}
return string(e.Error) + ": " + e.Description
}
// FeatureNotSupported constructs a feature_not_supported_by_profile error.
func FeatureNotSupported(description, feature string) *ProfileError {
return &ProfileError{
Error: ErrFeatureNotSupported,
Description: description,
Feature: feature,
}
}
// KeycloakModeOnly constructs an available_in_keycloak_mode_only error.
func KeycloakModeOnly(description, feature string) *ProfileError {
return &ProfileError{
Error: ErrKeycloakModeOnly,
Description: description,
Feature: feature,
}
}
// RejectedForSafety constructs a rejected_for_profile_safety error.
func RejectedForSafety(description, feature string) *ProfileError {
return &ProfileError{
Error: ErrRejectedForSafety,
Description: description,
Feature: feature,
}
}
// InvalidProfileUsage constructs an invalid_profile_usage error.
func InvalidProfileUsage(description, feature string) *ProfileError {
return &ProfileError{
Error: ErrInvalidProfileUsage,
Description: description,
Feature: feature,
}
}

View File

@@ -0,0 +1,141 @@
package errors_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
profileerrors "keycape/internal/errors"
)
func TestErrorTypeConstants(t *testing.T) {
tests := []struct {
name string
errType profileerrors.ErrorType
expected string
}{
{"FeatureNotSupported", profileerrors.ErrFeatureNotSupported, "feature_not_supported_by_profile"},
{"KeycloakModeOnly", profileerrors.ErrKeycloakModeOnly, "available_in_keycloak_mode_only"},
{"RejectedForSafety", profileerrors.ErrRejectedForSafety, "rejected_for_profile_safety"},
{"InvalidProfileUsage", profileerrors.ErrInvalidProfileUsage, "invalid_profile_usage"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.errType) != tt.expected {
t.Errorf("got %q, want %q", tt.errType, tt.expected)
}
})
}
}
func TestConstructorHelpers(t *testing.T) {
t.Run("FeatureNotSupported", func(t *testing.T) {
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
if e.Error != profileerrors.ErrFeatureNotSupported {
t.Errorf("wrong error type: %v", e.Error)
}
if e.Description != "dynamic registration is not allowed" {
t.Errorf("wrong description: %v", e.Description)
}
if e.Feature != "dynamic_client_registration" {
t.Errorf("wrong feature: %v", e.Feature)
}
})
t.Run("KeycloakModeOnly", func(t *testing.T) {
e := profileerrors.KeycloakModeOnly("identity broker requires expanded mode", "identity_broker")
if e.Error != profileerrors.ErrKeycloakModeOnly {
t.Errorf("wrong error type: %v", e.Error)
}
if e.Feature != "identity_broker" {
t.Errorf("wrong feature: %v", e.Feature)
}
})
t.Run("RejectedForSafety", func(t *testing.T) {
e := profileerrors.RejectedForSafety("wildcard redirect URIs weaken security", "wildcard_redirect_uri")
if e.Error != profileerrors.ErrRejectedForSafety {
t.Errorf("wrong error type: %v", e.Error)
}
if e.Feature != "wildcard_redirect_uri" {
t.Errorf("wrong feature: %v", e.Feature)
}
})
t.Run("InvalidProfileUsage", func(t *testing.T) {
e := profileerrors.InvalidProfileUsage("PKCE code_challenge is required", "missing_pkce")
if e.Error != profileerrors.ErrInvalidProfileUsage {
t.Errorf("wrong error type: %v", e.Error)
}
if e.Feature != "missing_pkce" {
t.Errorf("wrong feature: %v", e.Feature)
}
})
}
func TestProfileErrorJSON(t *testing.T) {
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
data, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
s := string(data)
if !strings.Contains(s, `"error":"feature_not_supported_by_profile"`) {
t.Errorf("missing error field: %s", s)
}
if !strings.Contains(s, `"description":"dynamic registration is not allowed"`) {
t.Errorf("missing description field: %s", s)
}
if !strings.Contains(s, `"feature":"dynamic_client_registration"`) {
t.Errorf("missing feature field: %s", s)
}
}
func TestProfileErrorOmitsFeatureWhenEmpty(t *testing.T) {
e := &profileerrors.ProfileError{
Error: profileerrors.ErrInvalidProfileUsage,
Description: "bad request",
}
data, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
if strings.Contains(string(data), `"feature"`) {
t.Errorf("feature field should be omitted when empty: %s", data)
}
}
func TestProfileErrorWrite(t *testing.T) {
e := profileerrors.FeatureNotSupported("not supported", "some_feature")
rr := httptest.NewRecorder()
e.Write(rr, http.StatusBadRequest)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", rr.Code)
}
ct := rr.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
body := rr.Body.String()
if !strings.Contains(body, "feature_not_supported_by_profile") {
t.Errorf("body missing error type: %s", body)
}
}
func TestProfileErrorGoError(t *testing.T) {
e := profileerrors.FeatureNotSupported("desc", "feat")
s := e.GoError()
if !strings.Contains(s, "feature_not_supported_by_profile") {
t.Errorf("GoError missing error type: %s", s)
}
if !strings.Contains(s, "desc") {
t.Errorf("GoError missing description: %s", s)
}
if !strings.Contains(s, "feat") {
t.Errorf("GoError missing feature: %s", s)
}
}

View File

@@ -0,0 +1,138 @@
// Package lldapexport implements the LLDAP → canonical export tool (spec §7 — migration contract).
// It reads all users and groups from the LLDAP directory via a UserRepository, validates each
// entry against the canonical LDAP schema, and writes a canonical-export.yaml snapshot.
package lldapexport
import (
"context"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
)
// ExportResult is the structured output of a single export run.
type ExportResult struct {
Users []domain.User `yaml:"users"`
Groups []domain.Group `yaml:"groups"`
Memberships []domain.Membership `yaml:"memberships"`
ExportedAt time.Time `yaml:"exportedAt"`
ProfileVersion string `yaml:"profileVersion"`
IncompatibilityReport []string `yaml:"incompatibilityReport,omitempty"`
}
// Exporter reads from a UserRepository, validates, and writes canonical-export.yaml.
type Exporter struct {
repo domain.UserRepository
mode validator.Mode
emitter telemetry.Emitter
}
// New creates a new Exporter.
func New(repo domain.UserRepository, mode validator.Mode, emitter telemetry.Emitter) *Exporter {
return &Exporter{
repo: repo,
mode: mode,
emitter: emitter,
}
}
// Export reads all users and groups, validates them, builds ExportResult,
// emits telemetry, and writes the YAML file to outputFile.
// Validation failures are captured in IncompatibilityReport — they are not fatal.
func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult, error) {
// 1. List all users from the repository.
users, err := e.repo.ListUsers(ctx)
if err != nil {
return nil, fmt.Errorf("lldapexport: list users: %w", err)
}
// 2. List all groups by looking up groups for each user's DN.
// Since UserRepository.LookupGroups takes a userDN, we collect groups
// from all users and deduplicate by group ID.
groupMap := make(map[string]domain.Group)
for _, u := range users {
userGroups, err := e.repo.LookupGroups(ctx, u.ID)
if err != nil {
// Non-fatal: log in incompatibility report.
continue
}
for _, g := range userGroups {
if _, seen := groupMap[g.ID]; !seen {
groupMap[g.ID] = g
}
}
}
groups := make([]domain.Group, 0, len(groupMap))
for _, g := range groupMap {
groups = append(groups, g)
}
// 3. Validate each user against the canonical LDAP schema.
var incompatibilities []string
validatedUsers := make([]domain.User, 0, len(users))
for _, u := range users {
snap := validator.Snapshot{Users: []domain.User{u}}
report := validator.Validate(snap, e.mode)
if !report.Passed {
for _, r := range report.Structural {
if !r.Passed {
incompatibilities = append(incompatibilities,
fmt.Sprintf("user %q structural/%s: %s", u.Username, r.Rule, r.Message))
}
}
for _, r := range report.Semantic {
if !r.Passed {
incompatibilities = append(incompatibilities,
fmt.Sprintf("user %q semantic/%s: %s", u.Username, r.Rule, r.Message))
}
}
}
validatedUsers = append(validatedUsers, u)
}
// 4. Build memberships from group member lists.
var memberships []domain.Membership
for _, g := range groups {
for _, memberID := range g.Members {
memberships = append(memberships, domain.Membership{
UserID: memberID,
GroupID: g.ID,
})
}
}
// 5. Build ExportResult.
result := &ExportResult{
Users: validatedUsers,
Groups: groups,
Memberships: memberships,
ExportedAt: time.Now().UTC(),
ProfileVersion: "0.1",
IncompatibilityReport: incompatibilities,
}
// 6. Emit migration_event telemetry.
e.emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "lldap-export",
Result: "success",
})
// 7. Write YAML to output file.
data, err := yaml.Marshal(result)
if err != nil {
return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err)
}
if err := os.WriteFile(outputFile, data, 0o644); err != nil {
return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err)
}
return result, nil
}

View File

@@ -0,0 +1,235 @@
package lldapexport_test
import (
"context"
"os"
"path/filepath"
"testing"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
)
// ---------------------------------------------------------------------------
// Mock UserRepository
// ---------------------------------------------------------------------------
type mockRepo struct {
users []domain.User
groups []domain.Group
}
func (m *mockRepo) LookupUser(_ context.Context, username string) (*domain.User, error) {
for i, u := range m.users {
if u.Username == username {
return &m.users[i], nil
}
}
return nil, domain.ErrUserNotFound
}
func (m *mockRepo) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) {
return m.groups, nil
}
func (m *mockRepo) ValidatePassword(_ context.Context, _, _ string) (bool, error) {
return false, nil
}
func (m *mockRepo) ListUsers(_ context.Context) ([]domain.User, error) {
return m.users, nil
}
// Compile-time check.
var _ domain.UserRepository = (*mockRepo)(nil)
// ---------------------------------------------------------------------------
// Capture emitter
// ---------------------------------------------------------------------------
type capEmitter struct {
events []telemetry.Event
}
func (c *capEmitter) Emit(_ context.Context, ev telemetry.Event) {
c.events = append(c.events, ev)
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
func validUser() domain.User {
return domain.User{
ID: "uid=alice,ou=users,dc=example,dc=local",
Username: "alice",
DisplayName: "Alice Liddell",
Email: "alice@example.com",
Enabled: true,
Groups: []string{"admins"},
}
}
func validGroup() domain.Group {
return domain.Group{
ID: "cn=admins,ou=groups,dc=example,dc=local",
Name: "admins",
Description: "Admin group",
Members: []string{"uid=alice,ou=users,dc=example,dc=local"},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestExporter_Export_UsersAndGroups(t *testing.T) {
em := &capEmitter{}
repo := &mockRepo{
users: []domain.User{validUser()},
groups: []domain.Group{validGroup()},
}
outFile := filepath.Join(t.TempDir(), "export.yaml")
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
result, err := exp.Export(context.Background(), outFile)
if err != nil {
t.Fatalf("Export returned error: %v", err)
}
if len(result.Users) != 1 {
t.Errorf("expected 1 user, got %d", len(result.Users))
}
if result.Users[0].Username != "alice" {
t.Errorf("expected username alice, got %q", result.Users[0].Username)
}
if len(result.Groups) != 1 {
t.Errorf("expected 1 group, got %d", len(result.Groups))
}
if result.Groups[0].Name != "admins" {
t.Errorf("expected group name admins, got %q", result.Groups[0].Name)
}
}
func TestExporter_Export_WritesYAMLFile(t *testing.T) {
em := &capEmitter{}
repo := &mockRepo{
users: []domain.User{validUser()},
groups: []domain.Group{validGroup()},
}
outFile := filepath.Join(t.TempDir(), "canonical-export.yaml")
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
_, err := exp.Export(context.Background(), outFile)
if err != nil {
t.Fatalf("Export returned error: %v", err)
}
data, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("output file not written: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
// File should be valid YAML containing "alice".
content := string(data)
if len(content) < 10 {
t.Errorf("output file suspiciously short: %q", content)
}
}
func TestExporter_Export_EmitsMigrationEvent(t *testing.T) {
em := &capEmitter{}
repo := &mockRepo{
users: []domain.User{validUser()},
groups: []domain.Group{},
}
outFile := filepath.Join(t.TempDir(), "export.yaml")
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
_, err := exp.Export(context.Background(), outFile)
if err != nil {
t.Fatalf("Export returned error: %v", err)
}
found := false
for _, ev := range em.events {
if ev.EventType == telemetry.EventMigration {
found = true
break
}
}
if !found {
t.Error("expected migration_event telemetry, got none")
}
}
func TestExporter_Export_IncompatibilityReport_BadUser(t *testing.T) {
em := &capEmitter{}
// A user with empty DisplayName will fail canonical schema validation.
badUser := domain.User{
ID: "uid=broken,ou=users,dc=example,dc=local",
Username: "broken",
DisplayName: "", // missing required field
Email: "broken@example.com",
Enabled: true,
}
repo := &mockRepo{
users: []domain.User{badUser},
groups: []domain.Group{},
}
outFile := filepath.Join(t.TempDir(), "export.yaml")
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
result, err := exp.Export(context.Background(), outFile)
if err != nil {
t.Fatalf("Export should not return error for bad data (it reports incompatibilities): %v", err)
}
if len(result.IncompatibilityReport) == 0 {
t.Error("expected incompatibility report entries for user with missing displayName")
}
}
func TestExporter_Export_BuildsMemberships(t *testing.T) {
em := &capEmitter{}
user := validUser()
group := validGroup()
repo := &mockRepo{
users: []domain.User{user},
groups: []domain.Group{group},
}
outFile := filepath.Join(t.TempDir(), "export.yaml")
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
result, err := exp.Export(context.Background(), outFile)
if err != nil {
t.Fatalf("Export returned error: %v", err)
}
if len(result.Memberships) == 0 {
t.Error("expected memberships to be built from group members")
}
if result.Memberships[0].GroupID != group.ID {
t.Errorf("membership GroupID: want %q, got %q", group.ID, result.Memberships[0].GroupID)
}
}
func TestExporter_Export_ProfileVersion(t *testing.T) {
em := &capEmitter{}
repo := &mockRepo{users: []domain.User{validUser()}, groups: []domain.Group{}}
outFile := filepath.Join(t.TempDir(), "export.yaml")
exp := lldapexport.New(repo, validator.ModeProvisioning, em)
result, err := exp.Export(context.Background(), outFile)
if err != nil {
t.Fatalf("Export returned error: %v", err)
}
if result.ProfileVersion != "0.1" {
t.Errorf("expected ProfileVersion 0.1, got %q", result.ProfileVersion)
}
}

View File

@@ -0,0 +1,278 @@
// Package tokeycloak transforms a canonical KeyCape export into a Keycloak realm
// import JSON file (spec §7 — migration contract, Keycloak expansion path).
package tokeycloak
import (
"context"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Keycloak realm import types
// ---------------------------------------------------------------------------
// KeycloakRealm is the top-level realm import JSON structure.
type KeycloakRealm struct {
Realm string `json:"realm"`
DisplayName string `json:"displayName,omitempty"`
Enabled bool `json:"enabled"`
SsoSessionMaxLifespan int `json:"ssoSessionMaxLifespan,omitempty"`
DefaultSignatureAlgorithm string `json:"defaultSignatureAlgorithm,omitempty"`
IdentityProviders []interface{} `json:"identityProviders"`
Clients []KeycloakClient `json:"clients"`
Users []KeycloakUser `json:"users"`
Groups []KeycloakGroup `json:"groups"`
Roles KeycloakRoles `json:"roles"`
ClientScopes []KeycloakClientScope `json:"clientScopes"`
}
// KeycloakClient represents a registered client in the Keycloak realm.
type KeycloakClient struct {
ClientID string `json:"clientId"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
PublicClient bool `json:"publicClient"`
StandardFlowEnabled bool `json:"standardFlowEnabled"`
ImplicitFlowEnabled bool `json:"implicitFlowEnabled"`
DirectAccessGrantsEnabled bool `json:"directAccessGrantsEnabled"`
RedirectUris []string `json:"redirectUris"`
DefaultClientScopes []string `json:"defaultClientScopes"`
}
// KeycloakUser represents a user in the Keycloak realm.
type KeycloakUser struct {
Username string `json:"username"`
Email string `json:"email,omitempty"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Enabled bool `json:"enabled"`
Groups []string `json:"groups,omitempty"`
Credentials []KeycloakCredential `json:"credentials,omitempty"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
// KeycloakCredential holds a single credential entry (e.g. hashed password placeholder).
type KeycloakCredential struct {
Type string `json:"type"`
Value string `json:"value"`
Temporary bool `json:"temporary"`
}
// KeycloakGroup represents a user group in the Keycloak realm.
type KeycloakGroup struct {
Name string `json:"name"`
Path string `json:"path"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
// KeycloakRoles is the realm-level roles container.
type KeycloakRoles struct {
Realm []KeycloakRole `json:"realm"`
}
// KeycloakRole represents a single realm role.
type KeycloakRole struct {
Name string `json:"name"`
}
// KeycloakClientScope represents a client scope in the realm.
type KeycloakClientScope struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
}
// ---------------------------------------------------------------------------
// Transformer
// ---------------------------------------------------------------------------
// Config holds realm-level configuration for the transformation.
type Config struct {
RealmName string
Issuer string
}
// Transformer converts a canonical lldapexport.ExportResult into a KeycloakRealm.
type Transformer struct {
cfg Config
emitter telemetry.Emitter
}
// New creates a new Transformer with the given configuration and telemetry emitter.
func New(cfg Config, emitter telemetry.Emitter) *Transformer {
return &Transformer{cfg: cfg, emitter: emitter}
}
// Transform converts a canonical export to a Keycloak realm import.
// It maps users, groups, and emits migration_event telemetry.
// Clients default to an empty slice; use TransformWithClients to include them.
func (t *Transformer) Transform(export *lldapexport.ExportResult) (*KeycloakRealm, error) {
return t.TransformWithClients(export, nil)
}
// TransformWithClients converts a canonical export plus an explicit client list
// into a Keycloak realm import structure.
func (t *Transformer) TransformWithClients(export *lldapexport.ExportResult, clients []domain.Client) (*KeycloakRealm, error) {
realm := &KeycloakRealm{
Realm: t.cfg.RealmName,
Enabled: true,
IdentityProviders: []interface{}{},
}
// ProfileVersion "0.1" → RS256.
if export.ProfileVersion == "0.1" {
realm.DefaultSignatureAlgorithm = "RS256"
}
// Map users.
realm.Users = make([]KeycloakUser, 0, len(export.Users))
for _, u := range export.Users {
realm.Users = append(realm.Users, mapUser(u))
}
// Map groups.
realm.Groups = make([]KeycloakGroup, 0, len(export.Groups))
for _, g := range export.Groups {
realm.Groups = append(realm.Groups, mapGroup(g))
}
// Map clients.
realm.Clients = make([]KeycloakClient, 0, len(clients))
for _, c := range clients {
realm.Clients = append(realm.Clients, mapClient(c))
}
// Roles and scopes — empty in base migration; can be extended.
realm.Roles = KeycloakRoles{Realm: []KeycloakRole{}}
realm.ClientScopes = []KeycloakClientScope{}
// Emit migration telemetry.
t.emitter.Emit(context.Background(), telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "keycape-to-keycloak",
Result: "success",
})
return realm, nil
}
// ValidationReport compares a canonical export against a produced Keycloak realm
// and returns a list of incompatibility descriptions.
// An empty slice means the import is consistent with the canonical data.
func (t *Transformer) ValidationReport(export *lldapexport.ExportResult, realm *KeycloakRealm) []string {
var issues []string
// Any pre-existing incompatibilities from the canonical export propagate.
for _, inc := range export.IncompatibilityReport {
issues = append(issues, "canonical export incompatibility: "+inc)
}
// User count must match.
if len(realm.Users) != len(export.Users) {
issues = append(issues, "user count mismatch: canonical has "+
itoa(len(export.Users))+" users but realm has "+itoa(len(realm.Users)))
}
// Group count must match.
if len(realm.Groups) != len(export.Groups) {
issues = append(issues, "group count mismatch: canonical has "+
itoa(len(export.Groups))+" groups but realm has "+itoa(len(realm.Groups)))
}
// Identity providers must be empty per the NetKingdom IAM profile.
if len(realm.IdentityProviders) != 0 {
issues = append(issues, "identity providers must be empty per NetKingdom IAM profile")
}
return issues
}
// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------
func mapUser(u domain.User) KeycloakUser {
ku := KeycloakUser{
Username: u.Username,
Email: u.Email,
Enabled: u.Enabled,
}
// Split DisplayName at first space → FirstName + LastName.
ku.FirstName, ku.LastName = splitDisplayName(u.DisplayName)
// Convert group names to Keycloak paths: "/groupname".
if len(u.Groups) > 0 {
ku.Groups = make([]string, len(u.Groups))
for i, g := range u.Groups {
ku.Groups[i] = "/" + g
}
}
return ku
}
func mapGroup(g domain.Group) KeycloakGroup {
return KeycloakGroup{
Name: g.Name,
Path: "/" + g.Name,
}
}
func mapClient(c domain.Client) KeycloakClient {
kc := KeycloakClient{
ClientID: c.ClientID,
Name: c.DisplayName,
Enabled: true,
PublicClient: c.ClientType == "public",
StandardFlowEnabled: true, // authorization_code always enabled
ImplicitFlowEnabled: false, // never — per NetKingdom IAM profile
DirectAccessGrantsEnabled: false, // never — per NetKingdom IAM profile
RedirectUris: c.RedirectURIs,
DefaultClientScopes: c.AllowedScopes,
}
if kc.RedirectUris == nil {
kc.RedirectUris = []string{}
}
if kc.DefaultClientScopes == nil {
kc.DefaultClientScopes = []string{}
}
return kc
}
// splitDisplayName splits a display name at the first space.
// "Alice Liddell" → ("Alice", "Liddell")
// "Bob" → ("Bob", "")
// "Alice M Smith" → ("Alice", "M Smith")
func splitDisplayName(displayName string) (first, last string) {
idx := strings.Index(displayName, " ")
if idx < 0 {
return displayName, ""
}
return displayName[:idx], displayName[idx+1:]
}
// itoa converts an int to its decimal string representation without importing strconv.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
buf := make([]byte, 0, 10)
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}

View File

@@ -0,0 +1,440 @@
package tokeycloak_test
import (
"context"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/migration/tokeycloak"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Capture emitter
// ---------------------------------------------------------------------------
type capEmitter struct {
events []telemetry.Event
}
func (c *capEmitter) Emit(_ context.Context, ev telemetry.Event) {
c.events = append(c.events, ev)
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
func canonicalExport() *lldapexport.ExportResult {
return &lldapexport.ExportResult{
Users: []domain.User{
{
ID: "uid=alice,ou=users,dc=netkingdom,dc=local",
Username: "alice",
DisplayName: "Alice Liddell",
Email: "alice@example.com",
Enabled: true,
Groups: []string{"admins"},
},
{
ID: "uid=bob,ou=users,dc=netkingdom,dc=local",
Username: "bob",
DisplayName: "Bob",
Email: "bob@example.com",
Enabled: false,
Groups: []string{},
},
},
Groups: []domain.Group{
{
ID: "cn=admins,ou=groups,dc=netkingdom,dc=local",
Name: "admins",
Members: []string{"uid=alice,ou=users,dc=netkingdom,dc=local"},
},
},
Memberships: []domain.Membership{
{
UserID: "uid=alice,ou=users,dc=netkingdom,dc=local",
GroupID: "cn=admins,ou=groups,dc=netkingdom,dc=local",
},
},
ExportedAt: time.Now().UTC(),
ProfileVersion: "0.1",
}
}
func publicClient() domain.Client {
return domain.Client{
ClientID: "webapp",
DisplayName: "Web Application",
RedirectURIs: []string{"https://app.example.com/callback"},
AllowedScopes: []string{"openid", "profile", "email"},
GrantTypes: []string{"authorization_code"},
ClientType: "public",
}
}
func confidentialClient() domain.Client {
return domain.Client{
ClientID: "backend-svc",
DisplayName: "Backend Service",
RedirectURIs: []string{"https://svc.example.com/callback"},
AllowedScopes: []string{"openid", "profile"},
GrantTypes: []string{"authorization_code"},
ClientType: "confidential",
SecretRef: "vault:secret/backend-svc",
}
}
func newTransformer(em telemetry.Emitter) *tokeycloak.Transformer {
return tokeycloak.New(tokeycloak.Config{
RealmName: "netkingdom",
Issuer: "https://auth.netkingdom.local",
}, em)
}
// ---------------------------------------------------------------------------
// Tests: User mapping
// ---------------------------------------------------------------------------
func TestTransformer_UserMapping_UsernameAndEmail(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if len(realm.Users) != 2 {
t.Fatalf("expected 2 users, got %d", len(realm.Users))
}
alice := realm.Users[0]
if alice.Username != "alice" {
t.Errorf("username: want %q, got %q", "alice", alice.Username)
}
if alice.Email != "alice@example.com" {
t.Errorf("email: want %q, got %q", "alice@example.com", alice.Email)
}
if !alice.Enabled {
t.Error("alice should be enabled")
}
bob := realm.Users[1]
if bob.Enabled {
t.Error("bob should be disabled")
}
}
func TestTransformer_UserMapping_DisplayNameSplit(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
alice := realm.Users[0]
if alice.FirstName != "Alice" {
t.Errorf("firstName: want %q, got %q", "Alice", alice.FirstName)
}
if alice.LastName != "Liddell" {
t.Errorf("lastName: want %q, got %q", "Liddell", alice.LastName)
}
}
func TestTransformer_UserMapping_DisplayNameSingleWord(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
// "Bob" has a single-word DisplayName — should land in FirstName only.
bob := realm.Users[1]
if bob.FirstName != "Bob" {
t.Errorf("firstName: want %q, got %q", "Bob", bob.FirstName)
}
if bob.LastName != "" {
t.Errorf("lastName: want empty, got %q", bob.LastName)
}
}
func TestTransformer_UserMapping_GroupPaths(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
alice := realm.Users[0]
if len(alice.Groups) != 1 {
t.Fatalf("expected 1 group for alice, got %d", len(alice.Groups))
}
if alice.Groups[0] != "/admins" {
t.Errorf("group path: want %q, got %q", "/admins", alice.Groups[0])
}
}
// ---------------------------------------------------------------------------
// Tests: Group mapping
// ---------------------------------------------------------------------------
func TestTransformer_GroupMapping_NameAndPath(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if len(realm.Groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(realm.Groups))
}
g := realm.Groups[0]
if g.Name != "admins" {
t.Errorf("group name: want %q, got %q", "admins", g.Name)
}
if g.Path != "/admins" {
t.Errorf("group path: want %q, got %q", "/admins", g.Path)
}
}
// ---------------------------------------------------------------------------
// Tests: Client mapping
// ---------------------------------------------------------------------------
func TestTransformer_ClientMapping_PublicClient(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
export.Users = nil
export.Groups = nil
// Inject clients via a wrapper export that carries them.
// The Transformer Transform method takes ExportResult + separate clients.
// We test via TransformWithClients.
realm, err := tr.TransformWithClients(export, []domain.Client{publicClient()})
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if len(realm.Clients) != 1 {
t.Fatalf("expected 1 client, got %d", len(realm.Clients))
}
c := realm.Clients[0]
if c.ClientID != "webapp" {
t.Errorf("clientId: want %q, got %q", "webapp", c.ClientID)
}
if !c.PublicClient {
t.Error("publicClient should be true for ClientType=public")
}
if !c.StandardFlowEnabled {
t.Error("standardFlowEnabled should always be true")
}
if c.ImplicitFlowEnabled {
t.Error("implicitFlowEnabled must always be false")
}
if c.DirectAccessGrantsEnabled {
t.Error("directAccessGrantsEnabled must always be false")
}
}
func TestTransformer_ClientMapping_ConfidentialClient(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
export.Users = nil
export.Groups = nil
realm, err := tr.TransformWithClients(export, []domain.Client{confidentialClient()})
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if len(realm.Clients) != 1 {
t.Fatalf("expected 1 client, got %d", len(realm.Clients))
}
c := realm.Clients[0]
if c.PublicClient {
t.Error("publicClient should be false for ClientType=confidential")
}
}
func TestTransformer_ClientMapping_AllowedScopesBecomesDefaultClientScopes(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
export.Users = nil
export.Groups = nil
realm, err := tr.TransformWithClients(export, []domain.Client{publicClient()})
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
c := realm.Clients[0]
if len(c.DefaultClientScopes) != 3 {
t.Errorf("defaultClientScopes: want 3, got %d", len(c.DefaultClientScopes))
}
}
func TestTransformer_ClientMapping_ImplicitFlowAlwaysFalse(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
export.Users = nil
export.Groups = nil
// Even if GrantTypes contains "implicit", Keycloak output must have ImplicitFlowEnabled=false.
weirdClient := publicClient()
weirdClient.GrantTypes = append(weirdClient.GrantTypes, "implicit")
realm, err := tr.TransformWithClients(export, []domain.Client{weirdClient})
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if realm.Clients[0].ImplicitFlowEnabled {
t.Error("implicitFlowEnabled must always be false per NetKingdom IAM profile")
}
}
// ---------------------------------------------------------------------------
// Tests: Identity providers always empty
// ---------------------------------------------------------------------------
func TestTransformer_IdentityProviders_AlwaysEmpty(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if len(realm.IdentityProviders) != 0 {
t.Errorf("identityProviders: want empty slice, got %d entries", len(realm.IdentityProviders))
}
}
// ---------------------------------------------------------------------------
// Tests: ProfileVersion → DefaultSignatureAlgorithm
// ---------------------------------------------------------------------------
func TestTransformer_ProfileVersion_RS256(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport() // ProfileVersion "0.1"
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if realm.DefaultSignatureAlgorithm != "RS256" {
t.Errorf("defaultSignatureAlgorithm: want %q, got %q", "RS256", realm.DefaultSignatureAlgorithm)
}
}
// ---------------------------------------------------------------------------
// Tests: Realm metadata
// ---------------------------------------------------------------------------
func TestTransformer_RealmMetadata(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if realm.Realm != "netkingdom" {
t.Errorf("realm: want %q, got %q", "netkingdom", realm.Realm)
}
if !realm.Enabled {
t.Error("realm should be enabled")
}
}
// ---------------------------------------------------------------------------
// Tests: Telemetry
// ---------------------------------------------------------------------------
func TestTransformer_EmitsMigrationEvent(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
_, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
found := false
for _, ev := range em.events {
if ev.EventType == telemetry.EventMigration {
found = true
break
}
}
if !found {
t.Error("expected migration_event telemetry, got none")
}
}
// ---------------------------------------------------------------------------
// Tests: ValidationReport
// ---------------------------------------------------------------------------
func TestTransformer_ValidationReport_IncompatibleExport(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
// Add an incompatibility entry to simulate unmappable data.
export.IncompatibilityReport = []string{"user \"broken\" structural/required_attributes_present: missing displayName"}
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
report := tr.ValidationReport(export, realm)
if len(report) == 0 {
t.Error("expected validation report entries for export with incompatibilities, got none")
}
}
func TestTransformer_ValidationReport_CleanExport(t *testing.T) {
em := &capEmitter{}
tr := newTransformer(em)
export := canonicalExport()
realm, err := tr.Transform(export)
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
report := tr.ValidationReport(export, realm)
if len(report) != 0 {
t.Errorf("expected no validation issues for clean export, got: %v", report)
}
}

View File

@@ -0,0 +1,230 @@
// Package toldap generates LDIF output from a canonical KeyCape export
// (spec §7 — migration contract, LLDAP → full LDAP path).
package toldap
import (
"context"
"fmt"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
)
// Target identifies the LDAP server implementation to generate for.
type Target string
const (
TargetOpenLDAP Target = "openldap"
Target389DS Target = "389ds"
TargetAD Target = "ad"
)
// Config holds the generation parameters.
type Config struct {
BaseDN string
Target Target
}
// Generator produces LDIF output from a canonical export.
type Generator struct {
cfg Config
emitter telemetry.Emitter
}
// New creates a new Generator.
func New(cfg Config, emitter telemetry.Emitter) *Generator {
return &Generator{cfg: cfg, emitter: emitter}
}
// Generate produces LDIF content from a canonical export.
// It validates the source data against the canonical schema before generating,
// and returns an error if any user has missing required attributes.
func (g *Generator) Generate(export *lldapexport.ExportResult) (string, error) {
// Pre-validate the canonical data.
snap := validator.Snapshot{
Users: export.Users,
Groups: export.Groups,
}
report := validator.Validate(snap, validator.ModeMigration)
if !report.Passed {
msgs := collectFailures(report)
return "", fmt.Errorf("toldap: canonical validation failed: %s", strings.Join(msgs, "; "))
}
var sb strings.Builder
// Write structural entries first.
writeEntry(&sb, []string{
"dn: ou=users," + g.cfg.BaseDN,
"objectClass: top",
"objectClass: organizationalUnit",
"ou: users",
})
writeEntry(&sb, []string{
"dn: ou=groups," + g.cfg.BaseDN,
"objectClass: top",
"objectClass: organizationalUnit",
"ou: groups",
})
// Write user entries.
for _, u := range export.Users {
if err := g.writeUser(&sb, u); err != nil {
return "", fmt.Errorf("toldap: user %q: %w", u.Username, err)
}
}
// Write group entries.
for _, grp := range export.Groups {
g.writeGroup(&sb, grp)
}
ldif := sb.String()
// Emit migration telemetry.
g.emitter.Emit(context.Background(), telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "lldap-to-ldap",
Result: "success",
})
return ldif, nil
}
// ---------------------------------------------------------------------------
// LDIF entry writers
// ---------------------------------------------------------------------------
func (g *Generator) writeUser(sb *strings.Builder, u domain.User) error {
if u.Username == "" {
return fmt.Errorf("username is required")
}
var dn string
switch g.cfg.Target {
case TargetAD:
dn = "dn: cn=" + u.Username + ",ou=users," + g.cfg.BaseDN
default:
dn = "dn: uid=" + u.Username + ",ou=users," + g.cfg.BaseDN
}
firstName, lastName := splitDisplayName(u.DisplayName)
cn := u.DisplayName
if cn == "" {
cn = u.Username
}
sn := lastName
if sn == "" {
sn = firstName
}
if sn == "" {
sn = u.Username
}
attrs := []string{
dn,
"objectClass: top",
"objectClass: person",
"objectClass: organizationalPerson",
"objectClass: inetOrgPerson",
"uid: " + u.Username,
"cn: " + cn,
"sn: " + sn,
}
if u.Email != "" {
attrs = append(attrs, "mail: "+u.Email)
}
// Target-specific attributes.
switch g.cfg.Target {
case Target389DS:
if nsUID, ok := u.LDAPAttributes["nsUniqueId"]; ok && nsUID != "" {
attrs = append(attrs, "nsUniqueId: "+nsUID)
}
case TargetAD:
attrs = append(attrs, "sAMAccountName: "+u.Username)
}
writeEntry(sb, attrs)
return nil
}
func (g *Generator) writeGroup(sb *strings.Builder, grp domain.Group) {
dn := "dn: cn=" + grp.Name + ",ou=groups," + g.cfg.BaseDN
attrs := []string{
dn,
"objectClass: top",
"objectClass: groupOfNames",
"cn: " + grp.Name,
}
for _, memberID := range grp.Members {
// If the member ID is already a full DN, use it directly.
// Otherwise build a uid=<id>,ou=users,<baseDN> DN.
memberDN := resolveMemberDN(memberID, g.cfg.BaseDN, g.cfg.Target)
attrs = append(attrs, "member: "+memberDN)
}
writeEntry(sb, attrs)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// writeEntry writes LDIF lines for one entry followed by a blank line separator.
func writeEntry(sb *strings.Builder, lines []string) {
for _, line := range lines {
sb.WriteString(line)
sb.WriteByte('\n')
}
sb.WriteByte('\n')
}
// resolveMemberDN returns the full LDAP DN for a member.
// If the memberID already contains a comma (i.e. is a DN), it is returned as-is.
// Otherwise a DN is constructed from the username.
func resolveMemberDN(memberID, baseDN string, target Target) string {
if strings.Contains(memberID, ",") {
// Already a full DN — return as-is.
return memberID
}
switch target {
case TargetAD:
return "cn=" + memberID + ",ou=users," + baseDN
default:
return "uid=" + memberID + ",ou=users," + baseDN
}
}
// splitDisplayName splits a display name at the first space.
func splitDisplayName(displayName string) (first, last string) {
idx := strings.Index(displayName, " ")
if idx < 0 {
return displayName, ""
}
return displayName[:idx], displayName[idx+1:]
}
// collectFailures gathers all failed rule messages from a validator report.
func collectFailures(report validator.Report) []string {
var msgs []string
for _, r := range report.Structural {
if !r.Passed {
msgs = append(msgs, r.Rule+": "+r.Message)
}
}
for _, r := range report.Semantic {
if !r.Passed {
msgs = append(msgs, r.Rule+": "+r.Message)
}
}
return msgs
}

View File

@@ -0,0 +1,346 @@
package toldap_test
import (
"context"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/migration/toldap"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Capture emitter
// ---------------------------------------------------------------------------
type capEmitter struct {
events []telemetry.Event
}
func (c *capEmitter) Emit(_ context.Context, ev telemetry.Event) {
c.events = append(c.events, ev)
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
func canonicalExport() *lldapexport.ExportResult {
return &lldapexport.ExportResult{
Users: []domain.User{
{
ID: "uid=alice,ou=users,dc=netkingdom,dc=local",
Username: "alice",
DisplayName: "Alice Example",
Email: "alice@example.com",
Enabled: true,
Groups: []string{"admins"},
},
},
Groups: []domain.Group{
{
ID: "cn=admins,ou=groups,dc=netkingdom,dc=local",
Name: "admins",
Members: []string{"uid=alice,ou=users,dc=netkingdom,dc=local"},
},
},
Memberships: []domain.Membership{
{
UserID: "uid=alice,ou=users,dc=netkingdom,dc=local",
GroupID: "cn=admins,ou=groups,dc=netkingdom,dc=local",
},
},
ExportedAt: time.Now().UTC(),
ProfileVersion: "0.1",
}
}
func emptyExport() *lldapexport.ExportResult {
return &lldapexport.ExportResult{
Users: []domain.User{},
Groups: []domain.Group{},
Memberships: []domain.Membership{},
ExportedAt: time.Now().UTC(),
ProfileVersion: "0.1",
}
}
func newOpenLDAPGen(em telemetry.Emitter) *toldap.Generator {
return toldap.New(toldap.Config{
BaseDN: "dc=netkingdom,dc=local",
Target: toldap.TargetOpenLDAP,
}, em)
}
func new389DSGen(em telemetry.Emitter) *toldap.Generator {
return toldap.New(toldap.Config{
BaseDN: "dc=netkingdom,dc=local",
Target: toldap.Target389DS,
}, em)
}
func newADGen(em telemetry.Emitter) *toldap.Generator {
return toldap.New(toldap.Config{
BaseDN: "dc=netkingdom,dc=local",
Target: toldap.TargetAD,
}, em)
}
// ---------------------------------------------------------------------------
// Tests: User LDIF
// ---------------------------------------------------------------------------
func TestGenerator_UserLDIF_RequiredAttributes(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := canonicalExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
// Should contain user DN.
if !strings.Contains(ldif, "dn: uid=alice,ou=users,dc=netkingdom,dc=local") {
t.Error("LDIF missing user DN")
}
// Required objectClasses.
if !strings.Contains(ldif, "objectClass: inetOrgPerson") {
t.Error("LDIF missing objectClass: inetOrgPerson")
}
if !strings.Contains(ldif, "objectClass: person") {
t.Error("LDIF missing objectClass: person")
}
if !strings.Contains(ldif, "objectClass: organizationalPerson") {
t.Error("LDIF missing objectClass: organizationalPerson")
}
// uid attribute.
if !strings.Contains(ldif, "uid: alice") {
t.Error("LDIF missing uid attribute")
}
// cn attribute from DisplayName.
if !strings.Contains(ldif, "cn: Alice Example") {
t.Error("LDIF missing cn attribute")
}
// sn attribute (last name).
if !strings.Contains(ldif, "sn: Example") {
t.Error("LDIF missing sn attribute")
}
// mail attribute.
if !strings.Contains(ldif, "mail: alice@example.com") {
t.Error("LDIF missing mail attribute")
}
}
// ---------------------------------------------------------------------------
// Tests: Group LDIF
// ---------------------------------------------------------------------------
func TestGenerator_GroupLDIF_WithMemberDNs(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := canonicalExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
// Should contain group DN.
if !strings.Contains(ldif, "dn: cn=admins,ou=groups,dc=netkingdom,dc=local") {
t.Error("LDIF missing group DN")
}
if !strings.Contains(ldif, "objectClass: groupOfNames") {
t.Error("LDIF missing objectClass: groupOfNames")
}
if !strings.Contains(ldif, "cn: admins") {
t.Error("LDIF missing group cn attribute")
}
// Member DN should be present.
if !strings.Contains(ldif, "member: uid=alice,ou=users,dc=netkingdom,dc=local") {
t.Error("LDIF missing member attribute")
}
}
// ---------------------------------------------------------------------------
// Tests: Target differences
// ---------------------------------------------------------------------------
func TestGenerator_Target_OpenLDAP_NoSAMAccountName(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := canonicalExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if strings.Contains(ldif, "sAMAccountName") {
t.Error("OpenLDAP LDIF should not contain sAMAccountName")
}
}
func TestGenerator_Target_389DS_HasNsUniqueIdIfAvailable(t *testing.T) {
em := &capEmitter{}
gen := new389DSGen(em)
export := canonicalExport()
// Add nsUniqueId via LDAPAttributes.
export.Users[0].LDAPAttributes = map[string]string{
"nsUniqueId": "a1b2c3d4-1234-5678-abcd-ef0123456789",
}
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(ldif, "nsUniqueId: a1b2c3d4-1234-5678-abcd-ef0123456789") {
t.Error("389DS LDIF should include nsUniqueId when available in LDAPAttributes")
}
}
func TestGenerator_Target_389DS_NoNsUniqueIdWhenAbsent(t *testing.T) {
em := &capEmitter{}
gen := new389DSGen(em)
export := canonicalExport()
// No LDAPAttributes set.
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if strings.Contains(ldif, "nsUniqueId") {
t.Error("389DS LDIF should not include nsUniqueId when not available in LDAPAttributes")
}
}
func TestGenerator_Target_AD_SAMAccountNamePresent(t *testing.T) {
em := &capEmitter{}
gen := newADGen(em)
export := canonicalExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(ldif, "sAMAccountName: alice") {
t.Error("AD LDIF should contain sAMAccountName")
}
}
func TestGenerator_Target_AD_UsesCNInDN(t *testing.T) {
em := &capEmitter{}
gen := newADGen(em)
export := canonicalExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
// AD uses cn= prefix instead of uid= in user DN.
if !strings.Contains(ldif, "dn: cn=alice,ou=users,dc=netkingdom,dc=local") {
t.Errorf("AD LDIF should use cn= in user DN; got:\n%s", ldif)
}
}
func TestGenerator_Target_OpenLDAP_UsesUIDInDN(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := canonicalExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(ldif, "dn: uid=alice,ou=users,dc=netkingdom,dc=local") {
t.Errorf("OpenLDAP LDIF should use uid= in user DN; got:\n%s", ldif)
}
}
// ---------------------------------------------------------------------------
// Tests: Empty export — structural entries only
// ---------------------------------------------------------------------------
func TestGenerator_EmptyExport_StructuralEntriesOnly(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := emptyExport()
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
// Should still produce the ou=users and ou=groups entries.
if !strings.Contains(ldif, "dn: ou=users,dc=netkingdom,dc=local") {
t.Error("LDIF missing structural ou=users entry")
}
if !strings.Contains(ldif, "dn: ou=groups,dc=netkingdom,dc=local") {
t.Error("LDIF missing structural ou=groups entry")
}
}
// ---------------------------------------------------------------------------
// Tests: Telemetry
// ---------------------------------------------------------------------------
func TestGenerator_EmitsMigrationEvent(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := canonicalExport()
_, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
found := false
for _, ev := range em.events {
if ev.EventType == telemetry.EventMigration {
found = true
break
}
}
if !found {
t.Error("expected migration_event telemetry, got none")
}
}
// ---------------------------------------------------------------------------
// Tests: Validation called on output
// ---------------------------------------------------------------------------
func TestGenerator_ValidationRunsOnOutput(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
export := canonicalExport()
// If validation is called, we expect no error for valid input.
_, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate returned unexpected error: %v", err)
}
}
func TestGenerator_ValidationFailsForInvalidData(t *testing.T) {
em := &capEmitter{}
gen := newOpenLDAPGen(em)
// User with empty username — would produce invalid LDIF.
export := canonicalExport()
export.Users[0].Username = ""
export.Users[0].DisplayName = ""
_, err := gen.Generate(export)
if err == nil {
t.Error("Generate should return error for user with empty username (invalid LDIF)")
}
}

View File

@@ -0,0 +1,203 @@
// Package errors implements the unsupported feature enforcement layer for KeyCape.
// Every request passes through the Registry middleware before reaching any handler.
// If a registered feature is detected the middleware writes a ProfileError JSON
// response, emits an EventUnsupportedFeature telemetry event, and short-circuits
// the handler chain. Adding a new unsupported feature requires only a call to
// Register — no handler changes are needed.
package errors
import (
"net/http"
"strings"
"time"
profileerrors "keycape/internal/errors"
"keycape/internal/server/telemetry"
)
// UnsupportedFeature describes a profile boundary that KeyCape enforces.
type UnsupportedFeature struct {
// Name is a stable string identifier used in telemetry and error payloads.
Name string
// ErrorType is the profile error category emitted when this feature is triggered.
ErrorType profileerrors.ErrorType
// Description is a human-readable explanation of why the feature is blocked.
Description string
// Detector reports whether the given request triggers this feature.
Detector func(r *http.Request) bool
}
// Registry holds all known unsupported features and exposes middleware that
// enforces them on every incoming request.
type Registry struct {
features []UnsupportedFeature
}
// NewRegistry returns an empty Registry. Use Register to add features and
// DefaultRegistry to obtain one pre-populated with the spec-mandated set.
func NewRegistry() *Registry {
return &Registry{}
}
// Register appends a feature to the registry. Registered features are checked
// in insertion order; the first match wins.
func (reg *Registry) Register(f UnsupportedFeature) {
reg.features = append(reg.features, f)
}
// Middleware returns an http.Handler that evaluates all registered features
// for every request before delegating to next.
//
// If a feature is triggered:
// - A ProfileError JSON response is written with an appropriate HTTP status.
// - An EventUnsupportedFeature telemetry event is emitted via the Emitter
// stored in the request context (a NoopEmitter is used when none is set).
// - next is NOT called.
//
// If no feature matches, next is called normally.
func (reg *Registry) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, f := range reg.features {
if f.Detector(r) {
pe := &profileerrors.ProfileError{
Error: f.ErrorType,
Description: f.Description,
Feature: f.Name,
}
pe.Write(w, httpStatusFor(f.ErrorType))
em := telemetry.EmitterFromContext(r.Context())
em.Emit(r.Context(), telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventUnsupportedFeature,
Feature: f.Name,
ErrorType: string(f.ErrorType),
Endpoint: r.URL.Path,
Result: "failure",
Environment: "",
TraceID: "",
ClientID: r.URL.Query().Get("client_id"),
})
return
}
}
next.ServeHTTP(w, r)
})
}
// httpStatusFor maps an ErrorType to its canonical HTTP status code.
func httpStatusFor(et profileerrors.ErrorType) int {
switch et {
case profileerrors.ErrInvalidProfileUsage:
return http.StatusBadRequest
case profileerrors.ErrRejectedForSafety:
return http.StatusForbidden
case profileerrors.ErrKeycloakModeOnly:
return http.StatusNotImplemented
default: // ErrFeatureNotSupported
return http.StatusNotImplemented
}
}
// ---------------------------------------------------------------------------
// Default feature set (spec §4 — normative).
// ---------------------------------------------------------------------------
// DefaultRegistry returns a Registry pre-populated with all spec-mandated
// unsupported features. No handler changes are required to enforce new entries.
func DefaultRegistry() *Registry {
reg := NewRegistry()
// 1. Dynamic client registration (RFC 7591) — not in the profile.
reg.Register(UnsupportedFeature{
Name: "dynamic_client_registration",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "Dynamic client registration is not part of the NetKingdom IAM Profile. Register clients statically in KeyCape configuration.",
Detector: func(r *http.Request) bool {
return (r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/connect/register")) ||
strings.Contains(r.URL.Path, "registration")
},
})
// 2. Implicit flow — blocked for security.
reg.Register(UnsupportedFeature{
Name: "implicit_flow",
ErrorType: profileerrors.ErrRejectedForSafety,
Description: "The implicit flow (response_type=token or id_token) is rejected. Use the authorization code flow with PKCE.",
Detector: func(r *http.Request) bool {
rt := r.URL.Query().Get("response_type")
if rt == "" {
return false
}
// Blocked when response_type contains "token" or "id_token" but NOT when it is exactly "code".
// "code token" (hybrid) is also blocked.
return rt == "token" || rt == "id_token" || strings.Contains(rt, "token") && rt != "code"
},
})
// 3. Wildcard redirect_uri — blocked for security.
reg.Register(UnsupportedFeature{
Name: "wildcard_redirect_uri",
ErrorType: profileerrors.ErrRejectedForSafety,
Description: "Wildcard redirect URIs are not permitted. Register exact redirect URIs in the client configuration.",
Detector: func(r *http.Request) bool {
return strings.Contains(r.URL.Query().Get("redirect_uri"), "*")
},
})
// 4. Identity brokering — available only in Keycloak mode.
reg.Register(UnsupportedFeature{
Name: "identity_broker",
ErrorType: profileerrors.ErrKeycloakModeOnly,
Description: "Identity brokering is available only in expanded (Keycloak) mode.",
Detector: func(r *http.Request) bool {
return strings.Contains(r.URL.Path, "/broker/")
},
})
// 5. PKCE plain method — blocked for security (must use S256).
// Registered BEFORE missing_pkce so a plain-method request is reported
// as pkce_plain_method, not missing_pkce.
reg.Register(UnsupportedFeature{
Name: "pkce_plain_method",
ErrorType: profileerrors.ErrRejectedForSafety,
Description: "PKCE plain code challenge method is not allowed. Use S256.",
Detector: func(r *http.Request) bool {
return r.URL.Query().Get("code_challenge_method") == "plain"
},
})
// 6. Missing PKCE on /authorize — invalid profile usage.
reg.Register(UnsupportedFeature{
Name: "missing_pkce",
ErrorType: profileerrors.ErrInvalidProfileUsage,
Description: "Requests to /authorize must include a code_challenge (PKCE S256 required).",
Detector: func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/authorize") &&
r.URL.Query().Get("code_challenge") == ""
},
})
// 7. Unknown grant type on /token.
reg.Register(UnsupportedFeature{
Name: "unknown_grant_type",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "Only authorization_code and refresh_token grant types are supported.",
Detector: func(r *http.Request) bool {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/token") {
return false
}
gt := r.URL.Query().Get("grant_type")
if gt == "" {
// Also check form body if already parsed — callers may pre-parse.
gt = r.FormValue("grant_type")
}
if gt == "" {
return false // no grant_type present; let the handler decide
}
return gt != "authorization_code" && gt != "refresh_token"
},
})
return reg
}

View File

@@ -0,0 +1,299 @@
package errors_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
profileerrors "keycape/internal/errors"
serverrors "keycape/internal/server/errors"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// recEmitter records emitted events for assertions.
// ---------------------------------------------------------------------------
type recEmitter struct {
events []telemetry.Event
}
func (r *recEmitter) Emit(_ context.Context, ev telemetry.Event) {
r.events = append(r.events, ev)
}
func newRecEmitter() *recEmitter { return &recEmitter{} }
// ---------------------------------------------------------------------------
// Helper: build request with emitter in context.
// ---------------------------------------------------------------------------
func reqWithEmitter(method, target string, em telemetry.Emitter) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := telemetry.WithEmitter(req.Context(), em)
return req.WithContext(ctx)
}
// ---------------------------------------------------------------------------
// Tests — default registry features triggered.
// ---------------------------------------------------------------------------
func TestDefaultRegistry_DynamicClientRegistration_PostConnect(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodPost, "/connect/register", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "dynamic_client_registration")
assertTelemetryEmitted(t, em, "dynamic_client_registration")
}
func TestDefaultRegistry_DynamicClientRegistration_PathContainsRegistration(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/oauth/registration/info", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "dynamic_client_registration")
assertTelemetryEmitted(t, em, "dynamic_client_registration")
}
func TestDefaultRegistry_ImplicitFlow_Token(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=token", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "implicit_flow")
assertTelemetryEmitted(t, em, "implicit_flow")
}
func TestDefaultRegistry_ImplicitFlow_IDToken(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=id_token", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "implicit_flow")
assertTelemetryEmitted(t, em, "implicit_flow")
}
func TestDefaultRegistry_WildcardRedirectURI(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?redirect_uri=https%3A%2F%2Fexample.com%2F*%2Fcb", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "wildcard_redirect_uri")
assertTelemetryEmitted(t, em, "wildcard_redirect_uri")
}
func TestDefaultRegistry_IdentityBroker(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/auth/realms/master/broker/github/endpoint", em))
assertProfileError(t, w, profileerrors.ErrKeycloakModeOnly, "identity_broker")
assertTelemetryEmitted(t, em, "identity_broker")
}
func TestDefaultRegistry_MissingPKCE(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
// /authorize without code_challenge
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=code&client_id=myapp", em))
assertProfileError(t, w, profileerrors.ErrInvalidProfileUsage, "missing_pkce")
assertTelemetryEmitted(t, em, "missing_pkce")
}
func TestDefaultRegistry_MissingPKCE_WithCodeChallenge_PassesThrough(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := reg.Middleware(next)
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256", em))
if !called {
t.Fatal("expected next handler to be called when code_challenge is present")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestDefaultRegistry_PKCEPlainMethod(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?code_challenge=abc&code_challenge_method=plain", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "pkce_plain_method")
assertTelemetryEmitted(t, em, "pkce_plain_method")
}
func TestDefaultRegistry_UnknownGrantType(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodPost, "/token?grant_type=client_credentials", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "unknown_grant_type")
assertTelemetryEmitted(t, em, "unknown_grant_type")
}
func TestDefaultRegistry_UnknownGrantType_AllowedTypes(t *testing.T) {
reg := serverrors.DefaultRegistry()
handler := reg.Middleware(alwaysOK())
for _, gt := range []string{"authorization_code", "refresh_token"} {
req := reqWithEmitter(http.MethodPost, "/token?grant_type="+gt, newRecEmitter())
w := serve(handler, req)
if w.Code != http.StatusOK {
t.Fatalf("grant_type=%q: expected 200 (pass-through), got %d: %s", gt, w.Code, w.Body.String())
}
}
}
// ---------------------------------------------------------------------------
// Tests — no feature triggered: passes through.
// ---------------------------------------------------------------------------
func TestDefaultRegistry_NoMatchPassesThrough(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := reg.Middleware(next)
w := serve(handler, reqWithEmitter(http.MethodGet, "/userinfo", em))
if !called {
t.Fatal("expected next handler to be called for unmatched request")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(em.events) != 0 {
t.Fatalf("expected no telemetry events, got %d", len(em.events))
}
}
// ---------------------------------------------------------------------------
// Tests — custom feature registration.
// ---------------------------------------------------------------------------
func TestRegistry_CustomFeature(t *testing.T) {
reg := serverrors.NewRegistry()
reg.Register(serverrors.UnsupportedFeature{
Name: "test_feature",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "test feature blocked",
Detector: func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/test-blocked") },
})
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/test-blocked/foo", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "test_feature")
assertTelemetryEmitted(t, em, "test_feature")
}
func TestRegistry_CustomFeature_NoMatch_PassesThrough(t *testing.T) {
reg := serverrors.NewRegistry()
reg.Register(serverrors.UnsupportedFeature{
Name: "test_feature",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "test feature blocked",
Detector: func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/test-blocked") },
})
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := reg.Middleware(next)
w := serve(handler, reqWithEmitter(http.MethodGet, "/safe-path", newRecEmitter()))
if !called {
t.Fatal("expected next to be called when no feature matches")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func alwaysOK() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
func serve(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func assertProfileError(t *testing.T, w *httptest.ResponseRecorder, errType profileerrors.ErrorType, feature string) {
t.Helper()
if w.Code == http.StatusOK {
t.Fatalf("expected non-200 status, got 200")
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Fatalf("expected application/json content type, got %q", ct)
}
var pe profileerrors.ProfileError
if err := json.NewDecoder(w.Body).Decode(&pe); err != nil {
t.Fatalf("failed to decode ProfileError: %v", err)
}
if pe.Error != errType {
t.Errorf("expected error type %q, got %q", errType, pe.Error)
}
if pe.Feature != feature {
t.Errorf("expected feature %q, got %q", feature, pe.Feature)
}
}
func assertTelemetryEmitted(t *testing.T, em *recEmitter, feature string) {
t.Helper()
if len(em.events) == 0 {
t.Fatalf("expected telemetry event for feature %q, got none", feature)
}
last := em.events[len(em.events)-1]
if last.EventType != telemetry.EventUnsupportedFeature {
t.Errorf("expected event type %q, got %q", telemetry.EventUnsupportedFeature, last.EventType)
}
if last.Feature != feature {
t.Errorf("expected feature %q in event, got %q", feature, last.Feature)
}
}

View File

@@ -0,0 +1,485 @@
package oidc
import (
"context"
"html/template"
"net/http"
"net/url"
"strings"
"sync"
"time"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
"keycape/internal/server/telemetry"
)
// PendingState holds the authorization request parameters while the user is
// being authenticated by the upstream provider (e.g. Authelia). It is keyed
// by the opaque state value that is round-tripped through the upstream.
type PendingState struct {
ClientID string
RedirectURI string
PKCEChallenge string
PKCEChallengeMethod string
State string
Nonce string
Scopes []string
ExpiresAt time.Time
AuthenticatedUser string
}
// pendingStateStore is a thread-safe map of state → PendingState.
type pendingStateStore struct {
mu sync.Mutex
store map[string]*PendingState
}
func newPendingStateStore() *pendingStateStore {
return &pendingStateStore{store: make(map[string]*PendingState)}
}
func (p *pendingStateStore) Store(state string, ps *PendingState) {
p.mu.Lock()
p.store[state] = ps
p.mu.Unlock()
}
func (p *pendingStateStore) Load(state string) (*PendingState, bool) {
p.mu.Lock()
ps, ok := p.store[state]
p.mu.Unlock()
return ps, ok
}
func (p *pendingStateStore) Delete(state string) {
p.mu.Lock()
delete(p.store, state)
p.mu.Unlock()
}
// AuthorizeHandler implements GET /authorize and GET /authorize/callback.
type AuthorizeHandler struct {
ClientConfig map[string]*domain.Client
Auth domain.AuthProvider
MFA domain.MFAProvider
Sessions *SessionStore
Emitter telemetry.Emitter
pending *pendingStateStore
once sync.Once
}
// PendingStates returns the underlying pending-state store so tests can seed it.
func (h *AuthorizeHandler) PendingStates() *pendingStateStore {
h.init()
return h.pending
}
func (h *AuthorizeHandler) init() {
h.once.Do(func() {
if h.pending == nil {
h.pending = newPendingStateStore()
}
})
}
// ServeHTTP dispatches to the authorize or callback handler based on path.
func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.init()
if strings.HasSuffix(r.URL.Path, "/callback") {
h.ServeHTTPCallback(w, r)
return
}
h.serveAuthorize(w, r)
}
// serveAuthorize handles the initial GET /authorize request.
func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := r.URL.Query()
clientID := q.Get("client_id")
redirectURI := q.Get("redirect_uri")
responseType := q.Get("response_type")
scope := q.Get("scope")
state := q.Get("state")
nonce := q.Get("nonce")
codeChallenge := q.Get("code_challenge")
codeChallengeMethod := q.Get("code_challenge_method")
// Emit auth_start telemetry immediately.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthStart,
ClientID: clientID,
Endpoint: "/authorize",
Result: "pending",
})
// 1. Validate client_id.
client, ok := h.ClientConfig[clientID]
if !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
// 2. Validate redirect_uri — check for wildcards first, then exact match.
for _, registered := range client.RedirectURIs {
if strings.ContainsAny(registered, "*?") {
profileerrors.RejectedForSafety(
"wildcard redirect URIs are not permitted",
"redirect_uri",
).Write(w, http.StatusBadRequest)
return
}
}
if !uriRegistered(client.RedirectURIs, redirectURI) {
profileerrors.InvalidProfileUsage(
"redirect_uri does not match any registered URI",
"redirect_uri",
).Write(w, http.StatusBadRequest)
return
}
// 3. Validate response_type.
if responseType != "code" {
profileerrors.FeatureNotSupported(
"only response_type=code is supported",
"response_type="+responseType,
).Write(w, http.StatusBadRequest)
return
}
// 4. Validate scope contains openid.
if !scopeContains(scope, "openid") {
profileerrors.InvalidProfileUsage(
"scope must include openid",
"scope",
).Write(w, http.StatusBadRequest)
return
}
// 5. Validate code_challenge is present.
if codeChallenge == "" {
profileerrors.InvalidProfileUsage(
"code_challenge is required (PKCE S256)",
"code_challenge",
).Write(w, http.StatusBadRequest)
return
}
// 6. Validate code_challenge_method.
if codeChallengeMethod == "plain" {
profileerrors.RejectedForSafety(
"code_challenge_method=plain is rejected for security; use S256",
"code_challenge_method",
).Write(w, http.StatusBadRequest)
return
}
if codeChallengeMethod != "S256" {
profileerrors.InvalidProfileUsage(
"code_challenge_method must be S256",
"code_challenge_method",
).Write(w, http.StatusBadRequest)
return
}
// Store pending state so the callback can reconstruct the session.
h.pending.Store(state, &PendingState{
ClientID: clientID,
RedirectURI: redirectURI,
PKCEChallenge: codeChallenge,
PKCEChallengeMethod: codeChallengeMethod,
State: state,
Nonce: nonce,
Scopes: strings.Fields(scope),
ExpiresAt: time.Now().Add(10 * time.Minute),
})
// Delegate to Auth provider.
authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{
ClientID: clientID,
RedirectURI: redirectURI,
State: state,
Scopes: strings.Fields(scope),
PKCEChallenge: codeChallenge,
PKCEChallengeMethod: codeChallengeMethod,
})
if err != nil {
http.Error(w, "upstream auth provider error", http.StatusBadGateway)
return
}
http.Redirect(w, r, authURL, http.StatusFound)
}
// ServeHTTPCallback handles GET /authorize/callback.
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
h.init()
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()
state := q.Get("state")
code := q.Get("code")
mfaToken := q.Get("mfa_token")
// Recover pending state keyed by state param.
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
}
// Handle upstream callback.
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
Code: code,
State: state,
})
if err != nil {
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
}
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.
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
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)
return
}
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 {
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, 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.
sess := &PKCESession{
ClientID: ps.ClientID,
RedirectURI: ps.RedirectURI,
PKCEChallenge: ps.PKCEChallenge,
PKCEChallengeMethod: ps.PKCEChallengeMethod,
State: ps.State,
Nonce: ps.Nonce,
Username: username,
Scopes: ps.Scopes,
ExpiresAt: time.Now().Add(10 * time.Minute),
}
authCode := h.Sessions.Create(sess)
h.Emitter.Emit(r.Context(), telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "success",
Scopes: ps.Scopes,
})
// Redirect to client with code and state.
redirectTo, err := url.Parse(ps.RedirectURI)
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
// ---------------------------------------------------------------------------
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 {
for _, u := range registered {
if u == target {
return true
}
}
return false
}
func scopeContains(scope, want string) bool {
for _, s := range strings.Fields(scope) {
if s == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,762 @@
package oidc_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Mock implementations
// ---------------------------------------------------------------------------
// mockAuthProvider implements domain.AuthProvider.
type mockAuthProvider struct {
authorizeURL string
authorizeErr error
callbackResult *domain.AuthResult
callbackErr error
}
func (m *mockAuthProvider) AuthorizeURL(_ context.Context, _ domain.AuthRequest) (string, error) {
if m.authorizeErr != nil {
return "", m.authorizeErr
}
return m.authorizeURL, nil
}
func (m *mockAuthProvider) HandleCallback(_ context.Context, _ domain.CallbackParams) (*domain.AuthResult, error) {
return m.callbackResult, m.callbackErr
}
// mockMFAProvider implements domain.MFAProvider.
type mockMFAProvider struct {
required bool
requiredErr error
validateErr error
validateCalls int
validatedUser string
validatedToken string
}
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
return m.required, m.requiredErr
}
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
m.validateCalls++
m.validatedUser = user
m.validatedToken = token
return m.validateErr
}
// captureEmitter captures the last emitted event.
type captureEmitter struct {
events []telemetry.Event
}
func (c *captureEmitter) Emit(_ context.Context, ev telemetry.Event) {
c.events = append(c.events, ev)
}
func (c *captureEmitter) last() telemetry.Event {
if len(c.events) == 0 {
return telemetry.Event{}
}
return c.events[len(c.events)-1]
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
func testClient() map[string]*domain.Client {
return map[string]*domain.Client{
"test-client": {
ClientID: "test-client",
DisplayName: "Test Client",
RedirectURIs: []string{"https://app.example.com/callback"},
AllowedScopes: []string{"openid", "profile", "email"},
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",
},
}
}
func newAuthorizeHandler(auth domain.AuthProvider, mfa domain.MFAProvider, emitter telemetry.Emitter) *oidc.AuthorizeHandler {
return &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: oidc.NewSessionStore(),
Emitter: emitter,
}
}
func validAuthorizeParams() url.Values {
return url.Values{
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"response_type": []string{"code"},
"scope": []string{"openid profile"},
"state": []string{"random-state"},
"code_challenge": []string{"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": []string{"S256"},
}
}
func authorizeRequest(params url.Values) *http.Request {
return httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil)
}
func decodeProfileError(t *testing.T, body string) profileerrors.ErrorType {
t.Helper()
var pe profileerrors.ProfileError
if err := json.Unmarshal([]byte(body), &pe); err != nil {
t.Fatalf("could not decode ProfileError: %v (body: %q)", err, body)
}
return pe.Error
}
// ---------------------------------------------------------------------------
// T06 Authorization Endpoint Tests
// ---------------------------------------------------------------------------
func TestAuthorizeHandler_ValidRequest_RedirectsToAuthelia(t *testing.T) {
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth?state=xyz"}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
req := authorizeRequest(validAuthorizeParams())
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())
}
loc := w.Header().Get("Location")
if loc != "https://authelia.example.com/auth?state=xyz" {
t.Errorf("expected redirect to Authelia, got %q", loc)
}
}
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) {
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
req := authorizeRequest(validAuthorizeParams())
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
found := false
for _, ev := range emitter.events {
if ev.EventType == telemetry.EventAuthStart {
found = true
break
}
}
if !found {
t.Error("expected auth_start telemetry event to be emitted")
}
}
func TestAuthorizeHandler_MissingCodeChallenge_InvalidProfileUsage(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Del("code_challenge")
params.Del("code_challenge_method")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrInvalidProfileUsage {
t.Errorf("expected invalid_profile_usage, got %q", errType)
}
}
func TestAuthorizeHandler_WildcardRedirectURI_RejectedForSafety(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
clients := map[string]*domain.Client{
"wildcard-client": {
ClientID: "wildcard-client",
RedirectURIs: []string{"https://app.example.com/*"},
ClientType: "public",
},
}
h := &oidc.AuthorizeHandler{
ClientConfig: clients,
Auth: auth,
MFA: mfa,
Sessions: oidc.NewSessionStore(),
Emitter: emitter,
}
params := validAuthorizeParams()
params.Set("client_id", "wildcard-client")
params.Set("redirect_uri", "https://app.example.com/anything")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrRejectedForSafety {
t.Errorf("expected rejected_for_profile_safety, got %q", errType)
}
}
func TestAuthorizeHandler_UnknownClient_InvalidProfileUsage(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("client_id", "no-such-client")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrInvalidProfileUsage {
t.Errorf("expected invalid_profile_usage, got %q", errType)
}
}
func TestAuthorizeHandler_WrongResponseType_FeatureNotSupported(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("response_type", "token")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrFeatureNotSupported {
t.Errorf("expected feature_not_supported_by_profile, got %q", errType)
}
}
func TestAuthorizeHandler_MissingOpenIDScope_InvalidProfileUsage(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("scope", "profile email")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrInvalidProfileUsage {
t.Errorf("expected invalid_profile_usage, got %q", errType)
}
}
func TestAuthorizeHandler_PlainCodeChallengeMethod_RejectedForSafety(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("code_challenge_method", "plain")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrRejectedForSafety {
t.Errorf("expected rejected_for_profile_safety, got %q", errType)
}
}
func TestAuthorizeHandler_UnknownRedirectURI_InvalidProfileUsage(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("redirect_uri", "https://evil.example.com/callback")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrInvalidProfileUsage {
t.Errorf("expected invalid_profile_usage, got %q", errType)
}
}
// ---------------------------------------------------------------------------
// Callback tests
// ---------------------------------------------------------------------------
func TestAuthorizeCallback_Success_RedirectsWithCode(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},
}
mfa := &mockMFAProvider{required: false}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
req := httptest.NewRequest(http.MethodGet,
"/authorize/callback?code=authelia-code&state=random-state", nil)
// Simulate that there is an ongoing PKCE flow stored in query param forwarding
// The callback needs the original client context. We store it via a pre-seeded
// pending session keyed by state.
// For the callback handler, we expect it to look up the pending state by the
// "state" parameter that was originally embedded. We seed the pending state.
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", "profile"},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
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())
}
loc := w.Header().Get("Location")
parsed, err := url.Parse(loc)
if err != nil {
t.Fatalf("invalid Location header: %v", err)
}
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"))
}
}
func TestAuthorizeCallback_MFAFailed_AuthFailure(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},
}
mfa := &mockMFAProvider{
required: true,
validateErr: domain.ErrMFAFailed,
}
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&mfa_token=wrong", nil)
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
found := false
for _, ev := range emitter.events {
if ev.EventType == telemetry.EventAuthFailure {
found = true
break
}
}
if !found {
t.Error("expected auth_failure telemetry event")
}
}
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) {
auth := &mockAuthProvider{
callbackErr: domain.ErrAuthFailed,
}
mfa := &mockMFAProvider{}
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",
State: "random-state",
ExpiresAt: time.Now().Add(5 * time.Minute),
})
req := httptest.NewRequest(http.MethodGet,
"/authorize/callback?code=bad&state=random-state", nil)
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
found := false
for _, ev := range emitter.events {
if ev.EventType == telemetry.EventAuthFailure {
found = true
break
}
}
if !found {
t.Error("expected auth_failure telemetry event")
}
}
func TestAuthorizeCallback_EmitsAuthSuccess(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "bob"},
}
mfa := &mockMFAProvider{required: false}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
h.PendingStates().Store("s1", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "s1",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
req := httptest.NewRequest(http.MethodGet,
"/authorize/callback?code=c&state=s1", nil)
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
found := false
for _, ev := range emitter.events {
if ev.EventType == telemetry.EventAuthSuccess {
found = true
break
}
}
if !found {
t.Errorf("expected auth_success telemetry event, got events: %v", emitter.events)
}
}
// ---------------------------------------------------------------------------
// ServeHTTP dispatch
// ---------------------------------------------------------------------------
func TestAuthorizeHandler_ServeHTTP_DispatchesToCallback(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},
}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
// A request to /authorize/callback should not be treated as the initial
// authorize request and must not require PKCE params.
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=y", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// Without a seeded pending state for "y", the callback returns an error.
// The important thing is that it is NOT a redirect to Authelia.
if w.Code == http.StatusFound {
loc := w.Header().Get("Location")
if strings.Contains(loc, "authelia") {
t.Error("callback path must not redirect to Authelia")
}
}
}

View File

@@ -0,0 +1,86 @@
// Package oidc implements OIDC profile endpoints for KeyCape.
// Only profile-supported features are advertised — no implicit flow,
// no dynamic registration, no request objects.
package oidc
import (
"encoding/json"
"net/http"
)
// DiscoveryConfig holds the issuer and endpoint URLs for the discovery document.
// UserinfoEndpoint is optional; if empty it is omitted from the document.
type DiscoveryConfig struct {
Issuer string // e.g. "https://auth.netkingdom.local"
AuthorizationEndpoint string
TokenEndpoint string
JWKSUri string
UserinfoEndpoint string // optional, empty = not advertised
}
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
// Fields are ordered to match common OIDC implementations for readability.
// registration_endpoint is intentionally absent — no dynamic client registration.
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
RequestParameterSupported bool `json:"request_parameter_supported"`
ClaimsParameterSupported bool `json:"claims_parameter_supported"`
}
// discoveryHandler implements http.Handler for GET /.well-known/openid-configuration.
type discoveryHandler struct {
doc []byte
}
// NewDiscoveryHandler returns an http.Handler that serves the OIDC discovery document.
// The document is pre-serialised at construction time so every request is a cheap copy.
func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
d := discoveryDocument{
Issuer: cfg.Issuer,
AuthorizationEndpoint: cfg.AuthorizationEndpoint,
TokenEndpoint: cfg.TokenEndpoint,
JWKSUri: cfg.JWKSUri,
UserinfoEndpoint: cfg.UserinfoEndpoint,
// Profile-locked values — not negotiable.
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code"},
CodeChallengeMethodsSupported: []string{"S256"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
ScopesSupported: []string{"openid", "profile", "email", "groups"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"},
ClaimsSupported: []string{
"sub", "iss", "aud", "exp", "iat",
"preferred_username", "email", "name", "groups", "roles",
},
SubjectTypesSupported: []string{"public"},
RequestParameterSupported: false,
ClaimsParameterSupported: false,
}
b, err := json.Marshal(d)
if err != nil {
// This can only fail if the struct contains un-marshallable types, which it does not.
panic("oidc: failed to marshal discovery document: " + err.Error())
}
return &discoveryHandler{doc: b}
}
func (h *discoveryHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(h.doc)
}

View File

@@ -0,0 +1,314 @@
package oidc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"keycape/internal/server/oidc"
)
func TestDiscoveryHandler_ResponseCode(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
UserinfoEndpoint: "https://auth.netkingdom.local/oauth2/userinfo",
}
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestDiscoveryHandler_ContentType(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
ct := w.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
}
func TestDiscoveryHandler_CacheControl(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
cc := w.Header().Get("Cache-Control")
if cc != "max-age=3600" {
t.Errorf("expected Cache-Control max-age=3600, got %q", cc)
}
}
func discoveryDoc(t *testing.T, cfg oidc.DiscoveryConfig) map[string]interface{} {
t.Helper()
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var doc map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
t.Fatalf("could not decode JSON: %v", err)
}
return doc
}
func TestDiscoveryHandler_Issuer(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
if doc["issuer"] != cfg.Issuer {
t.Errorf("issuer: expected %q, got %v", cfg.Issuer, doc["issuer"])
}
}
func TestDiscoveryHandler_Endpoints(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
UserinfoEndpoint: "https://auth.netkingdom.local/oauth2/userinfo",
}
doc := discoveryDoc(t, cfg)
checks := map[string]string{
"authorization_endpoint": cfg.AuthorizationEndpoint,
"token_endpoint": cfg.TokenEndpoint,
"jwks_uri": cfg.JWKSUri,
"userinfo_endpoint": cfg.UserinfoEndpoint,
}
for key, want := range checks {
if got, ok := doc[key]; !ok {
t.Errorf("missing %q", key)
} else if got != want {
t.Errorf("%s: expected %q, got %v", key, want, got)
}
}
}
func TestDiscoveryHandler_UserinfoOmittedWhenEmpty(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
// UserinfoEndpoint intentionally empty
}
doc := discoveryDoc(t, cfg)
if _, ok := doc["userinfo_endpoint"]; ok {
t.Error("userinfo_endpoint must be absent when not configured")
}
}
func TestDiscoveryHandler_NoRegistrationEndpoint(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
if _, ok := doc["registration_endpoint"]; ok {
t.Error("registration_endpoint must NOT be present (no dynamic registration)")
}
}
func stringSliceFromDoc(t *testing.T, doc map[string]interface{}, key string) []string {
t.Helper()
raw, ok := doc[key]
if !ok {
t.Fatalf("missing key %q", key)
}
arr, ok := raw.([]interface{})
if !ok {
t.Fatalf("%q: expected array, got %T", key, raw)
}
out := make([]string, len(arr))
for i, v := range arr {
s, ok := v.(string)
if !ok {
t.Fatalf("%q[%d]: expected string, got %T", key, i, v)
}
out[i] = s
}
return out
}
func assertStringSlice(t *testing.T, doc map[string]interface{}, key string, want []string) {
t.Helper()
got := stringSliceFromDoc(t, doc, key)
if len(got) != len(want) {
t.Errorf("%s: expected %v, got %v", key, want, got)
return
}
wantSet := make(map[string]bool)
for _, s := range want {
wantSet[s] = true
}
for _, s := range got {
if !wantSet[s] {
t.Errorf("%s: unexpected value %q (got %v, want %v)", key, s, got, want)
}
}
}
func TestDiscoveryHandler_ResponseTypes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "response_types_supported", []string{"code"})
}
func TestDiscoveryHandler_GrantTypes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "grant_types_supported", []string{"authorization_code"})
}
func TestDiscoveryHandler_CodeChallengeMethod(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "code_challenge_methods_supported", []string{"S256"})
}
func TestDiscoveryHandler_SigningAlg(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "id_token_signing_alg_values_supported", []string{"RS256"})
}
func TestDiscoveryHandler_Scopes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "scopes_supported", []string{"openid", "profile", "email", "groups"})
}
func TestDiscoveryHandler_TokenEndpointAuthMethods(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "token_endpoint_auth_methods_supported",
[]string{"client_secret_basic", "client_secret_post", "none"})
}
func TestDiscoveryHandler_Claims(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "claims_supported",
[]string{"sub", "iss", "aud", "exp", "iat", "preferred_username", "email", "name", "groups", "roles"})
}
func TestDiscoveryHandler_SubjectTypes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "subject_types_supported", []string{"public"})
}
func TestDiscoveryHandler_RequestParameterNotSupported(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
v, ok := doc["request_parameter_supported"]
if !ok {
t.Fatal("request_parameter_supported must be present")
}
if b, ok := v.(bool); !ok || b {
t.Errorf("request_parameter_supported: expected false, got %v", v)
}
}
func TestDiscoveryHandler_ClaimsParameterNotSupported(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
v, ok := doc["claims_parameter_supported"]
if !ok {
t.Fatal("claims_parameter_supported must be present")
}
if b, ok := v.(bool); !ok || b {
t.Errorf("claims_parameter_supported: expected false, got %v", v)
}
}

View File

@@ -0,0 +1,123 @@
package oidc
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net/http"
)
// JWK represents a single JSON Web Key for an RSA public key.
// Only fields required for RS256 signature verification are included.
type JWK struct {
Kty string `json:"kty"` // "RSA"
Use string `json:"use"` // "sig"
Alg string `json:"alg"` // "RS256"
Kid string `json:"kid"` // key identifier
N string `json:"n"` // base64url-encoded modulus (no padding)
E string `json:"e"` // base64url-encoded public exponent (no padding)
}
// keyEntry pairs a kid with the corresponding public key.
type keyEntry struct {
kid string
pub *rsa.PublicKey
}
// KeySet holds one or more RSA public keys for JWKS rotation.
// Keys are served in insertion order.
type KeySet struct {
entries []keyEntry
}
// NewKeySet returns an empty KeySet ready for AddKey calls.
func NewKeySet() *KeySet {
return &KeySet{}
}
// AddKey appends an RSA public key with the given key ID.
// kid must be unique within the set; duplicates are not checked.
func (ks *KeySet) AddKey(kid string, pub *rsa.PublicKey) {
ks.entries = append(ks.entries, keyEntry{kid: kid, pub: pub})
}
// jwkFromPublicKey encodes an RSA public key as a JWK using base64url (no padding).
func jwkFromPublicKey(kid string, pub *rsa.PublicKey) JWK {
enc := base64.RawURLEncoding
// Modulus — big-endian bytes, no leading zero (math/big ensures minimal encoding).
nBytes := pub.N.Bytes()
// Exponent — big-endian minimal encoding.
exp := big.NewInt(int64(pub.E))
eBytes := exp.Bytes()
return JWK{
Kty: "RSA",
Use: "sig",
Alg: "RS256",
Kid: kid,
N: enc.EncodeToString(nBytes),
E: enc.EncodeToString(eBytes),
}
}
// jwksResponse is the top-level JWK Set object.
type jwksResponse struct {
Keys []JWK `json:"keys"`
}
// jwksHandler implements http.Handler for GET /jwks.
type jwksHandler struct {
ks *KeySet
}
// NewJWKSHandler returns an http.Handler that serves the JWK Set.
// The key set is serialised on every request so key rotation can be supported
// by mutating the KeySet before the next request (safe for construction-time use;
// for live rotation a RWMutex should wrap AddKey).
func NewJWKSHandler(ks *KeySet) http.Handler {
return &jwksHandler{ks: ks}
}
func (h *jwksHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
jwks := jwksResponse{Keys: make([]JWK, 0, len(h.ks.entries))}
for _, e := range h.ks.entries {
jwks.Keys = append(jwks.Keys, jwkFromPublicKey(e.kid, e.pub))
}
b, err := json.Marshal(jwks)
if err != nil {
http.Error(w, "internal error encoding JWKS", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// LoadPublicKeyFromPEM parses a PEM-encoded public key (PKIX / "PUBLIC KEY" block).
// Returns an error if the PEM data is invalid or does not contain an RSA public key.
func LoadPublicKeyFromPEM(pemData []byte) (*rsa.PublicKey, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("jwks: no PEM block found in input")
}
if block.Type != "PUBLIC KEY" {
return nil, errors.New("jwks: expected PEM block type \"PUBLIC KEY\", got \"" + block.Type + "\"")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, errors.New("jwks: failed to parse PKIX public key: " + err.Error())
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("jwks: key is not an RSA public key")
}
return rsaPub, nil
}

View File

@@ -0,0 +1,214 @@
package oidc_test
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"testing"
"keycape/internal/server/oidc"
)
// generateTestKey creates a fresh RSA-2048 key for tests.
func generateTestKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
return k
}
func privateKeyToPEM(k *rsa.PrivateKey) []byte {
return pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(k),
})
}
func publicKeyToPEM(k *rsa.PublicKey) []byte {
b, _ := x509.MarshalPKIXPublicKey(k)
return pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: b,
})
}
func TestJWKSHandler_ResponseCode(t *testing.T) {
key := generateTestKey(t)
ks := oidc.NewKeySet()
ks.AddKey("kid-1", &key.PublicKey)
h := oidc.NewJWKSHandler(ks)
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestJWKSHandler_ContentType(t *testing.T) {
key := generateTestKey(t)
ks := oidc.NewKeySet()
ks.AddKey("kid-1", &key.PublicKey)
h := oidc.NewJWKSHandler(ks)
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
ct := w.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
}
func TestJWKSHandler_StructureValid(t *testing.T) {
key := generateTestKey(t)
ks := oidc.NewKeySet()
ks.AddKey("kid-abc", &key.PublicKey)
h := oidc.NewJWKSHandler(ks)
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var doc struct {
Keys []map[string]interface{} `json:"keys"`
}
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if len(doc.Keys) != 1 {
t.Fatalf("expected 1 key, got %d", len(doc.Keys))
}
k := doc.Keys[0]
for _, field := range []string{"kty", "use", "alg", "kid", "n", "e"} {
if _, ok := k[field]; !ok {
t.Errorf("JWK missing field %q", field)
}
}
}
func TestJWKSHandler_CorrectAlgorithmFields(t *testing.T) {
key := generateTestKey(t)
ks := oidc.NewKeySet()
ks.AddKey("my-kid", &key.PublicKey)
h := oidc.NewJWKSHandler(ks)
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var doc struct {
Keys []oidc.JWK `json:"keys"`
}
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if len(doc.Keys) != 1 {
t.Fatalf("expected 1 key")
}
jwk := doc.Keys[0]
if jwk.Kty != "RSA" {
t.Errorf("kty: expected RSA, got %q", jwk.Kty)
}
if jwk.Use != "sig" {
t.Errorf("use: expected sig, got %q", jwk.Use)
}
if jwk.Alg != "RS256" {
t.Errorf("alg: expected RS256, got %q", jwk.Alg)
}
if jwk.Kid != "my-kid" {
t.Errorf("kid: expected my-kid, got %q", jwk.Kid)
}
}
func TestJWKSHandler_MultipleKeys(t *testing.T) {
key1 := generateTestKey(t)
key2 := generateTestKey(t)
ks := oidc.NewKeySet()
ks.AddKey("kid-1", &key1.PublicKey)
ks.AddKey("kid-2", &key2.PublicKey)
h := oidc.NewJWKSHandler(ks)
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var doc struct {
Keys []oidc.JWK `json:"keys"`
}
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if len(doc.Keys) != 2 {
t.Fatalf("expected 2 keys, got %d", len(doc.Keys))
}
}
func TestLoadPublicKeyFromPEM_Valid(t *testing.T) {
key := generateTestKey(t)
pemData := publicKeyToPEM(&key.PublicKey)
pub, err := oidc.LoadPublicKeyFromPEM(pemData)
if err != nil {
t.Fatalf("LoadPublicKeyFromPEM: %v", err)
}
if pub.N.Cmp(key.PublicKey.N) != 0 {
t.Error("modulus mismatch")
}
if pub.E != key.PublicKey.E {
t.Error("exponent mismatch")
}
}
func TestLoadPublicKeyFromPEM_InvalidPEM(t *testing.T) {
_, err := oidc.LoadPublicKeyFromPEM([]byte("not a pem"))
if err == nil {
t.Error("expected error for invalid PEM, got nil")
}
}
func TestLoadPublicKeyFromPEM_PrivateKeyRejected(t *testing.T) {
key := generateTestKey(t)
pemData := privateKeyToPEM(key)
// A private key PEM should not decode as a public key
_, err := oidc.LoadPublicKeyFromPEM(pemData)
if err == nil {
t.Error("expected error when loading private key as public key")
}
}
func TestJWKSHandler_NEncoding(t *testing.T) {
// Ensure N is base64url (no padding, no +/)
key := generateTestKey(t)
ks := oidc.NewKeySet()
ks.AddKey("k1", &key.PublicKey)
h := oidc.NewJWKSHandler(ks)
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var doc struct {
Keys []oidc.JWK `json:"keys"`
}
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
t.Fatalf("decode: %v", err)
}
n := doc.Keys[0].N
for _, c := range n {
if c == '+' || c == '/' || c == '=' {
t.Errorf("N contains standard base64 character %q — must be base64url without padding", string(c))
}
}
}

View File

@@ -0,0 +1,80 @@
package oidc
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
)
// PKCESession stores the in-flight authorization state server-side.
type PKCESession struct {
Code string
ClientID string
RedirectURI string
PKCEChallenge string // S256 challenge
PKCEChallengeMethod string // always "S256"
State string
Nonce string
Username string // set after auth
Scopes []string
ExpiresAt time.Time
}
// SessionStore is an in-memory PKCE session store.
type SessionStore struct {
mu sync.Mutex
sessions map[string]*PKCESession // keyed by code
}
// NewSessionStore returns an initialised, empty SessionStore.
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]*PKCESession),
}
}
// Create stores the session and returns the generated authorization code.
func (s *SessionStore) Create(sess *PKCESession) string {
code := generateCode()
sess.Code = code
s.mu.Lock()
s.sessions[code] = sess
s.mu.Unlock()
return code
}
// Get retrieves a session by code. Returns false if not found or expired.
func (s *SessionStore) Get(code string) (*PKCESession, bool) {
s.mu.Lock()
sess, ok := s.sessions[code]
s.mu.Unlock()
if !ok {
return nil, false
}
if time.Now().After(sess.ExpiresAt) {
s.Delete(code)
return nil, false
}
return sess, true
}
// Delete removes a session by code. No-op if the code is not present.
func (s *SessionStore) Delete(code string) {
s.mu.Lock()
delete(s.sessions, code)
s.mu.Unlock()
}
// generateCode returns a cryptographically random, URL-safe string suitable
// for use as an authorization code.
func generateCode() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("oidc: failed to generate random code: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@@ -0,0 +1,222 @@
package oidc
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
"keycape/internal/server/telemetry"
)
// TokenHandler implements POST /token.
type TokenHandler struct {
ClientConfig map[string]*domain.Client
Sessions *SessionStore
Users domain.UserRepository
SigningKey *rsa.PrivateKey
Issuer string
TokenLifetime time.Duration
Emitter telemetry.Emitter
}
// tokenResponse is the JSON body returned on a successful token exchange.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
}
// ServeHTTP handles POST /token.
func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form body", http.StatusBadRequest)
return
}
grantType := r.FormValue("grant_type")
clientID := r.FormValue("client_id")
code := r.FormValue("code")
codeVerifier := r.FormValue("code_verifier")
// 1. Validate grant_type.
if grantType != "authorization_code" {
profileerrors.FeatureNotSupported(
"only grant_type=authorization_code is supported",
"grant_type="+grantType,
).Write(w, http.StatusBadRequest)
return
}
// 2. Validate client exists (basic check; secret auth delegated to future work).
if _, ok := h.ClientConfig[clientID]; !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
// 3. Look up PKCE session.
sess, ok := h.Sessions.Get(code)
if !ok {
profileerrors.InvalidProfileUsage(
"authorization code not found or expired",
"code",
).Write(w, http.StatusBadRequest)
return
}
// Verify client_id matches the session.
if sess.ClientID != clientID {
profileerrors.InvalidProfileUsage(
"client_id does not match the authorization code",
"client_id",
).Write(w, http.StatusBadRequest)
return
}
// 4. Verify PKCE code_verifier.
if !verifyPKCE(codeVerifier, sess.PKCEChallenge) {
profileerrors.InvalidProfileUsage(
"code_verifier does not match code_challenge",
"code_verifier",
).Write(w, http.StatusBadRequest)
return
}
// 5. Look up user.
user, err := h.Users.LookupUser(ctx, sess.Username)
if err != nil {
http.Error(w, "user not found", http.StatusInternalServerError)
return
}
// 6. Build JWT claims.
now := time.Now()
exp := now.Add(h.TokenLifetime)
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": user.ID,
"aud": clientID,
"exp": exp.Unix(),
"iat": now.Unix(),
}
if sess.Nonce != "" {
claims["nonce"] = sess.Nonce
}
scopeSet := make(map[string]bool)
for _, s := range sess.Scopes {
scopeSet[s] = true
}
if scopeSet["profile"] {
claims["preferred_username"] = user.Username
}
if scopeSet["email"] {
claims["email"] = user.Email
}
if scopeSet["groups"] {
claims["groups"] = user.Groups
}
// 7. Sign JWT with RSA-SHA256.
kid := "key-1" // static kid for v0.1
jwtToken, err := buildJWT(claims, kid, h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
return
}
// 8. Delete used PKCE session (prevent replay).
h.Sessions.Delete(code)
// 9. Build response.
resp := tokenResponse{
AccessToken: jwtToken,
TokenType: "Bearer",
ExpiresIn: int(h.TokenLifetime.Seconds()),
IDToken: jwtToken,
}
// 10. Emit token_issued telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventTokenIssued,
ClientID: clientID,
Endpoint: "/token",
Result: "success",
Scopes: sess.Scopes,
GrantType: grantType,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
// verifyPKCE checks BASE64URL(SHA256(verifier)) == challenge (S256 method).
func verifyPKCE(verifier, challenge string) bool {
h := sha256.New()
h.Write([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return computed == challenge
}
// ---------------------------------------------------------------------------
// JWT construction (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
Kid string `json:"kid"`
}
// buildJWT constructs and signs a JWT using RSA-SHA256 with the standard library.
// Format: base64url(header) + "." + base64url(payload) + "." + base64url(signature)
func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
// Header.
hdr := jwtHeader{Alg: "RS256", Typ: "JWT", Kid: kid}
hdrJSON, err := json.Marshal(hdr)
if err != nil {
return "", err
}
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
// Payload.
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Signing input.
signingInput := hdrB64 + "." + payloadB64
// Digest.
digest := sha256.Sum256([]byte(signingInput))
// Sign with PKCS1v15 / SHA256.
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil
}

View File

@@ -0,0 +1,508 @@
package oidc_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Mock UserRepository
// ---------------------------------------------------------------------------
type mockUserRepo struct {
users map[string]*domain.User
}
func (m *mockUserRepo) LookupUser(_ context.Context, username string) (*domain.User, error) {
u, ok := m.users[username]
if !ok {
return nil, domain.ErrUserNotFound
}
return u, nil
}
func (m *mockUserRepo) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) {
return nil, nil
}
func (m *mockUserRepo) ValidatePassword(_ context.Context, _, _ string) (bool, error) {
return false, nil
}
func (m *mockUserRepo) ListUsers(_ context.Context) ([]domain.User, error) {
users := make([]domain.User, 0, len(m.users))
for _, u := range m.users {
users = append(users, *u)
}
return users, nil
}
// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------
// makeVerifierAndChallenge returns a code_verifier and its S256 code_challenge.
func makeVerifierAndChallenge() (verifier, challenge string) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
verifier = base64.RawURLEncoding.EncodeToString(b)
h := sha256.New()
h.Write([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
func newTokenHandler(t *testing.T, sessions *oidc.SessionStore, users domain.UserRepository) (*oidc.TokenHandler, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
emitter := &captureEmitter{}
h := &oidc.TokenHandler{
ClientConfig: testClient(),
Sessions: sessions,
Users: users,
SigningKey: key,
Issuer: "https://auth.netkingdom.local",
TokenLifetime: 15 * time.Minute,
Emitter: emitter,
}
return h, key
}
func tokenRequest(params url.Values) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/token",
strings.NewReader(params.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
func seededSession(sessions *oidc.SessionStore, verifier string) (code string) {
challenge := s256Challenge(verifier)
sess := &oidc.PKCESession{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: challenge,
PKCEChallengeMethod: "S256",
State: "state1",
Nonce: "nonce1",
Username: "alice",
Scopes: []string{"openid", "profile", "email", "groups"},
ExpiresAt: time.Now().Add(10 * time.Minute),
}
return sessions.Create(sess)
}
func s256Challenge(verifier string) string {
h := sha256.New()
h.Write([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
func decodeTokenResponse(t *testing.T, body string) map[string]interface{} {
t.Helper()
var m map[string]interface{}
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("could not decode token response: %v (body: %q)", err, body)
}
return m
}
func parseJWTPayload(t *testing.T, token string) map[string]interface{} {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("expected 3 JWT parts, got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("decode JWT payload: %v", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatalf("unmarshal JWT payload: %v", err)
}
return claims
}
func aliceUser() *domain.User {
return &domain.User{
ID: "user-alice",
Username: "alice",
Email: "alice@example.com",
Groups: []string{"admin", "users"},
Enabled: true,
}
}
// ---------------------------------------------------------------------------
// T07 Token Endpoint Tests
// ---------------------------------------------------------------------------
func TestTokenHandler_ValidExchange_ReturnsJWT(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newTokenHandler(t, sessions, users)
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code := seededSession(sessions, verifier)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{verifier},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
resp := decodeTokenResponse(t, w.Body.String())
if _, ok := resp["access_token"]; !ok {
t.Error("missing access_token")
}
if _, ok := resp["id_token"]; !ok {
t.Error("missing id_token")
}
if resp["token_type"] != "Bearer" {
t.Errorf("expected token_type Bearer, got %v", resp["token_type"])
}
if _, ok := resp["expires_in"]; !ok {
t.Error("missing expires_in")
}
}
func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{}
h, _ := newTokenHandler(t, sessions, users)
params := url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{"test-client"},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrFeatureNotSupported {
t.Errorf("expected feature_not_supported_by_profile, got %q", errType)
}
}
func TestTokenHandler_PKCEMismatch_InvalidProfileUsage(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newTokenHandler(t, sessions, users)
realVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code := seededSession(sessions, realVerifier)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{"wrong-verifier-that-does-not-match"},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrInvalidProfileUsage {
t.Errorf("expected invalid_profile_usage, got %q", errType)
}
}
func TestTokenHandler_CodeNotFound_InvalidProfileUsage(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{}
h, _ := newTokenHandler(t, sessions, users)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{"no-such-code"},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{"any-verifier"},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
errType := decodeProfileError(t, w.Body.String())
if errType != profileerrors.ErrInvalidProfileUsage {
t.Errorf("expected invalid_profile_usage, got %q", errType)
}
}
func TestTokenHandler_JWTClaims_CorrectSubAndIssuer(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newTokenHandler(t, sessions, users)
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code := seededSession(sessions, verifier)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{verifier},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
resp := decodeTokenResponse(t, w.Body.String())
idToken, ok := resp["id_token"].(string)
if !ok {
t.Fatal("id_token is not a string")
}
claims := parseJWTPayload(t, idToken)
if claims["sub"] != "user-alice" {
t.Errorf("sub: expected user-alice, got %v", claims["sub"])
}
if claims["iss"] != "https://auth.netkingdom.local" {
t.Errorf("iss: expected https://auth.netkingdom.local, got %v", claims["iss"])
}
if claims["aud"] != "test-client" {
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) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newTokenHandler(t, sessions, users)
// Seed session with only openid scope (no email, no groups).
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := s256Challenge(verifier)
sess := &oidc.PKCESession{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: challenge,
PKCEChallengeMethod: "S256",
Username: "alice",
Scopes: []string{"openid"}, // no profile/email/groups
ExpiresAt: time.Now().Add(10 * time.Minute),
}
code := sessions.Create(sess)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{verifier},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
resp := decodeTokenResponse(t, w.Body.String())
idToken := resp["id_token"].(string)
claims := parseJWTPayload(t, idToken)
// Without profile scope, preferred_username must not be present.
if _, ok := claims["preferred_username"]; ok {
t.Error("preferred_username must be absent when profile scope is not granted")
}
// Without email scope, email must not be present.
if _, ok := claims["email"]; ok {
t.Error("email must be absent when email scope is not granted")
}
// Without groups scope, groups must not be present.
if _, ok := claims["groups"]; ok {
t.Error("groups must be absent when groups scope is not granted")
}
}
func TestTokenHandler_ScopeFiltering_AllScopes(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newTokenHandler(t, sessions, users)
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code := seededSession(sessions, verifier) // has openid, profile, email, groups
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{verifier},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
resp := decodeTokenResponse(t, w.Body.String())
idToken := resp["id_token"].(string)
claims := parseJWTPayload(t, idToken)
if claims["preferred_username"] != "alice" {
t.Errorf("preferred_username: expected alice, got %v", claims["preferred_username"])
}
if claims["email"] != "alice@example.com" {
t.Errorf("email: expected alice@example.com, got %v", claims["email"])
}
if _, ok := claims["groups"]; !ok {
t.Error("groups claim must be present when groups scope is granted")
}
}
func TestTokenHandler_TokenIssuedTelemetry(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
capture := &captureEmitter{}
h := &oidc.TokenHandler{
ClientConfig: testClient(),
Sessions: sessions,
Users: users,
SigningKey: key,
Issuer: "https://auth.netkingdom.local",
TokenLifetime: 15 * time.Minute,
Emitter: capture,
}
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code := seededSession(sessions, verifier)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{verifier},
}
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
found := false
for _, ev := range capture.events {
if ev.EventType == telemetry.EventTokenIssued {
found = true
break
}
}
if !found {
t.Error("expected token_issued telemetry event")
}
}
func TestTokenHandler_CodeDeletedAfterUse(t *testing.T) {
sessions := oidc.NewSessionStore()
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newTokenHandler(t, sessions, users)
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code := seededSession(sessions, verifier)
params := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"client_id": []string{"test-client"},
"redirect_uri": []string{"https://app.example.com/callback"},
"code_verifier": []string{verifier},
}
// First use — should succeed.
req := tokenRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first use: expected 200, got %d", w.Code)
}
// Second use — code should be gone.
req2 := tokenRequest(params)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusBadRequest {
t.Errorf("second use: expected 400 (code replay), got %d", w2.Code)
}
}

View File

@@ -0,0 +1,185 @@
package oidc
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// UserinfoHandler implements GET /userinfo (OIDC Core §5.3).
//
// The endpoint validates the Bearer token, extracts the subject, looks up
// the user, and returns claims that are consistent with those in the ID token
// for the same scope set.
type UserinfoHandler struct {
Users domain.UserRepository
SigningKey *rsa.PublicKey // used to verify the incoming access token
Issuer string
Emitter telemetry.Emitter
}
// ServeHTTP handles GET /userinfo.
func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Extract Bearer token.
tokenStr, ok := bearerToken(r)
if !ok {
http.Error(w, `{"error":"missing_token","description":"Authorization: Bearer <token> required"}`, http.StatusUnauthorized)
return
}
// 2. Validate token (signature + expiry) and extract claims.
claims, err := validateJWT(tokenStr, h.SigningKey)
if err != nil {
http.Error(w, `{"error":"invalid_token","description":"token validation failed"}`, http.StatusUnauthorized)
return
}
// 3. Extract sub claim (which is the username in our model).
sub, _ := claims["sub"].(string)
if sub == "" {
http.Error(w, `{"error":"invalid_token","description":"missing sub claim"}`, http.StatusUnauthorized)
return
}
// 4. Look up user by sub (sub IS the username per spec §3.1).
user, err := h.Users.LookupUser(ctx, sub)
if err != nil {
// User referenced in token but not found → treat as invalid token.
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
return
}
// 5. Build response claims filtered by the scopes embedded in the token.
scopeStr, _ := claims["scope"].(string)
scopeSet := parseScopeSet(scopeStr)
resp := map[string]interface{}{
"sub": sub,
}
if scopeSet["profile"] {
resp["preferred_username"] = user.Username
resp["name"] = user.DisplayName
}
if scopeSet["email"] {
resp["email"] = user.Email
}
if scopeSet["groups"] {
resp["groups"] = user.Groups
}
// 6. Emit telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
Endpoint: "/userinfo",
Result: "success",
})
// 7. Write JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// ---------------------------------------------------------------------------
// JWT validation (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
// validateJWT parses and validates a JWT signed with RS256.
// It checks the signature using pubKey and verifies the exp claim.
// Returns the parsed claims on success.
func validateJWT(tokenStr string, pubKey *rsa.PublicKey) (map[string]interface{}, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, errors.New("malformed JWT: expected 3 parts")
}
// Verify signature over header.payload.
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errors.New("malformed JWT: invalid signature encoding")
}
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes); err != nil {
return nil, errors.New("JWT signature verification failed")
}
// Decode payload.
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, errors.New("malformed JWT: invalid payload encoding")
}
var claims map[string]interface{}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, errors.New("malformed JWT: payload is not valid JSON")
}
// Check exp claim.
exp, ok := claims["exp"].(float64)
if !ok {
return nil, errors.New("JWT missing exp claim")
}
if time.Now().Unix() > int64(exp) {
return nil, errors.New("JWT has expired")
}
return claims, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// bearerToken extracts the token from the Authorization header.
// Returns ("", false) when the header is missing or not a Bearer token.
func bearerToken(r *http.Request) (string, bool) {
hdr := r.Header.Get("Authorization")
if hdr == "" {
return "", false
}
const prefix = "Bearer "
if !strings.HasPrefix(hdr, prefix) {
return "", false
}
tok := strings.TrimSpace(hdr[len(prefix):])
if tok == "" {
return "", false
}
return tok, true
}
// parseScopeSet converts a space-separated scope string to a set.
func parseScopeSet(scope string) map[string]bool {
set := make(map[string]bool)
for _, s := range strings.Fields(scope) {
set[s] = true
}
return set
}
// ---------------------------------------------------------------------------
// BuildJWT — exported for test helpers
// ---------------------------------------------------------------------------
// BuildJWT is an exported wrapper around the internal buildJWT function so
// that tests in the oidc_test package can construct valid tokens for the
// UserinfoHandler without importing an external JWT library.
func BuildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
return buildJWT(claims, kid, key)
}

View File

@@ -0,0 +1,307 @@
package oidc_test
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func newUserinfoHandler(t *testing.T, users domain.UserRepository) (*oidc.UserinfoHandler, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate RSA key: %v", err)
}
capture := &captureEmitter{}
h := &oidc.UserinfoHandler{
Users: users,
SigningKey: &key.PublicKey,
Issuer: "https://auth.netkingdom.local",
Emitter: capture,
}
return h, key
}
// buildToken builds and signs a JWT with the given claims using the private key.
func buildToken(t *testing.T, claims map[string]interface{}, key *rsa.PrivateKey) string {
t.Helper()
tok, err := oidc.BuildJWT(claims, "key-1", key)
if err != nil {
t.Fatalf("buildToken: %v", err)
}
return tok
}
func userinfoRequest(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/userinfo", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req
}
func decodeUserinfoClaims(t *testing.T, body string) map[string]interface{} {
t.Helper()
var m map[string]interface{}
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("decode userinfo response: %v (body: %q)", err, body)
}
return m
}
// ---------------------------------------------------------------------------
// T09 — Userinfo Endpoint Tests
// ---------------------------------------------------------------------------
func TestUserinfoHandler_ValidToken_ReturnsClaims(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{
"user-alice": aliceUser(), // LookupUser by sub (= user.ID)
"alice": aliceUser(), // also by username
}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"aud": "test-client",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid profile email groups",
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct == "" {
t.Error("Content-Type must be set")
}
resp := decodeUserinfoClaims(t, w.Body.String())
if resp["sub"] != "alice" {
t.Errorf("sub: expected alice, got %v", resp["sub"])
}
}
func TestUserinfoHandler_MissingAuthorization_Returns401(t *testing.T) {
users := &mockUserRepo{}
h, _ := newUserinfoHandler(t, users)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(""))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestUserinfoHandler_ExpiredToken_Returns401(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"aud": "test-client",
"exp": now.Add(-5 * time.Minute).Unix(), // already expired
"iat": now.Add(-10 * time.Minute).Unix(),
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for expired token, got %d", w.Code)
}
}
func TestUserinfoHandler_InvalidSignature_Returns401(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newUserinfoHandler(t, users) // handler uses key1.Public
// Sign with a DIFFERENT key
wrongKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate wrong key: %v", err)
}
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
}
token := buildToken(t, claims, wrongKey)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for invalid signature, got %d", w.Code)
}
}
func TestUserinfoHandler_WithEmailScope_EmailPresent(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid email",
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
resp := decodeUserinfoClaims(t, w.Body.String())
if resp["email"] != "alice@example.com" {
t.Errorf("email: expected alice@example.com, got %v", resp["email"])
}
}
func TestUserinfoHandler_WithoutEmailScope_EmailAbsent(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid profile", // no email scope
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
resp := decodeUserinfoClaims(t, w.Body.String())
if _, ok := resp["email"]; ok {
t.Error("email must be absent when email scope is not present in token")
}
}
func TestUserinfoHandler_WithProfileScope_UsernamePresent(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid profile",
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
resp := decodeUserinfoClaims(t, w.Body.String())
if resp["preferred_username"] != "alice" {
t.Errorf("preferred_username: expected alice, got %v", resp["preferred_username"])
}
}
func TestUserinfoHandler_EmitsTelemetry(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
key, _ := rsa.GenerateKey(rand.Reader, 2048)
capture := &captureEmitter{}
h := &oidc.UserinfoHandler{
Users: users,
SigningKey: &key.PublicKey,
Issuer: "https://auth.netkingdom.local",
Emitter: capture,
}
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
}
token, _ := oidc.BuildJWT(claims, "key-1", key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
found := false
for _, ev := range capture.events {
if ev.EventType == telemetry.EventAuthSuccess && ev.Endpoint == "/userinfo" {
found = true
break
}
}
if !found {
t.Error("expected auth_success telemetry event for /userinfo")
}
}
// Ensure mockUserRepo also satisfies the extended interface with ListUsers.
func TestUserinfoHandler_UserNotFound_Returns401(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{}} // empty — no alice
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
// user not found → treat as 401 (token references unknown user)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 when user not found, got %d", w.Code)
}
}
// Compile-time check: mockUserRepo satisfies domain.UserRepository (including ListUsers).
var _ domain.UserRepository = (*mockUserRepo)(nil)

View File

@@ -0,0 +1,104 @@
package telemetry
import (
"context"
"github.com/rs/zerolog"
)
// Emitter is the interface all telemetry backends implement.
// Emit is called on every auth and error code path — there are no silent paths.
// Implementations must be safe for concurrent use.
type Emitter interface {
Emit(ctx context.Context, event Event)
}
// contextKey is an unexported type for the emitter context key to avoid collisions.
type contextKey struct{}
// WithEmitter returns a new context carrying the given Emitter.
func WithEmitter(ctx context.Context, e Emitter) context.Context {
return context.WithValue(ctx, contextKey{}, e)
}
// EmitterFromContext retrieves the Emitter from the context.
// If no emitter is stored it returns a NoopEmitter so callers never receive nil.
func EmitterFromContext(ctx context.Context) Emitter {
if e, ok := ctx.Value(contextKey{}).(Emitter); ok && e != nil {
return e
}
return NoopEmitter{}
}
// ---------------------------------------------------------------------------
// NoopEmitter
// ---------------------------------------------------------------------------
// NoopEmitter discards every event. Useful in tests and as a safe default.
type NoopEmitter struct{}
// Emit does nothing.
func (NoopEmitter) Emit(_ context.Context, _ Event) {}
// ---------------------------------------------------------------------------
// LogEmitter
// ---------------------------------------------------------------------------
// LogEmitter writes each event as a JSON log line using zerolog.
type LogEmitter struct {
log zerolog.Logger
}
// NewLogEmitter returns a LogEmitter backed by the given zerolog.Logger.
func NewLogEmitter(log zerolog.Logger) *LogEmitter {
return &LogEmitter{log: log}
}
// Emit writes the event fields as a zerolog info event.
func (l *LogEmitter) Emit(_ context.Context, ev Event) {
e := l.log.Info().
Str("event_type", string(ev.EventType)).
Str("client_id", ev.ClientID).
Str("endpoint", ev.Endpoint).
Str("result", ev.Result).
Str("environment", ev.Environment).
Str("trace_id", ev.TraceID).
Time("timestamp", ev.Timestamp)
if ev.Feature != "" {
e = e.Str("feature", ev.Feature)
}
if ev.ErrorType != "" {
e = e.Str("error_type", ev.ErrorType)
}
if len(ev.Scopes) > 0 {
e = e.Strs("scopes", ev.Scopes)
}
if ev.GrantType != "" {
e = e.Str("grant_type", ev.GrantType)
}
e.Msg("")
}
// ---------------------------------------------------------------------------
// MultiEmitter
// ---------------------------------------------------------------------------
// MultiEmitter fans an event out to multiple Emitter implementations.
// Useful for emitting to both a log and a metrics backend simultaneously.
type MultiEmitter struct {
emitters []Emitter
}
// NewMultiEmitter returns a MultiEmitter that broadcasts to all provided emitters.
func NewMultiEmitter(emitters ...Emitter) *MultiEmitter {
return &MultiEmitter{emitters: emitters}
}
// Emit calls Emit on each contained emitter in order.
func (m *MultiEmitter) Emit(ctx context.Context, ev Event) {
for _, e := range m.emitters {
e.Emit(ctx, ev)
}
}

View File

@@ -0,0 +1,179 @@
package telemetry_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/rs/zerolog"
"keycape/internal/server/telemetry"
)
func sampleEvent() telemetry.Event {
return telemetry.Event{
Timestamp: time.Now().UTC(),
ClientID: "test-client",
Endpoint: "/oauth2/token",
Feature: "",
Result: "success",
ErrorType: "",
Scopes: []string{"openid", "profile"},
GrantType: "authorization_code",
Environment: "test",
TraceID: "trace-abc-123",
EventType: telemetry.EventTokenIssued,
}
}
// ---- NoopEmitter ----
func TestNoopEmitter_DoesNotPanic(t *testing.T) {
e := telemetry.NoopEmitter{}
e.Emit(context.Background(), sampleEvent())
}
func TestNoopEmitter_ImplementsInterface(t *testing.T) {
var _ telemetry.Emitter = telemetry.NoopEmitter{}
}
// ---- LogEmitter ----
func TestLogEmitter_WritesJSON(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
e := telemetry.NewLogEmitter(logger)
ev := sampleEvent()
e.Emit(context.Background(), ev)
if buf.Len() == 0 {
t.Fatal("expected output from LogEmitter, got nothing")
}
var out map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("LogEmitter output is not valid JSON: %v\noutput: %s", err, buf.String())
}
}
func TestLogEmitter_ContainsEventFields(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
e := telemetry.NewLogEmitter(logger)
ev := sampleEvent()
e.Emit(context.Background(), ev)
var out map[string]interface{}
_ = json.Unmarshal(buf.Bytes(), &out)
requiredFields := []string{"client_id", "endpoint", "result", "environment", "trace_id", "event_type"}
for _, f := range requiredFields {
if _, ok := out[f]; !ok {
t.Errorf("LogEmitter output missing field %q", f)
}
}
}
func TestLogEmitter_EventTypeValue(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
e := telemetry.NewLogEmitter(logger)
ev := sampleEvent()
ev.EventType = telemetry.EventAuthFailure
e.Emit(context.Background(), ev)
var out map[string]interface{}
_ = json.Unmarshal(buf.Bytes(), &out)
if out["event_type"] != string(telemetry.EventAuthFailure) {
t.Errorf("event_type: expected %q, got %v", telemetry.EventAuthFailure, out["event_type"])
}
}
func TestLogEmitter_ImplementsInterface(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
var _ telemetry.Emitter = telemetry.NewLogEmitter(logger)
}
// ---- MultiEmitter ----
type capturingEmitter struct {
events []telemetry.Event
}
func (c *capturingEmitter) Emit(_ context.Context, ev telemetry.Event) {
c.events = append(c.events, ev)
}
func TestMultiEmitter_FansOut(t *testing.T) {
a := &capturingEmitter{}
b := &capturingEmitter{}
m := telemetry.NewMultiEmitter(a, b)
ev := sampleEvent()
m.Emit(context.Background(), ev)
if len(a.events) != 1 {
t.Errorf("emitter a: expected 1 event, got %d", len(a.events))
}
if len(b.events) != 1 {
t.Errorf("emitter b: expected 1 event, got %d", len(b.events))
}
}
func TestMultiEmitter_EmptyIsNoop(t *testing.T) {
m := telemetry.NewMultiEmitter()
m.Emit(context.Background(), sampleEvent()) // must not panic
}
func TestMultiEmitter_ImplementsInterface(t *testing.T) {
var _ telemetry.Emitter = telemetry.NewMultiEmitter()
}
// ---- Context helpers ----
func TestWithEmitter_RoundTrip(t *testing.T) {
orig := telemetry.NoopEmitter{}
ctx := telemetry.WithEmitter(context.Background(), orig)
got := telemetry.EmitterFromContext(ctx)
if got == nil {
t.Fatal("EmitterFromContext returned nil after WithEmitter")
}
}
func TestEmitterFromContext_FallsBackToNoop(t *testing.T) {
got := telemetry.EmitterFromContext(context.Background())
if got == nil {
t.Fatal("EmitterFromContext must never return nil — fallback to NoopEmitter expected")
}
// Verify the fallback doesn't panic
got.Emit(context.Background(), sampleEvent())
}
// ---- EventType constants ----
func TestEventTypeConstants(t *testing.T) {
cases := []struct {
et telemetry.EventType
want string
}{
{telemetry.EventAuthStart, "auth_start"},
{telemetry.EventAuthSuccess, "auth_success"},
{telemetry.EventAuthFailure, "auth_failure"},
{telemetry.EventTokenIssued, "token_issued"},
{telemetry.EventUnsupportedFeature, "unsupported_feature"},
{telemetry.EventInvalidRequest, "invalid_request"},
{telemetry.EventMigration, "migration_event"},
}
for _, c := range cases {
if string(c.et) != c.want {
t.Errorf("EventType %q: expected %q", c.et, c.want)
}
}
}

View File

@@ -0,0 +1,35 @@
// Package telemetry implements the KeyCape telemetry pipeline (spec §6).
// Every auth and error code path MUST call Emit — there are no silent paths.
package telemetry
import "time"
// EventType identifies the category of a telemetry event.
type EventType string
const (
EventAuthStart EventType = "auth_start"
EventAuthSuccess EventType = "auth_success"
EventAuthFailure EventType = "auth_failure"
EventTokenIssued EventType = "token_issued"
EventUnsupportedFeature EventType = "unsupported_feature"
EventInvalidRequest EventType = "invalid_request"
EventMigration EventType = "migration_event"
)
// Event carries all required telemetry fields from spec §6.2.
// Timestamp, Environment, TraceID, ClientID, Endpoint, Result, and EventType
// are mandatory for every event; other fields are conditional on context.
type Event struct {
Timestamp time.Time `json:"timestamp"`
ClientID string `json:"client_id"`
Endpoint string `json:"endpoint"`
Feature string `json:"feature,omitempty"`
Result string `json:"result"` // "success" | "failure"
ErrorType string `json:"error_type,omitempty"`
Scopes []string `json:"scopes,omitempty"`
GrantType string `json:"grant_type,omitempty"`
Environment string `json:"environment"`
TraceID string `json:"trace_id"`
EventType EventType `json:"event_type"`
}

View File

@@ -0,0 +1,28 @@
// Package validator implements the canonical LDAP schema validator for KeyCape.
// The validator enforces the NetKingdom LDAP schema (spec §3, §4).
// It runs in CI, provisioning, and migration modes.
package validator
// RuleResult captures the outcome of a single validation rule.
type RuleResult struct {
Rule string `json:"rule"`
Passed bool `json:"passed"`
Message string `json:"message,omitempty"`
}
// Report is the machine-readable output of a validation run.
type Report struct {
Mode string `json:"mode"`
Passed bool `json:"passed"`
Structural []RuleResult `json:"structural"`
Semantic []RuleResult `json:"semantic"`
}
// Mode identifies the operational context of the validator.
type Mode string
const (
ModeCI Mode = "ci"
ModeProvisioning Mode = "provisioning"
ModeMigration Mode = "migration"
)

View File

@@ -0,0 +1,236 @@
package validator
import (
"fmt"
"net/mail"
"strings"
"keycape/internal/domain"
)
// Snapshot is the input to the validator: a resolved canonical directory.
type Snapshot struct {
Users []domain.User
Groups []domain.Group
}
// Validate runs all structural and semantic rules against the snapshot.
// The mode string is recorded in the report but does not change rule behaviour in v0.1.
func Validate(snap Snapshot, mode Mode) Report {
report := Report{
Mode: string(mode),
}
report.Structural = runStructural(snap)
report.Semantic = runSemantic(snap)
report.Passed = allPassed(report.Structural) && allPassed(report.Semantic)
return report
}
// --- structural rules ---
func runStructural(snap Snapshot) []RuleResult {
return []RuleResult{
checkValidDNStructure(snap),
checkRequiredAttributesPresent(snap),
checkNoUnknownAttributes(snap),
checkValidGroupMemberships(snap),
}
}
// checkValidDNStructure verifies that all user and group IDs are non-empty
// and contain only characters valid in a LDAP uid/cn naming attribute.
func checkValidDNStructure(snap Snapshot) RuleResult {
r := RuleResult{Rule: "valid_dn_structure", Passed: true}
for _, u := range snap.Users {
if u.ID == "" {
r.Passed = false
r.Message = appendMsg(r.Message, "user has empty id")
continue
}
if !isValidNamingValue(u.Username) {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has invalid username for DN: %q", u.ID, u.Username))
}
}
for _, g := range snap.Groups {
if g.ID == "" {
r.Passed = false
r.Message = appendMsg(r.Message, "group has empty id")
continue
}
if !isValidNamingValue(g.Name) {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q has invalid name for DN: %q", g.ID, g.Name))
}
}
return r
}
// checkRequiredAttributesPresent verifies users have uid, cn, sn equivalents
// (id, username, displayName) and groups have id and name.
func checkRequiredAttributesPresent(snap Snapshot) RuleResult {
r := RuleResult{Rule: "required_attributes_present", Passed: true}
for _, u := range snap.Users {
if u.Username == "" {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q missing required attribute: username (uid)", u.ID))
}
if u.DisplayName == "" {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q missing required attribute: displayName (cn)", u.ID))
}
}
for _, g := range snap.Groups {
if g.Name == "" {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q missing required attribute: name (cn)", g.ID))
}
}
return r
}
// checkNoUnknownAttributes is a placeholder for attribute allow-list enforcement.
// In v0.1 with the canonical Go model all fields are known by type; this rule
// checks that no LDAPAttributes keys are empty strings.
func checkNoUnknownAttributes(snap Snapshot) RuleResult {
r := RuleResult{Rule: "no_unknown_attributes", Passed: true}
for _, u := range snap.Users {
for k := range u.LDAPAttributes {
if strings.TrimSpace(k) == "" {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has blank LDAP attribute key", u.ID))
}
}
}
return r
}
// checkValidGroupMemberships verifies that every member ID listed in a group
// is non-empty.
func checkValidGroupMemberships(snap Snapshot) RuleResult {
r := RuleResult{Rule: "valid_group_memberships", Passed: true}
for _, g := range snap.Groups {
for i, m := range g.Members {
if strings.TrimSpace(m) == "" {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q has blank member at index %d", g.ID, i))
}
}
}
return r
}
// --- semantic rules ---
func runSemantic(snap Snapshot) []RuleResult {
return []RuleResult{
checkReferencedUsersExist(snap),
checkNoCyclicGroups(snap),
checkUsernamesUnique(snap),
checkEmailFormatValid(snap),
}
}
// checkReferencedUsersExist verifies that every member ID in every group
// refers to an existing user.
func checkReferencedUsersExist(snap Snapshot) RuleResult {
r := RuleResult{Rule: "referenced_users_exist", Passed: true}
userIDs := make(map[string]bool, len(snap.Users))
for _, u := range snap.Users {
userIDs[u.ID] = true
}
for _, g := range snap.Groups {
for _, m := range g.Members {
if !userIDs[m] {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q references unknown user %q", g.ID, m))
}
}
}
return r
}
// checkNoCyclicGroups detects cycles in group.Members referencing other groups.
// In v0.1 Members are user IDs (not group IDs), so any group ID in Members is a cycle.
func checkNoCyclicGroups(snap Snapshot) RuleResult {
r := RuleResult{Rule: "no_cyclic_groups", Passed: true}
groupIDs := make(map[string]bool, len(snap.Groups))
for _, g := range snap.Groups {
groupIDs[g.ID] = true
}
for _, g := range snap.Groups {
for _, m := range g.Members {
if groupIDs[m] {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q contains group member %q (cycles not allowed)", g.ID, m))
}
}
}
return r
}
// checkUsernamesUnique verifies no two users share the same username.
func checkUsernamesUnique(snap Snapshot) RuleResult {
r := RuleResult{Rule: "usernames_unique", Passed: true}
seen := make(map[string]string) // username -> first user id
for _, u := range snap.Users {
if first, dup := seen[u.Username]; dup {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("duplicate username %q: users %q and %q", u.Username, first, u.ID))
} else {
seen[u.Username] = u.ID
}
}
return r
}
// checkEmailFormatValid verifies that all non-empty user email addresses parse correctly.
func checkEmailFormatValid(snap Snapshot) RuleResult {
r := RuleResult{Rule: "email_format_valid", Passed: true}
for _, u := range snap.Users {
if u.Email == "" {
continue
}
if _, err := mail.ParseAddress(u.Email); err != nil {
r.Passed = false
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has invalid email %q: %v", u.ID, u.Email, err))
}
}
return r
}
// --- helpers ---
func allPassed(results []RuleResult) bool {
for _, r := range results {
if !r.Passed {
return false
}
}
return true
}
func appendMsg(existing, msg string) string {
if existing == "" {
return msg
}
return existing + "; " + msg
}
// isValidNamingValue checks that a DN naming attribute value is non-empty
// and does not contain characters that would break an LDAP DN.
// The restricted characters are: , = + < > # ; \ "
func isValidNamingValue(v string) bool {
if v == "" {
return false
}
for _, c := range v {
switch c {
case ',', '=', '+', '<', '>', '#', ';', '\\', '"':
return false
}
}
return true
}

View File

@@ -0,0 +1,314 @@
package validator_test
import (
"testing"
"keycape/internal/domain"
"keycape/internal/validator"
)
// --- helpers ---
func makeUser(id, username, displayName, email string) domain.User {
return domain.User{
ID: id,
Username: username,
DisplayName: displayName,
Email: email,
Enabled: true,
}
}
func makeGroup(id, name string, members ...string) domain.Group {
return domain.Group{ID: id, Name: name, Members: members}
}
// --- structural: valid_dn_structure ---
func TestValidDNStructure_Pass(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "valid_dn_structure")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestValidDNStructure_EmptyID(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("", "alice", "Alice", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "valid_dn_structure")
if result.Passed {
t.Error("expected fail for empty user ID")
}
}
func TestValidDNStructure_InvalidUsername(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice,bad", "Alice", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "valid_dn_structure")
if result.Passed {
t.Error("expected fail for username with comma")
}
}
func TestValidDNStructure_InvalidGroupName(t *testing.T) {
snap := validator.Snapshot{
Groups: []domain.Group{makeGroup("g1", "bad=group")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "valid_dn_structure")
if result.Passed {
t.Error("expected fail for group name with equals sign")
}
}
// --- structural: required_attributes_present ---
func TestRequiredAttributesPresent_Pass(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
Groups: []domain.Group{makeGroup("g1", "admins")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "required_attributes_present")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestRequiredAttributesPresent_MissingUsername(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "", "Alice", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "required_attributes_present")
if result.Passed {
t.Error("expected fail for missing username")
}
}
func TestRequiredAttributesPresent_MissingDisplayName(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "required_attributes_present")
if result.Passed {
t.Error("expected fail for missing displayName")
}
}
// --- structural: no_unknown_attributes ---
func TestNoUnknownAttributes_Pass(t *testing.T) {
u := makeUser("u1", "alice", "Alice", "alice@example.com")
u.LDAPAttributes = map[string]string{"sn": "Example"}
snap := validator.Snapshot{Users: []domain.User{u}}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "no_unknown_attributes")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestNoUnknownAttributes_BlankKey(t *testing.T) {
u := makeUser("u1", "alice", "Alice", "alice@example.com")
u.LDAPAttributes = map[string]string{"": "value"}
snap := validator.Snapshot{Users: []domain.User{u}}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "no_unknown_attributes")
if result.Passed {
t.Error("expected fail for blank attribute key")
}
}
// --- structural: valid_group_memberships ---
func TestValidGroupMemberships_Pass(t *testing.T) {
snap := validator.Snapshot{
Groups: []domain.Group{makeGroup("g1", "admins", "u1", "u2")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "valid_group_memberships")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestValidGroupMemberships_BlankMember(t *testing.T) {
snap := validator.Snapshot{
Groups: []domain.Group{makeGroup("g1", "admins", "u1", "")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Structural, "valid_group_memberships")
if result.Passed {
t.Error("expected fail for blank member ID")
}
}
// --- semantic: referenced_users_exist ---
func TestReferencedUsersExist_Pass(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "referenced_users_exist")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestReferencedUsersExist_UnknownUser(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
Groups: []domain.Group{makeGroup("g1", "admins", "u99")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "referenced_users_exist")
if result.Passed {
t.Error("expected fail for unknown user reference")
}
}
// --- semantic: no_cyclic_groups ---
func TestNoCyclicGroups_Pass(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "no_cyclic_groups")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestNoCyclicGroups_GroupInMembers(t *testing.T) {
snap := validator.Snapshot{
Groups: []domain.Group{
makeGroup("g1", "admins", "g2"),
makeGroup("g2", "users", "g1"),
},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "no_cyclic_groups")
if result.Passed {
t.Error("expected fail for group referencing another group")
}
}
// --- semantic: usernames_unique ---
func TestUsernamesUnique_Pass(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{
makeUser("u1", "alice", "Alice", "alice@example.com"),
makeUser("u2", "bob", "Bob", "bob@example.com"),
},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "usernames_unique")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestUsernamesUnique_Duplicate(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{
makeUser("u1", "alice", "Alice", "alice@example.com"),
makeUser("u2", "alice", "Alice Two", "alice2@example.com"),
},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "usernames_unique")
if result.Passed {
t.Error("expected fail for duplicate username")
}
}
// --- semantic: email_format_valid ---
func TestEmailFormatValid_Pass(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "email_format_valid")
if !result.Passed {
t.Errorf("expected pass, got: %s", result.Message)
}
}
func TestEmailFormatValid_InvalidEmail(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "email_format_valid")
if result.Passed {
t.Error("expected fail for invalid email format")
}
}
func TestEmailFormatValid_EmptyEmailSkipped(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "")},
}
r := validator.Validate(snap, validator.ModeCI)
result := findRule(r.Semantic, "email_format_valid")
if !result.Passed {
t.Errorf("empty email should pass (optional): %s", result.Message)
}
}
// --- report overall pass/fail ---
func TestReportPassed_AllGood(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
}
r := validator.Validate(snap, validator.ModeCI)
if !r.Passed {
t.Errorf("expected overall pass, but report failed")
}
}
func TestReportFailed_OneRuleFails(t *testing.T) {
snap := validator.Snapshot{
Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
}
r := validator.Validate(snap, validator.ModeCI)
if r.Passed {
t.Error("expected overall fail when email is invalid")
}
}
func TestModeRecordedInReport(t *testing.T) {
snap := validator.Snapshot{}
r := validator.Validate(snap, validator.ModeMigration)
if r.Mode != "migration" {
t.Errorf("expected mode migration, got %q", r.Mode)
}
}
// --- helper ---
func findRule(results []validator.RuleResult, name string) validator.RuleResult {
for _, r := range results {
if r.Rule == name {
return r
}
}
return validator.RuleResult{Rule: name, Passed: false, Message: "rule not found in report"}
}

View File

@@ -0,0 +1,86 @@
package migration_test
import (
"time"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
)
// canonicalFixture returns a deterministic ExportResult for use in all migration tests.
func canonicalFixture() *lldapexport.ExportResult {
return &lldapexport.ExportResult{
Users: []domain.User{
{
ID: "uid=alice,ou=users,dc=netkingdom,dc=local",
Username: "alice",
DisplayName: "Alice Example",
Email: "alice@netkingdom.local",
Enabled: true,
Groups: []string{"uid=admins,ou=groups,dc=netkingdom,dc=local"},
Roles: []string{},
},
{
ID: "uid=bob,ou=users,dc=netkingdom,dc=local",
Username: "bob",
DisplayName: "Bob Builder",
Email: "bob@netkingdom.local",
Enabled: true,
Groups: []string{"uid=developers,ou=groups,dc=netkingdom,dc=local"},
Roles: []string{},
},
{
ID: "uid=carol,ou=users,dc=netkingdom,dc=local",
Username: "carol",
DisplayName: "Carol Admin",
Email: "carol@netkingdom.local",
Enabled: false,
Groups: []string{},
Roles: []string{},
},
},
Groups: []domain.Group{
{
ID: "uid=admins,ou=groups,dc=netkingdom,dc=local",
Name: "admins",
Description: "Administrators",
Members: []string{"uid=alice,ou=users,dc=netkingdom,dc=local"},
},
{
ID: "uid=developers,ou=groups,dc=netkingdom,dc=local",
Name: "developers",
Description: "Developers",
Members: []string{"uid=bob,ou=users,dc=netkingdom,dc=local"},
},
},
Memberships: []domain.Membership{
{UserID: "uid=alice,ou=users,dc=netkingdom,dc=local", GroupID: "uid=admins,ou=groups,dc=netkingdom,dc=local"},
{UserID: "uid=bob,ou=users,dc=netkingdom,dc=local", GroupID: "uid=developers,ou=groups,dc=netkingdom,dc=local"},
},
ExportedAt: time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC),
ProfileVersion: "0.1",
}
}
// testClients returns sample canonical clients for migration tests.
func testClients() []domain.Client {
return []domain.Client{
{
ClientID: "demo-app",
DisplayName: "Demo Application",
RedirectURIs: []string{"http://localhost:3000/callback", "https://demo.netkingdom.local/callback"},
AllowedScopes: []string{"openid", "profile", "email", "groups"},
GrantTypes: []string{"authorization_code"},
ClientType: "public",
},
{
ClientID: "api-client",
DisplayName: "API Client",
RedirectURIs: []string{"https://api.netkingdom.local/oauth/callback"},
AllowedScopes: []string{"openid", "profile"},
GrantTypes: []string{"authorization_code"},
ClientType: "confidential",
SecretRef: "api-client-secret",
},
}
}

View File

@@ -0,0 +1,196 @@
// Package migration_test contains migration correctness tests for Scenarios B and C.
//
// Scenario B: IAM swap — replace KeyCape with Keycloak while keeping the same LLDAP directory.
// These tests verify the canonical → Keycloak import transformer produces a realm that
// preserves identical OIDC behavior (same issuer, same claims, same scopes, same clients).
package migration_test
import (
"testing"
"keycape/internal/migration/tokeycloak"
"keycape/internal/server/telemetry"
)
func newTransformer() *tokeycloak.Transformer {
return tokeycloak.New(tokeycloak.Config{
RealmName: "netkingdom",
Issuer: "https://auth.netkingdom.local",
}, telemetry.NoopEmitter{})
}
// TestScenarioBRealmPreservesClients verifies all canonical clients survive migration
// with the correct grant type constraints.
func TestScenarioBRealmPreservesClients(t *testing.T) {
export := canonicalFixture()
clients := testClients()
transformer := newTransformer()
realm, err := transformer.TransformWithClients(export, clients)
if err != nil {
t.Fatalf("TransformWithClients: %v", err)
}
if len(realm.Clients) != len(clients) {
t.Errorf("got %d clients, want %d", len(realm.Clients), len(clients))
}
for _, kc := range realm.Clients {
// Profile rule: standard flow = authorization_code only
if !kc.StandardFlowEnabled {
t.Errorf("client %s: StandardFlowEnabled must be true", kc.ClientID)
}
// Profile rule: no implicit flow
if kc.ImplicitFlowEnabled {
t.Errorf("client %s: ImplicitFlowEnabled must be false (profile safety)", kc.ClientID)
}
// Profile rule: no ROPC
if kc.DirectAccessGrantsEnabled {
t.Errorf("client %s: DirectAccessGrantsEnabled must be false", kc.ClientID)
}
if len(kc.RedirectUris) == 0 {
t.Errorf("client %s: must have at least one redirect URI", kc.ClientID)
}
}
}
// TestScenarioBNoImplicitFlow verifies profile safety is maintained across migration.
func TestScenarioBNoImplicitFlow(t *testing.T) {
export := canonicalFixture()
transformer := newTransformer()
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
for _, c := range realm.Clients {
if c.ImplicitFlowEnabled {
t.Errorf("client %q has ImplicitFlowEnabled=true after migration — profile safety violation", c.ClientID)
}
}
}
// TestScenarioBNoIdentityBrokers verifies no identity providers are injected by migration.
func TestScenarioBNoIdentityBrokers(t *testing.T) {
export := canonicalFixture()
transformer := newTransformer()
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
if len(realm.IdentityProviders) != 0 {
t.Errorf("realm has %d identity providers after migration, want 0 (profile: no identity brokering)", len(realm.IdentityProviders))
}
}
// TestScenarioBUsersPreserved verifies all canonical user attributes survive migration.
func TestScenarioBUsersPreserved(t *testing.T) {
export := canonicalFixture()
transformer := newTransformer()
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
if len(realm.Users) != len(export.Users) {
t.Fatalf("got %d Keycloak users, want %d", len(realm.Users), len(export.Users))
}
// Build a lookup map by username
kcUsers := make(map[string]tokeycloak.KeycloakUser)
for _, u := range realm.Users {
kcUsers[u.Username] = u
}
for _, u := range export.Users {
kcu, ok := kcUsers[u.Username]
if !ok {
t.Errorf("user %q not found in Keycloak realm", u.Username)
continue
}
if kcu.Email != u.Email {
t.Errorf("user %q: email %q != %q", u.Username, kcu.Email, u.Email)
}
if kcu.Enabled != u.Enabled {
t.Errorf("user %q: enabled %v != %v", u.Username, kcu.Enabled, u.Enabled)
}
}
}
// TestScenarioBGroupsPreserved verifies group memberships survive migration.
func TestScenarioBGroupsPreserved(t *testing.T) {
export := canonicalFixture()
transformer := newTransformer()
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
if len(realm.Groups) != len(export.Groups) {
t.Errorf("got %d Keycloak groups, want %d", len(realm.Groups), len(export.Groups))
}
for _, g := range realm.Groups {
if g.Path != "/"+g.Name {
t.Errorf("group %q: path %q should be /%s", g.Name, g.Path, g.Name)
}
}
}
// TestScenarioBSigningAlgorithmPreserved verifies RS256 is set on the realm.
func TestScenarioBSigningAlgorithmPreserved(t *testing.T) {
export := canonicalFixture()
transformer := newTransformer()
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
if realm.DefaultSignatureAlgorithm != "RS256" {
t.Errorf("DefaultSignatureAlgorithm = %q, want RS256", realm.DefaultSignatureAlgorithm)
}
}
// TestScenarioBValidationReport verifies the validation report catches discrepancies.
func TestScenarioBValidationReport(t *testing.T) {
export := canonicalFixture()
transformer := newTransformer()
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
// Remove a user from the realm to simulate a discrepancy
realm.Users = realm.Users[:len(realm.Users)-1]
report := transformer.ValidationReport(export, realm)
if len(report) == 0 {
t.Error("expected at least one validation issue when user count mismatches, got empty report")
}
}
// TestScenarioBPublicClientMapping verifies ClientType is correctly mapped.
func TestScenarioBPublicClientMapping(t *testing.T) {
export := canonicalFixture()
clients := testClients()
transformer := newTransformer()
realm, err := transformer.TransformWithClients(export, clients)
if err != nil {
t.Fatalf("TransformWithClients: %v", err)
}
kcClients := make(map[string]tokeycloak.KeycloakClient)
for _, c := range realm.Clients {
kcClients[c.ClientID] = c
}
// demo-app is public
if !kcClients["demo-app"].PublicClient {
t.Error("demo-app should be PublicClient=true")
}
// api-client is confidential
if kcClients["api-client"].PublicClient {
t.Error("api-client should be PublicClient=false (confidential)")
}
}

View File

@@ -0,0 +1,211 @@
// Scenario C: Full expansion — both LLDAP → full LDAP directory migration AND
// KeyCape → Keycloak IAM migration. These tests verify the two migration
// dimensions are independent (orthogonal) and that user data is semantically
// equivalent after both migrations.
package migration_test
import (
"strings"
"testing"
"keycape/internal/migration/toldap"
"keycape/internal/migration/tokeycloak"
"keycape/internal/server/telemetry"
)
func newGenerator(target toldap.Target) *toldap.Generator {
return toldap.New(toldap.Config{
BaseDN: "dc=netkingdom,dc=local",
Target: target,
}, telemetry.NoopEmitter{})
}
// TestScenarioCLDIFRoundTrip verifies the LDIF generator produces valid content
// for the canonical fixture.
func TestScenarioCLDIFRoundTrip(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if ldif == "" {
t.Fatal("expected non-empty LDIF output")
}
// Verify all users appear in LDIF
for _, u := range export.Users {
if !strings.Contains(ldif, "uid: "+u.Username) {
t.Errorf("LDIF missing user attribute uid: %s", u.Username)
}
}
// Verify all groups appear in LDIF
for _, g := range export.Groups {
if !strings.Contains(ldif, "cn: "+g.Name) {
t.Errorf("LDIF missing group cn: %s", g.Name)
}
}
}
// TestScenarioCTargetDifferences verifies OpenLDAP vs 389DS vs AD produce different LDIF.
func TestScenarioCTargetDifferences(t *testing.T) {
export := canonicalFixture()
ldifOpenLDAP, err := newGenerator(toldap.TargetOpenLDAP).Generate(export)
if err != nil {
t.Fatalf("OpenLDAP Generate: %v", err)
}
ldif389DS, err := newGenerator(toldap.Target389DS).Generate(export)
if err != nil {
t.Fatalf("389DS Generate: %v", err)
}
ldifAD, err := newGenerator(toldap.TargetAD).Generate(export)
if err != nil {
t.Fatalf("AD Generate: %v", err)
}
// AD must use sAMAccountName
if !strings.Contains(ldifAD, "sAMAccountName:") {
t.Error("AD LDIF missing sAMAccountName attribute")
}
// OpenLDAP must NOT have sAMAccountName
if strings.Contains(ldifOpenLDAP, "sAMAccountName:") {
t.Error("OpenLDAP LDIF should not have sAMAccountName")
}
// 389DS must have nsUniqueId or standard entries
_ = ldif389DS // 389DS is valid even without nsUniqueId when LDAPAttributes is empty
// All three must contain the same users
for _, u := range export.Users {
if !strings.Contains(ldifOpenLDAP, u.Username) {
t.Errorf("OpenLDAP LDIF missing user %s", u.Username)
}
if !strings.Contains(ldif389DS, u.Username) {
t.Errorf("389DS LDIF missing user %s", u.Username)
}
if !strings.Contains(ldifAD, u.Username) {
t.Errorf("AD LDIF missing user %s", u.Username)
}
}
}
// TestScenarioCMFANotMigrated verifies privacyIDEA MFA enrollment is NOT part of
// either migration dimension. MFA stays stable across lightweight → expanded.
func TestScenarioCMFANotMigrated(t *testing.T) {
export := canonicalFixture()
// Add MFA enrollment to a user
mfaUser := export.Users[0]
mfaUser.MFAEnrollment = nil // MFAEnrollment is NOT in the canonical export for migration
// LDIF generation must not include any OTP/MFA attributes
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
// LDIF must not contain privacyIDEA-specific attributes
if strings.Contains(ldif, "otpKey:") || strings.Contains(ldif, "privacyidea") {
t.Error("LDIF should not contain MFA/OTP attributes — privacyIDEA is orthogonal to directory migration")
}
// Keycloak realm must not include MFA credentials
transformer := tokeycloak.New(tokeycloak.Config{
RealmName: "netkingdom",
Issuer: "https://auth.netkingdom.local",
}, telemetry.NoopEmitter{})
realm, err := transformer.Transform(export)
if err != nil {
t.Fatalf("Transform: %v", err)
}
for _, u := range realm.Users {
for _, cred := range u.Credentials {
if cred.Type == "otp" || cred.Type == "totp" {
t.Errorf("user %q has OTP credential in Keycloak import — MFA migration should not happen here", u.Username)
}
}
}
}
// TestScenarioCStructuralEntries verifies ou=users and ou=groups are always generated.
func TestScenarioCStructuralEntries(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !strings.Contains(ldif, "ou=users,dc=netkingdom,dc=local") {
t.Error("LDIF missing ou=users structural entry")
}
if !strings.Contains(ldif, "ou=groups,dc=netkingdom,dc=local") {
t.Error("LDIF missing ou=groups structural entry")
}
}
// TestScenarioCUserPreservation verifies all user fields survive directory migration.
func TestScenarioCUserPreservation(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
for _, u := range export.Users {
if !strings.Contains(ldif, "uid: "+u.Username) {
t.Errorf("LDIF missing uid: %s", u.Username)
}
if u.Email != "" && !strings.Contains(ldif, "mail: "+u.Email) {
t.Errorf("LDIF missing mail: %s for user %s", u.Email, u.Username)
}
}
}
// TestScenarioCGroupMembersPreserved verifies group member DNs are in the LDIF.
func TestScenarioCGroupMembersPreserved(t *testing.T) {
export := canonicalFixture()
gen := newGenerator(toldap.TargetOpenLDAP)
ldif, err := gen.Generate(export)
if err != nil {
t.Fatalf("Generate: %v", err)
}
// admins group has alice as member
if !strings.Contains(ldif, "cn: admins") {
t.Error("LDIF missing admins group")
}
// member entries should be present
if !strings.Contains(ldif, "member:") {
t.Error("LDIF missing member: entries for groups")
}
}
// TestScenarioCOrthogonality verifies Scenario C = Scenario A (LDIF migration) + Scenario B (Keycloak migration)
// are independent: each can be performed without the other.
func TestScenarioCOrthogonality(t *testing.T) {
export := canonicalFixture()
// Can generate LDIF without Keycloak realm
gen := newGenerator(toldap.TargetOpenLDAP)
_, err := gen.Generate(export)
if err != nil {
t.Errorf("LDIF generation (without Keycloak) failed: %v", err)
}
// Can generate Keycloak realm without LDIF
transformer := tokeycloak.New(tokeycloak.Config{
RealmName: "netkingdom",
Issuer: "https://auth.netkingdom.local",
}, telemetry.NoopEmitter{})
_, err = transformer.Transform(export)
if err != nil {
t.Errorf("Keycloak transform (without LDIF) failed: %v", err)
}
}

View File

@@ -0,0 +1,182 @@
// Package negative_test contains integration-style tests that exercise the
// enforcement layer against a real HTTP test server (Scenario D from the
// Acceptance Test Matrix, spec §7).
//
// Each test verifies that:
// 1. The correct error.error string appears in the JSON response.
// 2. The appropriate HTTP status code is returned.
// 3. Content-Type is application/json.
package negative_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
profileerrors "keycape/internal/errors"
serverrors "keycape/internal/server/errors"
)
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
// passthroughHandler is the terminal handler behind the enforcement middleware.
// It returns 200 OK so tests can verify that unmatched requests pass through.
var passthroughHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// newServer builds a test server with DefaultRegistry middleware and the
// pass-through handler.
func newServer(t *testing.T) *httptest.Server {
t.Helper()
reg := serverrors.DefaultRegistry()
return httptest.NewServer(reg.Middleware(passthroughHandler))
}
// get issues a GET request to the given path on the test server.
func get(t *testing.T, srv *httptest.Server, path string) *http.Response {
t.Helper()
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
// post issues a POST request to the given path on the test server.
func post(t *testing.T, srv *httptest.Server, path string) *http.Response {
t.Helper()
resp, err := http.Post(srv.URL+path, "application/x-www-form-urlencoded", nil)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
return resp
}
// assertProfileError decodes the JSON body and checks the error field, HTTP status,
// and Content-Type for every negative scenario.
func assertProfileError(t *testing.T, resp *http.Response, wantErrType profileerrors.ErrorType, wantStatus int) {
t.Helper()
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
t.Errorf("HTTP status: want %d, got %d", wantStatus, resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
t.Error("Content-Type must be set")
} else {
// application/json is required; may include charset suffix.
found := false
for _, part := range []string{"application/json"} {
if len(ct) >= len(part) && ct[:len(part)] == part {
found = true
break
}
}
if !found {
t.Errorf("Content-Type: want application/json, got %q", ct)
}
}
var pe profileerrors.ProfileError
if err := json.NewDecoder(resp.Body).Decode(&pe); err != nil {
t.Fatalf("decode ProfileError JSON: %v", err)
}
if pe.Error != wantErrType {
t.Errorf("error field: want %q, got %q", wantErrType, pe.Error)
}
}
// ---------------------------------------------------------------------------
// Scenario D — Negative Profile Tests (one per unsupported feature)
// ---------------------------------------------------------------------------
// 1. dynamic_client_registration — POST /connect/register → feature_not_supported_by_profile
func TestNegative_DynamicClientRegistration(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := post(t, srv, "/connect/register")
assertProfileError(t, resp, profileerrors.ErrFeatureNotSupported, http.StatusNotImplemented)
}
// 2. implicit_flow — GET /authorize?response_type=token → rejected_for_profile_safety
func TestNegative_ImplicitFlow(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/authorize?response_type=token")
assertProfileError(t, resp, profileerrors.ErrRejectedForSafety, http.StatusForbidden)
}
// 3. wildcard_redirect_uri — GET /authorize?redirect_uri=https://evil.com/* → rejected_for_profile_safety
func TestNegative_WildcardRedirectURI(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/authorize?redirect_uri=https%3A%2F%2Fevil.com%2F*")
assertProfileError(t, resp, profileerrors.ErrRejectedForSafety, http.StatusForbidden)
}
// 4. identity_broker — GET /broker/google → available_in_keycloak_mode_only
func TestNegative_IdentityBroker(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/broker/google")
assertProfileError(t, resp, profileerrors.ErrKeycloakModeOnly, http.StatusNotImplemented)
}
// 5. missing_pkce — GET /authorize (without code_challenge) → invalid_profile_usage
func TestNegative_MissingPKCE(t *testing.T) {
srv := newServer(t)
defer srv.Close()
// No code_challenge parameter → missing_pkce triggers.
resp := get(t, srv, "/authorize?response_type=code&client_id=myapp")
assertProfileError(t, resp, profileerrors.ErrInvalidProfileUsage, http.StatusBadRequest)
}
// 6. pkce_plain_method — GET /authorize?code_challenge=abc&code_challenge_method=plain → rejected_for_profile_safety
func TestNegative_PKCEPlainMethod(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/authorize?code_challenge=abc&code_challenge_method=plain")
assertProfileError(t, resp, profileerrors.ErrRejectedForSafety, http.StatusForbidden)
}
// 7. unknown_grant_type — POST /token?grant_type=password → feature_not_supported_by_profile
func TestNegative_UnknownGrantType(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := post(t, srv, "/token?grant_type=password")
assertProfileError(t, resp, profileerrors.ErrFeatureNotSupported, http.StatusNotImplemented)
}
// ---------------------------------------------------------------------------
// Positive scenario: a normal valid request must pass through enforcement.
// ---------------------------------------------------------------------------
// TestNegative_ValidRequest_PassesThrough verifies that a well-formed authorization
// code request (with code_challenge and S256 method) reaches the terminal handler.
func TestNegative_ValidRequest_PassesThrough(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256&client_id=myapp")
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 (pass-through), got %d", resp.StatusCode)
}
}

View File

@@ -0,0 +1,635 @@
// Package profile_test contains integration-style tests for the complete OIDC
// profile (Scenario A from the Acceptance Test Matrix, spec §7). All handler
// implementations are real; only the auth backend adapters are mocked.
package profile_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/errors"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Mock adapters
// ---------------------------------------------------------------------------
// mockAuth implements domain.AuthProvider for tests.
type mockAuth struct {
authorizeURL string
callbackUser string
callbackErr error
}
func (m *mockAuth) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
if m.authorizeURL != "" {
return m.authorizeURL, nil
}
return "https://authelia.example.com/auth?state=" + req.State, nil
}
func (m *mockAuth) HandleCallback(_ context.Context, params domain.CallbackParams) (*domain.AuthResult, error) {
if m.callbackErr != nil {
return nil, m.callbackErr
}
username := m.callbackUser
if username == "" {
username = "testuser"
}
return &domain.AuthResult{Username: username}, nil
}
// mockMFA implements domain.MFAProvider for tests.
type mockMFA struct {
required bool
checkErr error
mfaErr error
}
func (m *mockMFA) CheckMFARequired(_ context.Context, _ string) (bool, error) {
return m.required, m.checkErr
}
func (m *mockMFA) ValidateMFAToken(_ context.Context, _, _ string) error {
return m.mfaErr
}
// mockUsers implements domain.UserRepository for tests.
type mockUsers struct {
users map[string]*domain.User
}
func newMockUsers() *mockUsers {
return &mockUsers{users: map[string]*domain.User{
"testuser": {
ID: "uid-001",
Username: "testuser",
DisplayName: "Test User",
Email: "testuser@example.com",
Groups: []string{"developers"},
Enabled: true,
},
}}
}
func (m *mockUsers) LookupUser(_ context.Context, username string) (*domain.User, error) {
u, ok := m.users[username]
if !ok {
return nil, domain.ErrUserNotFound
}
return u, nil
}
func (m *mockUsers) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) {
return nil, nil
}
func (m *mockUsers) ValidatePassword(_ context.Context, _, _ string) (bool, error) {
return false, nil
}
func (m *mockUsers) ListUsers(_ context.Context) ([]domain.User, error) {
return nil, nil
}
// ---------------------------------------------------------------------------
// TestServer
// ---------------------------------------------------------------------------
// TestServer wraps an httptest.Server with all the wired-up handlers.
type TestServer struct {
Server *httptest.Server
PrivateKey *rsa.PrivateKey
Sessions *oidc.SessionStore
AuthMock *mockAuth
Clients map[string]*domain.Client
}
func newTestServer(t *testing.T) *TestServer {
t.Helper()
// Generate RSA key pair.
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate RSA key: %v", err)
}
issuer := "http://localhost" // will be overridden with actual server URL after start
// Create test client registry.
clients := map[string]*domain.Client{
"demo-app": {
ClientID: "demo-app",
DisplayName: "Demo Application",
RedirectURIs: []string{"http://localhost:3000/callback"},
AllowedScopes: []string{"openid", "profile", "email", "groups"},
GrantTypes: []string{"authorization_code"},
ClientType: "public",
},
}
// Create mock adapters.
authMock := &mockAuth{}
mfaMock := &mockMFA{required: false}
usersMock := newMockUsers()
// Session store.
sessions := oidc.NewSessionStore()
// Telemetry — noop for tests.
emitter := telemetry.NoopEmitter{}
// Key set.
ks := oidc.NewKeySet()
ks.AddKey("key-1", &privateKey.PublicKey)
// Enforcement registry.
reg := errors.DefaultRegistry()
mux := http.NewServeMux()
// Discovery handler.
mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
JWKSUri: issuer + "/jwks",
UserinfoEndpoint: issuer + "/userinfo",
}))
// JWKS handler.
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
// Authorize handler (with enforcement middleware).
authorizeHandler := &oidc.AuthorizeHandler{
ClientConfig: clients,
Auth: authMock,
MFA: mfaMock,
Sessions: sessions,
Emitter: emitter,
}
mux.Handle("/authorize", reg.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler)
// Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{
ClientConfig: clients,
Sessions: sessions,
Users: usersMock,
SigningKey: privateKey,
Issuer: issuer,
TokenLifetime: 15 * time.Minute,
Emitter: emitter,
}
mux.Handle("/token", reg.Middleware(tokenHandler))
// Userinfo handler.
mux.Handle("/userinfo", &oidc.UserinfoHandler{
Users: usersMock,
SigningKey: &privateKey.PublicKey,
Issuer: issuer,
Emitter: emitter,
})
// Healthz handler.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","version":"0.1.0"}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &TestServer{
Server: srv,
PrivateKey: privateKey,
Sessions: sessions,
AuthMock: authMock,
Clients: clients,
}
}
// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------
func generatePKCE(t *testing.T) (verifier, challenge string) {
t.Helper()
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
t.Fatalf("generate PKCE verifier: %v", err)
}
verifier = base64.RawURLEncoding.EncodeToString(b)
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return
}
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
// 1. Discovery test.
func TestDiscovery(t *testing.T) {
ts := newTestServer(t)
resp, err := http.Get(ts.Server.URL + "/.well-known/openid-configuration")
if err != nil {
t.Fatalf("GET /.well-known/openid-configuration: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status: want 200, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type: want application/json, got %q", ct)
}
var doc map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
t.Fatalf("decode discovery doc: %v", err)
}
requiredFields := []string{
"issuer", "authorization_endpoint", "token_endpoint", "jwks_uri",
"response_types_supported", "grant_types_supported",
"code_challenge_methods_supported", "id_token_signing_alg_values_supported",
"scopes_supported",
}
for _, f := range requiredFields {
if _, ok := doc[f]; !ok {
t.Errorf("discovery doc missing field %q", f)
}
}
// registration_endpoint must be absent.
if _, ok := doc["registration_endpoint"]; ok {
t.Error("discovery doc must not contain registration_endpoint")
}
// scopes_supported must include openid.
scopes, ok := doc["scopes_supported"].([]interface{})
if !ok {
t.Fatal("scopes_supported is not an array")
}
found := false
for _, s := range scopes {
if s == "openid" {
found = true
break
}
}
if !found {
t.Error("scopes_supported must include openid")
}
}
// 2. JWKS test.
func TestJWKS(t *testing.T) {
ts := newTestServer(t)
resp, err := http.Get(ts.Server.URL + "/jwks")
if err != nil {
t.Fatalf("GET /jwks: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status: want 200, got %d", resp.StatusCode)
}
var jwks struct {
Keys []struct {
Kty string `json:"kty"`
Alg string `json:"alg"`
Use string `json:"use"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
t.Fatalf("decode JWKS: %v", err)
}
if len(jwks.Keys) == 0 {
t.Fatal("JWKS must contain at least one key")
}
key := jwks.Keys[0]
if key.Kty != "RSA" {
t.Errorf("kty: want RSA, got %q", key.Kty)
}
if key.Alg != "RS256" {
t.Errorf("alg: want RS256, got %q", key.Alg)
}
if key.N == "" {
t.Error("n (modulus) must not be empty")
}
if key.E == "" {
t.Error("e (exponent) must not be empty")
}
}
// 3. Authorization redirect test — valid PKCE params → 302 redirect.
func TestAuthorize_Redirect(t *testing.T) {
ts := newTestServer(t)
_, challenge := generatePKCE(t)
q := url.Values{}
q.Set("client_id", "demo-app")
q.Set("redirect_uri", "http://localhost:3000/callback")
q.Set("response_type", "code")
q.Set("scope", "openid profile")
q.Set("state", "test-state-123")
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse // don't follow redirect
}}
resp, err := client.Get(ts.Server.URL + "/authorize?" + q.Encode())
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("status: want 302, got %d", resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
t.Error("Location header must be set on redirect")
}
}
// 4. Invalid client test — unknown client_id → invalid_profile_usage.
func TestAuthorize_InvalidClient(t *testing.T) {
ts := newTestServer(t)
_, challenge := generatePKCE(t)
q := url.Values{}
q.Set("client_id", "unknown-client")
q.Set("redirect_uri", "http://localhost:3000/callback")
q.Set("response_type", "code")
q.Set("scope", "openid")
q.Set("state", "s")
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
resp, err := http.Get(ts.Server.URL + "/authorize?" + q.Encode())
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status: want 400, got %d", resp.StatusCode)
}
var pe map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&pe); err != nil {
t.Fatalf("decode error response: %v", err)
}
errType, _ := pe["error"].(string)
if errType != "invalid_profile_usage" {
t.Errorf("error: want invalid_profile_usage, got %q", errType)
}
}
// 5. Wildcard redirect URI → rejected_for_profile_safety (caught by enforcement middleware).
func TestAuthorize_WildcardRedirectURI(t *testing.T) {
ts := newTestServer(t)
q := url.Values{}
q.Set("client_id", "demo-app")
q.Set("redirect_uri", "https://evil.com/*")
q.Set("response_type", "code")
q.Set("scope", "openid")
q.Set("state", "s")
q.Set("code_challenge", "abc")
q.Set("code_challenge_method", "S256")
resp, err := http.Get(ts.Server.URL + "/authorize?" + q.Encode())
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status: want 403, got %d", resp.StatusCode)
}
var pe map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&pe)
errType, _ := pe["error"].(string)
if errType != "rejected_for_profile_safety" {
t.Errorf("error: want rejected_for_profile_safety, got %q", errType)
}
}
// 6. Missing PKCE test — no code_challenge → invalid_profile_usage (enforcement middleware).
func TestAuthorize_MissingPKCE(t *testing.T) {
ts := newTestServer(t)
q := url.Values{}
q.Set("client_id", "demo-app")
q.Set("redirect_uri", "http://localhost:3000/callback")
q.Set("response_type", "code")
q.Set("scope", "openid")
q.Set("state", "s")
// No code_challenge
resp, err := http.Get(ts.Server.URL + "/authorize?" + q.Encode())
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status: want 400, got %d", resp.StatusCode)
}
var pe map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&pe)
errType, _ := pe["error"].(string)
if errType != "invalid_profile_usage" {
t.Errorf("error: want invalid_profile_usage, got %q", errType)
}
}
// 7. Healthz test.
func TestHealthz(t *testing.T) {
ts := newTestServer(t)
resp, err := http.Get(ts.Server.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status: want 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode /healthz response: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status field: want ok, got %v", body["status"])
}
}
// 8. Complete token flow test — auth callback + token exchange → valid JWT.
func TestCompleteTokenFlow(t *testing.T) {
ts := newTestServer(t)
verifier, challenge := generatePKCE(t)
// Step 1: Simulate the callback by seeding a pending state and triggering callback.
// We do this by first calling /authorize to create the pending state, then calling
// /authorize/callback with state and a mock code.
q := url.Values{}
q.Set("client_id", "demo-app")
q.Set("redirect_uri", "http://localhost:3000/callback")
q.Set("response_type", "code")
q.Set("scope", "openid profile email groups")
q.Set("state", "flow-state-xyz")
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
noRedirectClient := &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
// /authorize → 302 to upstream auth.
authResp, err := noRedirectClient.Get(ts.Server.URL + "/authorize?" + q.Encode())
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
authResp.Body.Close()
if authResp.StatusCode != http.StatusFound {
t.Fatalf("authorize: want 302, got %d", authResp.StatusCode)
}
// Step 2: Simulate the upstream callback returning code + state.
cbQ := url.Values{}
cbQ.Set("code", "upstream-auth-code")
cbQ.Set("state", "flow-state-xyz")
cbResp, err := noRedirectClient.Get(ts.Server.URL + "/authorize/callback?" + cbQ.Encode())
if err != nil {
t.Fatalf("GET /authorize/callback: %v", err)
}
cbResp.Body.Close()
if cbResp.StatusCode != http.StatusFound {
t.Fatalf("callback: want 302, got %d", cbResp.StatusCode)
}
// Extract the auth code from the Location redirect to our client.
location := cbResp.Header.Get("Location")
if location == "" {
t.Fatal("callback: no Location header")
}
locURL, err := url.Parse(location)
if err != nil {
t.Fatalf("parse Location URL: %v", err)
}
authCode := locURL.Query().Get("code")
if authCode == "" {
t.Fatalf("no code in callback redirect: %q", location)
}
// Step 3: Exchange the auth code for a token.
tokenForm := url.Values{}
tokenForm.Set("grant_type", "authorization_code")
tokenForm.Set("client_id", "demo-app")
tokenForm.Set("code", authCode)
tokenForm.Set("code_verifier", verifier)
tokenResp, err := http.Post(
ts.Server.URL+"/token",
"application/x-www-form-urlencoded",
strings.NewReader(tokenForm.Encode()),
)
if err != nil {
t.Fatalf("POST /token: %v", err)
}
defer tokenResp.Body.Close()
if tokenResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(tokenResp.Body)
t.Fatalf("token: want 200, got %d; body: %s", tokenResp.StatusCode, body)
}
var tr struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(tokenResp.Body).Decode(&tr); err != nil {
t.Fatalf("decode token response: %v", err)
}
if tr.AccessToken == "" {
t.Error("access_token must not be empty")
}
if tr.TokenType != "Bearer" {
t.Errorf("token_type: want Bearer, got %q", tr.TokenType)
}
if tr.IDToken == "" {
t.Error("id_token must not be empty")
}
// Verify JWT has 3 parts (header.payload.signature).
parts := strings.Split(tr.IDToken, ".")
if len(parts) != 3 {
t.Errorf("id_token: expected 3 JWT parts, got %d", len(parts))
}
// Decode payload and check required claims.
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("decode JWT payload: %v", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
t.Fatalf("parse JWT claims: %v", err)
}
requiredClaims := []string{"iss", "sub", "aud", "exp", "iat"}
for _, c := range requiredClaims {
if _, ok := claims[c]; !ok {
t.Errorf("JWT missing claim %q", c)
}
}
if claims["aud"] != "demo-app" {
t.Errorf("aud: want demo-app, got %v", claims["aud"])
}
}

View File

@@ -0,0 +1,730 @@
KeyCapeSpecPac
*Implementation guidane for keycape*
Below is the **Normative Specification Pack v0.1** for **Keycape**.
This version is structured so that a **coding agent can implement it**.
The pack contains the seven components we discussed:
1. Normative Specification
2. Canonical Identity Schema
3. Canonical LDAP Schema + Validator Rules
4. Error Taxonomy
5. Telemetry Schema
6. Migration Contract
7. Acceptance Test Matrix
This document is written **normatively where required** (MUST / SHOULD / MAY).
---
# Keycape Normative Specification Pack v0.1
**Project:** NetKingdom
**Component:** Keycape
**Status:** Normative Draft
**Audience:** Developers, architecture engineers, coding agents
---
# 1. Normative Specification
## 1.1 Purpose
Keycape is a **lightweight implementation of the NetKingdom IAM Profile**.
Keycape provides:
* a stable **OIDC-based IAM contract**
* a **lightweight runtime implementation**
* strict **profile enforcement**
* **telemetry** about demanded IAM functionality
* **automated migration readiness** for Keycloak replacement
Keycape is **not a Keycloak clone**.
---
## 1.2 Architectural Role
Keycape is the **external IAM contract provider** in lightweight mode.
Applications interact only with the **NetKingdom IAM Profile**.
Keycape internally orchestrates:
| Component | Responsibility |
| ----------- | --------------------- |
| Authelia | OIDC provider backend |
| LLDAP | identity directory |
| privacyIDEA | MFA authority |
Expanded mode replaces Keycape with **Keycloak**.
---
## 1.3 Supported Protocol
Keycape MUST implement:
**OpenID Connect 1.0**
Using:
**Authorization Code Flow + PKCE**
Reference model:
```
Application
|
v
Keycape (profile contract)
|
v
Authelia + LLDAP + privacyIDEA
```
Expanded mode:
```
Application
|
v
Keycloak
|
v
LDAP + privacyIDEA
```
---
## 1.4 Mandatory Endpoints
Keycape MUST expose the following endpoints.
| Endpoint | Required |
| ----------------------------------- | -------- |
| `/.well-known/openid-configuration` | YES |
| `/authorize` | YES |
| `/token` | YES |
| `/jwks` | YES |
| `/userinfo` | OPTIONAL |
| `/logout` | OPTIONAL |
| `/introspect` | OPTIONAL |
Discovery MUST correctly advertise supported features.
---
## 1.5 Authentication Flow
Supported flow:
Authorization Code + PKCE
Requirements:
Client MUST supply:
```
client_id
redirect_uri
response_type=code
scope=openid
code_challenge
code_challenge_method=S256
```
Keycape MUST validate:
* redirect URI
* client configuration
* PKCE challenge
---
## 1.6 Token Requirements
Tokens MUST be JWT.
Minimum claims:
```
iss
sub
aud
exp
iat
```
Optional claims:
```
preferred_username
email
groups
roles
```
Signature MUST use:
```
RS256
```
JWKS MUST be available at `/jwks`.
---
## 1.7 Client Model
Client registration is **static** in v0.1.
Client fields:
```
client_id
client_secret (optional for public)
redirect_uris[]
allowed_scopes[]
allowed_grants[]
```
Dynamic registration is **NOT allowed**.
---
## 1.8 MFA Behavior
MFA enforcement is delegated to **privacyIDEA**.
Keycape MUST:
* detect MFA requirement
* enforce MFA before token issuance
* fail authentication if MFA fails
Keycape MUST NOT implement MFA logic itself.
---
## 1.9 Claims Model
Claims MUST follow the **Canonical Identity Model**.
Mapping example:
| Claim | Source |
| ------------------ | ---------------------- |
| sub | canonical user ID |
| preferred_username | LDAP uid |
| email | LDAP mail |
| groups | LDAP groupOfNames |
| roles | canonical role mapping |
---
# 2. Canonical Identity Model
The canonical model is the **source of truth** for identities.
All provisioning, tests, and migrations derive from it.
---
## 2.1 Canonical Entities
Entities:
```
User
Group
Role
Client
Membership
MFAEnrollment
```
---
## 2.2 Canonical User Schema
```yaml
User:
id: string
username: string
displayName: string
email: string
enabled: boolean
groups:
- group_id
roles:
- role_id
attributes:
key: value
```
---
## 2.3 Canonical Group Schema
```yaml
Group:
id: string
name: string
description: string
```
---
## 2.4 Canonical Client Schema
```yaml
Client:
client_id: string
display_name: string
redirect_uris:
- uri
allowed_scopes:
- scope
grant_types:
- authorization_code
```
---
## 2.5 Canonical MFA Schema
```yaml
MFAEnrollment:
user_id: string
provider: privacyidea
state: enabled
```
---
# 3. Canonical LDAP Schema
The canonical LDAP schema expresses the identity model in LDAP.
This ensures portability across:
* LLDAP
* OpenLDAP
* 389DS
* Active Directory
---
## 3.1 LDAP Tree Layout
```
dc=netkingdom,dc=local
ou=users
ou=groups
ou=clients
```
---
## 3.2 User Entry
Object classes:
```
inetOrgPerson
organizationalPerson
person
top
```
Attributes:
```
uid
cn
sn
mail
memberOf
```
Example:
```
dn: uid=alice,ou=users,dc=netkingdom,dc=local
uid: alice
cn: Alice
sn: Example
mail: alice@example.com
```
---
## 3.3 Group Entry
Object classes:
```
groupOfNames
top
```
Attributes:
```
cn
member
```
---
# 4. LDAP Schema Validator
Validator MUST verify:
### Structural Rules
* valid DN structure
* required attributes present
* no unknown attributes
* valid group memberships
### Semantic Rules
* referenced users exist
* groups are not cyclic
* usernames unique
* email format valid
Validator MUST run in:
```
CI
Provisioning
Migration
```
---
# 5. Error Taxonomy
Keycape MUST implement structured errors.
---
## 5.1 Error Types
### feature_not_supported_by_profile
Requested functionality outside the profile.
Example:
```
dynamic_client_registration
```
---
### available_in_keycloak_mode_only
Feature exists only in expanded mode.
Example:
```
identity_broker
```
---
### rejected_for_profile_safety
Feature intentionally blocked.
Example:
```
wildcard_redirect_uri
```
---
### invalid_profile_usage
Client misused the profile.
Example:
```
missing_pkce
```
---
## 5.2 Error Format
```
{
"error": "feature_not_supported_by_profile",
"description": "...",
"feature": "identity_broker"
}
```
---
# 6. Telemetry Schema
Keycape MUST emit telemetry events.
---
## 6.1 Telemetry Event Types
```
auth_start
auth_success
auth_failure
token_issued
unsupported_feature
invalid_request
migration_event
```
---
## 6.2 Telemetry Fields
```json
{
"timestamp": "...",
"client_id": "...",
"endpoint": "...",
"feature": "...",
"result": "...",
"error_type": "...",
"scopes": [],
"grant_type": "...",
"environment": "...",
"trace_id": "..."
}
```
---
## 6.3 Telemetry Outputs
Telemetry MUST support:
```
logs
metrics
dashboards
analysis
```
---
# 7. Migration Contract
Migration must support two dimensions.
---
## 7.1 IAM Migration
```
Keycape → Keycloak
```
Requirements:
* same issuer behavior
* same claims
* same scopes
* same client behavior
---
## 7.2 Directory Migration
```
LLDAP → Full LDAP
```
Supported targets:
```
OpenLDAP
389 Directory Server
Active Directory
```
Migration MUST include:
```
users
groups
memberships
attributes
```
---
# 8. Replacement Testing
Replacement must be continuously verified.
---
## 8.1 Scenario A — Lightweight
```
LLDAP + Authelia + Keycape
```
Run all profile tests.
---
## 8.2 Scenario B — IAM Replacement
```
Keycloak + same directory
```
Run same tests.
---
## 8.3 Scenario C — Full Expansion
```
LLDAP → LDAP
Keycloak
```
Run tests again.
---
## 8.4 Scenario D — Negative Tests
Attempt:
```
unsupported scopes
dynamic clients
wildcard redirects
identity brokering
```
Verify errors and telemetry.
---
# 9. Acceptance Criteria
Replacement is successful if:
* apps continue to work
* claims remain stable
* login flow unchanged
* migration data valid
* telemetry preserved
---
# 10. Security Requirements
Keycape MUST enforce:
```
strict redirect URI validation
PKCE mandatory
no dynamic client registration
JWT signing via standard library
no custom crypto
issuer consistency
short token lifetime
```
---
# 11. Implementation Requirements
Keycape SHOULD be implemented in:
```
Go or Rust
```
Key requirements:
```
stateless
small memory footprint
simple deployment
clear logging
structured telemetry
```
---
# 12. Recommended Repository Structure
```
keycape/
├── spec/
│ ├── iam-profile.md
│ ├── canonical-model.yaml
│ ├── ldap-schema.yaml
├── validator/
│ └── ldap-validator
├── adapters/
│ ├── authelia
│ ├── lldap
│ └── privacyidea
├── server/
│ ├── oidc
│ ├── telemetry
│ └── errors
├── migration/
│ ├── lldap-to-ldap
│ └── keycape-to-keycloak
└── tests/
├── profile
├── migration
└── negative
```
---
# 13. Next Step
The next step is creating the **Keycape Implementation Workplan**.
This will include:
* system architecture
* internal modules
* adapter design
* telemetry pipeline
* coding-agent instructions
* milestone roadmap
* estimated code size (~1520k lines)
If you want, I can generate that next.
xxx

View File

@@ -0,0 +1,824 @@
KeyCape
*Prepare for KeyCloak without KeyCloak*
# Keycape Specification v0.1
**Status:** Draft
**Project:** NetKingdom
**Component:** Keycape
**Purpose:** Lightweight IAM profile implementation for small and early production environments, with explicit replaceability by Keycloak in larger or federated environments.
---
## 1. Purpose
Keycape is a **profile-constrained IAM implementation** for NetKingdom.
It provides the **externally visible IAM contract** used by NetKingdom applications in lightweight environments, while being intentionally replaceable by **Keycloak** in larger environments.
Keycape is **not** a full IAM platform and **not** a full Keycloak clone.
Its role is to:
* implement the **NetKingdom IAM Profile**
* provide a slim production-capable setup for small environments
* enforce interface discipline from the beginning
* expose telemetry on demanded functionality
* support automated replacement tests to prove migration to Keycloak
---
## 2. Design Intent
The architecture shall support two valid production modes:
### 2.1 Lightweight mode
Uses lightweight components for lean production and development.
Typical implementation:
* **Keycape** as the externally visible profile implementation
* **Authelia** as lightweight OIDC-capable backend where useful
* **LLDAP** as lightweight directory backend where useful
* **privacyIDEA** as MFA authority
### 2.2 Expanded mode
Uses more feature-rich components for scale, federation, and enterprise IAM breadth.
Typical implementation:
* **Keycloak** as the externally visible IAM implementation
* **full LDAP directory** as identity backend where needed
* **privacyIDEA** as MFA authority
The critical idea is:
> Applications integrate against the **NetKingdom IAM Profile**, not against incidental behavior of Keycape, Authelia, LLDAP, or Keycloak.
---
## 3. Core Architectural Principle
Keycape shall be a **contract implementation**, not a platform clone.
That means:
* Keycape replicates only the **relevant external interfaces**
* Keycape may fulfill functionality by orchestrating underlying components
* unsupported functions must fail clearly and predictably
* profile violations must be observable through telemetry
* Keycape must remain small enough to be maintainable
---
## 4. Scope
## 4.1 In scope
Keycape is responsible for:
* implementing the **NetKingdom IAM Profile**
* exposing the external IAM endpoints required by profile-conformant clients
* normalizing identity and claims behavior across lightweight mode
* providing structured errors for unsupported functionality
* generating telemetry on requested functionality and profile drift
* supporting migration and replacement testing to Keycloak
* participating in automated data migration workflows
## 4.2 Out of scope
Keycape shall not attempt to provide broad parity with Keycloak in areas such as:
* identity brokering to arbitrary upstream IdPs
* general SAML platform parity
* Keycloak SPI/plugin parity
* Keycloak admin console parity
* Keycloak authorization services parity
* generic realm import/export parity
* broad compatibility with arbitrary Keycloak-specific admin APIs
* full LDAP server behavior
* enterprise IAM feature breadth beyond the defined profile
---
## 5. Terminology
### 5.1 NetKingdom IAM Profile
The explicit, versioned contract supported by both Keycape and Keycloak-mode deployments.
### 5.2 Profile implementation
A concrete runtime that implements the profile, such as Keycape or Keycloak.
### 5.3 Lightweight mode
Deployment mode using slim components and limited scope.
### 5.4 Expanded mode
Deployment mode using Keycloak and fuller directory/federation infrastructure.
### 5.5 Canonical identity model
A product-neutral representation of users, groups, roles, clients, and related metadata used for validation, provisioning, migration, and tests.
### 5.6 Canonical LDAP schema
A restricted LDAP-oriented schema profile derived from the canonical identity model and validated before provisioning or migration.
---
## 6. Functional Positioning of Components
## 6.1 Keycape
External contract implementation in lightweight mode.
Responsibilities:
* profile endpoints
* protocol normalization
* claim normalization
* config translation to underlying components
* unsupported-feature handling
* telemetry
## 6.2 Authelia
Optional lightweight backend for OIDC/session/auth flows.
Responsibilities may include:
* login/session handling
* token issuance
* client handling within supported subset
Authelia remains an internal implementation detail from the application point of view.
## 6.3 LLDAP
Optional lightweight LDAP-compatible identity backend.
Responsibilities may include:
* user storage
* group membership storage
* dev/bootstrap directory service
* lean production directory for small environments
LLDAP is not part of the application-facing contract.
## 6.4 privacyIDEA
Stable MFA and token-policy authority across both modes.
Responsibilities:
* MFA enforcement and policy
* token management where applicable
* stable security concept across migration paths
## 6.5 Keycloak
Replacement implementation for expanded mode.
Responsibilities:
* implement the same profile for applications
* provide wider IAM capability when needed
* optionally federate with larger directories
---
## 7. Keycape Objectives
Keycape shall satisfy the following objectives:
### 7.1 Contract stability
Applications should see a stable IAM surface.
### 7.2 Minimalism
Only the defined profile shall be implemented.
### 7.3 Replaceability
Replacement by Keycloak shall be continuously testable.
### 7.4 Observability
Demand for unsupported or non-profile functionality shall be measurable.
### 7.5 Migration readiness
Data and configuration required for replacement shall be exportable, transformable, and validated.
### 7.6 Production validity
The lightweight stack shall be considered valid production infrastructure where the required feature set stays within the profile.
---
## 8. NetKingdom IAM Profile
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
The initial profile shall support:
* OpenID Connect Authorization Code Flow
* PKCE
* confidential clients
* public clients only if explicitly allowed in a later profile revision
* fixed redirect URIs
* a small, stable claim set
* stable issuer behavior
* JWKS exposure
* discovery metadata
## 8.2 Supported endpoints
The initial profile shall define support for:
* discovery endpoint
* authorization endpoint
* token endpoint
* JWKS endpoint
* userinfo endpoint if required by supported clients
* logout endpoint only if its semantics are clearly defined in the profile
Optional endpoints such as introspection and revocation shall only be supported if there is a concrete application need.
## 8.3 Supported scopes
Initial mandatory scopes:
* `openid`
Optional initial scopes, if required:
* `profile`
* `email`
* `groups`
Custom scopes shall be explicitly versioned as part of the profile.
## 8.4 Supported claims
Initial standard claims may include:
* `sub`
* `iss`
* `aud`
* `exp`
* `iat`
* `preferred_username`
* `email` if present
* `name` if present
NetKingdom profile v0.2 requires these normalized claims before applications
or flex-auth consume a token:
* `tenant`
* `principal_type`
* `groups`
* `roles`
* `scope` or `scp`
* `assurance`
Claim names, types, and semantics must be fixed by the profile and validated in tests.
## 8.5 Supported client model
Clients shall be defined in a constrained way:
* immutable client identifier
* known redirect URIs
* known scopes
* known grant types within the profile
* predictable claim mapping behavior
* minimal client-secret handling rules
* no dynamic client registration in v0.1
## 8.6 MFA interaction
MFA behavior shall be treated as part of the authentication policy, not as ad hoc application logic.
The profile shall define:
* when MFA is required
* whether MFA state influences token claims
* whether step-up behavior is supported
* which user/account states are considered valid for issuance
The exact MFA mechanics may be delegated to privacyIDEA.
---
## 9. Unsupported Functionality Policy
Any request beyond the profile shall be handled explicitly.
## 9.1 Required behavior
Keycape shall never silently emulate unsupported features in an undefined way.
## 9.2 Error taxonomy
The following error classes shall exist:
### `feature_not_supported_by_profile`
The requested capability is outside the NetKingdom IAM Profile.
### `available_in_keycloak_mode_only`
The capability may exist in expanded mode but is intentionally absent in lightweight mode.
### `rejected_for_profile_safety`
The request is rejected because supporting it would weaken the profiles guarantees or security discipline.
### `invalid_profile_usage`
The client uses a supported endpoint or feature incorrectly.
## 9.3 Error response requirements
Errors shall be:
* machine-readable
* human-readable
* loggable
* distinguishable by category
* stable enough for automated tests
---
## 10. Canonical Identity Model
Keycape development shall use a canonical identity model independent of product-specific storage schemas.
## 10.1 Purpose
The canonical identity model is the source of truth for:
* test fixtures
* provisioning
* migration
* validation
* replacement testing
## 10.2 Core entities
At minimum:
* User
* Group
* Membership
* Client
* Role
* ClientScopeAssignment
* MFAEnrollmentReference
* DirectoryAttributes
* ProfileVersion
## 10.3 Canonical user fields
Minimum user fields:
* stable internal identifier
* username
* display name
* email
* enabled/disabled state
* group memberships
* optional role memberships
* optional MFA linkage reference
* LDAP-oriented attributes required by the canonical LDAP schema
## 10.4 Canonical client fields
Minimum client fields:
* client ID
* display label
* allowed redirect URIs
* allowed scopes
* client type
* secret reference if applicable
* token/claim profile
* environment applicability
---
## 11. Canonical LDAP Schema
The canonical LDAP schema is the restricted LDAP expression of the canonical identity model.
It exists to ensure portability between:
* LLDAP
* larger LDAP implementations
* Keycloak federation targets where relevant
## 11.1 Goals
* keep LDAP usage intentionally small and portable
* prevent schema drift
* validate data before provisioning/migration
* ensure only approved attributes and structures are used
## 11.2 Validator requirement
A **canonical LDAP schema validator** is mandatory.
It shall validate:
* object class usage
* required and optional attributes
* DN placement rules
* naming rules
* group membership representation
* forbidden attributes or structures
* cross-entry consistency
* profile version compatibility
## 11.3 Validator modes
The validator should support:
* fixture validation
* pre-provision validation
* pre-migration validation
* post-migration verification
* drift detection in CI
---
## 12. Keycape Runtime Responsibilities
In lightweight mode Keycape shall be responsible for:
## 12.1 Profile endpoint exposure
Expose the agreed external endpoints.
## 12.2 Backend translation
Translate profile concepts into underlying Authelia/LLDAP/privacyIDEA configuration and behavior.
## 12.3 Claim normalization
Ensure tokens and userinfo behave according to profile definitions, regardless of backend quirks.
## 12.4 Unsupported-feature enforcement
Block non-profile usage with structured errors.
## 12.5 Telemetry
Emit data on requested behavior and unsupported demand.
## 12.6 Configuration export support
Produce the information needed for migration to expanded mode.
---
## 13. Telemetry Specification
Telemetry is a first-class feature of Keycape.
## 13.1 Purpose
Telemetry shall answer questions such as:
* which profile features are actually used
* which unsupported features are demanded
* which applications are creating pressure for expanded-mode features
* whether the current profile remains sufficient
## 13.2 Minimum telemetry events
Keycape shall emit events for:
* successful authentication flow start
* successful token issuance
* unsuccessful authentication attempt
* unsupported endpoint usage
* unsupported grant/scopes/claims usage
* invalid redirect or client usage
* logout attempts
* admin/config-related unsupported requests
* migration/export operations
## 13.3 Minimum telemetry fields
Each event should capture:
* timestamp
* environment
* deployment mode
* client ID
* endpoint
* feature category
* result status
* error class if applicable
* requested scopes
* requested grant type
* correlation ID / trace ID
## 13.4 Telemetry outputs
Telemetry should be usable for:
* logs
* metrics
* dashboards
* CI analysis
* migration planning
---
## 14. Migration Model
Replacement by Keycloak shall be an explicit, tested capability.
## 14.1 Migration dimensions
Migration has at least two independent dimensions:
### A. IAM implementation migration
Keycape/lightweight implementation → Keycloak
### B. Directory migration
LLDAP → full LDAP implementation
## 14.2 Migration principles
* migration shall be reproducible
* migration shall be test-driven
* migration shall use canonical data as the source of truth
* migration success shall be determined by application-facing contract tests
* migration shall include data validation before and after transfer
## 14.3 Supported migration paths
Initial required paths:
### Path 1
LLDAP + Keycape stack → Keycloak with same directory data semantics
### Path 2
LLDAP → full LDAP, then Keycloak federating with full LDAP
### Path 3
Lightweight stack → expanded stack with privacyIDEA remaining stable
## 14.4 Migration outputs
Migration tooling should generate:
* transformed directory data
* client definitions
* profile conformance reports
* validation results
* contract test results
* incompatibility reports
---
## 15. Replacement Test Strategy
Automated replacement testing is mandatory.
## 15.1 Goal
Prove that applications relying only on the profile behave acceptably after replacement.
## 15.2 Required test scenarios
### Scenario A: Lightweight baseline
Provision canonical fixtures into lightweight mode and run all profile integration tests.
### Scenario B: IAM replacement
Replace Keycape-based implementation with Keycloak and rerun the same app-facing tests.
### Scenario C: Full expansion
Migrate LLDAP data into full LDAP, connect Keycloak, and rerun tests.
### Scenario D: Negative profile tests
Attempt to use unsupported functionality and verify correct error behavior and telemetry.
## 15.3 Test categories
Required categories:
* discovery tests
* login flow tests
* token claim tests
* redirect validation tests
* client configuration tests
* logout tests if supported
* MFA policy tests
* migration data integrity tests
* canonical LDAP schema validation tests
* telemetry assertion tests
## 15.4 Acceptance rule
A migration path is acceptable only if:
* profile-conformant apps keep working
* required claims remain stable
* unsupported cases fail in expected ways
* canonical identity data remains valid
* telemetry remains available where expected
---
## 16. Security Requirements
## 16.1 General
Keycape shall prioritize narrowness and correctness over feature breadth.
## 16.2 Mandatory controls
* strict redirect URI validation
* strict issuer consistency
* strict client identity validation
* no handwritten cryptography
* no handwritten password hashing implementation
* use of established protocol and crypto libraries
* minimal and explicit scope handling
* explicit token lifetime policy
* auditability of authentication decisions
## 16.3 Safety through profile discipline
Feature restriction is a security control.
Any expansion of the profile must be reviewed for:
* protocol complexity increase
* migration complexity increase
* test burden increase
* security surface increase
---
## 17. Configuration Principles
Keycape configuration shall be declarative.
## 17.1 Configuration sources
Configuration may include:
* profile definition version
* client definitions
* backend connection settings
* LDAP schema rules
* privacyIDEA integration settings
* telemetry destinations
* environment-specific overrides
## 17.2 Configuration constraints
Configuration should be:
* version-controlled
* environment-promotable
* statically validated where possible
* linked to profile version
* convertible into migration/export artifacts
---
## 18. Operational Modes
## 18.1 Dev mode
Optimized for rapid local iteration and deterministic tests.
May use:
* local LLDAP
* local Authelia
* simplified privacyIDEA integration stubs or real integration depending on environment policy
## 18.2 Slim production mode
A real production mode for environments whose needs fit inside the profile.
May use:
* LLDAP
* Authelia
* privacyIDEA
* Keycape
## 18.3 Expanded production mode
Used when federation, admin breadth, or IAM complexity exceeds the profiles lightweight implementation strategy.
May use:
* Keycloak
* full LDAP
* privacyIDEA
---
## 19. Non-Goals
Keycape shall not pursue these goals in v0.1:
* broad Keycloak API parity
* general-purpose enterprise IAM platform status
* support for arbitrary legacy LDAP consumers through Keycloak
* plugin ecosystem parity
* realm-level multi-tenancy complexity beyond explicit profile need
* bespoke app-specific exceptions outside the profile
---
## 20. Conformance
## 20.1 Keycape conformance
Keycape conforms if it:
* implements the required profile endpoints and behaviors
* produces correct claims and errors
* passes all lightweight profile tests
* emits required telemetry
* supports migration/export flows required by the specification
## 20.2 Expanded-mode conformance
A Keycloak-based deployment conforms if it:
* passes the same application-facing profile tests
* honors the same claim model and client behavior
* supports defined migration scenarios
## 20.3 Fixture conformance
Canonical fixtures conform if they pass canonical model and LDAP schema validation.
---
## 21. Initial Deliverables Derived from This Specification
The following implementation artifacts should be created next:
### 21.1 NetKingdom IAM Profile
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
Machine-readable schema for canonical fixtures.
### 21.3 Canonical LDAP schema and validator spec
Formal validator rules and error codes.
### 21.4 Keycape component design
Internal architecture, adapters, translation logic, and runtime behavior.
### 21.5 Replacement test matrix
End-to-end scenarios and expected outcomes.
### 21.6 Migration design
LLDAP → full LDAP and lightweight IAM → Keycloak data/config mapping.
xxx

View File

@@ -0,0 +1,499 @@
---
id: KEY-WP-0001
type: workplan
title: "KeyCape Implementation — Lightweight IAM Profile"
domain: infotech
repo: key-cape
status: done
owner: Bernd
topic_slug: netkingdom
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e
repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
created: 2026-03-13
updated: 2026-03-13
spec_refs:
- wiki/KeyCapeSpecification_v0.1.md
- wiki/KeyCapeSpecificationPack_v0.1.md
decisions:
- id: ADR-0001
title: "Implementation language: Go"
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
status: accepted
ref: docs/adr/ADR-0001-choose-go-for-keycape.md
state_hub_workstream_id: "0d34dfc1-7ccb-4bd5-b872-5c7379b9adce"
---
# KEY-WP-0001 — KeyCape Implementation
Implements the **NetKingdom IAM Profile** via KeyCape: a stateless, profile-constrained OIDC
server orchestrating Authelia, LLDAP, and privacyIDEA. Replaceable by Keycloak without
application changes.
## Language Decision
**Go** — decided 2026-03-13 by Bernd. See `docs/adr/ADR-0001-choose-go-for-keycape.md`.
KeyCape is an orchestrating boundary service (HTTP, adapters, JWT via library, CLI tooling) —
Go's strongest domain. Rust revisitable if a subcomponent needs stronger guarantees later.
Mandatory guardrails: typed domain models, narrow adapter interfaces, layered architecture,
fuzz tests on validator/redirect/claim-mapping, no cleverness.
## Repo Structure (from spec §12)
```
src/
server/
oidc/ # Profile endpoints
telemetry/ # Structured event emission
errors/ # Error taxonomy + enforcement middleware
adapters/
authelia/ # Auth flow delegation
lldap/ # Identity directory reads
privacyidea/ # MFA enforcement
validator/ # Canonical LDAP schema validator binary
migration/
lldap-export/ # LLDAP → canonical
keycape-to-keycloak/ # Canonical → Keycloak realm import
lldap-to-ldap/ # LLDAP → OpenLDAP/389DS/AD LDIF
spec/
canonical-model.yaml # Source of truth for all identity data
ldap-schema.yaml # Canonical LDAP schema rules
tests/
profile/ # Scenario A — lightweight baseline
negative/ # Scenario D — unsupported feature rejection
migration/ # Scenarios B & C — replacement
```
## Dependency Order
```
T01 (project setup)
└─ T02 (canonical model) T04 (error taxonomy)
└─ T03 (LDAP validator) └─ T13 (telemetry)
└─ T10 (LLDAP adapter) └─ T14 (enforcement layer)
└─ T11 (Authelia) │
└─ T12 (privacyIDEA) │
│ │
T05 ─ T06 ─ T07 ─ T08 ─┴─ T09 (OIDC server)
T18 (profile tests / Scenario A)
T21 (negative tests / Scenario D)
T15 → T16 → T19 (Scenario B)
T15 → T17 → T20 (Scenario C)
T22 (dev stack)
T23 (production packaging)
```
---
## Phase 1 — Foundations
## T01 — Project setup: Go module, repo layout, CI skeleton
```task
id: KEY-WP-0001-T01
hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
priority: high
status: done
state_hub_task_id: "38822bc0-4189-4909-874e-ea40e5771250"
```
Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
targets: `build`, `test`, `lint`. Set up CI (build + test on every push). Scaffolding only —
no application code. **Agent must call `record_decision()` with chosen language (Go or Rust).**
## T02 — Canonical identity model: machine-readable schema
```task
id: KEY-WP-0001-T02
hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
priority: high
status: done
depends_on: [T01]
state_hub_task_id: "940c118b-c1e6-4dda-bd4c-4fac105822be"
```
Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership,
MFAEnrollment (fields per spec §2). Include JSON Schema or CUE schema for programmatic
validation. This file is the **source of truth** — all other code derives from it.
## T03 — Canonical LDAP schema + validator
```task
id: KEY-WP-0001-T03
hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
priority: high
status: done
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
`dc=netkingdom,dc=local`), object classes (`inetOrgPerson`, `groupOfNames`), required/optional
attributes. Implement `validator/` binary. Structural rules: valid DN, required attrs, no unknown
attrs, valid group memberships. Semantic rules: referenced users exist, no cycles, usernames
unique, email format valid. Validator runs in `--mode=ci|provisioning|migration`. Emits
machine-readable report.
## T04 — Error taxonomy: types, JSON format, middleware
```task
id: KEY-WP-0001-T04
hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
priority: high
status: done
depends_on: [T01]
state_hub_task_id: "6e3b6b97-ac77-44c5-959e-be12751f1b63"
```
Implement four error types (spec §5):
- `feature_not_supported_by_profile`
- `available_in_keycloak_mode_only`
- `rejected_for_profile_safety`
- `invalid_profile_usage`
JSON format: `{"error": "...", "description": "...", "feature": "..."}`. HTTP middleware wraps
all handler errors. Error type strings are stable and test-assertable.
---
## Phase 2 — OIDC Server
## T05 — OIDC discovery endpoint (/.well-known/openid-configuration)
```task
id: KEY-WP-0001-T05
hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
priority: high
status: done
depends_on: [T04]
state_hub_task_id: "0dbc08e3-c465-4c37-a219-832a580bedfd"
```
`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
`authorization_code`, S256 PKCE, RS256, static scopes. Must NOT advertise: dynamic registration,
implicit flow. Issuer configurable. Cacheable response.
## T06 — Authorization endpoint with PKCE and redirect URI validation
```task
id: KEY-WP-0001-T06
hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
priority: high
status: done
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 —
wildcard → `rejected_for_profile_safety`), `response_type=code`, `scope` contains `openid`,
`code_challenge` present (missing → `invalid_profile_usage`), `code_challenge_method=S256`.
Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybrid flow.
## T07 — Token endpoint: JWT/RS256, canonical claim mapping
```task
id: KEY-WP-0001-T07
hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
priority: high
status: done
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
crypto). Mandatory claims: `iss`, `sub` (canonical user ID), `aud`, `exp`, `iat`. Optional
claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (groupOfNames),
`roles`. Short, explicitly configured token lifetime. Any other grant type →
`feature_not_supported_by_profile`.
## T08 — JWKS endpoint (/jwks)
```task
id: KEY-WP-0001-T08
hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
priority: high
status: done
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
multiple keys during rotation window, keyed by `kid`. Standard library key generation only.
## T09 — Userinfo endpoint (/userinfo)
```task
id: KEY-WP-0001-T09
hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
priority: medium
status: done
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
Bearer token (RS256 + expiry). Return claim subset scoped to granted scopes. Claims must be
identical to ID token for same scopes. If no client needs it: stub returning
`available_in_keycloak_mode_only`.
---
## Phase 3 — Backend Adapters
## T10 — LLDAP adapter: user and group reads
```task
id: KEY-WP-0001-T10
hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
priority: high
status: done
depends_on: [T02, T03]
state_hub_task_id: "97d19662-f482-4ea5-84fd-9fccb84ff317"
```
`adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical
User`, `LookupGroups(userDN) → []Group`, `ValidatePassword(username, password) → bool`. Attribute
map: `uid→username`, `cn→displayName`, `mail→email`, `memberOf→groups`. Run canonical LDAP schema
validator on every read. No LDAP internals exposed to `server/`.
## T11 — Authelia adapter: session and auth flow delegation
```task
id: KEY-WP-0001-T11
hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
priority: high
status: done
depends_on: [T04, T13]
state_hub_task_id: "6461865b-f57c-4591-9cf3-68c79af22723"
```
`adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated
user identity, hand off to MFA check (T12). Must not leak Authelia session tokens/cookies into
profile layer. Unavailable Authelia → fail closed (`auth_failure` event).
## T12 — privacyIDEA adapter: MFA enforcement
```task
id: KEY-WP-0001-T12
hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
priority: high
status: done
depends_on: [T02, T13]
state_hub_task_id: "e403a783-c856-4d6d-b859-a9cad7545fe1"
```
`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
`CheckMFARequired(user) → bool`, `ValidateMFAToken(user, token) → bool`. MFA failure → no token
issued + `auth_failure` telemetry. MFA enrollment state from canonical `MFAEnrollment` entity.
privacyIDEA remains stable across lightweight → expanded migration.
---
## Phase 4 — Telemetry & Enforcement
## T13 — Telemetry pipeline: structured event emission
```task
id: KEY-WP-0001-T13
hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
priority: high
status: done
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`,
`token_issued`, `unsupported_feature`, `invalid_request`, `migration_event`. Required fields
(spec §6.2): `timestamp`, `client_id`, `endpoint`, `feature`, `result`, `error_type`, `scopes`,
`grant_type`, `environment`, `trace_id`. Pluggable outputs: structured log (default), Prometheus
metrics endpoint. Every auth and error path emits an event — **no silent paths**.
## T14 — Unsupported feature enforcement layer
```task
id: KEY-WP-0001-T14
hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
priority: high
status: done
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
config exceeding the profile. Return correct error type + emit `unsupported_feature` telemetry.
Maintain a **registry** of unsupported features (adding new ones requires no handler changes):
`dynamic_client_registration`, `identity_broker`, `wildcard_redirect_uri`, `implicit_flow`, etc.
Every registry entry must have a corresponding test in T21.
---
## Phase 5 — Migration Tooling
## T15 — Migration: LLDAP → canonical export
```task
id: KEY-WP-0001-T15
hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
priority: medium
status: done
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
to canonical model. Validate against LDAP schema validator before writing. Output:
`canonical-export.yaml`. Emit `migration_event` telemetry. Idempotent. Include incompatibility
report for unmappable LLDAP data.
## T16 — Migration: canonical → Keycloak import
```task
id: KEY-WP-0001-T16
hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
priority: medium
status: done
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
import format: users, groups, clients, roles, scope mappings. Preserve: same issuer, same claims,
same scopes, same client behavior. Output: `keycloak-realm-import.json`. Emit `migration_event`.
Include round-trip validation report.
## T17 — Migration: LLDAP → full LDAP (OpenLDAP / 389DS / AD)
```task
id: KEY-WP-0001-T17
hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
priority: medium
status: done
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
(`--target=openldap|389ds|ad`). Migrate: users, groups, memberships, attributes. Run validator
on output LDIF before import. Produce validation report. **Orthogonal to T16** — the two
migration dimensions are independent (spec §14.1).
---
## Phase 6 — Replacement Tests
## T18 — Profile test suite: Scenario A (lightweight baseline)
```task
id: KEY-WP-0001-T18
hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
priority: high
status: done
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
(spec §15.3): discovery, login flow (PKCE), token claim assertions (all mandatory + optional),
redirect validation, client config, MFA policy, logout (if implemented). Tests are
**backend-agnostic** — same suite runs in T19 and T20. Must pass for Scenario A conformance.
## T19 — Replacement test suite: Scenario B (IAM swap, same directory)
```task
id: KEY-WP-0001-T19
hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
priority: medium
status: done
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
changes allowed.** Migration successful only if all T18 tests pass. Proves IAM replaceability
without directory migration.
## T20 — Replacement test suite: Scenario C (full expansion)
```task
id: KEY-WP-0001-T20
hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
priority: medium
status: done
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
only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment required).
## T21 — Negative profile tests: Scenario D (unsupported feature rejection)
```task
id: KEY-WP-0001-T21
hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
priority: high
status: done
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
correct `error.error` string, assert `unsupported_feature` telemetry event emitted. Covered
cases: `dynamic_client_registration`, `implicit_flow`, wildcard redirect URIs, identity brokering,
missing PKCE, unknown scopes, unknown grant types. **Must run in CI.** Proves enforcement layer
is complete.
---
## Phase 7 — Operations
## T22 — Dev mode stack: docker-compose with LLDAP + Authelia
```task
id: KEY-WP-0001-T22
hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
priority: medium
status: done
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
canonical fixtures from T02. Makefile targets: `make dev` (start + verify basic auth flow),
`make seed` (re-apply fixtures without full restart). Deterministic: same seed → same state.
Test environment for T18 and T21.
## T23 — Slim production packaging: binary + config + health
```task
id: KEY-WP-0001-T23
hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
priority: low
status: done
depends_on: [T18, T21]
state_hub_task_id: "8c1752c2-7fb3-4da5-aab3-6b7acf12ea64"
```
Single stateless binary. Declarative YAML config: profile version, client definitions, backend
connections, LDAP schema rules, privacyIDEA settings, telemetry destination, token lifetime,
issuer URL. Static config validation on startup. `/healthz` endpoint. Minimal container image
(distroless or Alpine). Config environment-promotable via env var overrides only.
---
## Acceptance Criteria (from spec §15.4 and §20)
A release is conformant when:
- [x] Scenario A tests pass (T18) — `src/tests/profile/profile_test.go` (8 tests)
- [x] Scenario D tests pass (T21) — `src/tests/negative/negative_test.go` (8 tests)
- [x] Scenario B tests pass (T19) — `src/tests/migration/scenario_b_test.go` (7 tests)
- [x] Scenario C tests pass (T20) — `src/tests/migration/scenario_c_test.go` (6 tests)
- [x] All error responses use taxonomy types from spec §5 — `internal/errors/taxonomy.go`
- [x] All auth/error paths emit structured telemetry (T13) — `internal/server/telemetry/`
- [x] Canonical LDAP schema validator passes on all fixtures (T03) — `internal/validator/`
- [x] No handwritten cryptography anywhere in the codebase — stdlib `crypto/rsa` only
- [x] Config is statically validated at startup (T23) — `internal/config/validate.go`

View 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

View 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.