Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/rustauth-plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::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

Public plugin and core option timeouts use [`time::Duration`](https://docs.rs/time/latest/time/struct.Duration.html).
Expand Down
2 changes: 1 addition & 1 deletion crates/rustauth-plugins/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::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. |
Expand Down
9 changes: 9 additions & 0 deletions crates/rustauth-plugins/src/generic_oauth/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +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),
/// 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)]
Expand Down Expand Up @@ -311,6 +317,9 @@ impl GenericOAuthConfig {
"profileSource": match self.profile_source {
GenericOAuthProfileSource::UserInfo => "userInfo",
GenericOAuthProfileSource::VerifiedIdToken(_) => "verifiedIdToken",
GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback => {
"unverifiedIdTokenWithUserInfoFallback"
}
},
})
}
Expand Down
42 changes: 42 additions & 0 deletions crates/rustauth-plugins/src/generic_oauth/id_token.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Option<OAuth2UserInfo>, 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,
Expand Down Expand Up @@ -105,3 +123,27 @@ fn distinct_audience_count(audience: Option<&Value>) -> usize {
.collect::<BTreeSet<_>>()
.len()
}

fn decode_unverified_jwt_payload(token: &str) -> Result<Value, OAuthError> {
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,
}
}
13 changes: 13 additions & 0 deletions crates/rustauth-plugins/src/generic_oauth/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ impl GenericOAuthProvider {
)
.await?
}
GenericOAuthProfileSource::UnverifiedIdTokenWithUserInfoFallback => {
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 {
Expand Down
7 changes: 7 additions & 0 deletions crates/rustauth-plugins/tests/generic_oauth/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::UnverifiedIdTokenWithUserInfoFallback;
config
}

pub(super) fn provider(
config: GenericOAuthConfig,
) -> rustauth_plugins::generic_oauth::GenericOAuthProvider {
Expand Down
149 changes: 149 additions & 0 deletions crates/rustauth-plugins/tests/generic_oauth/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let nonce = "nonce-1";
Expand Down
Loading
Loading