generated from coulomb/repo-seed
Compare commits
11 Commits
97d3fceea6
...
847abcba73
| Author | SHA1 | Date | |
|---|---|---|---|
| 847abcba73 | |||
| c18adb6441 | |||
| fa27adbc77 | |||
| 3ee8090a98 | |||
| 4097a7de8b | |||
| d05c73dc19 | |||
| b0adbc5daa | |||
| 22f7a7dc50 | |||
| 329e996619 | |||
| f3b1cdcba4 | |||
| 3780190456 |
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/
|
||||||
136
CLAUDE.md
Normal file
136
CLAUDE.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
# KeyCape — Claude Code Instructions
|
||||||
|
|
||||||
|
## What This Repo Is
|
||||||
|
|
||||||
|
**KeyCape** is the lightweight IAM component of NetKingdom.
|
||||||
|
|
||||||
|
> *"Prepare for Keycloak without Keycloak"*
|
||||||
|
|
||||||
|
KeyCape implements the **NetKingdom IAM Profile** — a versioned OIDC/PKCE contract
|
||||||
|
that NetKingdom applications integrate against. It orchestrates:
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|--------------|-------------------------------|
|
||||||
|
| Authelia | OIDC provider / session / tokens |
|
||||||
|
| LLDAP | Lightweight identity directory |
|
||||||
|
| privacyIDEA | MFA authority |
|
||||||
|
|
||||||
|
Keycape is intentionally replaceable by **Keycloak** in expanded mode. All apps
|
||||||
|
must target the profile, not Keycape or Keycloak incidentals.
|
||||||
|
|
||||||
|
## Custodian State Hub Integration
|
||||||
|
|
||||||
|
- **Domain:** `netkingdom`
|
||||||
|
- **Repo ID:** `8a99bb74-1ec0-4478-ac70-35a7cddb0e3c`
|
||||||
|
- **State Hub API:** `http://127.0.0.1:8000` (run `cd ~/the-custodian/state-hub && make api` if offline)
|
||||||
|
|
||||||
|
### Session Protocol
|
||||||
|
|
||||||
|
**Start of every session:**
|
||||||
|
```
|
||||||
|
get_domain_summary("netkingdom")
|
||||||
|
```
|
||||||
|
This gives the full picture of active workstreams, blocking decisions, and recent
|
||||||
|
progress for the NetKingdom domain at ~10% of the cost of `get_state_summary()`.
|
||||||
|
|
||||||
|
**During work:**
|
||||||
|
- `record_decision()` for any architectural choice (profile extensions, backend selection, etc.)
|
||||||
|
- `add_progress_event()` for milestones, blockers, discoveries
|
||||||
|
- `resolve_decision()` once a decision is closed
|
||||||
|
|
||||||
|
**End of every session:**
|
||||||
|
```
|
||||||
|
add_progress_event(summary="...", event_type="...", workstream_id="<active ws id>")
|
||||||
|
```
|
||||||
|
|
||||||
|
After modifying workplan files, run:
|
||||||
|
```
|
||||||
|
cd ~/the-custodian/state-hub && make fix-consistency REPO=key-cape
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Documents
|
||||||
|
|
||||||
|
| Document | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Keycape Specification v0.1 | `wiki/KeyCapeSpecification_v0.1.md` | Architecture, design intent, objectives |
|
||||||
|
| Normative Specification Pack v0.1 | `wiki/KeyCapeSpecificationPack_v0.1.md` | Normative spec for implementation agents: identity model, LDAP schema, error taxonomy, telemetry, migration contract, acceptance test matrix |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
key-cape/
|
||||||
|
wiki/ # Specifications (read before implementing)
|
||||||
|
workplans/ # Implementation workplans (ADR-001 convention)
|
||||||
|
src/ # Implementation (to be created)
|
||||||
|
tests/ # Test suite (to be created)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lightweight mode stack
|
||||||
|
|
||||||
|
```
|
||||||
|
Application ──→ NetKingdom IAM Profile
|
||||||
|
│
|
||||||
|
KeyCape ←── config translation, claim normalization
|
||||||
|
/ | \
|
||||||
|
Authelia LLDAP privacyIDEA
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expanded mode stack (Keycape → Keycloak)
|
||||||
|
|
||||||
|
```
|
||||||
|
Application ──→ NetKingdom IAM Profile
|
||||||
|
│
|
||||||
|
Keycloak (same profile, different runtime)
|
||||||
|
/ \
|
||||||
|
LDAP privacyIDEA
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Priorities (from spec)
|
||||||
|
|
||||||
|
1. **Profile endpoints** — OIDC discovery, authorization, token, JWKS, userinfo
|
||||||
|
2. **Canonical identity model** — product-neutral user/group/client schema
|
||||||
|
3. **Claim normalization** — stable claim set regardless of backend quirks
|
||||||
|
4. **Unsupported-feature enforcement** — structured errors, never silent emulation
|
||||||
|
5. **Telemetry** — demand visibility for unsupported features and auth events
|
||||||
|
6. **Migration tooling** — export/validate for LLDAP → Keycloak path
|
||||||
|
|
||||||
|
## Normative Constraints (from spec — binding on implementation)
|
||||||
|
|
||||||
|
**Never silently emulate unsupported features.** Any request outside the profile MUST fail with a structured error from this taxonomy:
|
||||||
|
- `feature_not_supported_by_profile` — outside the NetKingdom IAM Profile entirely
|
||||||
|
- `available_in_keycloak_mode_only` — exists in expanded mode, absent here by design
|
||||||
|
- `rejected_for_profile_safety` — would weaken profile guarantees or security discipline
|
||||||
|
- `invalid_profile_usage` — supported endpoint/feature used incorrectly
|
||||||
|
|
||||||
|
**Security hard rules:** No handwritten cryptography. No handwritten password hashing. Use established protocol and crypto libraries. Strict redirect URI validation. Strict issuer consistency.
|
||||||
|
|
||||||
|
**Canonical identity model** is the source of truth for test fixtures, provisioning, migration, and validation — not any backend's native schema.
|
||||||
|
|
||||||
|
**Spec Pack structure** (`wiki/KeyCapeSpecificationPack_v0.1.md`) contains 7 normative components agents must read before implementing:
|
||||||
|
1. Normative Specification — OIDC/PKCE contract, endpoints, scopes, claims, client model, MFA
|
||||||
|
2. Canonical Identity Schema — User, Group, Membership, Client, Role, MFAEnrollmentReference, etc.
|
||||||
|
3. Canonical LDAP Schema + Validator Rules — restricted LDAP expression of identity model
|
||||||
|
4. Error Taxonomy — machine-readable/human-readable/loggable structured errors
|
||||||
|
5. Telemetry Schema — event types, required fields (timestamp, env, client_id, endpoint, feature_category, correlation_id, …)
|
||||||
|
6. Migration Contract — LLDAP → full LDAP, KeyCape → Keycloak migration paths
|
||||||
|
7. Acceptance Test Matrix — lightweight baseline, IAM replacement, full expansion, negative profile tests
|
||||||
|
|
||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
Workplans live in `workplans/<id>-<slug>.md` with YAML frontmatter:
|
||||||
|
```yaml
|
||||||
|
id: KEY-WP-0001
|
||||||
|
type: workplan
|
||||||
|
title: "..."
|
||||||
|
domain: netkingdom
|
||||||
|
repo: key-cape
|
||||||
|
status: todo|active|done
|
||||||
|
owner: Bernd
|
||||||
|
topic_slug: netkingdom
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks are embedded as `## Task Title\n```task\nid: ...\nstatus: todo\n```\n` blocks.
|
||||||
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"]
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: dev seed build test lint
|
||||||
|
|
||||||
|
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 ./...
|
||||||
186
README.md
186
README.md
@@ -1,3 +1,185 @@
|
|||||||
# 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 the **NetKingdom IAM Profile** — a versioned OIDC/PKCE contract —
|
||||||
|
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)
|
||||||
|
▼
|
||||||
|
KeyCape ←── profile enforcement, claim normalization, telemetry
|
||||||
|
/ | \
|
||||||
|
Auth LLDAP privacyIDEA
|
||||||
|
elia
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expanded mode:** Replace KeyCape with Keycloak. Same profile, same tests pass.
|
||||||
|
|
||||||
|
## 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: "https://authelia.local"
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
|
||||||
|
|
||||||
|
## 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 /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.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
27
config/dev-config.yaml
Normal file
27
config/dev-config.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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"
|
||||||
|
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"
|
||||||
|
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.
|
||||||
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=
|
||||||
215
src/internal/adapters/authelia/adapter.go
Normal file
215
src/internal/adapters/authelia/adapter.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
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.
|
||||||
|
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
|
||||||
|
base := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/authorization"
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("client_id", req.ClientID)
|
||||||
|
q.Set("redirect_uri", req.RedirectURI)
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("state", req.State)
|
||||||
|
if req.Nonce != "" {
|
||||||
|
q.Set("nonce", req.Nonce)
|
||||||
|
}
|
||||||
|
if len(req.Scopes) > 0 {
|
||||||
|
q.Set("scope", strings.Join(req.Scopes, " "))
|
||||||
|
} else {
|
||||||
|
q.Set("scope", "openid profile")
|
||||||
|
}
|
||||||
|
if req.PKCEChallenge != "" {
|
||||||
|
q.Set("code_challenge", req.PKCEChallenge)
|
||||||
|
q.Set("code_challenge_method", req.PKCEChallengeMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base + "?" + q.Encode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.cfg.BaseURL, "/") + "/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)
|
||||||
|
body.Set("client_secret", a.cfg.ClientSecret)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
302
src/internal/adapters/authelia/adapter_test.go
Normal file
302
src/internal/adapters/authelia/adapter_test.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package authelia_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"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{})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []string{
|
||||||
|
"client_id=myapp",
|
||||||
|
"redirect_uri=",
|
||||||
|
"response_type=code",
|
||||||
|
"state=state-abc",
|
||||||
|
"nonce=nonce-xyz",
|
||||||
|
"code_challenge=challenge123",
|
||||||
|
"code_challenge_method=S256",
|
||||||
|
"scope=",
|
||||||
|
"openid",
|
||||||
|
}
|
||||||
|
for _, want := range checks {
|
||||||
|
if !strings.Contains(u, want) {
|
||||||
|
t.Errorf("AuthorizeURL missing %q in: %s", want, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/internal/adapters/authelia/config.go
Normal file
30
src/internal/adapters/authelia/config.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
// ClientID is the client ID registered in Authelia for KeyCape.
|
||||||
|
ClientID string
|
||||||
|
|
||||||
|
// ClientSecret is the client secret for the KeyCape client registration.
|
||||||
|
ClientSecret string
|
||||||
|
|
||||||
|
// RedirectURI is the callback URL registered in Authelia that points back
|
||||||
|
// to KeyCape's callback handler.
|
||||||
|
RedirectURI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}
|
||||||
337
src/internal/adapters/lldap/adapter.go
Normal file
337
src/internal/adapters/lldap/adapter.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// Run the canonical LDAP schema validator.
|
||||||
|
snap := validator.Snapshot{Users: []domain.User{user}}
|
||||||
|
report := validator.Validate(snap, validator.ModeProvisioning)
|
||||||
|
if !report.Passed {
|
||||||
|
return nil, fmt.Errorf("lldap: validation failed for user %q: %s", username, 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, "; ")
|
||||||
|
}
|
||||||
341
src/internal/adapters/lldap/adapter_test.go
Normal file
341
src/internal/adapters/lldap/adapter_test.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
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_ValidationFailure(t *testing.T) {
|
||||||
|
// Return an entry with an empty DisplayName and empty sn — will fail validator.
|
||||||
|
dn := "uid=broken,ou=users,dc=netkingdom,dc=local"
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
attrs := []*ldap.EntryAttribute{
|
||||||
|
{Name: "uid", Values: []string{"broken"}},
|
||||||
|
{Name: "cn", Values: []string{""}},
|
||||||
|
{Name: "sn", Values: []string{""}},
|
||||||
|
{Name: "mail", Values: []string{"broken@example.com"}},
|
||||||
|
}
|
||||||
|
return &ldap.SearchResult{
|
||||||
|
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
_, err := adapter.LookupUser(context.Background(), "broken")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// BindDN is the distinguished name used for the service account bind,
|
||||||
|
// e.g. "cn=admin,dc=netkingdom,dc=local".
|
||||||
|
BindDN string
|
||||||
|
|
||||||
|
// BindPW is the service account password.
|
||||||
|
BindPW string
|
||||||
|
|
||||||
|
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
|
||||||
|
BaseDN string
|
||||||
|
|
||||||
|
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
|
||||||
|
UserOU string
|
||||||
|
|
||||||
|
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
|
||||||
|
GroupOU string
|
||||||
|
|
||||||
|
// TLSSkipVerify disables TLS certificate verification. For development only.
|
||||||
|
TLSSkipVerify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
153
src/internal/adapters/privacyidea/adapter.go
Normal file
153
src/internal/adapters/privacyidea/adapter.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
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) {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
309
src/internal/adapters/privacyidea/adapter_test.go
Normal file
309
src/internal/adapters/privacyidea/adapter_test.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
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_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")
|
||||||
|
}
|
||||||
36
src/internal/adapters/privacyidea/config.go
Normal file
36
src/internal/adapters/privacyidea/config.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
// AdminToken is the service-account JWT used to authenticate requests to the
|
||||||
|
// privacyIDEA admin API.
|
||||||
|
AdminToken string
|
||||||
|
|
||||||
|
// Realm is the privacyIDEA realm to scope token and validate requests.
|
||||||
|
// Defaults to "netkingdom" when empty.
|
||||||
|
Realm string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
225
src/internal/config/config_test.go
Normal file
225
src/internal/config/config_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/internal/server/oidc/authorize.go
Normal file
320
src/internal/server/oidc/authorize.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"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
|
||||||
|
Scopes []string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
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,
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
h.pending.Delete(state)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check MFA requirement.
|
||||||
|
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mfaRequired {
|
||||||
|
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
||||||
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventType: telemetry.EventAuthFailure,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Endpoint: "/authorize/callback",
|
||||||
|
Result: "failure",
|
||||||
|
ErrorType: "mfa_failed",
|
||||||
|
})
|
||||||
|
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate authorization code and store PKCE session.
|
||||||
|
sess := &PKCESession{
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
RedirectURI: ps.RedirectURI,
|
||||||
|
PKCEChallenge: ps.PKCEChallenge,
|
||||||
|
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
||||||
|
State: state,
|
||||||
|
Username: result.Username,
|
||||||
|
Scopes: ps.Scopes,
|
||||||
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||||
|
}
|
||||||
|
authCode := h.Sessions.Create(sess)
|
||||||
|
|
||||||
|
h.Emitter.Emit(ctx, 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 := ps.RedirectURI + "?code=" + authCode + "&state=" + state
|
||||||
|
http.Redirect(w, r, redirectTo, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
565
src/internal/server/oidc/authorize_test.go
Normal file
565
src/internal/server/oidc/authorize_test.go
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
||||||
|
return m.required, m.requiredErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, _, _ string) error {
|
||||||
|
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",
|
||||||
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
||||||
|
AllowedScopes: []string{"openid", "profile", "email"},
|
||||||
|
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_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_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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/internal/server/oidc/session.go
Normal file
79
src/internal/server/oidc/session.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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
|
||||||
|
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)
|
||||||
|
}
|
||||||
219
src/internal/server/oidc/token.go
Normal file
219
src/internal/server/oidc/token.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
504
src/internal/server/oidc/token_test.go
Normal file
504
src/internal/server/oidc/token_test.go
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
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",
|
||||||
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
814
wiki/KeyCapeSpecification_v0.1.md
Normal file
814
wiki/KeyCapeSpecification_v0.1.md
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
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 v0.1
|
||||||
|
|
||||||
|
This section defines the initial minimum profile to be supported.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Optional NetKingdom-specific claims may include:
|
||||||
|
|
||||||
|
* groups
|
||||||
|
* roles
|
||||||
|
* tenant or environment markers if explicitly defined
|
||||||
|
|
||||||
|
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 v0.1
|
||||||
|
|
||||||
|
A more formal profile document with endpoint-by-endpoint detail.
|
||||||
|
|
||||||
|
### 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
|
||||||
475
workplans/KEY-WP-0001-keycape-implementation.md
Normal file
475
workplans/KEY-WP-0001-keycape-implementation.md
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
---
|
||||||
|
id: KEY-WP-0001
|
||||||
|
type: workplan
|
||||||
|
title: "KeyCape Implementation — Lightweight IAM Profile"
|
||||||
|
domain: netkingdom
|
||||||
|
repo: key-cape
|
||||||
|
status: active
|
||||||
|
owner: Bernd
|
||||||
|
topic_slug: netkingdom
|
||||||
|
workstream_id: 2c9caad8-2ced-492d-9d63-376387b4b9b0
|
||||||
|
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e
|
||||||
|
repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
|
||||||
|
created: 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
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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: todo
|
||||||
|
```
|
||||||
|
|
||||||
|
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: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
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: todo
|
||||||
|
depends_on: [T01, T02]
|
||||||
|
```
|
||||||
|
|
||||||
|
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: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
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: todo
|
||||||
|
depends_on: [T04]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T04, T11, T12, T13, T14]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T06, T08, T10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T07, T10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T02, T03]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T04, T13]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T02, T13]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T04, T13]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T02, T03, T10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T15]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T15]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T05, T06, T07, T08, T09, T22]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T18, T16]
|
||||||
|
```
|
||||||
|
|
||||||
|
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: todo
|
||||||
|
depends_on: [T19, T17]
|
||||||
|
```
|
||||||
|
|
||||||
|
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: todo
|
||||||
|
depends_on: [T14]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
`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: todo
|
||||||
|
depends_on: [T18, T21]
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
Reference in New Issue
Block a user