diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b21f162..adee0a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. This projec If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. ## Added +- OIDC: Fallback to `userinfo` endpoint when JWT token does not contain an email claim. - S3: `verifyAfterWrite` option to verify that objects have persisted after writing. ## Changed diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 30b90954..6d3b90ca 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -76,7 +76,15 @@ impl OpenIdDirectory { } self.validate_scopes(&token_data.claims)?; - return self.build_account(&token_data.claims); + + let (email, claims) = if let Ok(email) = self.resolve_email(&token_data.claims) + { + (email, token_data.claims) + } else { + let claims = self.fetch_userinfo(token).await?; + (self.resolve_email(&claims)?, claims) + }; + return self.build_account(email, &claims); } Err(e) => { last_err = Some(e); @@ -92,7 +100,7 @@ impl OpenIdDirectory { async fn authenticate_opaque(&self, token: &str) -> Result { let claims = self.fetch_userinfo(token).await?; - self.build_account(&claims) + self.build_account(self.resolve_email(&claims)?, &claims) } async fn get_key(&self, kid: Option<&str>) -> Result>, OidcError> { @@ -190,9 +198,13 @@ impl OpenIdDirectory { Ok(()) } - fn build_account(&self, claims: &serde_json::Value) -> Result { + fn build_account( + &self, + email: String, + claims: &serde_json::Value, + ) -> Result { Ok(Account { - email: self.resolve_email(claims)?, + email, email_aliases: Vec::new(), secret: None, groups: self diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 70b685bb..fc2d2521 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 2475ff30..67ec3b07 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -QRJ4vibPf1dYpzaA4YziIqMnAMR1UOHVUErrjOyjDeE \ No newline at end of file +BzNqAzrQXuc-FYcvnkey9hBXkjMDd9HrgWlyb4S9I7E \ No newline at end of file diff --git a/tests/docker/keycloak/stalwart-realm.json b/tests/docker/keycloak/stalwart-realm.json index d56d712a..e61a46bb 100644 --- a/tests/docker/keycloak/stalwart-realm.json +++ b/tests/docker/keycloak/stalwart-realm.json @@ -68,6 +68,71 @@ } } ] + }, + { + "clientId": "stalwart-fallback", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "stalwart-fallback-secret", + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "publicClient": false, + "protocol": "openid-connect", + "directAccessGrantsEnabled": true, + "standardFlowEnabled": true, + "serviceAccountsEnabled": true, + "defaultClientScopes": [ + "openid" + ], + "optionalClientScopes": [ + "email", + "profile", + "roles" + ], + "protocolMappers": [ + { + "name": "email-claim-userinfo-only", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "email", + "userinfo.token.claim": "true", + "jsonType.label": "String" + } + }, + { + "name": "groups-userinfo-only", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "false", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "stalwart", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] } ], "users": [ diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index 48d2a5e9..8a4175c1 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -70,6 +70,55 @@ pub async fn test() { } ); + // Test ODIC userinfo fallback + let mut config_userinfo_fallback = config.clone(); + config_userinfo_fallback.claim_username = "email".to_string(); + config_userinfo_fallback.require_scopes = Map::new(vec!["openid".to_string()]); + + let token_openid_only = get_token_for_client( + "stalwart-fallback", + "stalwart-fallback-secret", + "john.doe@example.org", + "this is an OIDC password", + "openid", + ) + .await; + + let mut oidc_broken_userinfo = OpenIdDirectory::open(config_userinfo_fallback.clone()) + .await + .unwrap(); + if let Directory::OpenId(directory) = &mut oidc_broken_userinfo { + directory.discovery.document.userinfo_endpoint = "http://invalid".to_string(); + } + assert!( + oidc_broken_userinfo + .authenticate(&Credentials::Bearer { + username: None, + token: token_openid_only.clone(), + }) + .await + .is_err() + ); + let oidc_userinfo_fallback = OpenIdDirectory::open(config_userinfo_fallback) + .await + .unwrap(); + assert_eq!( + oidc_userinfo_fallback + .authenticate(&Credentials::Bearer { + username: None, + token: token_openid_only, + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".to_string(), + email_aliases: vec![], + secret: None, + groups: vec!["sales@example.org".to_string()], + description: None, + } + ); + // Not matching the required audience should fail let mut config_wrong_audience = config.clone(); config_wrong_audience.require_audience = Some("wrong_audience".to_string()); @@ -115,17 +164,34 @@ pub async fn test() { } async fn get_token(username: &str, password: &str) -> String { + get_token_for_client( + "stalwart", + "stalwart-secret", + username, + password, + "openid email profile", + ) + .await +} + +async fn get_token_for_client( + client_id: &str, + client_secret: &str, + username: &str, + password: &str, + scope: &str, +) -> String { let client = reqwest::Client::new(); let response = client .post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token") .form(&[ ("grant_type", "password"), - ("client_id", "stalwart"), - ("client_secret", "stalwart-secret"), + ("client_id", client_id), + ("client_secret", client_secret), ("username", username), ("password", password), - ("scope", "openid email profile"), + ("scope", scope), ]) .send() .await