diff --git a/crates/rustauth-oauth-provider/src/token/introspection.rs b/crates/rustauth-oauth-provider/src/token/introspection.rs index cdffb746..d87dcc6b 100644 --- a/crates/rustauth-oauth-provider/src/token/introspection.rs +++ b/crates/rustauth-oauth-provider/src/token/introspection.rs @@ -13,6 +13,9 @@ pub(crate) async fn validate_access_token( .await? { let active = timestamp(&record, "expires_at").is_some_and(|expires| expires > now()); + if !active { + return Ok(Some(inactive_access_token())); + } let client_id = string(&record, "client_id"); if authenticated_client_id .is_some_and(|expected_client_id| client_id.as_deref() != Some(expected_client_id)) @@ -30,16 +33,19 @@ pub(crate) async fn validate_access_token( } _ => user_id.clone(), }; + let sid = active_session_id_for_claims(adapter, string(&record, "session_id")).await?; let mut claims = json!({ "active": active, "token_type": "access_token", "client_id": client_id, "sub": sub, - "sid": string(&record, "session_id"), "exp": timestamp(&record, "expires_at").map(OffsetDateTime::unix_timestamp), "iat": timestamp(&record, "created_at").map(OffsetDateTime::unix_timestamp), "scope": join_scope(&scopes), }); + if let (Value::Object(map), Some(sid)) = (&mut claims, sid) { + map.insert("sid".to_owned(), Value::String(sid)); + } if let Some(resolver) = &options.custom_access_token_claims { if let Value::Object(map) = &mut claims { let client = match client_id.as_deref() { @@ -50,17 +56,29 @@ pub(crate) async fn validate_access_token( Some(user_id) => find_user(adapter, user_id).await?, None => None, }; - map.extend( - resolver - .resolve(CustomAccessTokenClaimsInput { - user, - reference_id: string(&record, "reference_id"), - scopes: scopes.clone(), - resource: Vec::new(), - metadata: client.and_then(|client| client.metadata), - }) - .await?, - ); + let mut custom_claims = resolver + .resolve(CustomAccessTokenClaimsInput { + user, + reference_id: string(&record, "reference_id"), + scopes: scopes.clone(), + resource: Vec::new(), + metadata: client.and_then(|client| client.metadata), + }) + .await?; + custom_claims.retain(|claim, _| { + !matches!( + claim.as_str(), + "active" + | "token_type" + | "client_id" + | "sub" + | "sid" + | "exp" + | "iat" + | "scope" + ) + }); + map.extend(custom_claims); } } return Ok(Some(ValidatedAccessToken { @@ -100,6 +118,11 @@ pub(crate) async fn validate_access_token( }) { return Ok(Some(inactive_access_token())); } + let active_session_id = active_session_id_for_claims( + adapter, + claims.get("sid").and_then(Value::as_str).map(str::to_owned), + ) + .await?; let mut response = Value::Object(claims); if let Value::Object(map) = &mut response { map.insert("active".to_owned(), Value::Bool(true)); @@ -107,6 +130,9 @@ pub(crate) async fn validate_access_token( "token_type".to_owned(), Value::String("access_token".to_owned()), ); + if active_session_id.is_none() { + map.remove("sid"); + } if let Some(client_id) = &client_id { map.insert("client_id".to_owned(), Value::String(client_id.clone())); } @@ -194,21 +220,48 @@ async fn introspect_refresh_token( } let active = timestamp(&record, "revoked").is_none() && timestamp(&record, "expires_at").is_some_and(|expires| expires > now()); - return Ok(serde_json::json!({ + if !active { + return Ok(serde_json::json!({ "active": false })); + } + let sid = active_session_id_for_claims(adapter, string(&record, "session_id")).await?; + let mut response = serde_json::json!({ "active": active, "token_type": "refresh_token", "client_id": string(&record, "client_id"), "sub": string(&record, "user_id"), - "sid": string(&record, "session_id"), "exp": timestamp(&record, "expires_at").map(OffsetDateTime::unix_timestamp), "iat": timestamp(&record, "created_at").map(OffsetDateTime::unix_timestamp), "scope": string_array(&record, "scopes").map(|scopes| scopes.join(" ")), - })); + }); + if let (Value::Object(map), Some(sid)) = (&mut response, sid) { + map.insert("sid".to_owned(), Value::String(sid)); + } + return Ok(response); } } Ok(serde_json::json!({ "active": false })) } +async fn active_session_id_for_claims( + adapter: &dyn DbAdapter, + session_id: Option, +) -> Result, RustAuthError> { + let Some(session_id) = session_id else { + return Ok(None); + }; + let Some(session) = adapter + .find_one(find_by_string("session", "id", &session_id)) + .await? + else { + return Ok(None); + }; + if timestamp(&session, "expires_at").is_some_and(|expires_at| expires_at > now()) { + Ok(Some(session_id)) + } else { + Ok(None) + } +} + async fn stored_refresh_token_for_lookup( options: &ResolvedOAuthProviderOptions, token: &str, diff --git a/crates/rustauth-oauth-provider/tests/oauth_provider/tokens.rs b/crates/rustauth-oauth-provider/tests/oauth_provider/tokens.rs index 71a30d3a..a3a21c2b 100644 --- a/crates/rustauth-oauth-provider/tests/oauth_provider/tokens.rs +++ b/crates/rustauth-oauth-provider/tests/oauth_provider/tokens.rs @@ -1,4 +1,5 @@ use super::common::*; +use std::sync::atomic::{AtomicUsize, Ordering}; #[tokio::test] async fn token_endpoint_missing_grant_type_returns_unsupported_grant_type( @@ -282,6 +283,68 @@ async fn refresh_token_grant_rotates_and_revokes_previous_refresh_token( Ok(()) } +#[tokio::test] +async fn refresh_token_grant_allows_offline_access_after_sign_out( +) -> Result<(), Box> { + let adapter = adapter(); + seed_user_session(adapter.as_ref()).await?; + let cookie = signed_session_cookie("token_1")?; + let router = router( + oauth_provider(OAuthProviderOptions { + disable_jwt_plugin: true, + allow_dynamic_client_registration: true, + ..default_options() + })?, + Arc::clone(&adapter), + )?; + let client = register_client( + &router, + r#"{"redirect_uris":["https://rp.example/callback"],"scope":"openid offline_access","skip_consent":true}"#, + Some(&cookie), + ) + .await?; + let client_id = client["client_id"].as_str().ok_or("missing client_id")?; + let client_secret = client["client_secret"] + .as_str() + .ok_or("missing client_secret")?; + let first = exchange_authorization_code(&router, &cookie, client_id, client_secret).await?; + let refresh_token = first["refresh_token"] + .as_str() + .ok_or("missing refresh_token")?; + let sign_out = router + .handle_async(request( + Method::POST, + "/api/auth/sign-out", + "{}", + Some(&cookie), + )?) + .await?; + assert_eq!(sign_out.status(), StatusCode::OK); + let body = format!( + "grant_type=refresh_token&client_id={client_id}&client_secret={client_secret}&refresh_token={}", + query_encode(refresh_token) + ); + + let response = router + .handle_async(form_request(Method::POST, "/api/auth/oauth2/token", &body)?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let refreshed = json_body(response)?; + assert!(refreshed["access_token"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(refreshed["refresh_token"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert_ne!( + refreshed["refresh_token"].as_str(), + Some(refresh_token), + "refresh grant must rotate refresh tokens" + ); + assert_eq!(adapter.len("session").await, 0); + Ok(()) +} + #[tokio::test] async fn refresh_token_replay_revokes_refresh_token_family( ) -> Result<(), Box> { @@ -593,6 +656,298 @@ async fn introspect_and_revoke_are_bound_to_authenticated_client( Ok(()) } +#[tokio::test] +async fn introspection_keeps_tokens_active_after_sign_out_or_session_expiry_without_sid( +) -> Result<(), Box> { + let opaque_adapter = adapter(); + seed_user_session(opaque_adapter.as_ref()).await?; + let cookie = signed_session_cookie("token_1")?; + let opaque_router = router( + oauth_provider(OAuthProviderOptions { + disable_jwt_plugin: true, + allow_dynamic_client_registration: true, + ..default_options() + })?, + Arc::clone(&opaque_adapter), + )?; + let client = register_client( + &opaque_router, + r#"{"redirect_uris":["https://rp.example/callback"],"scope":"openid offline_access","skip_consent":true}"#, + Some(&cookie), + ) + .await?; + let client_id = client["client_id"].as_str().ok_or("missing client_id")?; + let client_secret = client["client_secret"] + .as_str() + .ok_or("missing client_secret")?; + let tokens = + exchange_authorization_code(&opaque_router, &cookie, client_id, client_secret).await?; + let access_token = tokens["access_token"] + .as_str() + .ok_or("missing access_token")?; + let refresh_token = tokens["refresh_token"] + .as_str() + .ok_or("missing refresh_token")?; + for (token, hint) in [ + (access_token, "access_token"), + (refresh_token, "refresh_token"), + ] { + let response = opaque_router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint={hint}&client_id={client_id}&client_secret={}", + query_encode(token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response)?; + assert_eq!(body["active"], true); + assert_eq!(body["sid"], "session_1"); + } + + let sign_out = opaque_router + .handle_async(request( + Method::POST, + "/api/auth/sign-out", + "{}", + Some(&cookie), + )?) + .await?; + assert_eq!(sign_out.status(), StatusCode::OK); + + for (token, hint) in [ + (access_token, "access_token"), + (refresh_token, "refresh_token"), + ] { + let response = opaque_router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint={hint}&client_id={client_id}&client_secret={}", + query_encode(token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response)?; + assert_eq!(body["active"], true); + assert!(body.get("sid").is_none()); + } + + let adapter = adapter(); + seed_user_session(adapter.as_ref()).await?; + let cookie = signed_session_cookie("token_1")?; + let router = router( + oauth_provider(OAuthProviderOptions { + allow_dynamic_client_registration: true, + valid_audiences: vec!["https://api.example.com".to_owned()], + ..default_options() + })?, + Arc::clone(&adapter), + )?; + let client = register_client( + &router, + r#"{"redirect_uris":["https://rp.example/callback"],"scope":"openid offline_access","skip_consent":true}"#, + Some(&cookie), + ) + .await?; + let client_id = client["client_id"].as_str().ok_or("missing client_id")?; + let client_secret = client["client_secret"] + .as_str() + .ok_or("missing client_secret")?; + let tokens = exchange_authorization_code_with_resource( + &router, + &cookie, + client_id, + client_secret, + Some("https://api.example.com"), + ) + .await?; + let access_token = tokens["access_token"] + .as_str() + .ok_or("missing access_token")?; + + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=access_token&client_id={client_id}&client_secret={}", + query_encode(access_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response)?; + assert_eq!(body["active"], true); + assert_eq!(body["sid"], "session_1"); + + adapter + .update( + Update::new("session") + .where_clause(Where::new("id", DbValue::String("session_1".to_owned()))) + .data( + "expires_at", + DbValue::Timestamp(OffsetDateTime::now_utc() - Duration::seconds(1)), + ), + ) + .await?; + + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=access_token&client_id={client_id}&client_secret={}", + query_encode(access_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response)?; + assert_eq!(body["active"], true); + assert!(body.get("sid").is_none()); + Ok(()) +} + +#[tokio::test] +async fn inactive_token_introspection_returns_only_active_false( +) -> Result<(), Box> { + let adapter = adapter(); + seed_user_session(adapter.as_ref()).await?; + let cookie = signed_session_cookie("token_1")?; + let resolver_calls = Arc::new(AtomicUsize::new(0)); + let resolver_calls_for_callback = Arc::clone(&resolver_calls); + let router = router( + oauth_provider(OAuthProviderOptions { + disable_jwt_plugin: true, + allow_dynamic_client_registration: true, + custom_access_token_claims: Some(CustomAccessTokenClaimsResolver::new(move |_| { + let resolver_calls = Arc::clone(&resolver_calls_for_callback); + async move { + resolver_calls.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::Map::new()) + } + })), + ..default_options() + })?, + Arc::clone(&adapter), + )?; + let client = register_client( + &router, + r#"{"redirect_uris":["https://rp.example/callback"],"scope":"openid offline_access","skip_consent":true}"#, + Some(&cookie), + ) + .await?; + let client_id = client["client_id"].as_str().ok_or("missing client_id")?; + let client_secret = client["client_secret"] + .as_str() + .ok_or("missing client_secret")?; + let tokens = exchange_authorization_code(&router, &cookie, client_id, client_secret).await?; + let access_token = tokens["access_token"] + .as_str() + .ok_or("missing access_token")?; + let refresh_token = tokens["refresh_token"] + .as_str() + .ok_or("missing refresh_token")?; + let resolver_calls_before_introspection = resolver_calls.load(Ordering::SeqCst); + + adapter + .update( + Update::new("oauth_access_token") + .where_clause(Where::new( + "client_id", + DbValue::String(client_id.to_owned()), + )) + .data( + "expires_at", + DbValue::Timestamp(OffsetDateTime::now_utc() - Duration::seconds(1)), + ), + ) + .await?; + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=access_token&client_id={client_id}&client_secret={}", + query_encode(access_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response)?, json!({ "active": false })); + assert_eq!( + resolver_calls.load(Ordering::SeqCst), + resolver_calls_before_introspection + ); + + adapter + .update( + Update::new("oauth_refresh_token") + .where_clause(Where::new( + "client_id", + DbValue::String(client_id.to_owned()), + )) + .data( + "expires_at", + DbValue::Timestamp(OffsetDateTime::now_utc() - Duration::seconds(1)), + ), + ) + .await?; + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=refresh_token&client_id={client_id}&client_secret={}", + query_encode(refresh_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response)?, json!({ "active": false })); + + adapter + .update( + Update::new("oauth_refresh_token") + .where_clause(Where::new( + "client_id", + DbValue::String(client_id.to_owned()), + )) + .data( + "expires_at", + DbValue::Timestamp(OffsetDateTime::now_utc() + Duration::hours(1)), + ) + .data("revoked", DbValue::Timestamp(OffsetDateTime::now_utc())), + ) + .await?; + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=refresh_token&client_id={client_id}&client_secret={}", + query_encode(refresh_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response)?, json!({ "active": false })); + Ok(()) +} + #[tokio::test] async fn introspect_and_revoke_respect_token_type_hint() -> Result<(), Box> { let adapter = adapter(); @@ -1054,6 +1409,109 @@ async fn custom_access_and_userinfo_claims_are_added() -> Result<(), Box Result<(), Box> { + let adapter = adapter(); + seed_user_session(adapter.as_ref()).await?; + let cookie = signed_session_cookie("token_1")?; + let router = router( + oauth_provider(OAuthProviderOptions { + disable_jwt_plugin: true, + allow_dynamic_client_registration: true, + custom_access_token_claims: Some(CustomAccessTokenClaimsResolver::new(|_| async { + Ok(serde_json::Map::from_iter([ + ("active".to_owned(), json!(false)), + ("token_type".to_owned(), json!("custom")), + ("client_id".to_owned(), json!("custom_client")), + ("sub".to_owned(), json!("custom_user")), + ("sid".to_owned(), json!("session_1")), + ("exp".to_owned(), json!(0)), + ("iat".to_owned(), json!(0)), + ("scope".to_owned(), json!("custom:scope")), + ( + "https://example.com/role".to_owned(), + json!("administrator"), + ), + ])) + })), + ..default_options() + })?, + adapter, + )?; + let client = register_client( + &router, + r#"{"redirect_uris":["https://rp.example/callback"],"scope":"openid offline_access","skip_consent":true}"#, + Some(&cookie), + ) + .await?; + let client_id = client["client_id"].as_str().ok_or("missing client_id")?; + let client_secret = client["client_secret"] + .as_str() + .ok_or("missing client_secret")?; + let tokens = exchange_authorization_code(&router, &cookie, client_id, client_secret).await?; + let access_token = tokens["access_token"] + .as_str() + .ok_or("missing access_token")?; + + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=access_token&client_id={client_id}&client_secret={}", + query_encode(access_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response)?; + assert_eq!(body["active"], true); + assert_eq!(body["token_type"], "access_token"); + assert_eq!(body["client_id"], client_id); + assert_eq!(body["sub"], "user_1"); + assert_eq!(body["sid"], "session_1"); + assert_eq!(body["scope"], "openid offline_access"); + assert!(body["exp"].as_i64().is_some_and(|value| value > 0)); + assert!(body["iat"].as_i64().is_some_and(|value| value > 0)); + assert_eq!(body["https://example.com/role"], "administrator"); + + let sign_out = router + .handle_async(request( + Method::POST, + "/api/auth/sign-out", + "{}", + Some(&cookie), + )?) + .await?; + assert_eq!(sign_out.status(), StatusCode::OK); + + let response = router + .handle_async(form_request( + Method::POST, + "/api/auth/oauth2/introspect", + &format!( + "token={}&token_type_hint=access_token&client_id={client_id}&client_secret={}", + query_encode(access_token), + query_encode(client_secret) + ), + )?) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response)?; + assert_eq!(body["active"], true); + assert_eq!(body["token_type"], "access_token"); + assert_eq!(body["client_id"], client_id); + assert_eq!(body["sub"], "user_1"); + assert!(body.get("sid").is_none()); + assert_eq!(body["scope"], "openid offline_access"); + assert!(body["exp"].as_i64().is_some_and(|value| value > 0)); + assert!(body["iat"].as_i64().is_some_and(|value| value > 0)); + assert_eq!(body["https://example.com/role"], "administrator"); + Ok(()) +} + #[tokio::test] async fn userinfo_returns_claims_by_explicit_openid_profile_and_email_scopes( ) -> Result<(), Box> { diff --git a/crates/rustauth-oauth-provider/tests/upstream_mapping.md b/crates/rustauth-oauth-provider/tests/upstream_mapping.md index b4476c49..23e80760 100644 --- a/crates/rustauth-oauth-provider/tests/upstream_mapping.md +++ b/crates/rustauth-oauth-provider/tests/upstream_mapping.md @@ -14,21 +14,21 @@ Reference: `reference/upstream-src/1.6.9/repository/packages/oauth-provider`. | Client credentials grant | `client_credentials_token_returns_bearer_token_and_persists_opaque_token`, `client_credentials_uses_default_scopes_when_client_has_no_scopes`, `token_endpoint_rejects_expired_client_secret` | Covers opaque access token persistence, upstream `clientCredentialGrantDefaultScopes` fallback for DB clients with no stored scopes, and expired confidential client secret rejection. | | Authorization code grant | `authorization_code_flow_issues_access_and_refresh_tokens` | Covers authorize redirect, code exchange, access token, refresh token. | | PKCE | `authorization_code_flow_enforces_pkce_s256_for_public_clients`, `authorization_code_flow_enforces_upstream_pkce_policy_for_confidential_clients` | Public clients require S256 PKCE. Confidential clients require PKCE by default, may opt out with `require_pkce=false`, and must still use PKCE for `offline_access`. Plain PKCE and partial PKCE parameters are rejected. | -| Refresh rotation | `refresh_token_grant_rotates_and_revokes_previous_refresh_token` | Covers refresh token rotation and revoked timestamp. | +| Refresh rotation | `refresh_token_grant_rotates_and_revokes_previous_refresh_token`, `refresh_token_grant_allows_offline_access_after_sign_out` | Covers refresh token rotation, revoked timestamp, and offline refresh-token grant usability after sign-out for `offline_access` parity. | | JWT/JWKS id_token | `openid_authorization_code_issues_signed_id_token_and_jwks` | Uses `rustauth-plugins::jwt` when enabled. | | JWT access token resource audience | `resource_parameter_issues_jwt_access_token_with_oauth_claims`, `resource_array_issues_jwt_access_token_with_multiple_audiences`, `resource_form_repeated_issues_multi_audience_and_invalid_json_resource_is_rejected`, `resource_parameter_rejects_unconfigured_audience` | Covers single, JSON array, and repeated form `resource`, `aud`, `azp`, `scope`, invalid JSON resource shape, and invalid audience rejection. | | Pairwise subjects | `pairwise_subject_is_stable_by_sector_and_used_for_userinfo_and_introspection`, `pairwise_registration_requires_single_redirect_sector` | Covers stable per-sector subject, different subjects across sectors, and same-sector DCR validation including port. | | Prompt handling | `authorize_prompt_none_*`, `authorize_prompt_none_returns_account_selection_required_when_needed`, `authorize_prompt_none_returns_interaction_required_for_post_login`, `authorize_json_accept_returns_redirect_payload_instead_of_302`, `authorize_success_redirect_includes_iss_parameter`, … | `prompt=none`, `shouldRedirect` errors, SPA JSON redirect, RFC 9207 `iss`, continue flow. | | Request URI / PAR resolver | `authorize_resolves_request_uri_parameters`, `authorize_rejects_unallowed_scope_and_request_uri_client_mismatch` | Supports upstream-style `request_uri` resolution before authorize validation, rejects client mismatch, and validates authorize scopes. No pushed authorization endpoint is added. | | Consent persistence | `consent_helpers_persist_update_delete_and_match_scopes`, `consent_endpoint_accepts_rejects_and_continue_without_flag_is_rejected`, `consent_endpoint_accepts_subset_and_rejects_unrequested_scope`, `consent_management_endpoints_enforce_owner_session`, `update_consent_rejects_scopes_not_allowed_for_client`, `update_consent_without_scopes_preserves_existing_scopes` | Covers grant scope matching, reference-aware upsert/delete, accept, reject, narrowed accepted scopes, unrequested consent scope rejection without consuming the pending request, continue-without-flag rejection, ownership, allowed scope validation, and partial update preservation. | -| Introspection | `introspect_and_revoke_require_valid_client_authentication`, `introspect_and_revoke_respect_token_type_hint`, `pairwise_subject_is_stable_by_sector_and_used_for_userinfo_and_introspection` | Requires valid client auth, respects `token_type_hint`, and returns pairwise `sub` for opaque tokens. | +| Introspection | `introspect_and_revoke_require_valid_client_authentication`, `introspect_and_revoke_respect_token_type_hint`, `pairwise_subject_is_stable_by_sector_and_used_for_userinfo_and_introspection`, `introspection_keeps_tokens_active_after_sign_out_or_session_expiry_without_sid`, `inactive_token_introspection_returns_only_active_false` | Requires valid client auth, respects `token_type_hint`, returns pairwise `sub` for opaque tokens, keeps tokens active after sign-out or web-session expiry while omitting stale `sid`, and returns only `active: false` for expired or revoked tokens. | | Revocation | `introspect_and_revoke_*`, `revoke_endpoint_returns_empty_body_on_success` | Client auth, token_type_hint, empty body on success. | | Token HTTP | `token_endpoint_sets_no_store_cache_headers`, `refresh_token_grant_rejects_scope_not_in_original_grant` | `no-store` / `no-cache`; refresh scope narrowing. | | Userinfo | `userinfo_returns_claims_by_explicit_openid_profile_and_email_scopes`, `pairwise_subject_is_stable_by_sector_and_used_for_userinfo_and_introspection` | `given_name`/`family_name` with `profile`; pairwise `sub`. | | RP-initiated logout | `rp_initiated_logout_rejects_invalid_id_token_hint`, `rp_initiated_logout_deletes_session_and_redirects_to_registered_uri` | Covers `id_token_hint`, session deletion, registered logout redirect, and state. | | Client ownership/admin guardrails | `client_management_endpoints_reject_cross_user_ownership`, `client_reference_owns_clients_and_flows_into_tokens`, `client_privileges_can_deny_client_crud_actions`, `cached_trusted_clients_reject_manual_update_delete_and_rotate`, `cached_trusted_clients_reuse_cached_db_client_on_later_reads`, `rotate_secret_rejects_public_clients`, `update_client_preserves_omitted_fields`, `update_client_rejects_invalid_scope`, `update_client_rejects_token_auth_method_changes` | User-facing client management endpoints reject cross-user access, support reference-owned clients, enforce `client_privileges`, reject manual mutation of `cached_trusted_clients`, cache trusted DB clients in-memory for later reads, preserve partial updates, validate merged metadata, keep auth method immutable, and prevent rotating secrets for public clients. | | Public prelogin | `public_client_prelogin_requires_allow_flag_and_signed_oauth_query` | Requires `allow_public_client_prelogin` and validates upstream-style signed `oauth_query` before exposing public client metadata. | -| Custom token hooks | `custom_id_token_claims_and_token_response_fields_are_added`, `custom_access_and_userinfo_claims_are_added` | Covers custom ID token, JWT/opaque access token introspection, userinfo claims, and custom token response fields while preventing standard token response field overrides. | +| Custom token hooks | `custom_id_token_claims_and_token_response_fields_are_added`, `custom_access_and_userinfo_claims_are_added`, `custom_access_claims_cannot_override_reserved_introspection_claims` | Covers custom ID token, JWT/opaque access token introspection, userinfo claims, and custom token response fields while preventing standard token response and introspection claim overrides. | | Scope/token customization | `scope_expirations_use_shortest_matching_scope`, `prefixes_and_custom_generators_are_applied_without_storing_prefixes`, `format_refresh_token_wraps_returned_token_and_decodes_refresh_grant`, `custom_store_hash_callbacks_are_used_for_client_secrets_and_tokens` | Covers scope-specific access token lifetime, generated client IDs/secrets/access/refresh tokens, public prefixes, custom refresh-token formatting/decoding, and custom hash callbacks for client secrets and token storage. | | MCP server-side helpers | `mcp_helpers_return_metadata_challenge_and_validate_bearer_tokens` | Covers metadata, challenge header value, active bearer validation, and inactive invalid tokens. | | Query serialization | `authorize_prompt_none_returns_login_required_without_session`, `authorize_prompt_none_returns_consent_required_without_grant`, `consent_endpoint_accepts_rejects_and_continue_without_flag_is_rejected`, `continue_requires_matching_prompt_flag_and_rechecks_consent` | Rust tests assert state preservation through redirects and stored verification state rather than porting TS `URLSearchParams` helper directly. |