Compare commits

...

7 commits

Author SHA1 Message Date
Till Wegmueller
ed6bb8d28c
chore: Bump chart version to 0.2.0-beta.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:38:47 +01:00
Till Wegmueller
9aa018fc93
feat: Add scope-gated OIDC profile and email claims
Implement standard OIDC claims support for the userinfo endpoint and
ID token. Claims are stored in the properties table and returned based
on the access token's granted scopes:

- profile scope: preferred_username (falls back to username), name,
  given_name, family_name, nickname, picture, profile, website,
  gender, birthdate, zoneinfo, locale, updated_at
- email scope: email, email_verified (with user record fallback)

Adds bulk property retrieval, shared gather_claims() function used by
both userinfo and build_id_token, and updated discovery metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:19:54 +01:00
Till Wegmüller
86ba1da7bc
Merge pull request #1 from CloudNebulaProject/claude/implement-next-steps-TIoEy
Add token introspection endpoint (RFC 7662)
2026-03-19 22:31:47 +01:00
Claude
7b16f54223
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
2026-03-19 20:30:31 +00:00
Till Wegmueller
9e64ce6744
chore: Bump chart version to 0.2.0-beta.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:07:30 +01:00
Till Wegmueller
210a27ca02
fix: Change device_code interval from i64 to i32
The migration creates the interval column as integer (INT4) but the
entity and storage struct used i64 (INT8), causing a type mismatch
error on PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:33:50 +01:00
Till Wegmueller
f6262b2128
fix: Pass env vars to user-sync init container
The init container was only getting RUST_LOG, not the main env block.
This caused it to connect to the config file's database URL (SQLite)
instead of the BARYCENTER__DATABASE__URL env var (PostgreSQL),
resulting in migrations and user-sync running against the wrong
database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:15:23 +01:00
12 changed files with 835 additions and 76 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

View file

