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