Skip to main content

rustauth_oauth_provider/
lib.rs

1//! OAuth 2.1 and OpenID Connect provider support for RustAuth.
2//!
3//! This crate ports the server-side Better Auth `oauth-provider` behavior into
4//! idiomatic Rust. It is intentionally separate from `rustauth-oauth`, which
5//! contains OAuth client and social-provider primitives.
6
7mod authorize;
8mod client;
9mod consent;
10mod endpoints;
11mod error;
12mod metadata;
13mod models;
14mod options;
15mod schema;
16mod token;
17mod utils;
18
19pub mod mcp;
20
21pub use error::OAuthProviderError;
22pub use options::{
23    ClientPrivilegeAction, ClientPrivilegesInput, ClientPrivilegesResolver, ClientReferenceInput,
24    ClientReferenceResolver, ClientSecretHashInput, ClientSecretHashResolver,
25    ClientSecretVerifyInput, ClientSecretVerifyResolver, CustomAccessTokenClaimsInput,
26    CustomAccessTokenClaimsResolver, CustomIdTokenClaimsInput, CustomIdTokenClaimsResolver,
27    CustomTokenResponseFieldsInput, CustomTokenResponseFieldsResolver, CustomUserInfoClaimsInput,
28    CustomUserInfoClaimsResolver, GrantType, McpMetadataOverrides, McpOptions,
29    OAuthProviderConfigError, OAuthProviderOptions, OAuthProviderRateLimit,
30    OAuthProviderRateLimits, OAuthTokenPrefixes, PromptRedirectInput, PromptRedirectResolver,
31    PromptShouldRedirectResolver, RefreshTokenFormatDecodeOutput, RefreshTokenFormatEncodeInput,
32    RefreshTokenFormatter, RequestUriResolver, RequestUriResolverInput, ResolvedMcpOptions,
33    ResolvedOAuthProviderOptions, SecretStorage, StringGeneratorResolver, TokenEndpointAuthMethod,
34    TokenHashInput, TokenHashResolver, TrustedClientCache,
35};
36pub use rustauth_plugins::jwt::JwtOptions;
37
38#[cfg(feature = "test-util")]
39pub use authorize::{decide_authorize, AuthorizeDecision};
40#[cfg(feature = "test-util")]
41pub use client::{
42    check_oauth_client, oauth_to_schema, schema_to_oauth, CreateOAuthClientInput, OAuthClient,
43};
44#[cfg(feature = "test-util")]
45pub use consent::{
46    delete_consent, find_consent, has_granted_scopes, upsert_consent, ConsentGrantInput,
47};
48#[cfg(feature = "test-util")]
49pub use metadata::{
50    auth_server_metadata, oauth_authorization_server_metadata, oidc_server_metadata,
51    well_known_metadata_response, WELL_KNOWN_METADATA_CACHE_CONTROL,
52};
53#[cfg(feature = "test-util")]
54pub use models::{OAuthAccessToken, OAuthConsent, OAuthRefreshToken, SchemaClient};
55#[cfg(feature = "test-util")]
56pub use schema::{
57    oauth_provider_schema, OAUTH_ACCESS_TOKEN_MODEL, OAUTH_CLIENT_MODEL, OAUTH_CONSENT_MODEL,
58    OAUTH_REFRESH_TOKEN_MODEL,
59};
60#[cfg(feature = "test-util")]
61pub use token::{
62    create_client_credentials_token, decode_refresh_token, store_client_secret, store_token,
63    verify_client_secret, TokenResponse,
64};
65
66#[cfg(feature = "mcp-client")]
67pub use mcp::client::{McpAuthClient, McpAuthClientOptions, McpSession};
68
69use std::collections::HashSet;
70use std::sync::Arc;
71
72use rustauth_core::error::RustAuthError;
73use rustauth_core::options::RateLimitRule;
74use rustauth_core::plugin::{AuthPlugin, PluginInitOutput, PluginRateLimitRule};
75
76/// Current crate version.
77pub const VERSION: &str = env!("CARGO_PKG_VERSION");
78
79/// Build the OAuth provider plugin.
80pub fn oauth_provider(
81    options: OAuthProviderOptions,
82) -> Result<AuthPlugin, OAuthProviderConfigError> {
83    let resolved = resolve_options(options)?;
84    let mut auth_plugin = AuthPlugin::new("oauth-provider").with_version(VERSION);
85    let shared = Arc::new(resolved);
86    let shared_for_init = Arc::clone(&shared);
87
88    auth_plugin = auth_plugin.with_init(move |context| {
89        if shared_for_init.disable_jwt_plugin {
90            return Ok(PluginInitOutput::default());
91        }
92        if !context.has_plugin("jwt") {
93            return Err(RustAuthError::InvalidConfig(
94                "oauth-provider requires the jwt plugin when disable_jwt_plugin is false; \
95                 register jwt(...) in plugins before oauth_provider(...)"
96                    .to_owned(),
97            ));
98        }
99        Ok(PluginInitOutput::default())
100    });
101
102    for contribution in schema::oauth_provider_schema() {
103        auth_plugin = auth_plugin.with_schema(contribution);
104    }
105    for endpoint in endpoints::oauth_provider_endpoints(Arc::clone(&shared)) {
106        auth_plugin = auth_plugin.with_endpoint(endpoint);
107    }
108    for rule in rate_limit_rules(&shared.rate_limits) {
109        auth_plugin = auth_plugin.with_rate_limit(rule);
110    }
111
112    Ok(auth_plugin)
113}
114
115/// Apply JWT-derived metadata defaults to resolved OAuth provider options.
116pub fn apply_jwt_metadata_defaults(resolved: &mut ResolvedOAuthProviderOptions, jwt: &JwtOptions) {
117    if resolved.advertised_jwks_uri.is_none() {
118        resolved.advertised_jwks_uri = jwt.jwks.remote_url.clone();
119    }
120    if resolved.advertised_id_token_signing_algorithms.is_empty() {
121        resolved.advertised_id_token_signing_algorithms.push(
122            jwt.jwks
123                .key_pair_algorithm
124                .unwrap_or(rustauth_plugins::jwt::JwkAlgorithm::EdDsa)
125                .as_str()
126                .to_owned(),
127        );
128    }
129    if resolved.jwks_path == "/jwks" && jwt.jwks.jwks_path != "/jwks" {
130        resolved.jwks_path = jwt.jwks.jwks_path.clone();
131    }
132}
133
134pub(crate) fn resolve_options(
135    options: OAuthProviderOptions,
136) -> Result<ResolvedOAuthProviderOptions, OAuthProviderConfigError> {
137    if options.login_page.is_empty() {
138        return Err(OAuthProviderConfigError::MissingLoginPage);
139    }
140    if options.consent_page.is_empty() {
141        return Err(OAuthProviderConfigError::MissingConsentPage);
142    }
143
144    let scopes = non_empty_or_default(
145        options.scopes,
146        ["openid", "profile", "email", "offline_access"],
147    );
148    let scope_set: HashSet<&str> = scopes.iter().map(String::as_str).collect();
149
150    let client_registration_default_scopes = options.client_registration_default_scopes;
151    let client_registration_allowed_scopes = merge_allowed_scopes(
152        options.client_registration_allowed_scopes,
153        &client_registration_default_scopes,
154    );
155    for scope in &client_registration_allowed_scopes {
156        if !scope_set.contains(scope.as_str()) {
157            return Err(OAuthProviderConfigError::UnknownClientRegistrationScope(
158                scope.clone(),
159            ));
160        }
161    }
162    for scope in &options.advertised_scopes_supported {
163        if !scope_set.contains(scope.as_str()) {
164            return Err(OAuthProviderConfigError::UnknownAdvertisedScope(
165                scope.clone(),
166            ));
167        }
168    }
169    for scope in &options.client_credential_grant_default_scopes {
170        if !scope_set.contains(scope.as_str()) {
171            return Err(OAuthProviderConfigError::UnknownClientCredentialGrantScope(
172                scope.clone(),
173            ));
174        }
175    }
176    if options
177        .pairwise_secret
178        .as_ref()
179        .is_some_and(|secret| secret.len() < 32)
180    {
181        return Err(OAuthProviderConfigError::PairwiseSecretTooShort);
182    }
183
184    let grant_types = if options.grant_types.is_empty() {
185        vec![
186            GrantType::AuthorizationCode,
187            GrantType::ClientCredentials,
188            GrantType::RefreshToken,
189        ]
190    } else {
191        options.grant_types
192    };
193    if grant_types.contains(&GrantType::RefreshToken)
194        && !grant_types.contains(&GrantType::AuthorizationCode)
195    {
196        return Err(OAuthProviderConfigError::RefreshTokenRequiresAuthorizationCode);
197    }
198
199    let mcp = resolve_mcp_options(options.mcp)?;
200    let store_client_secret =
201        resolve_client_secret_storage(options.store_client_secret, options.disable_jwt_plugin)?;
202    Ok(ResolvedOAuthProviderOptions {
203        claims: claims_for_scopes(&scope_set),
204        scopes,
205        client_registration_allowed_scopes,
206        grant_types,
207        login_page: options.login_page,
208        consent_page: options.consent_page,
209        signup_page: options.signup_page,
210        select_account_page: options.select_account_page,
211        post_login_page: options.post_login_page,
212        signup_redirect: options.signup_redirect,
213        select_account_redirect: options.select_account_redirect,
214        post_login_redirect: options.post_login_redirect,
215        signup_should_redirect: options.signup_should_redirect,
216        select_account_should_redirect: options.select_account_should_redirect,
217        post_login_should_redirect: options.post_login_should_redirect,
218        consent_reference_id: options.consent_reference_id,
219        code_expires_in: options.code_expires_in,
220        access_token_expires_in: options.access_token_expires_in,
221        m2m_access_token_expires_in: options.m2m_access_token_expires_in,
222        id_token_expires_in: options.id_token_expires_in,
223        refresh_token_expires_in: options.refresh_token_expires_in,
224        client_credential_grant_default_scopes: options.client_credential_grant_default_scopes,
225        scope_expirations: options.scope_expirations,
226        client_registration_default_scopes,
227        client_registration_client_secret_expiration: options
228            .client_registration_client_secret_expiration,
229        allow_unauthenticated_client_registration: options
230            .allow_unauthenticated_client_registration,
231        allow_dynamic_client_registration: options.allow_dynamic_client_registration,
232        allow_public_client_prelogin: options.allow_public_client_prelogin,
233        cached_trusted_clients: options.cached_trusted_clients,
234        trusted_client_cache: TrustedClientCache::default(),
235        client_reference: options.client_reference,
236        client_privileges: options.client_privileges,
237        custom_access_token_claims: options.custom_access_token_claims,
238        custom_id_token_claims: options.custom_id_token_claims,
239        custom_token_response_fields: options.custom_token_response_fields,
240        custom_userinfo_claims: options.custom_userinfo_claims,
241        request_uri_resolver: options.request_uri_resolver,
242        prefixes: options.prefixes,
243        generate_client_id: options.generate_client_id,
244        generate_client_secret: options.generate_client_secret,
245        generate_opaque_access_token: options.generate_opaque_access_token,
246        generate_refresh_token: options.generate_refresh_token,
247        format_refresh_token: options.format_refresh_token,
248        disable_jwt_plugin: options.disable_jwt_plugin,
249        store_client_secret,
250        store_tokens: options.store_tokens,
251        hash_client_secret: options.hash_client_secret,
252        verify_client_secret_hash: options.verify_client_secret_hash,
253        hash_token: options.hash_token,
254        pairwise_secret: options.pairwise_secret,
255        advertised_scopes_supported: options.advertised_scopes_supported,
256        advertised_claims_supported: options.advertised_claims_supported,
257        advertised_jwks_uri: options.advertised_jwks_uri,
258        advertised_id_token_signing_algorithms: options.advertised_id_token_signing_algorithms,
259        jwks_path: options.jwks_path,
260        valid_audiences: options.valid_audiences,
261        rate_limits: options.rate_limits,
262        mcp,
263    })
264}
265
266/// Resolve and validate OAuth provider options without building the plugin.
267pub fn resolve_oauth_provider_options(
268    options: OAuthProviderOptions,
269) -> Result<ResolvedOAuthProviderOptions, OAuthProviderConfigError> {
270    resolve_options(options)
271}
272
273fn resolve_mcp_options(
274    options: Option<McpOptions>,
275) -> Result<Option<ResolvedMcpOptions>, OAuthProviderConfigError> {
276    options
277        .map(|options| {
278            if let Some(resource) = &options.resource {
279                let parsed = url::Url::parse(resource)
280                    .map_err(|_| OAuthProviderConfigError::InvalidMcpResource)?;
281                if !parsed.has_host() {
282                    return Err(OAuthProviderConfigError::InvalidMcpResource);
283                }
284            }
285            Ok(ResolvedMcpOptions {
286                resource: options.resource,
287                metadata: options.metadata,
288            })
289        })
290        .transpose()
291}
292
293fn non_empty_or_default<const N: usize>(values: Vec<String>, default: [&str; N]) -> Vec<String> {
294    let values: Vec<String> = values
295        .into_iter()
296        .filter(|value| !value.is_empty())
297        .collect();
298    if values.is_empty() {
299        default.into_iter().map(str::to_owned).collect()
300    } else {
301        values
302    }
303}
304
305fn merge_allowed_scopes(mut allowed: Vec<String>, default_scopes: &[String]) -> Vec<String> {
306    if default_scopes.is_empty() {
307        return allowed;
308    }
309    for scope in default_scopes {
310        if !allowed.contains(scope) {
311            allowed.push(scope.clone());
312        }
313    }
314    allowed
315}
316
317fn claims_for_scopes(scopes: &HashSet<&str>) -> Vec<String> {
318    let mut claims = vec![
319        "sub".to_owned(),
320        "iss".to_owned(),
321        "aud".to_owned(),
322        "exp".to_owned(),
323        "iat".to_owned(),
324        "sid".to_owned(),
325        "scope".to_owned(),
326        "azp".to_owned(),
327    ];
328    if scopes.contains("email") {
329        claims.push("email".to_owned());
330        claims.push("email_verified".to_owned());
331    }
332    if scopes.contains("profile") {
333        claims.push("name".to_owned());
334        claims.push("picture".to_owned());
335        claims.push("family_name".to_owned());
336        claims.push("given_name".to_owned());
337    }
338    claims
339}
340
341fn resolve_client_secret_storage(
342    storage: SecretStorage,
343    disable_jwt_plugin: bool,
344) -> Result<SecretStorage, OAuthProviderConfigError> {
345    match (storage, disable_jwt_plugin) {
346        (SecretStorage::Auto, true) => Ok(SecretStorage::Encrypted),
347        (SecretStorage::Auto, false) => Ok(SecretStorage::Hashed),
348        (SecretStorage::Hashed, true) => {
349            Err(OAuthProviderConfigError::HashedClientSecretsRequireJwtPlugin)
350        }
351        (SecretStorage::Encrypted, false) => {
352            Err(OAuthProviderConfigError::EncryptedClientSecretsWithJwtPlugin)
353        }
354        (storage, _) => Ok(storage),
355    }
356}
357
358fn rate_limit_rules(options: &OAuthProviderRateLimits) -> Vec<PluginRateLimitRule> {
359    [
360        (
361            "/oauth2/token",
362            RateLimitRule::new(time::Duration::seconds(60), 20),
363            &options.token,
364        ),
365        (
366            "/oauth2/authorize",
367            RateLimitRule::new(time::Duration::seconds(60), 30),
368            &options.authorize,
369        ),
370        (
371            "/oauth2/introspect",
372            RateLimitRule::new(time::Duration::seconds(60), 100),
373            &options.introspect,
374        ),
375        (
376            "/oauth2/revoke",
377            RateLimitRule::new(time::Duration::seconds(60), 30),
378            &options.revoke,
379        ),
380        (
381            "/oauth2/register",
382            RateLimitRule::new(time::Duration::seconds(60), 5),
383            &options.register,
384        ),
385        (
386            "/oauth2/userinfo",
387            RateLimitRule::new(time::Duration::seconds(60), 60),
388            &options.userinfo,
389        ),
390    ]
391    .into_iter()
392    .filter_map(|(path, default, setting)| match setting {
393        OAuthProviderRateLimit::Default => Some(PluginRateLimitRule::new(path, default)),
394        OAuthProviderRateLimit::Disabled => None,
395        OAuthProviderRateLimit::Custom(rule) => Some(PluginRateLimitRule::new(path, rule.clone())),
396    })
397    .collect()
398}