rustauth-oauth-provider 0.3.0

OAuth 2.1 and OpenID Connect provider support for RustAuth.
Documentation
//! OAuth 2.1 and OpenID Connect provider support for RustAuth.
//!
//! This crate ports the server-side Better Auth `oauth-provider` behavior into
//! idiomatic Rust. It is intentionally separate from `rustauth-oauth`, which
//! contains OAuth client and social-provider primitives.

mod authorize;
mod client;
mod consent;
mod endpoints;
mod error;
mod metadata;
mod models;
mod options;
mod schema;
mod token;
mod utils;

pub mod mcp;

pub use error::OAuthProviderError;
pub use options::{
    ClientPrivilegeAction, ClientPrivilegesInput, ClientPrivilegesResolver, ClientReferenceInput,
    ClientReferenceResolver, ClientSecretHashInput, ClientSecretHashResolver,
    ClientSecretVerifyInput, ClientSecretVerifyResolver, CustomAccessTokenClaimsInput,
    CustomAccessTokenClaimsResolver, CustomIdTokenClaimsInput, CustomIdTokenClaimsResolver,
    CustomTokenResponseFieldsInput, CustomTokenResponseFieldsResolver, CustomUserInfoClaimsInput,
    CustomUserInfoClaimsResolver, GrantType, McpMetadataOverrides, McpOptions,
    OAuthProviderConfigError, OAuthProviderOptions, OAuthProviderRateLimit,
    OAuthProviderRateLimits, OAuthTokenPrefixes, PromptRedirectInput, PromptRedirectResolver,
    PromptShouldRedirectResolver, RefreshTokenFormatDecodeOutput, RefreshTokenFormatEncodeInput,
    RefreshTokenFormatter, RequestUriResolver, RequestUriResolverInput, ResolvedMcpOptions,
    ResolvedOAuthProviderOptions, SecretStorage, StringGeneratorResolver, TokenEndpointAuthMethod,
    TokenHashInput, TokenHashResolver, TrustedClientCache,
};
pub use rustauth_plugins::jwt::JwtOptions;

#[cfg(feature = "test-util")]
pub use authorize::{decide_authorize, AuthorizeDecision};
#[cfg(feature = "test-util")]
pub use client::{
    check_oauth_client, oauth_to_schema, schema_to_oauth, CreateOAuthClientInput, OAuthClient,
};
#[cfg(feature = "test-util")]
pub use consent::{
    delete_consent, find_consent, has_granted_scopes, upsert_consent, ConsentGrantInput,
};
#[cfg(feature = "test-util")]
pub use metadata::{
    auth_server_metadata, oauth_authorization_server_metadata, oidc_server_metadata,
    well_known_metadata_response, WELL_KNOWN_METADATA_CACHE_CONTROL,
};
#[cfg(feature = "test-util")]
pub use models::{OAuthAccessToken, OAuthConsent, OAuthRefreshToken, SchemaClient};
#[cfg(feature = "test-util")]
pub use schema::{
    oauth_provider_schema, OAUTH_ACCESS_TOKEN_MODEL, OAUTH_CLIENT_MODEL, OAUTH_CONSENT_MODEL,
    OAUTH_REFRESH_TOKEN_MODEL,
};
#[cfg(feature = "test-util")]
pub use token::{
    create_client_credentials_token, decode_refresh_token, store_client_secret, store_token,
    verify_client_secret, TokenResponse,
};

#[cfg(feature = "mcp-client")]
pub use mcp::client::{McpAuthClient, McpAuthClientOptions, McpSession};

use std::collections::HashSet;
use std::sync::Arc;

use rustauth_core::error::RustAuthError;
use rustauth_core::options::RateLimitRule;
use rustauth_core::plugin::{AuthPlugin, PluginInitOutput, PluginRateLimitRule};

/// Current crate version.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

