mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 21:20:41 +00:00
Compare commits
7 commits
v0.2.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6bb8d28c | ||
|
|
9aa018fc93 | ||
|
|
86ba1da7bc | ||
|
|
7b16f54223 | ||
|
|
9e64ce6744 | ||
|
|
210a27ca02 | ||
|
|
f6262b2128 |
12 changed files with 835 additions and 76 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ The response is a JSON object containing claims about the user. The claims retur
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"preferred_username": "alice",
|
||||||
"name": "Alice Johnson",
|
"name": "Alice Johnson",
|
||||||
"given_name": "Alice",
|
"given_name": "Alice",
|
||||||
"family_name": "Johnson",
|
"family_name": "Johnson",
|
||||||
|
|
@ -47,7 +48,7 @@ The response is a JSON object containing claims about the user. The claims retur
|
||||||
|
|
||||||
## Scope-Based Claims
|
## 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)
|
### `openid` (required)
|
||||||
|
|
||||||
|
|
@ -59,13 +60,23 @@ The `openid` scope is mandatory for all OIDC requests. It grants access to the s
|
||||||
|
|
||||||
### `profile`
|
### `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 |
|
| 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. |
|
| `given_name` | string | First name / given name. |
|
||||||
| `family_name` | string | Last name / surname / family 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`
|
### `email`
|
||||||
|
|
||||||
|
|
@ -73,19 +84,61 @@ The `email` scope grants access to the user's email address and verification sta
|
||||||
|
|
||||||
| Claim | Type | Description |
|
| Claim | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `email` | string | The user's email address. |
|
| `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. |
|
| `email_verified` | boolean | Whether the email address has been verified. Falls back to the `email_verified` field on the user record. |
|
||||||
|
|
||||||
### Summary Table
|
### Summary Table
|
||||||
|
|
||||||
| Scope | Claims Returned |
|
| Scope | Claims Returned |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `openid` | `sub` |
|
| `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 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
|
## Error Responses
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ apiVersion: v2
|
||||||
name: barycenter
|
name: barycenter
|
||||||
description: OpenID Connect Identity Provider with federation and auto-registration
|
description: OpenID Connect Identity Provider with federation and auto-registration
|
||||||
type: application
|
type: application
|
||||||
version: "0.2.0-alpha.15"
|
version: "0.2.0-beta.5"
|
||||||
appVersion: "0.2.0-alpha.15"
|
appVersion: "0.2.0-beta.5"
|
||||||
keywords:
|
keywords:
|
||||||
- openid
|
- openid
|
||||||
- oauth2
|
- oauth2
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,9 @@ spec:
|
||||||
- sync-users
|
- sync-users
|
||||||
- --file
|
- --file
|
||||||
- /secrets/{{ .Values.userSync.secretKey }}
|
- /secrets/{{ .Values.userSync.secretKey }}
|
||||||
|
{{- if .Values.env }}
|
||||||
env:
|
env:
|
||||||
- name: RUST_LOG
|
{{- toYaml .Values.env | nindent 8 }}
|
||||||
value: "info"
|
|
||||||
{{- if .Values.userSync.env }}
|
|
||||||
{{- toYaml .Values.userSync.env | nindent 8 }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -14,7 +14,7 @@ pub struct Model {
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
pub last_poll_at: Option<i64>,
|
pub last_poll_at: Option<i64>,
|
||||||
pub interval: i64,
|
pub interval: i32,
|
||||||
pub status: String, // "pending" | "approved" | "denied" | "consumed"
|
pub status: String, // "pending" | "approved" | "denied" | "consumed"
|
||||||
pub subject: Option<String>,
|
pub subject: Option<String>,
|
||||||
pub auth_time: Option<i64>,
|
pub auth_time: Option<i64>,
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ pub struct DeviceCode {
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
pub last_poll_at: Option<i64>,
|
pub last_poll_at: Option<i64>,
|
||||||
pub interval: i64,
|
pub interval: i32,
|
||||||
pub status: String, // "pending" | "approved" | "denied" | "consumed"
|
pub status: String, // "pending" | "approved" | "denied" | "consumed"
|
||||||
pub subject: Option<String>,
|
pub subject: Option<String>,
|
||||||
pub auth_time: Option<i64>,
|
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(
|
pub async fn set_property(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
owner: &str,
|
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 {
|
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);
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ pub async fn sync_users_from_file(db: &DatabaseConnection, file_path: &str) -> R
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
miette::miette!(
|
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
|
e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
312
src/web.rs
312
src/web.rs
|
|
@ -28,6 +28,92 @@ use std::time::SystemTime;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use urlencoding;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub settings: Arc<Settings>,
|
pub settings: Arc<Settings>,
|
||||||
|
|
@ -122,6 +208,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,12 +328,17 @@ 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"],
|
||||||
"claims_supported": [
|
"claims_supported": [
|
||||||
"sub", "iss", "aud", "exp", "iat", "auth_time", "nonce",
|
"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
|
// OIDC Core 1.0 features
|
||||||
"prompt_values_supported": ["none", "login", "consent", "select_account"],
|
"prompt_values_supported": ["none", "login", "consent", "select_account"],
|
||||||
|
|
@ -721,6 +813,7 @@ async fn authorize(
|
||||||
&state,
|
&state,
|
||||||
&q.client_id,
|
&q.client_id,
|
||||||
&subject,
|
&subject,
|
||||||
|
&q.scope,
|
||||||
nonce.as_deref(),
|
nonce.as_deref(),
|
||||||
auth_time,
|
auth_time,
|
||||||
None,
|
None,
|
||||||
|
|
@ -783,6 +876,7 @@ async fn authorize(
|
||||||
&state,
|
&state,
|
||||||
&q.client_id,
|
&q.client_id,
|
||||||
&subject,
|
&subject,
|
||||||
|
&q.scope,
|
||||||
nonce.as_deref(),
|
nonce.as_deref(),
|
||||||
auth_time,
|
auth_time,
|
||||||
Some(&access.token),
|
Some(&access.token),
|
||||||
|
|
@ -1083,6 +1177,7 @@ async fn build_id_token(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
|
scope: &str,
|
||||||
nonce: Option<&str>,
|
nonce: Option<&str>,
|
||||||
auth_time: Option<i64>,
|
auth_time: Option<i64>,
|
||||||
access_token: Option<&str>, // For at_hash calculation
|
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)));
|
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
|
state
|
||||||
.jwks
|
.jwks
|
||||||
.sign_jwt_rs256(&payload)
|
.sign_jwt_rs256(&payload)
|
||||||
|
|
@ -1325,6 +1431,7 @@ async fn handle_authorization_code_grant(
|
||||||
&state,
|
&state,
|
||||||
&client_id,
|
&client_id,
|
||||||
&code_row.subject,
|
&code_row.subject,
|
||||||
|
&code_row.scope,
|
||||||
code_row.nonce.as_deref(),
|
code_row.nonce.as_deref(),
|
||||||
code_row.auth_time,
|
code_row.auth_time,
|
||||||
Some(&access.token),
|
Some(&access.token),
|
||||||
|
|
@ -1576,6 +1683,7 @@ async fn handle_refresh_token_grant(
|
||||||
&state,
|
&state,
|
||||||
&client_id,
|
&client_id,
|
||||||
&refresh_token.subject,
|
&refresh_token.subject,
|
||||||
|
&refresh_token.scope,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&access.token),
|
Some(&access.token),
|
||||||
|
|
@ -1695,7 +1803,7 @@ async fn handle_device_code_grant(
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let elapsed = now - last_poll;
|
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
|
// Polling too fast - increment interval and return slow_down
|
||||||
let _ = storage::increment_device_code_interval(&state.db, &device_code_str).await;
|
let _ = storage::increment_device_code_interval(&state.db, &device_code_str).await;
|
||||||
|
|
||||||
|
|
@ -1806,6 +1914,7 @@ async fn handle_device_code_grant(
|
||||||
&state,
|
&state,
|
||||||
&client_id,
|
&client_id,
|
||||||
&subject,
|
&subject,
|
||||||
|
&consumed_device_code.scope,
|
||||||
None, // No nonce for device flow
|
None, // No nonce for device flow
|
||||||
consumed_device_code.auth_time,
|
consumed_device_code.auth_time,
|
||||||
Some(&access.token),
|
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(
|
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>,
|
||||||
|
|
@ -1945,26 +2225,18 @@ async fn userinfo(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut claims = serde_json::Map::new();
|
let claims = match gather_claims(&state.db, &token_row.subject, &token_row.scope).await {
|
||||||
claims.insert(
|
Ok(c) => c,
|
||||||
"sub".to_string(),
|
Err(_) => {
|
||||||
serde_json::Value::String(token_row.subject.clone()),
|
let mut c = serde_json::Map::new();
|
||||||
);
|
c.insert(
|
||||||
// Optional: email claims from properties
|
"sub".to_string(),
|
||||||
if let Ok(Some(email)) = storage::get_property(&state.db, &token_row.subject, "email").await {
|
Value::String(token_row.subject.clone()),
|
||||||
if let Some(email_str) = email.as_str() {
|
|
||||||
claims.insert(
|
|
||||||
"email".to_string(),
|
|
||||||
serde_json::Value::String(email_str.to_string()),
|
|
||||||
);
|
);
|
||||||
|
c
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
if let Ok(Some(verified)) =
|
(StatusCode::OK, Json(Value::Object(claims))).into_response()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue