From 9aa018fc93570302b2c9829014f67226cfcc36a4 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Tue, 24 Mar 2026 22:18:17 +0100 Subject: [PATCH] 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 --- book/src/oidc/userinfo.md | 69 ++++++++++++++++--- src/storage.rs | 20 ++++++ src/user_sync.rs | 2 +- src/web.rs | 136 ++++++++++++++++++++++++++++++++------ 4 files changed, 199 insertions(+), 28 deletions(-) diff --git a/book/src/oidc/userinfo.md b/book/src/oidc/userinfo.md index 21fad52..0303372 100644 --- a/book/src/oidc/userinfo.md +++ b/book/src/oidc/userinfo.md @@ -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//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 diff --git a/src/storage.rs b/src/storage.rs index 876fde0..da69d2e 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -161,6 +161,26 @@ pub async fn get_property( } } +pub async fn get_properties_for_owner( + db: &DatabaseConnection, + owner: &str, +) -> Result, 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, diff --git a/src/user_sync.rs b/src/user_sync.rs index 6394338..359b5fc 100644 --- a/src/user_sync.rs +++ b/src/user_sync.rs @@ -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 ) })?; diff --git a/src/web.rs b/src/web.rs index 5271ccb..ff68688 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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, 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, @@ -249,7 +335,10 @@ async fn discovery(State(state): State) -> impl IntoResponse { "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"], @@ -724,6 +813,7 @@ async fn authorize( &state, &q.client_id, &subject, + &q.scope, nonce.as_deref(), auth_time, None, @@ -786,6 +876,7 @@ async fn authorize( &state, &q.client_id, &subject, + &q.scope, nonce.as_deref(), auth_time, Some(&access.token), @@ -1086,6 +1177,7 @@ async fn build_id_token( state: &AppState, client_id: &str, subject: &str, + scope: &str, nonce: Option<&str>, auth_time: Option, access_token: Option<&str>, // For at_hash calculation @@ -1135,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) @@ -1328,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), @@ -1579,6 +1683,7 @@ async fn handle_refresh_token_grant( &state, &client_id, &refresh_token.subject, + &refresh_token.scope, None, None, Some(&access.token), @@ -1809,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), @@ -2119,26 +2225,18 @@ async fn userinfo( ) } }; - let mut claims = serde_json::Map::new(); - claims.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()), + 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(), + 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)]