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)]