mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 13:10:42 +00:00
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
This commit is contained in:
parent
9e64ce6744
commit
7b16f54223
7 changed files with 629 additions and 39 deletions
|
|
@ -267,11 +267,9 @@ See `docs/oidc-conformance.md` for detailed OIDC compliance requirements.
|
||||||
- Session-based AMR/ACR tracking
|
- Session-based AMR/ACR tracking
|
||||||
|
|
||||||
**Pending:**
|
**Pending:**
|
||||||
- Cache-Control headers on token endpoint
|
|
||||||
- Consent flow (currently auto-consents)
|
|
||||||
- Token revocation and introspection endpoints
|
|
||||||
- OpenID Federation trust chain validation
|
- OpenID Federation trust chain validation
|
||||||
- User account management UI
|
- User account management UI
|
||||||
|
- Key rotation and multi-key JWKS
|
||||||
|
|
||||||
## Admin GraphQL API
|
## Admin GraphQL API
|
||||||
|
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -139,16 +139,21 @@ This is an early-stage implementation. See `docs/next-iteration-plan.md` for pla
|
||||||
**Currently Implemented:**
|
**Currently Implemented:**
|
||||||
- Authorization Code flow with PKCE (S256)
|
- Authorization Code flow with PKCE (S256)
|
||||||
- Dynamic client registration
|
- Dynamic client registration
|
||||||
- Token issuance and validation
|
- Token issuance with RS256 ID Token signing (at_hash, nonce, auth_time, AMR, ACR)
|
||||||
- ID Token generation with RS256 signing
|
|
||||||
- UserInfo endpoint
|
- 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:**
|
**Pending Implementation:**
|
||||||
- User authentication and session management
|
- OpenID Federation trust chain validation
|
||||||
- Consent flow
|
- User account management UI
|
||||||
- Refresh tokens
|
- Key rotation and multi-key JWKS
|
||||||
- Token revocation and introspection
|
|
||||||
- OpenID Federation support
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|
|
||||||
185
docs/flows.md
Normal file
185
docs/flows.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -20,7 +20,7 @@ Scope references (context7 up-to-date pointers):
|
||||||
- Token endpoint: POST /token (application/x-www-form-urlencoded)
|
- Token endpoint: POST /token (application/x-www-form-urlencoded)
|
||||||
- Grant type: authorization_code
|
- Grant type: authorization_code
|
||||||
- Parameters: grant_type, code, redirect_uri, client authentication
|
- 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)
|
- 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
|
- Output: JSON with access_token, token_type=bearer, expires_in, id_token (JWT), possibly refresh_token
|
||||||
- Error model: RFC 6749 + OIDC specific where applicable
|
- Error model: RFC 6749 + OIDC specific where applicable
|
||||||
|
|
@ -79,7 +79,7 @@ Scope references (context7 up-to-date pointers):
|
||||||
- grant_types_supported: ["authorization_code"]
|
- grant_types_supported: ["authorization_code"]
|
||||||
- subject_types_supported: ["public"]
|
- subject_types_supported: ["public"]
|
||||||
- id_token_signing_alg_values_supported: ["RS256"]
|
- 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"]
|
- code_challenge_methods_supported: ["S256"]
|
||||||
- scopes_supported: ["openid", "profile", "email"]
|
- scopes_supported: ["openid", "profile", "email"]
|
||||||
- claims_supported: ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "name", "given_name", "family_name", "email", "email_verified"]
|
- 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
|
6) Current status in this repository
|
||||||
- Implemented:
|
- 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
|
- 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)
|
- Simple property storage API (non-standard)
|
||||||
- Federation trust anchors stub
|
- Federation trust anchors stub
|
||||||
|
|
||||||
- Missing for OIDC Core compliance:
|
- Remaining for full OIDC compliance:
|
||||||
- /authorize (Authorization Code + PKCE)
|
- OpenID Federation trust chain validation
|
||||||
- /token (code exchange, ID Token signing)
|
- User account management UI
|
||||||
- /userinfo
|
- Key rotation and multi-key JWKS
|
||||||
- Storage for auth codes and tokens
|
|
||||||
- Full error models and input validation across endpoints
|
|
||||||
- Robust client registration validation + optional configuration endpoint
|
|
||||||
|
|
||||||
|
|
||||||
7) Minimal viable roadmap (incremental)
|
7) Roadmap (remaining work)
|
||||||
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
|
|
||||||
|
|
||||||
Step 2: Authorization Code + PKCE
|
Steps 1-5 of the original roadmap are complete. Remaining items:
|
||||||
- Implement /authorize to issue short-lived codes; validate redirect_uri, scope, client, state, nonce, PKCE
|
|
||||||
|
|
||||||
Step 3: Token endpoint and ID Token
|
Step 6: Federation
|
||||||
- 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)
|
|
||||||
- Entity statement issuance, publication, and trust chain verification; policy application to registration
|
- 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
|
Implementation notes
|
||||||
- Keep issuer stable and correct in settings.server.public_base_url for production
|
- Keep issuer stable and correct in settings.server.public_base_url for production
|
||||||
- Ensure JWKS kid selection and alg entry match discovery
|
- Ensure JWKS kid selection and alg entry match discovery
|
||||||
- Prefer S256 for PKCE; do not support plain
|
- 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
|
||||||
|
|
|
||||||
170
scripts/validate-oidc.sh
Executable file
170
scripts/validate-oidc.sh
Executable file
|
|
@ -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
|
||||||
|
|
@ -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<Option<AccessToken>, 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<Option<RefreshToken>, 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 {
|
fn random_id() -> String {
|
||||||
let mut bytes = [0u8; 24];
|
let mut bytes = [0u8; 24];
|
||||||
rand::thread_rng().fill_bytes(&mut bytes);
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
|
|
||||||
174
src/web.rs
174
src/web.rs
|
|
@ -122,6 +122,7 @@ pub async fn serve(
|
||||||
.route("/consent", get(consent_page).post(consent_submit))
|
.route("/consent", get(consent_page).post(consent_submit))
|
||||||
.route("/token", post(token))
|
.route("/token", post(token))
|
||||||
.route("/revoke", post(token_revoke))
|
.route("/revoke", post(token_revoke))
|
||||||
|
.route("/introspect", post(token_introspect))
|
||||||
.route("/userinfo", get(userinfo))
|
.route("/userinfo", get(userinfo))
|
||||||
// Device Authorization Grant (RFC 8628) endpoints
|
// Device Authorization Grant (RFC 8628) endpoints
|
||||||
.route("/device_authorization", post(device_authorization))
|
.route("/device_authorization", post(device_authorization))
|
||||||
|
|
@ -241,6 +242,8 @@ async fn discovery(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
"subject_types_supported": ["public"],
|
"subject_types_supported": ["public"],
|
||||||
"id_token_signing_alg_values_supported": [state.settings.keys.alg],
|
"id_token_signing_alg_values_supported": [state.settings.keys.alg],
|
||||||
// Additional recommended metadata for better interoperability
|
// 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"],
|
"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"],
|
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
||||||
"code_challenge_methods_supported": ["S256"],
|
"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<String>,
|
||||||
|
client_id: Option<String>,
|
||||||
|
client_secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn token_introspect(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(req): Form<TokenIntrospectRequest>,
|
||||||
|
) -> 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(
|
async fn userinfo(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
req: axum::http::Request<axum::body::Body>,
|
req: axum::http::Request<axum::body::Body>,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue