generated from coulomb/repo-seed
Compare commits
38 Commits
97d3fceea6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd69f0374 | |||
| afc01456a5 | |||
| d076e7ee7b | |||
| c4f281a376 | |||
| bee021735c | |||
| c9838a4811 | |||
| 593b5af8dc | |||
| d6d41dd84f | |||
| 06d20c3379 | |||
| 937cb39de6 | |||
| 56d279a8e6 | |||
| 1d68639225 | |||
| 7e22fcf3c7 | |||
| 393abf3e0e | |||
| f45784f951 | |||
| 465a778c1f | |||
| 10868739a8 | |||
| a626dd5d4e | |||
| 926adfb3aa | |||
| cfa12e978d | |||
| a6af43b332 | |||
| 18dbad68ed | |||
| 7822ba0703 | |||
| 393ef3ca76 | |||
| 303663e48b | |||
| 80bf79de46 | |||
| ece58bc363 | |||
| 847abcba73 | |||
| c18adb6441 | |||
| fa27adbc77 | |||
| 3ee8090a98 | |||
| 4097a7de8b | |||
| d05c73dc19 | |||
| b0adbc5daa | |||
| 22f7a7dc50 | |||
| 329e996619 | |||
| f3b1cdcba4 | |||
| 3780190456 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## Kaizen Agents
|
||||
|
||||
Specialized agent personas available on demand via the state-hub MCP.
|
||||
|
||||
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||
|
||||
Common agents:
|
||||
|
||||
| Agent | Category | When to use |
|
||||
|-------|----------|-------------|
|
||||
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||
| `project-management` | process | Track status, determine next steps |
|
||||
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||
|
||||
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Architecture
|
||||
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
|
||||
## Quick Reference
|
||||
|
||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Credential and access routing
|
||||
|
||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=key-cape` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||
| Authorization decision | flex-auth | No — route only |
|
||||
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## First Session Protocol
|
||||
|
||||
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||
The project is registered but work has not yet been structured.
|
||||
|
||||
**Step 1 — Read, don't write**
|
||||
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||
- Scan repo root: README, directory structure, existing code or docs
|
||||
|
||||
**Step 2 — Survey in-progress work**
|
||||
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||
|
||||
**Step 3 — Propose workstreams to Bernd**
|
||||
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||
roadmap phase. **Wait for approval before creating.**
|
||||
|
||||
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||
```
|
||||
workplans/KEY-WP-NNNN-<slug>.md ← write this first
|
||||
```
|
||||
Then register in the hub:
|
||||
```
|
||||
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
|
||||
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||
```
|
||||
|
||||
**Step 5 — Record the setup**
|
||||
```
|
||||
add_progress_event(
|
||||
summary="First session: structured infotech into N workstreams, M tasks",
|
||||
event_type="milestone",
|
||||
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||
detail={"workstreams": [...], "tasks_created": M}
|
||||
)
|
||||
```
|
||||
|
||||
<!-- Delete or archive this file once past first session -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Repo boundary
|
||||
|
||||
This repo owns **KeyCape** only. It does not own:
|
||||
|
||||
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||
- SSH key management → railiance-infra/
|
||||
- State hub code → state-hub/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
||||
**Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode.
|
||||
|
||||
**Domain:** infotech
|
||||
**Repo slug:** key-cape
|
||||
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
||||
## Session Protocol
|
||||
|
||||
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||
MCP server name in `~/.claude.json`: `dev-hub`
|
||||
|
||||
**Step 1 — Orient**
|
||||
|
||||
Read the offline-safe brief first — it works without a live hub connection:
|
||||
```bash
|
||||
cat .custodian-brief.md
|
||||
```
|
||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||
```
|
||||
get_domain_summary("infotech")
|
||||
```
|
||||
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||
```
|
||||
If the hub is offline: `cd ~/state-hub && make api`
|
||||
|
||||
**Step 2 — Check inbox**
|
||||
With MCP tools:
|
||||
```
|
||||
get_messages(to_agent="key-cape", unread_only=True)
|
||||
```
|
||||
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||
requests before proceeding.
|
||||
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=key-cape&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
**Step 3 — Scan workplans**
|
||||
```bash
|
||||
ls workplans/
|
||||
```
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`wait`/`todo`/`progress` tasks.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||
2. **Pending tasks** from `workplans/` + any `[repo:key-cape]` hub tasks
|
||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||
4. **Suggested next action** — highest-priority open item
|
||||
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||
|
||||
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||
|
||||
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||
|
||||
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||
|
||||
**Session close:**
|
||||
With MCP tools:
|
||||
```
|
||||
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
|
||||
```
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||
```
|
||||
If workplan files were modified, ensure the local copy is up to date first:
|
||||
```bash
|
||||
git -C <repo_path> pull --ff-only
|
||||
cd ~/state-hub && make fix-consistency REPO=key-cape
|
||||
```
|
||||
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||
use the combined target which pulls before fixing:
|
||||
```bash
|
||||
cd ~/state-hub && make fix-consistency-remote REPO=key-cape
|
||||
```
|
||||
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||
until you pull — intentional to prevent clobbering remote progress.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Stack
|
||||
|
||||
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||
- **Language:**
|
||||
- **Key deps:**
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
# TODO: Fill in the standard commands for this repo
|
||||
|
||||
# Install dependencies
|
||||
|
||||
# Run tests
|
||||
|
||||
# Lint / type check
|
||||
|
||||
# Build / package (if applicable)
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
File location: `workplans/KEY-WP-NNNN-<slug>.md`
|
||||
ID prefix: `KEY-WP-`
|
||||
|
||||
Work items originate as files in this repo **before** being registered in the hub.
|
||||
|
||||
Canonical workplan/workstream frontmatter statuses are:
|
||||
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||
repo state, and `finished` when implementation is complete. `stalled` and
|
||||
`needs_review` are derived health labels, not stored statuses.
|
||||
|
||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||
prefix: `YYMMDD-KEY-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
unchanged; the prefix is only for quick visual reference.
|
||||
|
||||
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||
multiple planned phases into a normal workplan.
|
||||
|
||||
Ecosystem todos from other agents arrive as `[repo:key-cape]` hub tasks —
|
||||
visible at session start. Pick one up by creating the workplan file, then registering
|
||||
the workstream.
|
||||
|
||||
Task blocks use this shape:
|
||||
|
||||
```task
|
||||
id: KEY-WP-NNNN-T01
|
||||
status: wait | todo | progress | done | cancel
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
```
|
||||
|
||||
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||
blocked work and `cancel` for stopped work.
|
||||
|
||||
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||
18
.custodian-brief.md
Normal file
18
.custodian-brief.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — key-cape
|
||||
|
||||
**Domain:** communication
|
||||
**Last synced:** 2026-06-22 16:02 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
*(none — repo may need first-session setup)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("communication")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
51
.gitea/workflows/image.yaml
Normal file
51
.gitea/workflows/image.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Build and Publish Container Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY: 92.205.130.254:32166
|
||||
IMAGE_NAME: coulomb/key-cape
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: act_runner
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=main-,format=short,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
43
.github/workflows/ci.yml
vendored
Normal file
43
.github/workflows/ci.yml
vendored
Normal 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
26
.repo-classification.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
repo_classification:
|
||||
standard: Repo Classification Standard
|
||||
version: '1.0'
|
||||
classified_at: '2026-06-22'
|
||||
classified_by: human
|
||||
category: product
|
||||
domain: infotech
|
||||
secondary_domains:
|
||||
- communication
|
||||
capability_tags:
|
||||
- identity
|
||||
- access-control
|
||||
- security
|
||||
- platform
|
||||
- operations
|
||||
business_stake:
|
||||
- technology
|
||||
- operations
|
||||
- legal
|
||||
- product
|
||||
business_mechanics:
|
||||
- control
|
||||
- operation
|
||||
- adaptation
|
||||
notes: NetKingdom IAM Profile lightweight mode (Authelia/LLDAP/privacyIDEA); human
|
||||
corrected domain from communication→infotech.
|
||||
219
AGENTS.md
Normal file
219
AGENTS.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# KeyCape — Agent Instructions
|
||||
|
||||
## Repo Identity
|
||||
|
||||
**Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode.
|
||||
|
||||
**Domain:** infotech
|
||||
**Repo slug:** key-cape
|
||||
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||
**Workplan prefix:** `KEY-WP-`
|
||||
|
||||
---
|
||||
|
||||
## State Hub Integration
|
||||
|
||||
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||
there is no MCP server for Codex agents.
|
||||
|
||||
| Context | URL |
|
||||
|---------|-----|
|
||||
| Local workstation | `http://127.0.0.1:8000` |
|
||||
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||
|
||||
### Orient at session start
|
||||
|
||||
```bash
|
||||
# Offline brief — works without hub connection
|
||||
cat .custodian-brief.md
|
||||
|
||||
# Active workstreams for this domain
|
||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||
| python3 -m json.tool
|
||||
|
||||
# Check inbox
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=key-cape&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Mark a message read:
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
### Log progress (required at session close)
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"summary": "what was done",
|
||||
"event_type": "note",
|
||||
"author": "codex",
|
||||
"workstream_id": "<uuid>",
|
||||
"task_id": "<uuid>"
|
||||
}'
|
||||
```
|
||||
|
||||
Omit `workstream_id` / `task_id` when not applicable.
|
||||
|
||||
### Update task status
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "progress"}'
|
||||
# values: wait | todo | progress | done | cancel
|
||||
```
|
||||
|
||||
### Flag a task for human review
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Protocol
|
||||
|
||||
**Start:**
|
||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||
2. Check inbox: `GET /messages/?to_agent=key-cape&unread_only=true`; mark read
|
||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||
|
||||
**During work:**
|
||||
- Update task statuses in workplan files as tasks progress
|
||||
- Record significant decisions via `POST /decisions/`
|
||||
|
||||
**Close:**
|
||||
1. Update workplan file task statuses to reflect progress
|
||||
2. Log: `POST /progress/` with a summary of what changed
|
||||
3. Note for the custodian operator: after workplan file changes, run from
|
||||
`~/state-hub`:
|
||||
```bash
|
||||
make fix-consistency REPO=key-cape
|
||||
```
|
||||
This syncs task status from files into the hub DB.
|
||||
|
||||
---
|
||||
|
||||
## Credential and access routing
|
||||
|
||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=key-cape` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||
| Authorization decision | flex-auth | No — route only |
|
||||
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
|
||||
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||
<!-- Append repo-specific agent instructions below this marker.
|
||||
The state-hub template sync preserves content after this line. -->
|
||||
|
||||
---
|
||||
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
Work items originate as files in this repo — not in the hub. The hub is a
|
||||
read/cache/index layer that rebuilds from files.
|
||||
|
||||
**File location:** `workplans/KEY-WP-NNNN-<slug>.md`
|
||||
|
||||
**Archived location:** finished workplans may move to
|
||||
`workplans/archived/YYMMDD-KEY-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||
the completion/archive date; the frontmatter `id` does not change.
|
||||
|
||||
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||
this only for low-risk work completed directly; create a normal workplan for
|
||||
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||
|
||||
**Frontmatter:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: KEY-WP-NNNN
|
||||
type: workplan
|
||||
title: "..."
|
||||
domain: infotech
|
||||
repo: key-cape
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: codex
|
||||
topic_slug: ...
|
||||
created: "YYYY-MM-DD"
|
||||
updated: "YYYY-MM-DD"
|
||||
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
---
|
||||
```
|
||||
|
||||
Use `proposed` for a new draft, `ready` after review against current repo
|
||||
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||
derived health labels, not frontmatter statuses.
|
||||
|
||||
**Task block format** (one per `##` section):
|
||||
|
||||
```
|
||||
## Task Title
|
||||
|
||||
` ` `task
|
||||
id: KEY-WP-NNNN-T01
|
||||
status: wait | todo | progress | done | cancel
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
` ` `
|
||||
|
||||
Task description text.
|
||||
```
|
||||
|
||||
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||
|
||||
To create a new workplan:
|
||||
1. Write the file following the format above
|
||||
2. Notify the custodian operator to run `make fix-consistency REPO=key-cape`
|
||||
(or send a message to the hub agent via `POST /messages/`)
|
||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal 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
11
Dockerfile
Normal 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
96
INTENT.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# INTENT
|
||||
|
||||
## Purpose
|
||||
|
||||
This repository exists to provide a **lightweight, profile-conformant identity and access management (IAM) system**.
|
||||
|
||||
It ensures that applications can rely on a **stable, versioned authentication contract** independent of the underlying IAM implementation.
|
||||
|
||||
---
|
||||
|
||||
## Primary Utility
|
||||
|
||||
The repository provides an implementation of a **versioned IAM profile** that:
|
||||
|
||||
* Delivers OIDC/PKCE-based authentication with strong security constraints
|
||||
* Normalizes identity data across heterogeneous backend systems
|
||||
* Enforces strict adherence to the defined IAM contract
|
||||
* Enables seamless migration between lightweight and expanded IAM modes
|
||||
|
||||
It transforms IAM from a system dependency into a **replaceable, contract-driven capability**.
|
||||
|
||||
---
|
||||
|
||||
## Intended Users
|
||||
|
||||
* Application developers integrating against the IAM profile
|
||||
* Infrastructure operators (`adm`) deploying IAM in constrained environments
|
||||
* Automation systems (`atm`) managing identity, migration, and validation workflows
|
||||
* LLM agents (`agt`) interacting with authenticated services
|
||||
|
||||
---
|
||||
|
||||
## Strategic Role in the System
|
||||
|
||||
This repository serves as the **lightweight IAM layer**:
|
||||
|
||||
* It provides a **resource-efficient implementation** of the IAM profile for environments with limited resources
|
||||
* It anchors IAM around a **profile contract rather than a specific implementation**
|
||||
* It enables a **two-mode architecture**:
|
||||
|
||||
* Lightweight mode (this implementation)
|
||||
* Expanded mode (a heavier, full-featured implementation)
|
||||
|
||||
The profile ensures that both modes are **interchangeable without application changes**.
|
||||
|
||||
---
|
||||
|
||||
## Strategic Boundaries
|
||||
|
||||
This repository is **not** intended to:
|
||||
|
||||
* Become a full-featured, general-purpose IAM platform
|
||||
* Extend beyond the defined IAM profile
|
||||
* Support features that weaken security guarantees (e.g., implicit flow, wildcard redirects)
|
||||
* Replace or wrap the heavier expanded-mode implementation
|
||||
|
||||
Its responsibility is limited to **strict, secure, and transparent profile implementation**.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
* **Contract over implementation**
|
||||
Applications depend on the IAM profile, not on KeyCape internals
|
||||
|
||||
* **Security through constraint**
|
||||
Only explicitly allowed features are supported; unsafe patterns are rejected
|
||||
|
||||
* **Explicitness over convenience**
|
||||
Unsupported features must fail clearly and predictably
|
||||
|
||||
* **Replaceability by design**
|
||||
The system must be swappable with a heavier profile implementation without breaking integrations
|
||||
|
||||
* **Canonical identity model**
|
||||
Identity data must be normalized and consistent across all backends
|
||||
|
||||
---
|
||||
|
||||
## Maturity Target
|
||||
|
||||
A mature version of this repository should:
|
||||
|
||||
* Fully implement and enforce the **IAM profile** with zero ambiguity
|
||||
* Provide **complete migration pathways** between lightweight and expanded modes
|
||||
* Offer **deterministic and testable behavior** across all supported scenarios
|
||||
* Act as a **reference implementation** of the IAM profile
|
||||
* Enable IAM deployments that are **minimal, secure, and operationally efficient**
|
||||
|
||||
---
|
||||
|
||||
## Stability Note
|
||||
|
||||
Changes to this file represent a **deliberate shift in the IAM contract, scope, or architectural role** of this repository.
|
||||
|
||||
Such changes must be made with explicit intent, as they directly affect all dependent applications.
|
||||
30
Makefile
Normal file
30
Makefile
Normal 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
262
README.md
@@ -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
115
SCOPE.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# SCOPE
|
||||
|
||||
> This file helps you quickly understand what this repository is about,
|
||||
> when it is relevant, and when it is not.
|
||||
> It is intentionally lightweight and may be incomplete.
|
||||
|
||||
---
|
||||
|
||||
## One-liner
|
||||
|
||||
Lightweight IAM implementation of the NetKingdom IAM Profile — orchestrates Authelia, LLDAP, and privacyIDEA to provide OIDC/PKCE authentication as a drop-in Keycloak alternative.
|
||||
|
||||
---
|
||||
|
||||
## Core Idea
|
||||
|
||||
NetKingdom applications target the "NetKingdom IAM Profile" — a versioned OIDC/PKCE contract. KeyCape implements that profile in lightweight mode (Authelia + LLDAP + privacyIDEA) with intentional constraints: no implicit flow, no wildcard redirects, no dynamic client registration. The same profile is implemented in expanded mode by Keycloak, so applications can migrate between modes without code changes.
|
||||
|
||||
---
|
||||
|
||||
## In Scope
|
||||
|
||||
- OIDC profile endpoints (discovery, authorization, token, JWKS, userinfo) per NetKingdom IAM Profile
|
||||
- Canonical identity model: users, groups, clients, MFA
|
||||
- Claim normalization across Authelia/LLDAP/privacyIDEA backend quirks
|
||||
- Profile enforcement with structured error taxonomy (no silent emulation of unsupported features)
|
||||
- Telemetry for unsupported-feature requests
|
||||
- Migration tooling: LLDAP export, Keycloak import, LDIF generation
|
||||
- LDAP schema validation
|
||||
- Full acceptance test suite (profile baseline, migration scenarios, negative tests)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- General-purpose IAM (profile-specific only; no out-of-profile extensions)
|
||||
- Dynamic client registration
|
||||
- Implicit flow
|
||||
- Wildcard redirect URIs
|
||||
- Identity brokering beyond OIDC
|
||||
- Keycloak operations (KeyCape is the lightweight alternative, not a Keycloak wrapper)
|
||||
|
||||
---
|
||||
|
||||
## Relevant When
|
||||
|
||||
- Deploying NetKingdom IAM in lightweight mode (no Keycloak license/resources needed)
|
||||
- Applications need OIDC authentication with MFA in a constrained environment
|
||||
- Migrating from lightweight (KeyCape) to expanded (Keycloak) mode
|
||||
- Validating LDAP schema or generating migration artifacts
|
||||
|
||||
---
|
||||
|
||||
## Not Relevant When
|
||||
|
||||
- Expanded-mode Keycloak is already running (applications use the same profile; no code changes needed)
|
||||
- Need out-of-profile IAM features (dynamic client registration, implicit flow, etc.)
|
||||
- Non-NetKingdom OIDC deployments
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
- Status: stable (v0.1 complete)
|
||||
- Implementation: complete — all 23 workplan tasks implemented and tested
|
||||
- Stability: high — profile-constrained; no silent failures; acceptance tests passing
|
||||
- Usage: internal NetKingdom stack; replaces Keycloak in lightweight deployments
|
||||
|
||||
---
|
||||
|
||||
## How It Fits
|
||||
|
||||
- Upstream dependencies: Authelia (OIDC provider/sessions), LLDAP (identity directory), privacyIDEA (MFA)
|
||||
- Downstream consumers: all NetKingdom applications; net-kingdom (parent domain)
|
||||
- Often used with: net-kingdom (SSO/MFA workplan), railiance (deployed on Railiance infrastructure)
|
||||
|
||||
---
|
||||
|
||||
## Terminology
|
||||
|
||||
- Preferred terms: NetKingdom IAM Profile, lightweight mode, expanded mode, profile enforcement, canonical model
|
||||
- Also known as: "KeyCape", "key-cape"
|
||||
- Potentially confusing terms: "lightweight mode" = KeyCape stack; "expanded mode" = Keycloak stack; both implement the same OIDC profile
|
||||
|
||||
---
|
||||
|
||||
## Related / Overlapping
|
||||
|
||||
- `net-kingdom` — parent platform domain; KeyCape is the lightweight IAM implementation of its IAM Profile
|
||||
|
||||
---
|
||||
|
||||
## Provided Capabilities
|
||||
|
||||
```capability
|
||||
type: security
|
||||
title: OIDC/PKCE authentication (lightweight mode)
|
||||
description: Provides OIDC/PKCE endpoints conforming to the NetKingdom IAM Profile via Authelia + LLDAP + privacyIDEA — a drop-in Keycloak alternative for constrained environments.
|
||||
keywords: [oidc, pkce, authentication, iam, sso, authelia, lldap, mfa, identity]
|
||||
```
|
||||
|
||||
```capability
|
||||
type: security
|
||||
title: Identity migration tooling
|
||||
description: Migrate identities between lightweight (KeyCape) and expanded (Keycloak) IAM modes — LLDAP export, Keycloak import, LDIF generation.
|
||||
keywords: [migration, identity, lldap, keycloak, ldif, iam]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Oriented
|
||||
|
||||
- Start with: `wiki/KeyCapeSpecification_v0.1.md` (architecture and design intent)
|
||||
- Key files / directories: `wiki/KeyCapeSpecificationPack_v0.1.md` (normative spec), `src/cmd/` (binary entrypoints), `src/internal/` (implementation), `tests/` (acceptance suite)
|
||||
- Entry points: `keycape server` binary; `keycape migrate` for migration tooling
|
||||
39
config/dev-config.yaml
Normal file
39
config/dev-config.yaml
Normal 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
48
docker-compose.dev.yml
Normal 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:
|
||||
119
docs/adr/ADR-0001-choose-go-for-keycape.md
Normal file
119
docs/adr/ADR-0001-choose-go-for-keycape.md
Normal 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
12
registry/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Capability Registry
|
||||
|
||||
Markdown-first capability index for federation and reuse planning.
|
||||
|
||||
## Authoring
|
||||
|
||||
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
|
||||
2. Add the row to `indexes/capabilities.yaml`.
|
||||
3. Run `reuse-surface validate` from a checkout with the CLI installed.
|
||||
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
|
||||
|
||||
Federation contract: reuse-surface `docs/RegistryFederation.md`.
|
||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
4
registry/indexes/capabilities.yaml
Normal file
4
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
version: 1
|
||||
updated: '2026-06-16'
|
||||
domain: helix_forge
|
||||
capabilities: []
|
||||
44
scripts/test-scenario-b.sh
Executable file
44
scripts/test-scenario-b.sh
Executable 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
60
scripts/test-scenario-c.sh
Executable 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
174
spec/canonical-model.yaml
Normal 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
91
spec/ldap-schema.yaml
Normal 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
29
src/Makefile
Normal 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)
|
||||
75
src/cmd/keycape-to-keycloak/main.go
Normal file
75
src/cmd/keycape-to-keycloak/main.go
Normal 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
271
src/cmd/keycape/main.go
Normal 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))
|
||||
})
|
||||
}
|
||||
65
src/cmd/lldap-export/main.go
Normal file
65
src/cmd/lldap-export/main.go
Normal 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
|
||||
}
|
||||
}
|
||||
81
src/cmd/lldap-to-ldap/main.go
Normal file
81
src/cmd/lldap-to-ldap/main.go
Normal 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
69
src/cmd/validator/main.go
Normal 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
19
src/go.mod
Normal 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
54
src/go.sum
Normal 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=
|
||||
223
src/internal/adapters/authelia/adapter.go
Normal file
223
src/internal/adapters/authelia/adapter.go
Normal 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
|
||||
}
|
||||
385
src/internal/adapters/authelia/adapter_test.go
Normal file
385
src/internal/adapters/authelia/adapter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
38
src/internal/adapters/authelia/config.go
Normal file
38
src/internal/adapters/authelia/config.go
Normal 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{}
|
||||
342
src/internal/adapters/lldap/adapter.go
Normal file
342
src/internal/adapters/lldap/adapter.go
Normal 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, "; ")
|
||||
}
|
||||
356
src/internal/adapters/lldap/adapter_test.go
Normal file
356
src/internal/adapters/lldap/adapter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
55
src/internal/adapters/lldap/config.go
Normal file
55
src/internal/adapters/lldap/config.go
Normal 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
|
||||
}
|
||||
157
src/internal/adapters/privacyidea/adapter.go
Normal file
157
src/internal/adapters/privacyidea/adapter.go
Normal 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"`
|
||||
}
|
||||
330
src/internal/adapters/privacyidea/adapter_test.go
Normal file
330
src/internal/adapters/privacyidea/adapter_test.go
Normal 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")
|
||||
}
|
||||
41
src/internal/adapters/privacyidea/config.go
Normal file
41
src/internal/adapters/privacyidea/config.go
Normal 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{}
|
||||
63
src/internal/config/config.go
Normal file
63
src/internal/config/config.go
Normal 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
|
||||
}
|
||||
341
src/internal/config/config_test.go
Normal file
341
src/internal/config/config_test.go
Normal 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
|
||||
}
|
||||
61
src/internal/config/validate.go
Normal file
61
src/internal/config/validate.go
Normal 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
|
||||
}
|
||||
45
src/internal/domain/auth.go
Normal file
45
src/internal/domain/auth.go
Normal 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")
|
||||
23
src/internal/domain/mfa.go
Normal file
23
src/internal/domain/mfa.go
Normal 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")
|
||||
68
src/internal/domain/model.go
Normal file
68
src/internal/domain/model.go
Normal 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"`
|
||||
}
|
||||
31
src/internal/domain/repository.go
Normal file
31
src/internal/domain/repository.go
Normal 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) }
|
||||
85
src/internal/errors/taxonomy.go
Normal file
85
src/internal/errors/taxonomy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
141
src/internal/errors/taxonomy_test.go
Normal file
141
src/internal/errors/taxonomy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
138
src/internal/migration/lldapexport/exporter.go
Normal file
138
src/internal/migration/lldapexport/exporter.go
Normal 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
|
||||
}
|
||||
235
src/internal/migration/lldapexport/exporter_test.go
Normal file
235
src/internal/migration/lldapexport/exporter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
278
src/internal/migration/tokeycloak/transformer.go
Normal file
278
src/internal/migration/tokeycloak/transformer.go
Normal 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)
|
||||
}
|
||||
440
src/internal/migration/tokeycloak/transformer_test.go
Normal file
440
src/internal/migration/tokeycloak/transformer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
230
src/internal/migration/toldap/generator.go
Normal file
230
src/internal/migration/toldap/generator.go
Normal 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
|
||||
}
|
||||
346
src/internal/migration/toldap/generator_test.go
Normal file
346
src/internal/migration/toldap/generator_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
203
src/internal/server/errors/enforcement.go
Normal file
203
src/internal/server/errors/enforcement.go
Normal 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
|
||||
}
|
||||
299
src/internal/server/errors/enforcement_test.go
Normal file
299
src/internal/server/errors/enforcement_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
485
src/internal/server/oidc/authorize.go
Normal file
485
src/internal/server/oidc/authorize.go
Normal 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
|
||||
}
|
||||
762
src/internal/server/oidc/authorize_test.go
Normal file
762
src/internal/server/oidc/authorize_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/internal/server/oidc/discovery.go
Normal file
86
src/internal/server/oidc/discovery.go
Normal 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)
|
||||
}
|
||||
314
src/internal/server/oidc/discovery_test.go
Normal file
314
src/internal/server/oidc/discovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
123
src/internal/server/oidc/jwks.go
Normal file
123
src/internal/server/oidc/jwks.go
Normal 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
|
||||
}
|
||||
214
src/internal/server/oidc/jwks_test.go
Normal file
214
src/internal/server/oidc/jwks_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/internal/server/oidc/session.go
Normal file
80
src/internal/server/oidc/session.go
Normal 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)
|
||||
}
|
||||
222
src/internal/server/oidc/token.go
Normal file
222
src/internal/server/oidc/token.go
Normal 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
|
||||
}
|
||||
508
src/internal/server/oidc/token_test.go
Normal file
508
src/internal/server/oidc/token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
185
src/internal/server/oidc/userinfo.go
Normal file
185
src/internal/server/oidc/userinfo.go
Normal 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)
|
||||
}
|
||||
307
src/internal/server/oidc/userinfo_test.go
Normal file
307
src/internal/server/oidc/userinfo_test.go
Normal 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)
|
||||
104
src/internal/server/telemetry/emitter.go
Normal file
104
src/internal/server/telemetry/emitter.go
Normal 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)
|
||||
}
|
||||
}
|
||||
179
src/internal/server/telemetry/emitter_test.go
Normal file
179
src/internal/server/telemetry/emitter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/internal/server/telemetry/events.go
Normal file
35
src/internal/server/telemetry/events.go
Normal 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"`
|
||||
}
|
||||
28
src/internal/validator/report.go
Normal file
28
src/internal/validator/report.go
Normal 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"
|
||||
)
|
||||
236
src/internal/validator/validator.go
Normal file
236
src/internal/validator/validator.go
Normal 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
|
||||
}
|
||||
314
src/internal/validator/validator_test.go
Normal file
314
src/internal/validator/validator_test.go
Normal 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"}
|
||||
}
|
||||
86
src/tests/migration/fixtures_test.go
Normal file
86
src/tests/migration/fixtures_test.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
196
src/tests/migration/scenario_b_test.go
Normal file
196
src/tests/migration/scenario_b_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
211
src/tests/migration/scenario_c_test.go
Normal file
211
src/tests/migration/scenario_c_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
182
src/tests/negative/negative_test.go
Normal file
182
src/tests/negative/negative_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
635
src/tests/profile/profile_test.go
Normal file
635
src/tests/profile/profile_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
730
wiki/KeyCapeSpecificationPack_v0.1.md
Normal file
730
wiki/KeyCapeSpecificationPack_v0.1.md
Normal 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 (~15–20k lines)
|
||||
|
||||
If you want, I can generate that next.
|
||||
|
||||
|
||||
xxx
|
||||
824
wiki/KeyCapeSpecification_v0.1.md
Normal file
824
wiki/KeyCapeSpecification_v0.1.md
Normal 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 profile’s 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 profile’s 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
|
||||
499
workplans/KEY-WP-0001-keycape-implementation.md
Normal file
499
workplans/KEY-WP-0001-keycape-implementation.md
Normal 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`
|
||||
245
workplans/KEY-WP-0002-container-image-gitea.md
Normal file
245
workplans/KEY-WP-0002-container-image-gitea.md
Normal file
@@ -0,0 +1,245 @@
|
||||
---
|
||||
id: KEY-WP-0002
|
||||
type: workplan
|
||||
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
|
||||
domain: infotech
|
||||
repo: key-cape
|
||||
status: done
|
||||
owner: netkingdom
|
||||
topic_slug: netkingdom
|
||||
created: "2026-03-22"
|
||||
updated: "2026-03-21"
|
||||
capability_request_id: ""
|
||||
state_hub_workstream_id: "c8843c7a-460a-47a2-b45a-b8d3940f9aa2"
|
||||
---
|
||||
|
||||
# KEY-WP-0002 — KeyCape Container Image — Build & Publish to Gitea OCI Registry
|
||||
|
||||
## Problem
|
||||
|
||||
KeyCape has a `Dockerfile` but no automated build pipeline and no published
|
||||
image. Other services (k3s deployments, local dev) that need to run KeyCape
|
||||
must build locally from source. There is no versioned artefact to reference
|
||||
in Helm charts or manifests.
|
||||
|
||||
The capability request for this work was originally misrouted to railiance.
|
||||
It belongs here: KeyCape owns its own image.
|
||||
|
||||
## Goal
|
||||
|
||||
Produce a versioned OCI image for KeyCape, published to the Gitea container
|
||||
registry on CoulombCore (`92.205.130.254:32166`), triggered automatically on
|
||||
every merge to `main` and on semver tags (`v*`).
|
||||
|
||||
**Gitea OCI registry endpoint:** `92.205.130.254:32166`
|
||||
**Image name:** `92.205.130.254:32166/coulomb/key-cape`
|
||||
|
||||
> **Why Gitea, not GHCR?**
|
||||
> The net-kingdom cluster is self-hosted. Keeping images in Gitea (also
|
||||
> self-hosted on CoulombCore) avoids any external registry dependency and
|
||||
> keeps image pulls within the cluster network. GHCR is a future option
|
||||
> once public distribution is needed.
|
||||
|
||||
## Design
|
||||
|
||||
### Image naming & tagging
|
||||
|
||||
| Trigger | Tags applied |
|
||||
|---------|-------------|
|
||||
| push to `main` | `latest`, `main-<short-sha>` |
|
||||
| tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
|
||||
|
||||
### Build
|
||||
|
||||
Multi-stage Dockerfile already present — no changes needed to the build
|
||||
itself. The image builds to a distroless static binary (~10 MB).
|
||||
|
||||
### Registry auth
|
||||
|
||||
Gitea issues a personal access token (or machine account token) with
|
||||
`write:packages` scope. Stored as Gitea Actions secret `REGISTRY_TOKEN`;
|
||||
username stored as `REGISTRY_USER`.
|
||||
|
||||
For local `make push`, credentials are passed via `docker login` before
|
||||
the push target runs.
|
||||
|
||||
### Makefile targets
|
||||
|
||||
```makefile
|
||||
IMAGE_REGISTRY ?= 92.205.130.254:32166
|
||||
IMAGE_REPO ?= coulomb/key-cape
|
||||
IMAGE_TAG ?= latest
|
||||
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||
|
||||
image:
|
||||
docker build -t $(IMAGE) .
|
||||
|
||||
push: image
|
||||
docker push $(IMAGE)
|
||||
|
||||
image-tag:
|
||||
docker tag $(IMAGE) $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||
```
|
||||
|
||||
### Gitea Actions workflow
|
||||
|
||||
`.gitea/workflows/image.yaml` — triggers on push to `main` and on `v*` tags:
|
||||
- Checkout
|
||||
- Set up Docker Buildx
|
||||
- Login to `92.205.130.254:32166` using secrets
|
||||
- Build and push with metadata-action tags
|
||||
- (Optional) sign with cosign if available
|
||||
|
||||
### k3s insecure registry
|
||||
|
||||
Gitea runs over plain HTTP on port 32166 (NodePort). k3s must be configured
|
||||
to treat this endpoint as an insecure registry so image pulls work from
|
||||
within the cluster:
|
||||
|
||||
```yaml
|
||||
# /etc/rancher/k3s/registries.yaml (on CoulombCore)
|
||||
mirrors:
|
||||
"92.205.130.254:32166":
|
||||
endpoint:
|
||||
- "http://92.205.130.254:32166"
|
||||
```
|
||||
|
||||
k3s picks this up on restart (or SIGHUP). Worker nodes (if any) need the
|
||||
same file.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Makefile: image, push, image-tag targets
|
||||
|
||||
```task
|
||||
id: KEY-WP-0002-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "749472fc-edb9-4948-9ebc-58d5f38327ee"
|
||||
```
|
||||
|
||||
Add `image`, `push`, and `image-tag` targets to `Makefile` with
|
||||
`IMAGE_REGISTRY`, `IMAGE_REPO`, `IMAGE_TAG` variables defaulting to the
|
||||
Gitea endpoint and `coulomb/key-cape:latest`.
|
||||
|
||||
Gate: `make image` builds successfully locally; `IMAGE_TAG=dev make image`
|
||||
produces a differently-tagged image.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Gitea Actions workflow
|
||||
|
||||
```task
|
||||
id: KEY-WP-0002-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8ecf18cc-a3bb-4ede-a09c-fcd0d26d7f9d"
|
||||
```
|
||||
|
||||
Create `.gitea/workflows/image.yaml`:
|
||||
- Trigger: `push` to `main`, `push` tags matching `v*`
|
||||
- Runner: `act_runner` label (or `ubuntu-latest` if configured)
|
||||
- Steps: checkout → docker buildx → login → build+push
|
||||
- Tags via `docker/metadata-action`: `latest` on main, semver on tags
|
||||
|
||||
Secrets required (document in README.md under "CI"):
|
||||
- `REGISTRY_USER` — Gitea username or machine account
|
||||
- `REGISTRY_TOKEN` — Gitea personal access token with `write:packages`
|
||||
|
||||
Gate: workflow file is syntactically valid; documented in README.
|
||||
|
||||
---
|
||||
|
||||
### T03 — k3s insecure registry config on CoulombCore
|
||||
|
||||
```task
|
||||
id: KEY-WP-0002-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2dde67f9-944f-418d-a2e9-7367bc556425"
|
||||
```
|
||||
|
||||
On CoulombCore, create/update `/etc/rancher/k3s/registries.yaml` to add
|
||||
the Gitea NodePort as an HTTP mirror. Restart k3s (or send SIGHUP) and
|
||||
verify `crictl pull 92.205.130.254:32166/coulomb/key-cape:latest` works.
|
||||
|
||||
Gate: image pull from within the cluster succeeds without TLS errors.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Create Gitea machine account & token
|
||||
|
||||
```task
|
||||
id: KEY-WP-0002-T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "25775e10-3164-4adb-9c41-835c86fde5f8"
|
||||
```
|
||||
|
||||
In Gitea (http://92.205.130.254:32166), create a machine account
|
||||
`ci-netkingdom` (or reuse an existing service account) with access to
|
||||
the `netkingdom` organisation. Generate a token with `write:packages`
|
||||
scope and store it in:
|
||||
- Gitea Actions secrets on the `key-cape` repo: `REGISTRY_USER`, `REGISTRY_TOKEN`
|
||||
- The net-kingdom credential store (SOPS-encrypted) under
|
||||
`credentials/gitea-ci-token.enc.yaml`
|
||||
|
||||
Gate: `docker login 92.205.130.254:32166` succeeds with the token;
|
||||
secret is in the credential store.
|
||||
|
||||
---
|
||||
|
||||
### T05 — Smoke test: push and pull a dev image
|
||||
|
||||
```task
|
||||
id: KEY-WP-0002-T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "0f6ab38f-6d34-41af-9180-f19c687947b5"
|
||||
```
|
||||
|
||||
Manually trigger a build-and-push:
|
||||
```bash
|
||||
docker login 92.205.130.254:32166
|
||||
IMAGE_TAG=dev make push
|
||||
```
|
||||
|
||||
Then verify the image is pullable from CoulombCore:
|
||||
```bash
|
||||
# on CoulombCore
|
||||
crictl pull 92.205.130.254:32166/coulomb/key-cape:dev
|
||||
```
|
||||
|
||||
Gate: pull succeeds; image is listed in Gitea -> Packages -> coulomb/key-cape.
|
||||
|
||||
---
|
||||
|
||||
### T06 — Update README with registry & CI docs
|
||||
|
||||
```task
|
||||
id: KEY-WP-0002-T06
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "946cd34d-94da-4fa9-a781-ed36f6c827a3"
|
||||
```
|
||||
|
||||
Add a "Container Image" section to `README.md` documenting:
|
||||
- Registry URL and image name
|
||||
- How to pull (`docker pull 92.205.130.254:32166/coulomb/key-cape:latest`)
|
||||
- How to build and push locally (Makefile targets)
|
||||
- CI secrets required for the Actions workflow
|
||||
|
||||
Gate: README section present and accurate.
|
||||
|
||||
---
|
||||
|
||||
## Done Criteria
|
||||
|
||||
- [ ] `make image` and `make push` work locally
|
||||
- [ ] `.gitea/workflows/image.yaml` present and documented
|
||||
- [ ] k3s can pull the image from Gitea without TLS errors
|
||||
- [ ] Machine account token stored in credential store
|
||||
- [ ] Smoke test: `dev` image pushed and pulled successfully
|
||||
- [ ] README updated
|
||||
200
workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md
Normal file
200
workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
id: KEY-WP-0003
|
||||
type: workplan
|
||||
title: "Bootstrap Console OIDC Login and MFA Verification"
|
||||
domain: infotech
|
||||
repo: key-cape
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-05-24"
|
||||
updated: "2026-05-24"
|
||||
state_hub_workstream_id: "02990009-a2b3-44f6-a579-487fbacae41a"
|
||||
---
|
||||
|
||||
# KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification
|
||||
|
||||
## Problem
|
||||
|
||||
The NetKingdom security bootstrap console now acts as a local OIDC client
|
||||
callback so the operator can verify the dedicated platform-root login before
|
||||
approving custody mode. The current live KeyCape deployment rejects that flow
|
||||
with:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_profile_usage",
|
||||
"description": "redirect_uri does not match any registered URI",
|
||||
"feature": "redirect_uri"
|
||||
}
|
||||
```
|
||||
|
||||
That error is correct profile enforcement: KeyCape only accepts exact
|
||||
registered redirect URIs. The live `demo-app` registration has not yet been
|
||||
updated to allow the local bootstrap console callback:
|
||||
|
||||
- `http://127.0.0.1:8876/oidc/callback`
|
||||
- `http://localhost:8876/oidc/callback`
|
||||
|
||||
After that is fixed, there is a second usability/security gap. KeyCape checks
|
||||
privacyIDEA MFA after the Authelia callback, but the browser flow currently
|
||||
expects an `mfa_token` query parameter instead of presenting a proper OTP
|
||||
challenge page to the human operator.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the bootstrap console's "Start demo OIDC login" button a real
|
||||
end-to-end verification path for the current lightweight IAM stack:
|
||||
|
||||
1. KeyCape accepts the bootstrap console callback URI by exact registration.
|
||||
2. The browser leaves KeyCape for the public Authelia login URL.
|
||||
3. After password login, KeyCape presents a minimal MFA challenge when
|
||||
privacyIDEA requires one.
|
||||
4. KeyCape issues an OIDC authorization code to the bootstrap console callback.
|
||||
5. The console can exchange the code and let the operator mark
|
||||
`OIDC login verified` without exposing tokens or secrets.
|
||||
|
||||
This keeps KeyCape's security posture intact: no wildcard redirect URIs, no
|
||||
dynamic client registration, no token display, and no storage of OTP material.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- Prefer a dedicated public client named `netkingdom-bootstrap-console` for
|
||||
long-lived clarity. Reusing `demo-app` is acceptable for the immediate
|
||||
unblock only if the deployment/runbook clearly labels it as a bootstrap test
|
||||
client.
|
||||
- The bootstrap callback is local-only and operator-attended. It must be an
|
||||
exact URI in config, not a wildcard or dynamic registration exception.
|
||||
- Browser-facing Authelia redirects must use the public Authelia base URL
|
||||
(`https://auth.coulomb.social`) so the human login page opens correctly.
|
||||
- KeyCape may still need an internal service URL for back-channel token
|
||||
exchange. If so, split the current single Authelia URL into browser-facing
|
||||
authorize URL and internal token URL instead of making the browser use an
|
||||
in-cluster hostname.
|
||||
- The MFA prompt should collect only a one-time code, post it back to KeyCape,
|
||||
validate with privacyIDEA, and then continue the normal OIDC code flow.
|
||||
- This work unblocks the NetKingdom custody gate in
|
||||
`NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**2026-05-24:** Implemented in source:
|
||||
|
||||
- added `netkingdom-bootstrap-console` as a public OIDC client in the sample
|
||||
KeyCape config, while keeping the local callback registered on `demo-app`
|
||||
for compatibility,
|
||||
- split Authelia browser redirects from server-side token exchange via
|
||||
`browserBaseURL` and `tokenBaseURL`,
|
||||
- added a browser MFA challenge page at `POST /authorize/callback` that
|
||||
validates the one-time code with privacyIDEA before issuing the downstream
|
||||
OIDC authorization code,
|
||||
- updated NetKingdom's `keycape-config` generation template and bootstrap
|
||||
console to use the dedicated client,
|
||||
- added regression tests for callback registration, split Authelia URLs, MFA
|
||||
challenge rendering, valid OTP continuation, and invalid OTP failure.
|
||||
|
||||
Live use still requires deployment: build/publish the updated KeyCape image,
|
||||
refresh the live `keycape-config` Secret through the custodian age-key unlock
|
||||
ceremony, and restart the KeyCape deployment.
|
||||
|
||||
---
|
||||
|
||||
## T01 - Register the bootstrap console callback client
|
||||
|
||||
```task
|
||||
id: KEY-WP-0003-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b396c99f-d711-475a-9cba-4f03a1db561d"
|
||||
```
|
||||
|
||||
Add a KeyCape client registration for the bootstrap console. Either create a
|
||||
dedicated `netkingdom-bootstrap-console` public client or update `demo-app`
|
||||
temporarily with these exact redirect URIs:
|
||||
|
||||
- `http://127.0.0.1:8876/oidc/callback`
|
||||
- `http://localhost:8876/oidc/callback`
|
||||
|
||||
Update the sample config, tests, and deployment/runbook references so the
|
||||
registered client is reproducible and not just a live-cluster patch.
|
||||
|
||||
Gate: an authorize request using the local callback no longer returns
|
||||
`invalid_profile_usage` for `redirect_uri`.
|
||||
|
||||
## T02 - Separate browser-facing and internal Authelia URLs if needed
|
||||
|
||||
```task
|
||||
id: KEY-WP-0003-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "46172e6d-3e11-493c-b223-79c2fc321aec"
|
||||
```
|
||||
|
||||
Confirm whether the current `authelia.baseURL` is safe to use for both browser
|
||||
redirects and server-side token exchange. If not, add explicit configuration
|
||||
for the browser authorize base URL and internal token/userinfo base URL.
|
||||
|
||||
Gate: the first browser redirect leaves `https://kc.coulomb.social` for
|
||||
`https://auth.coulomb.social/...`; server-side token exchange still works from
|
||||
inside the deployment.
|
||||
|
||||
## T03 - Add a browser MFA challenge step
|
||||
|
||||
```task
|
||||
id: KEY-WP-0003-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "92fca4d0-6215-4ea6-9f80-9178ae183acb"
|
||||
```
|
||||
|
||||
When `CheckMFARequired` returns true after the Authelia callback, render a
|
||||
minimal KeyCape MFA challenge page instead of requiring `mfa_token` in the
|
||||
callback query string. The page should:
|
||||
|
||||
- show the authenticated username and client display name,
|
||||
- collect only the OTP code,
|
||||
- preserve the pending OIDC state server-side,
|
||||
- validate with privacyIDEA,
|
||||
- continue to issue the normal authorization code on success,
|
||||
- fail closed with the existing telemetry on invalid MFA.
|
||||
|
||||
Gate: a user enrolled in privacyIDEA can complete password + OTP in the
|
||||
browser and is returned to the registered downstream callback.
|
||||
|
||||
## T04 - Add end-to-end profile tests for the bootstrap login path
|
||||
|
||||
```task
|
||||
id: KEY-WP-0003-T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "079a5929-1864-4461-a64c-746cebca469d"
|
||||
```
|
||||
|
||||
Add tests that cover:
|
||||
|
||||
- local bootstrap callback registration,
|
||||
- rejection of unregistered callbacks remains intact,
|
||||
- Authelia browser redirect uses the expected public URL,
|
||||
- MFA-required login presents a challenge instead of immediate failure,
|
||||
- invalid OTP fails closed,
|
||||
- valid OTP produces an authorization code bound to the original PKCE session.
|
||||
|
||||
Gate: `make test` passes and the negative redirect URI tests remain green.
|
||||
|
||||
## T05 - Document the live rollout ceremony
|
||||
|
||||
```task
|
||||
id: KEY-WP-0003-T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "1d67225d-a20b-4e36-9b2e-20836be2f439"
|
||||
```
|
||||
|
||||
Document the deployment path for updating live KeyCape config without
|
||||
regenerating unrelated secrets. The runbook must fit the NetKingdom custodian
|
||||
age-key model: decrypt or unlock only during an attended ceremony, apply the
|
||||
updated client registration/config, restart KeyCape, and remove plaintext
|
||||
secret material afterward.
|
||||
|
||||
Gate: an operator can update the live `keycape-config` Secret and verify the
|
||||
bootstrap console OIDC login without printing or committing secrets.
|
||||
Reference in New Issue
Block a user