Merge pull request #1 from CloudNebulaProject/claude/implement-next-steps-TIoEy

Add token introspection endpoint (RFC 7662)
This commit is contained in:
Till Wegmüller 2026-03-19 22:31:47 +01:00 committed by GitHub
commit 86ba1da7bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 629 additions and 39 deletions

View file

@ -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

View file

@ -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

185
docs/flows.md Normal file
View 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.

View file

@ -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

170
scripts/validate-oidc.sh Executable file
View 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

View file

@ -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 {
let mut bytes = [0u8; 24];
rand::thread_rng().fill_bytes(&mut bytes);

View file

@ -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<AppState>) -> 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<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(
State(state): State<AppState>,
req: axum::http::Request<axum::body::Body>,