/// Build the OAuth provider plugin.
pub fn oauth_provider(
    options: OAuthProviderOptions,
) -> Result<AuthPlugin, OAuthProviderConfigError> {
    let resolved = resolve_options(options)?;
    let mut auth_plugin = AuthPlugin::new("oauth-provider").with_version(VERSION);
    let shared = Arc::new(resolved);
    let shared_for_init = Arc::clone(&shared);

    auth_plugin = auth_plugin.with_init(move |context| {
        if shared_for_init.disable_jwt_plugin {
            return Ok(PluginInitOutput::default());
        }
        if !context.has_plugin("jwt") {
            return Err(RustAuthError::InvalidConfig(
                "oauth-provider requires the jwt plugin when disable_jwt_plugin is false; \
                 register jwt(...) in plugins before oauth_provider(...)"
                    .to_owned(),
            ));
        }
        Ok(PluginInitOutput::default())
    });

    for contribution in schema::oauth_provider_schema() {
        auth_plugin = auth_plugin.with_schema(contribution);
    }
    for endpoint in endpoints::oauth_provider_endpoints(Arc::clone(&shared)) {
        auth_plugin = auth_plugin.with_endpoint(endpoint);
    }
    for rule in rate_limit_rules(&shared.rate_limits) {
        auth_plugin = auth_plugin.with_rate_limit(rule);
    }

    Ok(auth_plugin)
}

/// Apply JWT-derived metadata defaults to resolved OAuth provider options.
pub fn apply_jwt_metadata_defaults(resolved: &mut ResolvedOAuthProviderOptions, jwt: &JwtOptions) {
    if resolved.advertised_jwks_uri.is_none() {
        resolved.advertised_jwks_uri = jwt.jwks.remote_url.clone();
    }
    if resolved.advertised_id_token_signing_algorithms.is_empty() {
        resolved.advertised_id_token_signing_algorithms.push(
            jwt.jwks
                .key_pair_algorithm
                .unwrap_or(rustauth_plugins::jwt::JwkAlgorithm::EdDsa)
                .as_str()
                .to_owned(),
        );
    }
    if resolved.jwks_path == "/jwks" && jwt.jwks.jwks_path != "/jwks" {
        resolved.jwks_path = jwt.jwks.jwks_path.clone();
    }
}

