From 7b16f542232a158fa53a41d1cb2c9730d510bb1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 20:30:31 +0000 Subject: [PATCH] feat: Add token introspection endpoint, docs, and validation scripts Implement the remaining items from docs/next-iteration-plan.md: - Add POST /introspect endpoint (RFC 7662) with client authentication, support for access and refresh tokens, and token_type_hint - Add raw token lookup functions in storage for introspection - Add revocation_endpoint and introspection_endpoint to discovery metadata - Create docs/flows.md with end-to-end curl examples for all OIDC flows - Create scripts/validate-oidc.sh to verify discovery, JWKS, registration, introspection, and revocation endpoints - Update docs/oidc-conformance.md to reflect actual implementation status - Update README.md and CLAUDE.md pending sections to be accurate https://claude.ai/code/session_01JBxVy75XfwwZB8iBXjTxT3 --- CLAUDE.md | 4 +- README.md | 19 ++-- docs/flows.md | 185 +++++++++++++++++++++++++++++++++++++++ docs/oidc-conformance.md | 61 +++++++------ scripts/validate-oidc.sh | 170 +++++++++++++++++++++++++++++++++++ src/storage.rs | 55 ++++++++++++ src/web.rs | 174 ++++++++++++++++++++++++++++++++++++ 7 files changed, 629 insertions(+), 39 deletions(-) create mode 100644 docs/flows.md create mode 100755 scripts/validate-oidc.sh 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, CrabError> { + use entities::access_token::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::Token.eq(token)) + .one(db) + .await? + { + Ok(Some(AccessToken { + token: model.token, + client_id: model.client_id, + subject: model.subject, + scope: model.scope, + created_at: model.created_at, + expires_at: model.expires_at, + revoked: model.revoked, + })) + } else { + Ok(None) + } +} + +/// Like get_refresh_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_refresh_token_raw( + db: &DatabaseConnection, + token: &str, +) -> Result, CrabError> { + use entities::refresh_token::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::Token.eq(token)) + .one(db) + .await? + { + Ok(Some(RefreshToken { + token: model.token, + client_id: model.client_id, + subject: model.subject, + scope: model.scope, + created_at: model.created_at, + expires_at: model.expires_at, + revoked: model.revoked, + parent_token: model.parent_token, + })) + } else { + Ok(None) + } +} + fn random_id() -> String { let mut bytes = [0u8; 24]; rand::thread_rng().fill_bytes(&mut bytes); diff --git a/src/web.rs b/src/web.rs index d17ed1e..5271ccb 100644 --- a/src/web.rs +++ b/src/web.rs @@ -122,6 +122,7 @@ pub async fn serve( .route("/consent", get(consent_page).post(consent_submit)) .route("/token", post(token)) .route("/revoke", post(token_revoke)) + .route("/introspect", post(token_introspect)) .route("/userinfo", get(userinfo)) // Device Authorization Grant (RFC 8628) endpoints .route("/device_authorization", post(device_authorization)) @@ -241,6 +242,8 @@ async fn discovery(State(state): State) -> impl IntoResponse { "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": [state.settings.keys.alg], // Additional recommended metadata for better interoperability + "revocation_endpoint": format!("{}/revoke", issuer), + "introspection_endpoint": format!("{}/introspect", issuer), "grant_types_supported": ["authorization_code", "refresh_token", "implicit", "urn:ietf:params:oauth:grant-type:device_code"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "code_challenge_methods_supported": ["S256"], @@ -1908,6 +1911,177 @@ fn authenticate_client( } } +/// POST /introspect - Token introspection endpoint (RFC 7662) +#[derive(Debug, Deserialize)] +struct TokenIntrospectRequest { + token: String, + token_type_hint: Option, + client_id: Option, + client_secret: Option, +} + +async fn token_introspect( + State(state): State, + headers: HeaderMap, + Form(req): Form, +) -> Response { + // Client authentication: try Basic auth first, then form body + let mut basic_client: Option<(String, String)> = None; + if let Some(auth_val) = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + { + if let Some(b64) = auth_val.strip_prefix("Basic ") { + if let Ok(mut decoded) = Base64::decode_vec(b64) { + if let Ok(s) = String::from_utf8(std::mem::take(&mut decoded)) { + if let Some((id, sec)) = s.split_once(':') { + basic_client = Some((id.to_string(), sec.to_string())); + } + } + } + } + } + + let (client_id, client_secret) = if let Some(pair) = basic_client { + pair + } else { + match (req.client_id.clone(), req.client_secret.clone()) { + (Some(id), Some(sec)) => (id, sec), + _ => { + return json_with_headers( + StatusCode::UNAUTHORIZED, + json!({"error":"invalid_client"}), + &[( + "www-authenticate", + "Basic realm=\"token\", error=\"invalid_client\"".to_string(), + )], + ) + } + } + }; + + // Verify client exists and credentials match + let client = match storage::get_client(&state.db, &client_id).await { + Ok(Some(c)) => c, + _ => { + return json_with_headers( + StatusCode::UNAUTHORIZED, + json!({"error":"invalid_client"}), + &[( + "www-authenticate", + "Basic realm=\"token\", error=\"invalid_client\"".to_string(), + )], + ) + } + }; + + if client.client_secret != client_secret { + return json_with_headers( + StatusCode::UNAUTHORIZED, + json!({"error":"invalid_client"}), + &[( + "www-authenticate", + "Basic realm=\"token\", error=\"invalid_client\"".to_string(), + )], + ); + } + + let inactive_response = || { + json_with_headers( + StatusCode::OK, + json!({"active": false}), + &[ + ("cache-control", "no-store".to_string()), + ("pragma", "no-cache".to_string()), + ], + ) + }; + + // Look up as access token first (or follow hint) + let hint = req.token_type_hint.as_deref(); + + if hint != Some("refresh_token") { + if let Ok(Some(at)) = storage::get_access_token_raw(&state.db, &req.token).await { + let now = chrono::Utc::now().timestamp(); + let active = at.revoked == 0 && now <= at.expires_at && at.client_id == client_id; + if active { + return json_with_headers( + StatusCode::OK, + json!({ + "active": true, + "scope": at.scope, + "client_id": at.client_id, + "sub": at.subject, + "exp": at.expires_at, + "iat": at.created_at, + "token_type": "bearer" + }), + &[ + ("cache-control", "no-store".to_string()), + ("pragma", "no-cache".to_string()), + ], + ); + } + return inactive_response(); + } + } + + // Look up as refresh token + if let Ok(Some(rt)) = storage::get_refresh_token_raw(&state.db, &req.token).await { + let now = chrono::Utc::now().timestamp(); + let active = rt.revoked == 0 && now <= rt.expires_at && rt.client_id == client_id; + if active { + return json_with_headers( + StatusCode::OK, + json!({ + "active": true, + "scope": rt.scope, + "client_id": rt.client_id, + "sub": rt.subject, + "exp": rt.expires_at, + "iat": rt.created_at, + "token_type": "refresh_token" + }), + &[ + ("cache-control", "no-store".to_string()), + ("pragma", "no-cache".to_string()), + ], + ); + } + return inactive_response(); + } + + // If hint was refresh_token but not found there, try access token + if hint == Some("refresh_token") { + if let Ok(Some(at)) = storage::get_access_token_raw(&state.db, &req.token).await { + let now = chrono::Utc::now().timestamp(); + let active = at.revoked == 0 && now <= at.expires_at && at.client_id == client_id; + if active { + return json_with_headers( + StatusCode::OK, + json!({ + "active": true, + "scope": at.scope, + "client_id": at.client_id, + "sub": at.subject, + "exp": at.expires_at, + "iat": at.created_at, + "token_type": "bearer" + }), + &[ + ("cache-control", "no-store".to_string()), + ("pragma", "no-cache".to_string()), + ], + ); + } + return inactive_response(); + } + } + + // Token not found at all — return inactive per RFC 7662 section 2.2 + inactive_response() +} + async fn userinfo( State(state): State, req: axum::http::Request,