OIDC: Fallback to userinfo endpoint when JWT token does not contain an email claim.

This commit is contained in:
Maurus Decimus
2026-04-28 14:59:55 +02:00
parent 3e07a0fb19
commit c0c889b7fb
6 changed files with 152 additions and 8 deletions

View File

@@ -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": [

View File

@@ -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