pub(crate) fn resolve_options(
    options: OAuthProviderOptions,
) -> Result<ResolvedOAuthProviderOptions, OAuthProviderConfigError> {
    if options.login_page.is_empty() {
        return Err(OAuthProviderConfigError::MissingLoginPage);
    }
    if options.consent_page.is_empty() {
        return Err(OAuthProviderConfigError::MissingConsentPage);
    }

    let scopes = non_empty_or_default(
        options.scopes,
        ["openid", "profile", "email", "offline_access"],
    );
    let scope_set: HashSet<&str> = scopes.iter().map(String::as_str).collect();

    let client_registration_default_scopes = options.client_registration_default_scopes;
    let client_registration_allowed_scopes = merge_allowed_scopes(
        options.client_registration_allowed_scopes,
        &client_registration_default_scopes,
    );
    for scope in &client_registration_allowed_scopes {
        if !scope_set.contains(scope.as_str()) {
            return Err(OAuthProviderConfigError::UnknownClientRegistrationScope(
                scope.clone(),
            ));
        }
    }
    for scope in &options.advertised_scopes_supported {
        if !scope_set.contains(scope.as_str()) {
            return Err(OAuthProviderConfigError::UnknownAdvertisedScope(
                scope.clone(),
            ));
        }
    }
    for scope in &options.client_credential_grant_default_scopes {
        if !scope_set.contains(scope.as_str()) {
            return Err(OAuthProviderConfigError::UnknownClientCredentialGrantScope(
                scope.clone(),
            ));
        }
    }
    if options
        .pairwise_secret
        .as_ref()
        .is_some_and(|secret| secret.len() < 32)
    {
        return Err(OAuthProviderConfigError::PairwiseSecretTooShort);
    }

    let grant_types = if options.grant_types.is_empty() {
        vec![
            GrantType::AuthorizationCode,
            GrantType::ClientCredentials,
            GrantType::RefreshToken,
        ]
    } else {
        options.grant_types
    };
    if grant_types.contains(&GrantType::RefreshToken)
        && !grant_types.contains(&GrantType::AuthorizationCode)
    {
        return Err(OAuthProviderConfigError::RefreshTokenRequiresAuthorizationCode);
    }

    let mcp = resolve_mcp_options(options.mcp)?;
    let store_client_secret =
        resolve_client_secret_storage(options.store_client_secret, options.disable_jwt_plugin)?;
    Ok(ResolvedOAuthProviderOptions {
        claims: claims_for_scopes(&scope_set),
        scopes,
        client_registration_allowed_scopes,
        grant_types,
        login_page: options.login_page,
        consent_page: options.consent_page,
        signup_page: options.signup_page,
        select_account_page: options.select_account_page,
        post_login_page: options.post_login_page,
        signup_redirect: options.signup_redirect,
        select_account_redirect: options.select_account_redirect,
        post_login_redirect: options.post_login_redirect,
        signup_should_redirect: options.signup_should_redirect,
        select_account_should_redirect: options.select_account_should_redirect,
        post_login_should_redirect: options.post_login_should_redirect,
        consent_reference_id: options.consent_reference_id,
        code_expires_in: options.code_expires_in,
        access_token_expires_in: options.access_token_expires_in,
        m2m_access_token_expires_in: options.m2m_access_token_expires_in,
        id_token_expires_in: options.id_token_expires_in,
        refresh_token_expires_in: options.refresh_token_expires_in,
        client_credential_grant_default_scopes: options.client_credential_grant_default_scopes,
        scope_expirations: options.scope_expirations,
        client_registration_default_scopes,
        client_registration_client_secret_expiration: options
            .client_registration_client_secret_expiration,
        allow_unauthenticated_client_registration: options
            .allow_unauthenticated_client_registration,
        allow_dynamic_client_registration: options.allow_dynamic_client_registration,
        allow_public_client_prelogin: options.allow_public_client_prelogin,
        cached_trusted_clients: options.cached_trusted_clients,
        trusted_client_cache: TrustedClientCache::default(),
        client_reference: options.client_reference,
        client_privileges: options.client_privileges,
        custom_access_token_claims: options.custom_access_token_claims,
        custom_id_token_claims: options.custom_id_token_claims,
        custom_token_response_fields: options.custom_token_response_fields,
        custom_userinfo_claims: options.custom_userinfo_claims,
        request_uri_resolver: options.request_uri_resolver,
        prefixes: options.prefixes,
        generate_client_id: options.generate_client_id,
        generate_client_secret: options.generate_client_secret,
        generate_opaque_access_token: options.generate_opaque_access_token,
        generate_refresh_token: options.generate_refresh_token,
        format_refresh_token: options.format_refresh_token,
        disable_jwt_plugin: options.disable_jwt_plugin,
        store_client_secret,
        store_tokens: options.store_tokens,
        hash_client_secret: options.hash_client_secret,
        verify_client_secret_hash: options.verify_client_secret_hash,
        hash_token: options.hash_token,
        pairwise_secret: options.pairwise_secret,
        advertised_scopes_supported: options.advertised_scopes_supported,
        advertised_claims_supported: options.advertised_claims_supported,
        advertised_jwks_uri: options.advertised_jwks_uri,
        advertised_id_token_signing_algorithms: options.advertised_id_token_signing_algorithms,
        jwks_path: options.jwks_path,
        valid_audiences: options.valid_audiences,
        rate_limits: options.rate_limits,
        mcp,
    })
}

/// Resolve and validate OAuth provider options without building the plugin.
pub fn resolve_oauth_provider_options(
    options: OAuthProviderOptions,
) -> Result<ResolvedOAuthProviderOptions, OAuthProviderConfigError> {
    resolve_options(options)
}

