mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 13:10:42 +00:00
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>
This commit is contained in:
parent
86ba1da7bc
commit
9aa018fc93
4 changed files with 199 additions and 28 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
134
src/web.rs
134
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>,
|
||||||
|
|
@ -249,7 +335,10 @@ async fn discovery(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
"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"],
|
||||||
|
|
@ -724,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,
|
||||||
|
|
@ -786,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),
|
||||||
|
|
@ -1086,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
|
||||||
|
|
@ -1135,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)
|
||||||
|
|
@ -1328,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),
|
||||||
|
|
@ -1579,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),
|
||||||
|
|
@ -1809,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),
|
||||||
|
|
@ -2119,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,
|
||||||
|
Err(_) => {
|
||||||
|
let mut c = serde_json::Map::new();
|
||||||
|
c.insert(
|
||||||
"sub".to_string(),
|
"sub".to_string(),
|
||||||
serde_json::Value::String(token_row.subject.clone()),
|
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()),
|
|
||||||
);
|
);
|
||||||
|
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