diff --git a/CLAUDE.md b/CLAUDE.md
index ec2aee9..17bcf93 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -267,11 +267,9 @@ See `docs/oidc-conformance.md` for detailed OIDC compliance requirements.
- Session-based AMR/ACR tracking
**Pending:**
-- Cache-Control headers on token endpoint
-- Consent flow (currently auto-consents)
-- Token revocation and introspection endpoints
- OpenID Federation trust chain validation
- User account management UI
+- Key rotation and multi-key JWKS
## Admin GraphQL API
diff --git a/README.md b/README.md
index 289d7b1..4477f48 100644
--- a/README.md
+++ b/README.md
@@ -139,16 +139,21 @@ This is an early-stage implementation. See `docs/next-iteration-plan.md` for pla
**Currently Implemented:**
- Authorization Code flow with PKCE (S256)
- Dynamic client registration
-- Token issuance and validation
-- ID Token generation with RS256 signing
+- Token issuance with RS256 ID Token signing (at_hash, nonce, auth_time, AMR, ACR)
- UserInfo endpoint
+- Token endpoint with client_secret_basic and client_secret_post
+- User authentication with sessions (password + passkey/WebAuthn)
+- Two-factor authentication (admin-enforced, context-based)
+- Consent flow with database persistence
+- Refresh token grant with rotation
+- Token revocation (RFC 7009) and introspection (RFC 7662)
+- Device Authorization Grant (RFC 8628)
+- Admin GraphQL API
**Pending Implementation:**
-- User authentication and session management
-- Consent flow
-- Refresh tokens
-- Token revocation and introspection
-- OpenID Federation support
+- OpenID Federation trust chain validation
+- User account management UI
+- Key rotation and multi-key JWKS
## Deployment
diff --git a/docs/flows.md b/docs/flows.md
new file mode 100644
index 0000000..d8081db
--- /dev/null
+++ b/docs/flows.md
@@ -0,0 +1,185 @@
+### End-to-End OIDC Flows
+
+This document provides example curl/browser commands for the complete Authorization Code + PKCE flow.
+
+All examples assume the server is running at `http://localhost:9090`.
+
+---
+
+#### 1. Check Discovery
+
+```bash
+curl -s http://localhost:9090/.well-known/openid-configuration | jq .
+```
+
+Verify key fields: `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `userinfo_endpoint`, `introspection_endpoint`, `revocation_endpoint`.
+
+---
+
+#### 2. Register a Client
+
+```bash
+curl -s -X POST http://localhost:9090/connect/register \
+ -H "Content-Type: application/json" \
+ -d '{
+ "redirect_uris": ["http://localhost:8080/callback"],
+ "client_name": "Test Client",
+ "token_endpoint_auth_method": "client_secret_basic"
+ }' | jq .
+```
+
+Save `client_id` and `client_secret` from the response.
+
+---
+
+#### 3. Generate PKCE Challenge
+
+```bash
+# Generate code_verifier (43-128 chars, base64url)
+CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
+
+# Derive code_challenge (S256)
+CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '+/' '-_')
+
+echo "code_verifier: $CODE_VERIFIER"
+echo "code_challenge: $CODE_CHALLENGE"
+```
+
+---
+
+#### 4. Start Authorization (Browser)
+
+Open this URL in a browser (replace `CLIENT_ID` with your actual client_id):
+
+```
+http://localhost:9090/authorize?client_id=CLIENT_ID&redirect_uri=http://localhost:8080/callback&response_type=code&scope=openid%20profile%20email&code_challenge=CODE_CHALLENGE&code_challenge_method=S256&state=random123&nonce=nonce456
+```
+
+If not logged in, you will be redirected to `/login`. Enter credentials (e.g., `admin` / `password123`).
+
+After login and consent, the browser redirects to:
+```
+http://localhost:8080/callback?code=AUTH_CODE&state=random123
+```
+
+Copy the `code` parameter.
+
+---
+
+#### 5. Exchange Code for Tokens
+
+Using **client_secret_basic** (HTTP Basic auth):
+
+```bash
+# Base64 encode client_id:client_secret
+AUTH=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w0)
+
+curl -s -X POST http://localhost:9090/token \
+ -H "Authorization: Basic $AUTH" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:8080/callback&code_verifier=$CODE_VERIFIER" | jq .
+```
+
+Using **client_secret_post** (form body):
+
+```bash
+curl -s -X POST http://localhost:9090/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:8080/callback&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code_verifier=$CODE_VERIFIER" | jq .
+```
+
+The response includes `access_token`, `id_token`, `token_type`, and `expires_in`.
+
+---
+
+#### 6. Decode the ID Token
+
+```bash
+# Extract and decode the JWT payload (second segment)
+echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
+```
+
+Expected claims: `iss`, `sub`, `aud`, `exp`, `iat`, `auth_time`, `nonce`, `at_hash`, `amr`, `acr`.
+
+---
+
+#### 7. Call UserInfo
+
+```bash
+curl -s http://localhost:9090/userinfo \
+ -H "Authorization: Bearer $ACCESS_TOKEN" | jq .
+```
+
+Returns claims based on the granted scopes (e.g., `sub`, `name`, `email`).
+
+Error case (missing/invalid token):
+```bash
+curl -s -w "\nHTTP %{http_code}\n" http://localhost:9090/userinfo
+# Returns 401 with WWW-Authenticate header
+```
+
+---
+
+#### 8. Introspect a Token
+
+```bash
+AUTH=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w0)
+
+curl -s -X POST http://localhost:9090/introspect \
+ -H "Authorization: Basic $AUTH" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "token=$ACCESS_TOKEN" | jq .
+```
+
+Active token response:
+```json
+{
+ "active": true,
+ "scope": "openid profile email",
+ "client_id": "...",
+ "sub": "...",
+ "exp": 1234567890,
+ "iat": 1234564290,
+ "token_type": "bearer"
+}
+```
+
+Expired/revoked/unknown token:
+```json
+{
+ "active": false
+}
+```
+
+---
+
+#### 9. Revoke a Token
+
+```bash
+AUTH=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w0)
+
+curl -s -X POST http://localhost:9090/revoke \
+ -H "Authorization: Basic $AUTH" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "token=$ACCESS_TOKEN"
+# Returns 200 OK with empty body
+```
+
+After revocation, introspecting the same token returns `{"active": false}`.
+
+---
+
+#### 10. Refresh Token Flow
+
+If the `offline_access` scope was granted, a `refresh_token` is included in the token response.
+
+```bash
+AUTH=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w0)
+
+curl -s -X POST http://localhost:9090/token \
+ -H "Authorization: Basic $AUTH" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=refresh_token&refresh_token=$REFRESH_TOKEN" | jq .
+```
+
+Token rotation: the old refresh token is revoked and a new one is issued.
diff --git a/docs/oidc-conformance.md b/docs/oidc-conformance.md
index bb14e60..e326d86 100644
--- a/docs/oidc-conformance.md
+++ b/docs/oidc-conformance.md
@@ -20,7 +20,7 @@ Scope references (context7 up-to-date pointers):
- Token endpoint: POST /token (application/x-www-form-urlencoded)
- Grant type: authorization_code
- Parameters: grant_type, code, redirect_uri, client authentication
- - Client auth: client_secret_post (initial support); consider client_secret_basic later
+ - Client auth: client_secret_post and client_secret_basic (both supported)
- PKCE verification: code_verifier must match stored code_challenge (S256)
- Output: JSON with access_token, token_type=bearer, expires_in, id_token (JWT), possibly refresh_token
- Error model: RFC 6749 + OIDC specific where applicable
@@ -79,7 +79,7 @@ Scope references (context7 up-to-date pointers):
- grant_types_supported: ["authorization_code"]
- subject_types_supported: ["public"]
- id_token_signing_alg_values_supported: ["RS256"]
-- token_endpoint_auth_methods_supported: ["client_secret_post"]
+- token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"]
- code_challenge_methods_supported: ["S256"]
- scopes_supported: ["openid", "profile", "email"]
- claims_supported: ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "name", "given_name", "family_name", "email", "email_verified"]
@@ -88,45 +88,48 @@ Scope references (context7 up-to-date pointers):
6) Current status in this repository
- Implemented:
- - Discovery endpoint (basic subset)
+ - Authorization Code flow with PKCE (S256 enforced)
+ - Token endpoint with client_secret_post and client_secret_basic authentication
+ - ID Token signing (RS256) with at_hash, nonce, auth_time, AMR, and ACR claims
+ - UserInfo endpoint with Bearer token authentication
+ - Discovery endpoint with full metadata
- JWKS publication with key generation and persistence
- - Dynamic client auto-registration (basic)
+ - Dynamic client registration
+ - Refresh token grant with rotation
+ - Token revocation (RFC 7009)
+ - Token introspection (RFC 7662)
+ - Device Authorization Grant (RFC 8628)
+ - User authentication with sessions (password + passkey/WebAuthn)
+ - Two-factor authentication (admin-enforced, context-based)
+ - Consent flow with database persistence
+ - Background jobs for cleanup (sessions, tokens, challenges)
+ - Admin GraphQL API for user management
- Simple property storage API (non-standard)
- Federation trust anchors stub
-- Missing for OIDC Core compliance:
- - /authorize (Authorization Code + PKCE)
- - /token (code exchange, ID Token signing)
- - /userinfo
- - Storage for auth codes and tokens
- - Full error models and input validation across endpoints
- - Robust client registration validation + optional configuration endpoint
+- Remaining for full OIDC compliance:
+ - OpenID Federation trust chain validation
+ - User account management UI
+ - Key rotation and multi-key JWKS
-7) Minimal viable roadmap (incremental)
-Step 1: Data model and discovery metadata
-- Add DB tables for auth_codes and access_tokens
-- Extend discovery to include grant_types_supported, token_endpoint_auth_methods_supported, code_challenge_methods_supported, claims_supported
+7) Roadmap (remaining work)
-Step 2: Authorization Code + PKCE
-- Implement /authorize to issue short-lived codes; validate redirect_uri, scope, client, state, nonce, PKCE
+Steps 1-5 of the original roadmap are complete. Remaining items:
-Step 3: Token endpoint and ID Token
-- Implement /token; client_secret_post, PKCE verification; sign ID Token with RS256 using current JWK; include required claims
-
-Step 4: UserInfo
-- Implement /userinfo backed by properties or a user table; authorize via access token
-
-Step 5: Hardening and cleanup
-- Proper errors per specs; input validation; token lifetimes; background pruning of consumed/expired artifacts
-- Optional: client_secret_basic, refresh tokens, rotation, revocation, introspection
-
-Step 6: Federation (later)
+Step 6: Federation
- Entity statement issuance, publication, and trust chain verification; policy application to registration
+Step 7: Account management UI
+- User-facing pages for profile, passkey management, and session management
+
+Step 8: Advanced key management
+- Key rotation, multi-key JWKS, algorithm agility
+
Implementation notes
- Keep issuer stable and correct in settings.server.public_base_url for production
- Ensure JWKS kid selection and alg entry match discovery
- Prefer S256 for PKCE; do not support plain
-- Add tests or curl scripts to verify end-to-end flows
+- See docs/flows.md for end-to-end curl examples
+- See scripts/validate-oidc.sh for automated validation
diff --git a/scripts/validate-oidc.sh b/scripts/validate-oidc.sh
new file mode 100755
index 0000000..c8d5d68
--- /dev/null
+++ b/scripts/validate-oidc.sh
@@ -0,0 +1,170 @@
+#!/usr/bin/env bash
+# validate-oidc.sh — Validate OIDC endpoints on a running Barycenter instance.
+#
+# Usage: ./scripts/validate-oidc.sh [BASE_URL]
+# BASE_URL defaults to http://localhost:9090
+
+set -euo pipefail
+
+BASE_URL="${1:-http://localhost:9090}"
+PASS=0
+FAIL=0
+
+pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
+fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
+check() {
+ local desc="$1" actual="$2" expected="$3"
+ if [ "$actual" = "$expected" ]; then
+ pass "$desc"
+ else
+ fail "$desc (expected '$expected', got '$actual')"
+ fi
+}
+
+echo "=== Barycenter OIDC Validation ==="
+echo "Base URL: $BASE_URL"
+echo
+
+# ─── 1. Discovery ───────────────────────────────────────────────────────────
+echo "--- 1. Discovery Document ---"
+DISCO=$(curl -sf "$BASE_URL/.well-known/openid-configuration" 2>/dev/null) || {
+ fail "Discovery endpoint unreachable"
+ echo "Cannot continue without discovery. Is the server running?"
+ exit 1
+}
+
+check "issuer present" "$(echo "$DISCO" | jq -r '.issuer')" "$BASE_URL"
+check "authorization_endpoint" "$(echo "$DISCO" | jq -r '.authorization_endpoint')" "$BASE_URL/authorize"
+check "token_endpoint" "$(echo "$DISCO" | jq -r '.token_endpoint')" "$BASE_URL/token"
+check "userinfo_endpoint" "$(echo "$DISCO" | jq -r '.userinfo_endpoint')" "$BASE_URL/userinfo"
+check "jwks_uri" "$(echo "$DISCO" | jq -r '.jwks_uri')" "$BASE_URL/.well-known/jwks.json"
+check "registration_endpoint" "$(echo "$DISCO" | jq -r '.registration_endpoint')" "$BASE_URL/connect/register"
+check "revocation_endpoint" "$(echo "$DISCO" | jq -r '.revocation_endpoint')" "$BASE_URL/revoke"
+check "introspection_endpoint" "$(echo "$DISCO" | jq -r '.introspection_endpoint')" "$BASE_URL/introspect"
+
+# Check supported values
+check "response_types includes code" \
+ "$(echo "$DISCO" | jq '[.response_types_supported[] | select(. == "code")] | length')" "1"
+check "code_challenge_methods includes S256" \
+ "$(echo "$DISCO" | jq '[.code_challenge_methods_supported[] | select(. == "S256")] | length')" "1"
+check "auth methods include client_secret_basic" \
+ "$(echo "$DISCO" | jq '[.token_endpoint_auth_methods_supported[] | select(. == "client_secret_basic")] | length')" "1"
+check "auth methods include client_secret_post" \
+ "$(echo "$DISCO" | jq '[.token_endpoint_auth_methods_supported[] | select(. == "client_secret_post")] | length')" "1"
+check "signing alg includes RS256" \
+ "$(echo "$DISCO" | jq '[.id_token_signing_alg_values_supported[] | select(. == "RS256")] | length')" "1"
+echo
+
+# ─── 2. JWKS ────────────────────────────────────────────────────────────────
+echo "--- 2. JWKS ---"
+JWKS=$(curl -sf "$BASE_URL/.well-known/jwks.json" 2>/dev/null) || {
+ fail "JWKS endpoint unreachable"
+ JWKS="{}"
+}
+
+KEY_COUNT=$(echo "$JWKS" | jq '.keys | length' 2>/dev/null || echo 0)
+if [ "$KEY_COUNT" -gt 0 ]; then
+ pass "JWKS has $KEY_COUNT key(s)"
+ HAS_KID=$(echo "$JWKS" | jq -r '.keys[0].kid // empty')
+ if [ -n "$HAS_KID" ]; then
+ pass "First key has kid: $HAS_KID"
+ else
+ fail "First key missing kid"
+ fi
+else
+ fail "JWKS has no keys"
+fi
+echo
+
+# ─── 3. Dynamic Client Registration ─────────────────────────────────────────
+echo "--- 3. Client Registration ---"
+REG_RESP=$(curl -sf -X POST "$BASE_URL/connect/register" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "redirect_uris": ["http://localhost:8080/callback"],
+ "client_name": "Validation Script Client",
+ "token_endpoint_auth_method": "client_secret_basic"
+ }' 2>/dev/null) || {
+ fail "Client registration failed"
+ REG_RESP="{}"
+}
+
+CLIENT_ID=$(echo "$REG_RESP" | jq -r '.client_id // empty')
+CLIENT_SECRET=$(echo "$REG_RESP" | jq -r '.client_secret // empty')
+
+if [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ]; then
+ pass "Client registered (client_id: ${CLIENT_ID:0:12}...)"
+else
+ fail "Client registration did not return client_id/client_secret"
+fi
+echo
+
+# ─── 4. PKCE Generation ─────────────────────────────────────────────────────
+echo "--- 4. PKCE Generation ---"
+CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
+CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '+/' '-_')
+pass "code_verifier generated (${#CODE_VERIFIER} chars)"
+pass "code_challenge generated (S256)"
+echo
+
+# ─── 5. Authorization URL ───────────────────────────────────────────────────
+echo "--- 5. Authorization ---"
+AUTH_URL="$BASE_URL/authorize?client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&scope=openid%20profile%20email&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=validate123&nonce=nonce456"
+
+AUTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$AUTH_URL" 2>/dev/null)
+# Expect 302 redirect (to login) or 200 (if already logged in)
+if [ "$AUTH_STATUS" = "302" ] || [ "$AUTH_STATUS" = "303" ] || [ "$AUTH_STATUS" = "200" ]; then
+ pass "Authorization endpoint responds ($AUTH_STATUS)"
+else
+ fail "Authorization endpoint returned $AUTH_STATUS"
+fi
+echo
+
+# ─── 6. UserInfo (unauthenticated — expect 401) ─────────────────────────────
+echo "--- 6. UserInfo (unauthenticated) ---"
+UI_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/userinfo" 2>/dev/null)
+check "UserInfo without token returns 401" "$UI_STATUS" "401"
+echo
+
+# ─── 7. Token Introspection (with invalid token) ────────────────────────────
+echo "--- 7. Token Introspection ---"
+if [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ]; then
+ AUTH_HEADER=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w0 2>/dev/null || printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64 2>/dev/null)
+ INTROSPECT_RESP=$(curl -sf -X POST "$BASE_URL/introspect" \
+ -H "Authorization: Basic $AUTH_HEADER" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "token=invalid_token_12345" 2>/dev/null) || INTROSPECT_RESP="{}"
+
+ ACTIVE=$(echo "$INTROSPECT_RESP" | jq -r '.active // empty')
+ check "Introspection of invalid token returns active=false" "$ACTIVE" "false"
+else
+ fail "Skipping introspection (no client credentials)"
+fi
+echo
+
+# ─── 8. Token Revocation (with invalid token — should return 200) ───────────
+echo "--- 8. Token Revocation ---"
+if [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ]; then
+ REVOKE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/revoke" \
+ -H "Authorization: Basic $AUTH_HEADER" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "token=invalid_token_12345" 2>/dev/null)
+ check "Revocation of unknown token returns 200" "$REVOKE_STATUS" "200"
+else
+ fail "Skipping revocation (no client credentials)"
+fi
+echo
+
+# ─── Summary ─────────────────────────────────────────────────────────────────
+echo "=== Summary ==="
+echo " Passed: $PASS"
+echo " Failed: $FAIL"
+echo
+
+if [ "$FAIL" -gt 0 ]; then
+ echo "Some checks failed. Ensure the server is running and configured correctly."
+ exit 1
+else
+ echo "All checks passed."
+ exit 0
+fi
diff --git a/src/storage.rs b/src/storage.rs
index aafe7d9..876fde0 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -365,6 +365,61 @@ pub async fn get_access_token(
}
}
+/// Like get_access_token but returns the token even if expired or revoked.
+/// Used by the introspection endpoint to distinguish "inactive" from "not found".
+pub async fn get_access_token_raw(
+ db: &DatabaseConnection,
+ token: &str,
+) -> Result