@ -37,6 +37,7 @@ The response is a JSON object containing claims about the user. The claims retur
```json
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"preferred_username": "alice",
"name": "Alice Johnson",
"given_name": "Alice",
"family_name": "Johnson",
@ -47,7 +48,7 @@ The response is a JSON object containing claims about the user. The claims retur
## Scope-Based Claims
The set of claims returned is determined by the scopes granted to the access token:
The set of claims returned is determined by the scopes granted to the access token.
### `openid` (required)
@ -59,13 +60,23 @@ The `openid` scope is mandatory for all OIDC requests. It grants access to the s
### `profile`
The `profile` scope grants access to basic profile information.
The `profile` scope grants access to the user's profile information. Only claims that have a value stored for the user are included in the response.
| Claim | Type | Description |
|---|---|---|
| `name` | string | Full name of the user. |
| `preferred_username` | string | Short name the user prefers. **Defaults to the login username** if not explicitly set. |
| `name` | string | Full display name of the user. |
| `given_name` | string | First name / given name. |
| `family_name` | string | Last name / surname / family name. |
| `nickname` | string | Casual name or alias. |
| `picture` | string | URL of the user's profile picture. |
| `profile` | string | URL of the user's profile page. |
| `website` | string | URL of the user's website or blog. |
| `gender` | string | Gender of the user (e.g., `"female"`, `"male"`, or other values). |
| `birthdate` | string | Birthday in `YYYY-MM-DD` format (or `YYYY` for year only). |
| `zoneinfo` | string | Time zone from the [IANA Time Zone Database](https://www.iana.org/time-zones) (e.g., `"Europe/Zurich"`). |
| `locale` | string | Locale as a BCP47 language tag (e.g., `"en-US"`, `"de-CH"`). |
| `updated_at` | number | Unix timestamp of when the profile was last updated. |
### `email`
@ -73,19 +84,61 @@ The `email` scope grants access to the user's email address and verification sta
| Claim | Type | Description |
|---|---|---|
| `email` | string | The user's email address. |
| `email_verified` | boolean | Whether the email address has been verified. |
| `email` | string | The user's email address. Falls back to the `email` field on the user record if not set as a property. |
| `email_verified` | boolean | Whether the email address has been verified. Falls back to the `email_verified` field on the user record. |
### Summary Table
| Scope | Claims Returned |
|---|---|
| `openid` | `sub` |
| `openid profile` | `sub`, `name`, `given_name`, `family_name` |
| `openid profile` | `sub`, `preferred_username`, `name`, `given_name`, `family_name`, ... (all profile claims that have values) |
| `openid email` | `sub`, `email`, `email_verified` |
| `openid profile email` | `sub`, `name`, `given_name`, `family_name`, `email`, `email_verified` |
| `openid profile email` | `sub`, all profile claims, `email`, `email_verified` |
> **Note**: Claims are only included in the response if values exist for the user. For example, if a user has no `family_name` stored, that claim will be absent from the response even if the `profile` scope was granted.
> **Note**: Claims are only included in the response if values exist for the user. For example, if a user has no `picture` stored, that claim will be absent from the response even if the `profile` scope was granted. The exception is `preferred_username`, which always falls back to the login username.
## Setting User Claims
User claims are stored in the **properties table** as key-value pairs. They can be set in two ways:
### Via User Sync (JSON file)
Include claims in the `properties` field of the user definition:
```json
{
"users": [
{
"username": "alice",
"email": "alice@example.com",
"password": "secure-password",
"properties": {
"name": "Alice Johnson",
"given_name": "Alice",
"family_name": "Johnson",
"preferred_username": "alice",
"picture": "https://example.com/photos/alice.jpg",
"locale": "en-US",
"zoneinfo": "America/New_York"
}
}
]
}
```
### Via Properties API
```bash
# Set a single property
curl -X PUT https://idp.example.com/properties/<subject>/name \
-H "Content-Type: application/json" \
-d '"Alice Johnson"'
```
## ID Token Claims
The same scope-gated claims are also included in the **ID Token** (JWT) when the corresponding scopes are requested. This means clients can access profile and email claims directly from the ID token without making a separate call to the userinfo endpoint.
## Error Responses

View file

@ -2,8 +2,8 @@ apiVersion: v2
name: barycenter
description: OpenID Connect Identity Provider with federation and auto-registration
type: application
version: "0.2.0-alpha.15"
appVersion: "0.2.0-alpha.15"
version: "0.2.0-beta.5"
appVersion: "0.2.0-beta.5"
keywords:
- openid
- oauth2

View file

@ -47,11 +47,9 @@ spec:
- sync-users
- --file
- /secrets/{{ .Values.userSync.secretKey }}
{{- if .Values.env }}
env:
- name: RUST_LOG
value: "info"
{{- if .Values.userSync.env }}
{{- toYaml .Values.userSync.env | nindent 8 }}
{{- toYaml .Values.env | nindent 8 }}
{{- end }}
volumeMounts:
- name: config

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

@ -14,7 +14,7 @@ pub struct Model {
pub created_at: i64,
pub expires_at: i64,
pub last_poll_at: Option<i64>,
pub interval: i64,
pub interval: i32,
pub status: String, // "pending" | "approved" | "denied" | "consumed"
pub subject: Option<String>,
pub auth_time: Option<i64>,

View file

@ -103,7 +103,7 @@ pub struct DeviceCode {
pub created_at: i64,
pub expires_at: i64,
pub last_poll_at: Option<i64>,
pub interval: i64,
pub interval: i32,
pub status: String, // "pending" | "approved" | "denied" | "consumed"
pub subject: Option<String>,
pub auth_time: Option<i64>,
@ -161,6 +161,26 @@ pub async fn get_property(
}
}
pub async fn get_properties_for_owner(
db: &DatabaseConnection,
owner: &str,
) -> Result<std::collections::HashMap<String, Value>, CrabError> {
use entities::property::{Column, Entity};
let models = Entity::find()
.filter(Column::Owner.eq(owner))
.all(db)
.await?;
let mut map = std::collections::HashMap::new();
for model in models {
if let Ok(json) = serde_json::from_str(&model.value) {
map.insert(model.key, json);
}
}
Ok(map)
}
pub async fn set_property(
db: &DatabaseConnection,
owner: &str,
@ -365,6 +385,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

@ -50,7 +50,7 @@ pub async fn sync_users_from_file(db: &DatabaseConnection, file_path: &str) -> R
.into_diagnostic()
.map_err(|e| {
miette::miette!(
"Failed to parse users JSON file: {}\n\nExpected format:\n{{\n \"users\": [\n {{\n \"username\": \"alice\",\n \"email\": \"alice@example.com\",\n \"password\": \"secure-password\",\n \"enabled\": true,\n \"email_verified\": false,\n \"properties\": {{\n \"department\": \"Engineering\"\n }}\n }}\n ]\n}}",
"Failed to parse users JSON file: {}\n\nExpected format:\n{{\n \"users\": [\n {{\n \"username\": \"alice\",\n \"email\": \"alice@example.com\",\n \"password\": \"secure-password\",\n \"enabled\": true,\n \"email_verified\": false,\n \"properties\": {{\n \"name\": \"Alice Johnson\",\n \"given_name\": \"Alice\",\n \"family_name\": \"Johnson\",\n \"picture\": \"https://example.com/alice.jpg\"\n }}\n }}\n ]\n}}\n\nStandard OIDC claims (name, given_name, family_name, preferred_username,\nnickname, picture, profile, website, gender, birthdate, zoneinfo, locale,\nupdated_at) can be set as properties and will be returned via the\nuserinfo endpoint and ID token when the 'profile' scope is requested.",
e
)
})?;

View file

@ -28,6 +28,92 @@ use std::time::SystemTime;
use tower_http::services::ServeDir;
use urlencoding;
/// Standard OIDC profile claims (returned when `profile` scope is granted).
const PROFILE_CLAIMS: &[&str] = &[
"preferred_username",
"name",
"given_name",
"family_name",
"nickname",
"picture",
"profile",
"website",
"gender",
"birthdate",
"zoneinfo",
"locale",
"updated_at",
];
/// Standard OIDC email claims (returned when `email` scope is granted).
const EMAIL_CLAIMS: &[&str] = &["email", "email_verified"];
fn has_scope(scope_str: &str, target: &str) -> bool {
scope_str.split_whitespace().any(|s| s == target)
}
/// Gather OIDC claims for a user based on the granted scopes.
///
/// Reads all properties for the user in a single query and returns
/// the subset that matches the requested scopes.
async fn gather_claims(
db: &DatabaseConnection,
subject: &str,
scope: &str,
) -> Result<serde_json::Map<String, Value>, CrabError> {
let mut claims = serde_json::Map::new();
claims.insert("sub".to_string(), Value::String(subject.to_string()));
let include_profile = has_scope(scope, "profile");
let include_email = has_scope(scope, "email");
if !include_profile && !include_email {
return Ok(claims);
}
let props = storage::get_properties_for_owner(db, subject).await?;
let user = storage::get_user_by_subject(db, subject).await?;
if include_profile {
for &claim in PROFILE_CLAIMS {
if claim == "preferred_username" {
// Fall back to username from users table
if let Some(val) = props.get(claim) {
claims.insert(claim.to_string(), val.clone());
} else if let Some(ref u) = user {
claims.insert(
claim.to_string(),
Value::String(u.username.clone()),
);
}
} else if let Some(val) = props.get(claim) {
claims.insert(claim.to_string(), val.clone());
}
}
}
if include_email {
if let Some(val) = props.get("email") {
claims.insert("email".to_string(), val.clone());
} else if let Some(ref u) = user {
if let Some(ref email) = u.email {
claims.insert("email".to_string(), Value::String(email.clone()));
}
}
if let Some(val) = props.get("email_verified") {
claims.insert("email_verified".to_string(), val.clone());
} else if let Some(ref u) = user {
claims.insert(
"email_verified".to_string(),
Value::Bool(u.email_verified != 0),
);
}
}
Ok(claims)
}
#[derive(Clone)]
pub struct AppState {
pub settings: Arc<Settings>,
@ -122,6 +208,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,12 +328,17 @@ 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"],
"claims_supported": [
"sub", "iss", "aud", "exp", "iat", "auth_time", "nonce",
"name", "given_name", "family_name", "email", "email_verified"
"preferred_username", "name", "given_name", "family_name",
"nickname", "picture", "profile", "website",
"gender", "birthdate", "zoneinfo", "locale", "updated_at",
"email", "email_verified"
],
// OIDC Core 1.0 features
"prompt_values_supported": ["none", "login", "consent", "select_account"],
@ -721,6 +813,7 @@ async fn authorize(
&state,
&q.client_id,
&subject,
&q.scope,
nonce.as_deref(),
auth_time,
None,
@ -783,6 +876,7 @@ async fn authorize(
&state,
&q.client_id,
&subject,
&q.scope,
nonce.as_deref(),
auth_time,
Some(&access.token),
@ -1083,6 +1177,7 @@ async fn build_id_token(
state: &AppState,
client_id: &str,
subject: &str,
scope: &str,
nonce: Option<&str>,
auth_time: Option<i64>,
access_token: Option<&str>, // For at_hash calculation
@ -1132,6 +1227,17 @@ async fn build_id_token(
let _ = payload.set_claim("at_hash", Some(serde_json::Value::String(at_hash)));
}
// Add scope-gated profile/email claims
if let Ok(user_claims) = gather_claims(&state.db, subject, scope).await {
for (key, value) in user_claims {
// Skip sub/iss/aud/exp/iat — already set above
if key == "sub" {
continue;
}
let _ = payload.set_claim(&key, Some(value));
}
}
state
.jwks
.sign_jwt_rs256(&payload)
@ -1325,6 +1431,7 @@ async fn handle_authorization_code_grant(
&state,
&client_id,
&code_row.subject,
&code_row.scope,
code_row.nonce.as_deref(),
code_row.auth_time,
Some(&access.token),
@ -1576,6 +1683,7 @@ async fn handle_refresh_token_grant(
&state,
&client_id,
&refresh_token.subject,
&refresh_token.scope,
None,
None,
Some(&access.token),
@ -1695,7 +1803,7 @@ async fn handle_device_code_grant(
let now = chrono::Utc::now().timestamp();
let elapsed = now - last_poll;
if elapsed < device_code.interval {
if elapsed < device_code.interval as i64 {
// Polling too fast - increment interval and return slow_down
let _ = storage::increment_device_code_interval(&state.db, &device_code_str).await;
@ -1806,6 +1914,7 @@ async fn handle_device_code_grant(
&state,
&client_id,
&subject,
&consumed_device_code.scope,
None, // No nonce for device flow
consumed_device_code.auth_time,
Some(&access.token),
@ -1908,6 +2017,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>,
@ -1945,26 +2225,18 @@ async fn userinfo(
)
}
};
let mut claims = serde_json::Map::new();
claims.insert(
let claims = match gather_claims(&state.db, &token_row.subject, &token_row.scope).await {
Ok(c) => c,
Err(_) => {
let mut c = serde_json::Map::new();
c.insert(
"sub".to_string(),
serde_json::Value::String(token_row.subject.clone()),
);
// Optional: email claims from properties
if let Ok(Some(email)) = storage::get_property(&state.db, &token_row.subject, "email").await {
if let Some(email_str) = email.as_str() {
claims.insert(
"email".to_string(),
serde_json::Value::String(email_str.to_string()),
Value::String(token_row.subject.clone()),
);
c
}
}
if let Ok(Some(verified)) =
storage::get_property(&state.db, &token_row.subject, "email_verified").await
{
claims.insert("email_verified".to_string(), verified);
}
(StatusCode::OK, Json(serde_json::Value::Object(claims))).into_response()
};
(StatusCode::OK, Json(Value::Object(claims))).into_response()
}
#[derive(Debug, Deserialize)]