fn resolve_mcp_options(
    options: Option<McpOptions>,
) -> Result<Option<ResolvedMcpOptions>, OAuthProviderConfigError> {
    options
        .map(|options| {
            if let Some(resource) = &options.resource {
                let parsed = url::Url::parse(resource)
                    .map_err(|_| OAuthProviderConfigError::InvalidMcpResource)?;
                if !parsed.has_host() {
                    return Err(OAuthProviderConfigError::InvalidMcpResource);
                }
            }
            Ok(ResolvedMcpOptions {
                resource: options.resource,
                metadata: options.metadata,
            })
        })
        .transpose()
}

fn non_empty_or_default<const N: usize>(values: Vec<String>, default: [&str; N]) -> Vec<String> {
    let values: Vec<String> = values
        .into_iter()
        .filter(|value| !value.is_empty())
        .collect();
    if values.is_empty() {
        default.into_iter().map(str::to_owned).collect()
    } else {
        values
    }
}

fn merge_allowed_scopes(mut allowed: Vec<String>, default_scopes: &[String]) -> Vec<String> {
    if default_scopes.is_empty() {
        return allowed;
    }
    for scope in default_scopes {
        if !allowed.contains(scope) {
            allowed.push(scope.clone());
        }
    }
    allowed
}

fn claims_for_scopes(scopes: &HashSet<&str>) -> Vec<String> {
    let mut claims = vec![
        "sub".to_owned(),
        "iss".to_owned(),
        "aud".to_owned(),
        "exp".to_owned(),
        "iat".to_owned(),
        "sid".to_owned(),
        "scope".to_owned(),
        "azp".to_owned(),
    ];
    if scopes.contains("email") {
        claims.push("email".to_owned());
        claims.push("email_verified".to_owned());
    }
    if scopes.contains("profile") {
        claims.push("name".to_owned());
        claims.push("picture".to_owned());
        claims.push("family_name".to_owned());
        claims.push("given_name".to_owned());
    }
    claims
}

fn resolve_client_secret_storage(
    storage: SecretStorage,
    disable_jwt_plugin: bool,
) -> Result<SecretStorage, OAuthProviderConfigError> {
    match (storage, disable_jwt_plugin) {
        (SecretStorage::Auto, true) => Ok(SecretStorage::Encrypted),
        (SecretStorage::Auto, false) => Ok(SecretStorage::Hashed),
        (SecretStorage::Hashed, true) => {
            Err(OAuthProviderConfigError::HashedClientSecretsRequireJwtPlugin)
        }
        (SecretStorage::Encrypted, false) => {
            Err(OAuthProviderConfigError::EncryptedClientSecretsWithJwtPlugin)
        }
        (storage, _) => Ok(storage),
    }
}

fn rate_limit_rules(options: &OAuthProviderRateLimits) -> Vec<PluginRateLimitRule> {
    [
        (
            "/oauth2/token",
            RateLimitRule::new(time::Duration::seconds(60), 20),
            &options.token,
        ),
        (
            "/oauth2/authorize",
            RateLimitRule::new(time::Duration::seconds(60), 30),
            &options.authorize,
        ),
        (
            "/oauth2/introspect",
            RateLimitRule::new(time::Duration::seconds(60), 100),
            &options.introspect,
        ),
        (
            "/oauth2/revoke",
            RateLimitRule::new(time::Duration::seconds(60), 30),
            &options.revoke,
        ),
        (
            "/oauth2/register",
            RateLimitRule::new(time::Duration::seconds(60), 5),
            &options.register,
        ),
        (
            "/oauth2/userinfo",
            RateLimitRule::new(time::Duration::seconds(60), 60),
            &options.userinfo,
        ),
    ]
    .into_iter()
    .filter_map(|(path, default, setting)| match setting {
        OAuthProviderRateLimit::Default => Some(PluginRateLimitRule::new(path, default)),
        OAuthProviderRateLimit::Disabled => None,
        OAuthProviderRateLimit::Custom(rule) => Some(PluginRateLimitRule::new(path, rule.clone())),
    })
    .collect()
}