From 74455200b33b1b9aee4688b08b969a792d990ac8 Mon Sep 17 00:00:00 2001 From: sebastian Date: Thu, 2 Jul 2026 23:57:46 -0600 Subject: [PATCH 1/2] feat(plugins): add unverified generic oauth id token mode --- CHANGELOG.md | 6 + crates/rustauth-plugins/README.md | 8 + crates/rustauth-plugins/UPSTREAM.md | 2 +- .../src/generic_oauth/config.rs | 4 + .../src/generic_oauth/id_token.rs | 42 +++++ .../src/generic_oauth/provider.rs | 13 ++ .../tests/generic_oauth/common.rs | 7 + .../tests/generic_oauth/provider.rs | 149 ++++++++++++++++++ .../tests/generic_oauth/routes.rs | 99 ++++++++++++ 9 files changed, 329 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8673e9..7dcfd5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ project follows [Semantic Versioning](https://semver.org/) while the API is stil ## [Unreleased] +### Added + +- Generic OAuth now offers explicit opt-in unverified ID-token profile + extraction for Better Auth parity while keeping userinfo as the secure + default. + ## [0.3.1](https://github.com/salasebas/rustauth/compare/v0.3.0...v0.3.1) - 2026-07-02 ### Changed diff --git a/crates/rustauth-plugins/README.md b/crates/rustauth-plugins/README.md index 7ca296d2..c98b2171 100644 --- a/crates/rustauth-plugins/README.md +++ b/crates/rustauth-plugins/README.md @@ -167,6 +167,14 @@ issuer, client ID audience, expiration, subject, nonce, and authorized party when the token has multiple audiences before mapping claims. Existing `userinfo_url` and custom `get_user_info` flows remain supported. +For Better Auth parity with providers that rely on decode-only ID-token +profile claims, callers can explicitly opt into +`GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo`. This mode decodes +the JWT payload without verifying the signature or issuer/audience claims, uses +it only when the decoded profile has both `sub` and `email`, and otherwise +falls back to `userinfo_url`. Prefer the verified OIDC source for new +integrations. + ## Time units Public plugin and core option timeouts use [`time::Duration`](https://docs.rs/time/latest/time/struct.Duration.html). diff --git a/crates/rustauth-plugins/UPSTREAM.md b/crates/rustauth-plugins/UPSTREAM.md index 5af9b993..038cdc30 100644 --- a/crates/rustauth-plugins/UPSTREAM.md +++ b/crates/rustauth-plugins/UPSTREAM.md @@ -70,7 +70,7 @@ Status symbols are defined in the [parity index](../../docs/parity/README.md#sta | Serializable metadata | Exposes callback-driven plugin options | Omits closure/callback fields, preserves observable values | Runtime callbacks are not serializable metadata. | | OAuth proxy payloads | Object transport | Rust-owned encrypted structs | Payload is RustAuth-to-RustAuth transport, not a public cross-implementation API. | | Generic OAuth HTTP | Baseline outbound fetch behavior | SSRF-guarded default HTTP transport | Auth boundary should fail closed for private/internal targets. | -| Generic OAuth ID-token profiles | Decodes ID-token claims before userinfo | Uses ID-token claims only when `GenericOAuthProfileSource::VerifiedIdToken(...)` verifies issuer, audience, expiration, subject, nonce, asymmetric algorithm, and JWKS key | Avoids trusting unsigned or unverified profile claims while still supporting explicit Generic OIDC providers. | +| Generic OAuth ID-token profiles | Decodes ID-token claims before userinfo | Defaults to userinfo or verified `GenericOAuthProfileSource::VerifiedIdToken(...)`; `GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo` explicitly opts into decode-only parity | Keeps the default fail-closed while allowing maintainers to choose upstream-compatible unverified profile extraction. | | API-key cache revalidation | Cache-first secondary storage | Optional DB revalidation for cache hits | Preserves compatibility by default while offering immediate revocation visibility. | | API-key pure secondary listing across processes | Cache-first `customStorage` get/set/delete index | Atomic `SecondaryStorage::compare_and_set` / `delete_if_value` index updates, plus database fallback/revalidation options | Rust storage backends can provide cross-process compare-and-set semantics without forcing database fallback. | | Additional fields helper | Core user/session additional fields | Dedicated Rust server plugin helper | Gives Rust callers a plugin-shaped way to contribute schema/runtime metadata. | diff --git a/crates/rustauth-plugins/src/generic_oauth/config.rs b/crates/rustauth-plugins/src/generic_oauth/config.rs index 874c56b2..b69eabed 100644 --- a/crates/rustauth-plugins/src/generic_oauth/config.rs +++ b/crates/rustauth-plugins/src/generic_oauth/config.rs @@ -63,6 +63,7 @@ pub enum GenericOAuthProfileSource { #[default] UserInfo, VerifiedIdToken(GenericOidcIdTokenProfile), + UnverifiedIdTokenThenUserInfo, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -311,6 +312,9 @@ impl GenericOAuthConfig { "profileSource": match self.profile_source { GenericOAuthProfileSource::UserInfo => "userInfo", GenericOAuthProfileSource::VerifiedIdToken(_) => "verifiedIdToken", + GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo => { + "unverifiedIdTokenThenUserInfo" + } }, }) } diff --git a/crates/rustauth-plugins/src/generic_oauth/id_token.rs b/crates/rustauth-plugins/src/generic_oauth/id_token.rs index ec138225..f91c381a 100644 --- a/crates/rustauth-plugins/src/generic_oauth/id_token.rs +++ b/crates/rustauth-plugins/src/generic_oauth/id_token.rs @@ -1,3 +1,5 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use rustauth_oauth::oauth2::{ validate_token, OAuth2Tokens, OAuth2UserInfo, OAuthError, OAuthHttpClient, TokenValidationOptions, ValidateTokenOptions, @@ -8,6 +10,22 @@ use std::collections::BTreeSet; use super::config::{GenericOAuthConfig, GenericOAuthProfileSource}; use super::user_info; +pub(super) fn unverified_user_info( + tokens: &OAuth2Tokens, +) -> Result, OAuthError> { + let Some(id_token) = tokens.id_token.as_deref() else { + return Ok(None); + }; + let profile = decode_unverified_jwt_payload(id_token)?; + if !has_non_empty_claim(&profile, "sub") || !has_non_empty_claim(&profile, "email") { + return Ok(None); + } + let Some(user) = user_info::user_info_from_claims(&profile) else { + return Ok(None); + }; + Ok(Some(user)) +} + pub(super) async fn verified_user_info( tokens: &OAuth2Tokens, config: &GenericOAuthConfig, @@ -105,3 +123,27 @@ fn distinct_audience_count(audience: Option<&Value>) -> usize { .collect::>() .len() } + +fn decode_unverified_jwt_payload(token: &str) -> Result { + if token.split('.').count() != 3 { + return Err(OAuthError::InvalidResponse( + "id_token must be a JWT with three segments".to_owned(), + )); + } + let payload = token + .split('.') + .nth(1) + .ok_or_else(|| OAuthError::InvalidResponse("id_token must contain a payload".to_owned()))?; + let decoded = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|error| OAuthError::InvalidResponse(error.to_string()))?; + serde_json::from_slice(&decoded).map_err(|error| OAuthError::InvalidResponse(error.to_string())) +} + +fn has_non_empty_claim(profile: &Value, claim: &str) -> bool { + match profile.get(claim) { + Some(Value::String(value)) => !value.is_empty(), + Some(Value::Number(_)) => true, + _ => false, + } +} diff --git a/crates/rustauth-plugins/src/generic_oauth/provider.rs b/crates/rustauth-plugins/src/generic_oauth/provider.rs index 9f650b54..92ceb33d 100644 --- a/crates/rustauth-plugins/src/generic_oauth/provider.rs +++ b/crates/rustauth-plugins/src/generic_oauth/provider.rs @@ -84,6 +84,19 @@ impl GenericOAuthProvider { ) .await? } + GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo => { + match id_token::unverified_user_info(&tokens)? { + Some(user) => Some(user), + None => { + user_info::get_user_info( + &tokens, + self.config.user_info_url.as_deref(), + self.http_client()?, + ) + .await? + } + } + } } }; if let Some(map_profile) = &self.config.map_profile_to_user { diff --git a/crates/rustauth-plugins/tests/generic_oauth/common.rs b/crates/rustauth-plugins/tests/generic_oauth/common.rs index 56c29b92..1c205bec 100644 --- a/crates/rustauth-plugins/tests/generic_oauth/common.rs +++ b/crates/rustauth-plugins/tests/generic_oauth/common.rs @@ -81,6 +81,13 @@ pub(super) fn verified_id_token_config() -> GenericOAuthConfig { config } +pub(super) fn unverified_id_token_config() -> GenericOAuthConfig { + let mut config = example_config(); + config.user_info_url = None; + config.profile_source = GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo; + config +} + pub(super) fn provider( config: GenericOAuthConfig, ) -> rustauth_plugins::generic_oauth::GenericOAuthProvider { diff --git a/crates/rustauth-plugins/tests/generic_oauth/provider.rs b/crates/rustauth-plugins/tests/generic_oauth/provider.rs index 56b93773..37a0c174 100644 --- a/crates/rustauth-plugins/tests/generic_oauth/provider.rs +++ b/crates/rustauth-plugins/tests/generic_oauth/provider.rs @@ -228,6 +228,155 @@ async fn provider_ignores_unverified_id_token_claims_without_userinfo() -> Resul Ok(()) } +#[tokio::test] +async fn provider_unverified_id_token_mode_maps_unsigned_claims( +) -> Result<(), Box> { + let provider = provider(unverified_id_token_config()); + let Some(user) = provider + .get_user_info( + OAuth2Tokens { + id_token: Some(jwt_claims( + r#"{"sub":"forged-sub","email":"forged@example.com","name":"Forged","picture":"https://img.example.com/forged.png","email_verified":true}"#, + )), + ..OAuth2Tokens::default() + }, + None, + ) + .await? + else { + return Err("missing unverified user info".into()); + }; + + assert_eq!(user.id, "forged-sub"); + assert_eq!(user.email.as_deref(), Some("forged@example.com")); + assert_eq!(user.name.as_deref(), Some("Forged")); + assert_eq!( + user.image.as_deref(), + Some("https://img.example.com/forged.png") + ); + assert!(user.email_verified); + Ok(()) +} + +#[tokio::test] +async fn provider_unverified_id_token_mode_falls_back_to_userinfo_when_email_missing( +) -> Result<(), Box> { + let userinfo_request = Arc::new(Mutex::new(String::new())); + let user_info_url = capture_get_server( + Arc::clone(&userinfo_request), + r#"{"sub":"userinfo-sub","email":"userinfo@example.com","name":"User Info","email_verified":true}"#, + ); + let mut config = loopback_http_config(unverified_id_token_config()); + config.user_info_url = Some(user_info_url); + let provider = provider(config); + let Some(user) = provider + .get_user_info( + OAuth2Tokens { + access_token: Some("access-1".to_owned()), + id_token: Some(jwt_claims(r#"{"sub":"forged-sub","name":"Forged"}"#)), + ..OAuth2Tokens::default() + }, + None, + ) + .await? + else { + return Err("missing userinfo fallback user".into()); + }; + + assert_eq!(user.id, "userinfo-sub"); + assert_eq!(user.email.as_deref(), Some("userinfo@example.com")); + let userinfo_contains_authorization = userinfo_request + .lock() + .map(|request| request.contains("authorization: Bearer access-1")) + .unwrap_or(false); + assert!(userinfo_contains_authorization); + Ok(()) +} + +#[tokio::test] +async fn provider_unverified_id_token_mode_rejects_malformed_id_token( +) -> Result<(), Box> { + let provider = provider(unverified_id_token_config()); + let result = provider + .get_user_info( + OAuth2Tokens { + access_token: Some("access-1".to_owned()), + id_token: Some("not-a-jwt".to_owned()), + ..OAuth2Tokens::default() + }, + None, + ) + .await; + + assert!(result.is_err()); + Ok(()) +} + +#[tokio::test] +async fn provider_unverified_id_token_mode_still_applies_profile_mapper( +) -> Result<(), Box> { + let mut config = unverified_id_token_config(); + config.map_profile_to_user = Some(Arc::new(|mut profile: OAuth2UserInfo| { + Box::pin(async move { + profile.id = format!("mapped-{}", profile.id); + profile.email_verified = true; + Ok(profile) + }) + })); + let provider = provider(config); + let Some(user) = provider + .get_user_info( + OAuth2Tokens { + id_token: Some(jwt_claims( + r#"{"sub":"forged-sub","email":"forged@example.com","email_verified":false}"#, + )), + ..OAuth2Tokens::default() + }, + None, + ) + .await? + else { + return Err("missing mapped unverified user info".into()); + }; + + assert_eq!(user.id, "mapped-forged-sub"); + assert!(user.email_verified); + Ok(()) +} + +#[tokio::test] +async fn provider_unverified_id_token_mode_custom_get_user_info_takes_precedence( +) -> Result<(), Box> { + let mut config = unverified_id_token_config(); + config.get_user_info = Some(Arc::new(|_tokens| { + Box::pin(async { + Ok(Some(OAuth2UserInfo { + id: "custom-user".to_owned(), + name: Some("Custom User".to_owned()), + email: Some("custom@example.com".to_owned()), + image: None, + email_verified: true, + })) + }) + })); + let provider = provider(config); + let Some(user) = provider + .get_user_info( + OAuth2Tokens { + id_token: Some("not-a-jwt".to_owned()), + ..OAuth2Tokens::default() + }, + None, + ) + .await? + else { + return Err("missing custom user info".into()); + }; + + assert_eq!(user.id, "custom-user"); + Ok(()) +} + #[tokio::test] async fn provider_verified_id_token_maps_claims() -> Result<(), Box> { let nonce = "nonce-1"; diff --git a/crates/rustauth-plugins/tests/generic_oauth/routes.rs b/crates/rustauth-plugins/tests/generic_oauth/routes.rs index 618f8157..ce9a9d86 100644 --- a/crates/rustauth-plugins/tests/generic_oauth/routes.rs +++ b/crates/rustauth-plugins/tests/generic_oauth/routes.rs @@ -898,6 +898,72 @@ async fn oauth2_callback_verified_id_token_custom_get_user_info_still_works( Ok(()) } +#[tokio::test] +async fn oauth2_callback_unverified_id_token_mode_creates_user_without_userinfo( +) -> Result<(), Box> { + let (memory, context, response) = unverified_callback_response( + jwt_claims( + r#"{"sub":"forged-route-user","email":"forged@example.com","name":"Forged Route","picture":"https://img.example.com/forged-route.png","email_verified":true}"#, + ), + None, + ) + .await?; + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!(location(&response), Some("/dashboard")); + let user = DbUserStore::new(memory.as_ref()) + .find_user_by_email("forged@example.com") + .await? + .ok_or("missing unverified id token user")?; + assert_eq!(user.name, "Forged Route"); + assert!(DbUserStore::new(memory.as_ref()) + .find_account_by_provider_account("forged-route-user", "example") + .await? + .is_some()); + let token = session_token_from_response(&context, &response); + assert!(DbSessionStore::new(memory.as_ref()) + .find_session(&token) + .await? + .is_some()); + Ok(()) +} + +#[tokio::test] +async fn oauth2_callback_unverified_id_token_mode_falls_back_to_userinfo_when_email_missing( +) -> Result<(), Box> { + let user_info_url = capture_get_server( + Arc::new(Mutex::new(String::new())), + r#"{"sub":"userinfo-route-user","email":"userinfo@example.com","name":"User Info Route","email_verified":true}"#, + ); + let (memory, context, response) = unverified_callback_response( + jwt_claims(r#"{"sub":"forged-route-user","name":"Forged Route"}"#), + Some(user_info_url), + ) + .await?; + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!(location(&response), Some("/dashboard")); + assert!(DbUserStore::new(memory.as_ref()) + .find_user_by_email("forged@example.com") + .await? + .is_none()); + let user = DbUserStore::new(memory.as_ref()) + .find_user_by_email("userinfo@example.com") + .await? + .ok_or("missing userinfo fallback user")?; + assert_eq!(user.name, "User Info Route"); + assert!(DbUserStore::new(memory.as_ref()) + .find_account_by_provider_account("userinfo-route-user", "example") + .await? + .is_some()); + let token = session_token_from_response(&context, &response); + assert!(DbSessionStore::new(memory.as_ref()) + .find_session(&token) + .await? + .is_some()); + Ok(()) +} + async fn oidc_callback_response( token_for_nonce: F, ) -> Result<(Arc, AuthContext, Response>), Box> @@ -956,6 +1022,39 @@ fn oidc_route_config(jwks_url: String, id_token: Arc>>) -> config } +async fn unverified_callback_response( + id_token: String, + user_info_url: Option, +) -> Result<(Arc, AuthContext, Response>), Box> { + let memory = Arc::new(MemoryAdapter::new()); + let adapter = memory.clone() as Arc; + let mut config = loopback_http_config(unverified_id_token_config()); + config.user_info_url = user_info_url; + config.get_token = Some(Arc::new(move |_request| { + let id_token = id_token.clone(); + Box::pin(async move { + Ok(OAuth2Tokens { + access_token: Some("access-token".to_owned()), + id_token: Some(id_token), + ..OAuth2Tokens::default() + }) + }) + })); + let context = context_with_plugin(adapter, oauth_plugin(config)); + let router = AuthRouter::try_new(context.clone(), Vec::new())?; + let (sign_in, oauth_state_cookie) = + sign_in_url_with_oauth_cookie(&router, "example", "/dashboard", None, false).await?; + let state = query_value(&sign_in, "state").ok_or("missing state")?; + let response = oauth_callback( + &router, + "example", + "code-1", + &state_with_oauth_cookie(state, oauth_state_cookie), + ) + .await?; + Ok((memory, context, response)) +} + fn route_id_token_claims(nonce: &str) -> Value { serde_json::json!({ "iss": "https://idp.example.com", From 7ea78af5eb8a4972d732b2700750ef330f14844f Mon Sep 17 00:00:00 2001 From: sebastian Date: Fri, 3 Jul 2026 00:31:38 -0600 Subject: [PATCH 2/2] refactor(plugins): rename unverified id token fallback mode --- CHANGELOG.md | 6 -- crates/rustauth-plugins/README.md | 8 +-- crates/rustauth-plugins/UPSTREAM.md | 2 +- .../src/generic_oauth/config.rs | 11 +++- .../src/generic_oauth/provider.rs | 2 +- .../tests/generic_oauth/common.rs | 2 +- .../content/docs/plugins/generic-oauth.mdx | 56 +++++++++++++++++++ 7 files changed, 71 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcfd5bd..5e8673e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,6 @@ project follows [Semantic Versioning](https://semver.org/) while the API is stil ## [Unreleased] -### Added - -- Generic OAuth now offers explicit opt-in unverified ID-token profile - extraction for Better Auth parity while keeping userinfo as the secure - default. - ## [0.3.1](https://github.com/salasebas/rustauth/compare/v0.3.0...v0.3.1) - 2026-07-02 ### Changed diff --git a/crates/rustauth-plugins/README.md b/crates/rustauth-plugins/README.md index c98b2171..051569d1 100644 --- a/crates/rustauth-plugins/README.md +++ b/crates/rustauth-plugins/README.md @@ -169,10 +169,10 @@ when the token has multiple audiences before mapping claims. Existing For Better Auth parity with providers that rely on decode-only ID-token profile claims, callers can explicitly opt into -`GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo`. This mode decodes -the JWT payload without verifying the signature or issuer/audience claims, uses -it only when the decoded profile has both `sub` and `email`, and otherwise -falls back to `userinfo_url`. Prefer the verified OIDC source for new +`GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback`. This mode +decodes the JWT payload without verifying the signature or issuer/audience +claims, uses it only when the decoded profile has both `sub` and `email`, and +otherwise falls back to `userinfo_url`. Prefer the verified OIDC source for new integrations. ## Time units diff --git a/crates/rustauth-plugins/UPSTREAM.md b/crates/rustauth-plugins/UPSTREAM.md index 038cdc30..e1e0ffbb 100644 --- a/crates/rustauth-plugins/UPSTREAM.md +++ b/crates/rustauth-plugins/UPSTREAM.md @@ -70,7 +70,7 @@ Status symbols are defined in the [parity index](../../docs/parity/README.md#sta | Serializable metadata | Exposes callback-driven plugin options | Omits closure/callback fields, preserves observable values | Runtime callbacks are not serializable metadata. | | OAuth proxy payloads | Object transport | Rust-owned encrypted structs | Payload is RustAuth-to-RustAuth transport, not a public cross-implementation API. | | Generic OAuth HTTP | Baseline outbound fetch behavior | SSRF-guarded default HTTP transport | Auth boundary should fail closed for private/internal targets. | -| Generic OAuth ID-token profiles | Decodes ID-token claims before userinfo | Defaults to userinfo or verified `GenericOAuthProfileSource::VerifiedIdToken(...)`; `GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo` explicitly opts into decode-only parity | Keeps the default fail-closed while allowing maintainers to choose upstream-compatible unverified profile extraction. | +| Generic OAuth ID-token profiles | Decodes ID-token claims before userinfo | Defaults to userinfo or verified `GenericOAuthProfileSource::VerifiedIdToken(...)`; `GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback` explicitly opts into decode-only parity | Keeps the default fail-closed while allowing maintainers to choose upstream-compatible unverified profile extraction. | | API-key cache revalidation | Cache-first secondary storage | Optional DB revalidation for cache hits | Preserves compatibility by default while offering immediate revocation visibility. | | API-key pure secondary listing across processes | Cache-first `customStorage` get/set/delete index | Atomic `SecondaryStorage::compare_and_set` / `delete_if_value` index updates, plus database fallback/revalidation options | Rust storage backends can provide cross-process compare-and-set semantics without forcing database fallback. | | Additional fields helper | Core user/session additional fields | Dedicated Rust server plugin helper | Gives Rust callers a plugin-shaped way to contribute schema/runtime metadata. | diff --git a/crates/rustauth-plugins/src/generic_oauth/config.rs b/crates/rustauth-plugins/src/generic_oauth/config.rs index b69eabed..41f2fd59 100644 --- a/crates/rustauth-plugins/src/generic_oauth/config.rs +++ b/crates/rustauth-plugins/src/generic_oauth/config.rs @@ -60,10 +60,15 @@ pub struct GenericOAuthTokenRequest { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum GenericOAuthProfileSource { + /// Read profile data from a custom `get_user_info` hook or `userinfo_url`. #[default] UserInfo, + /// Verify and map OIDC `id_token` claims before trusting profile data. VerifiedIdToken(GenericOidcIdTokenProfile), - UnverifiedIdTokenThenUserInfo, + /// Decode `id_token` claims without verification, then fall back to `userinfo_url`. + /// + /// This is an insecure compatibility mode; prefer [`Self::VerifiedIdToken`] for OIDC. + UnverifiedIdTokenWithUserInfoFallback, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -312,8 +317,8 @@ impl GenericOAuthConfig { "profileSource": match self.profile_source { GenericOAuthProfileSource::UserInfo => "userInfo", GenericOAuthProfileSource::VerifiedIdToken(_) => "verifiedIdToken", - GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo => { - "unverifiedIdTokenThenUserInfo" + GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback => { + "unverifiedIdTokenWithUserInfoFallback" } }, }) diff --git a/crates/rustauth-plugins/src/generic_oauth/provider.rs b/crates/rustauth-plugins/src/generic_oauth/provider.rs index 92ceb33d..338fb77e 100644 --- a/crates/rustauth-plugins/src/generic_oauth/provider.rs +++ b/crates/rustauth-plugins/src/generic_oauth/provider.rs @@ -84,7 +84,7 @@ impl GenericOAuthProvider { ) .await? } - GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo => { + GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback => { match id_token::unverified_user_info(&tokens)? { Some(user) => Some(user), None => { diff --git a/crates/rustauth-plugins/tests/generic_oauth/common.rs b/crates/rustauth-plugins/tests/generic_oauth/common.rs index 1c205bec..6cdabc13 100644 --- a/crates/rustauth-plugins/tests/generic_oauth/common.rs +++ b/crates/rustauth-plugins/tests/generic_oauth/common.rs @@ -84,7 +84,7 @@ pub(super) fn verified_id_token_config() -> GenericOAuthConfig { pub(super) fn unverified_id_token_config() -> GenericOAuthConfig { let mut config = example_config(); config.user_info_url = None; - config.profile_source = GenericOAuthProfileSource::UnverifiedIdTokenThenUserInfo; + config.profile_source = GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback; config } diff --git a/docs-site/content/docs/plugins/generic-oauth.mdx b/docs-site/content/docs/plugins/generic-oauth.mdx index ce33844f..b3c74d99 100644 --- a/docs-site/content/docs/plugins/generic-oauth.mdx +++ b/docs-site/content/docs/plugins/generic-oauth.mdx @@ -61,10 +61,66 @@ Each **`GenericOAuthConfig`** entry: | `pkce` | PKCE settings | | `disable_implicit_sign_up` / `disable_sign_up` | Control auto-registration | | `authentication` | Token endpoint auth method | +| `profile_source` | Choose `userinfo_url`, verified ID-token claims, or explicit unverified fallback | | Custom hooks | `get_token`, `get_user_info`, `refresh_token`, `revoke_token`, `verify_id_token` | Issuer validation failures return `ISSUER_MISMATCH` or `ISSUER_MISSING`. +## OIDC profile claims + +Generic OAuth reads profile data from `userinfo_url` by default. It does not +trust `id_token` profile claims just because the token response includes one. + +For OIDC providers that return the profile only in `id_token`, use the verified +profile source: + +```rust +use rustauth::plugins::generic_oauth::{ + GenericOAuthConfig, GenericOAuthProfileSource, GenericOidcIdTokenProfile, +}; + +let mut config = GenericOAuthConfig::discovery( + "idp", + "client-id", + Some("client-secret"), + "https://idp.example.com/.well-known/openid-configuration", +); + +config.scopes = vec!["openid".into(), "email".into(), "profile".into()]; +config.profile_source = GenericOAuthProfileSource::VerifiedIdToken( + GenericOidcIdTokenProfile::new(), +); +``` + +With discovery, RustAuth fills the issuer and JWKS URL from the provider +metadata. Without discovery, configure both explicitly: + +```rust +config.profile_source = GenericOAuthProfileSource::VerifiedIdToken( + GenericOidcIdTokenProfile::new() + .issuer("https://idp.example.com") + .jwks_url("https://idp.example.com/.well-known/jwks.json"), +); +``` + +The verified path checks the JWKS signature, supported asymmetric algorithm, +issuer, client ID audience, expiration, subject, nonce, and authorized party +when a token has multiple audiences. RustAuth generates and validates the nonce +for the OAuth callback flow when this profile source is enabled. + +For legacy or Better Auth parity cases that depend on decode-only ID-token +claims, RustAuth also provides an explicit unsafe mode: + +```rust +config.profile_source = + GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback; +``` + +This mode decodes the JWT payload without verifying signature, issuer, audience, +or nonce. It uses the decoded profile only when both `sub` and `email` are +present, otherwise it falls back to `userinfo_url`. Prefer +`VerifiedIdToken(...)` for new integrations. + ## HTTP endpoints Clients call these routes under your configured `base_path`.