rustauth-oauth-provider 0.3.0

OAuth 2.1 and OpenID Connect provider support for RustAuth.
Documentation
use super::*;

pub(super) async fn create_id_token(
    context: &AuthContext,
    adapter: &dyn DbAdapter,
    options: &ResolvedOAuthProviderOptions,
    client: &SchemaClient,
    input: IdTokenInput<'_>,
) -> Result<Option<String>, RustAuthError> {
    if !input.scopes.iter().any(|scope| scope == "openid") {
        return Ok(None);
    }
    if options.disable_jwt_plugin && client.client_secret.is_none() {
        return Ok(None);
    }
    let Some(user) = find_user(adapter, input.user_id).await? else {
        return Err(OAuthProviderError::invalid_request("user not found").into());
    };
    let iat = now();
    let exp = iat + Duration::seconds(options.id_token_expires_in as i64);
    let mut claims = user_normal_claims(&user, input.scopes);
    if let Some(resolver) = &options.custom_id_token_claims {
        claims.extend(
            resolver
                .resolve(CustomIdTokenClaimsInput {
                    user: user.clone(),
                    scopes: input.scopes.to_vec(),
                    metadata: client.metadata.clone(),
                })
                .await?,
        );
    }
    claims.insert(
        "sub".to_owned(),
        Value::String(resolve_subject_identifier(input.user_id, client, options)?),
    );
    claims.insert("iss".to_owned(), Value::String(context.base_url.clone()));
    claims.insert("aud".to_owned(), Value::String(client.client_id.clone()));
    claims.insert("iat".to_owned(), Value::Number(iat.unix_timestamp().into()));
    claims.insert("exp".to_owned(), Value::Number(exp.unix_timestamp().into()));
    claims.insert(
        "acr".to_owned(),
        Value::String("urn:mace:incommon:iap:bronze".to_owned()),
    );
    if let Some(nonce) = input.nonce {
        claims.insert("nonce".to_owned(), Value::String(nonce.to_owned()));
    }
    if client.enable_end_session == Some(true) {
        if let Some(session_id) = input.session_id {
            claims.insert("sid".to_owned(), Value::String(session_id.to_owned()));
        }
    }
    if let Some(auth_time) = input.auth_time {
        claims.insert(
            "auth_time".to_owned(),
            Value::Number(auth_time.unix_timestamp().into()),
        );
    }

    if options.disable_jwt_plugin {
        let stored_secret = client.client_secret.as_deref().ok_or_else(|| {
            RustAuthError::Api("client_secret is required for HS256 id_token".to_owned())
        })?;
        let secret = symmetric_decrypt(context.secret.as_str(), stored_secret)?;
        return hs256_jwt::sign_jwt(&claims, &secret, options.id_token_expires_in as i64).map(Some);
    }

    rustauth_plugins::jwt::sign_jwt(context, claims, Some(resolved_jwt_options(context)))
        .await
        .map(Some)
}

pub(crate) fn user_normal_claims(user: &User, scopes: &[String]) -> Map<String, Value> {
    let mut claims = Map::new();
    if scopes.iter().any(|scope| scope == "profile") {
        claims.insert("name".to_owned(), Value::String(user.name.clone()));
        if let Some(image) = &user.image {
            claims.insert("picture".to_owned(), Value::String(image.clone()));
        }
        let names = user
            .name
            .split_whitespace()
            .filter(|value| !value.is_empty())
            .collect::<Vec<_>>();
        if names.len() > 1 {
            claims.insert(
                "given_name".to_owned(),
                Value::String(names[..names.len() - 1].join(" ")),
            );
            claims.insert(
                "family_name".to_owned(),
                Value::String(names[names.len() - 1].to_owned()),
            );
        }
    }
    if scopes.iter().any(|scope| scope == "email") {
        claims.insert("email".to_owned(), Value::String(user.email.clone()));
        claims.insert(
            "email_verified".to_owned(),
            Value::Bool(user.email_verified),
        );
    }
    claims
}

pub(super) async fn find_user(
    adapter: &dyn DbAdapter,
    user_id: &str,
) -> Result<Option<User>, RustAuthError> {
    adapter
        .find_one(find_by_string("user", "id", user_id))
        .await?
        .map(user_from_record)
        .transpose()
}

pub(crate) fn resolve_subject_identifier(
    user_id: &str,
    client: &SchemaClient,
    options: &ResolvedOAuthProviderOptions,
) -> Result<String, RustAuthError> {
    let Some(secret) = options.pairwise_secret.as_deref() else {
        return Ok(user_id.to_owned());
    };
    if client.subject_type.as_deref() != Some("pairwise") {
        return Ok(user_id.to_owned());
    }
    let sector = sector_identifier(client)?;
    hmac_sha256_base64url(&format!("{sector}.{user_id}"), secret)
}

pub(crate) async fn validate_id_token_hint(
    context: &AuthContext,
    adapter: &dyn DbAdapter,
    options: &ResolvedOAuthProviderOptions,
    token: &str,
    client_id_hint: Option<&str>,
) -> Result<ValidatedIdTokenHint, OAuthProviderError> {
    let unverified = unverified_jwt_claims(token).ok_or_else(|| {
        OAuthProviderError::new(
            http::StatusCode::UNAUTHORIZED,
            "invalid_token",
            "invalid id token",
        )
    })?;
    let client_id = client_id_hint
        .map(str::to_owned)
        .or_else(|| client_id_from_audience(&unverified))
        .ok_or_else(|| OAuthProviderError::invalid_request("id token missing audience"))?;
    let Some(client) = get_client_cached(adapter, options, &client_id)
        .await
        .map_err(|error| OAuthProviderError::invalid_request(error.to_string()))?
    else {
        return Err(OAuthProviderError::invalid_client("client doesn't exist"));
    };
    if client.disabled == Some(true) {
        return Err(OAuthProviderError::invalid_client("client is disabled"));
    }
    if client.enable_end_session != Some(true) {
        return Err(OAuthProviderError::unauthorized("client unable to logout"));
    }

    let claims = if options.disable_jwt_plugin {
        let stored_secret = client
            .client_secret
            .as_deref()
            .ok_or_else(|| OAuthProviderError::invalid_client("missing required credentials"))?;
        let secret = symmetric_decrypt(context.secret.as_str(), stored_secret).map_err(|_| {
            OAuthProviderError::new(
                http::StatusCode::UNAUTHORIZED,
                "invalid_token",
                "invalid id token",
            )
        })?;
        let value: Value = hs256_jwt::verify_jwt(token, &secret)
            .map_err(|_| {
                OAuthProviderError::new(
                    http::StatusCode::UNAUTHORIZED,
                    "invalid_token",
                    "invalid id token",
                )
            })?
            .ok_or_else(|| {
                OAuthProviderError::new(
                    http::StatusCode::UNAUTHORIZED,
                    "invalid_token",
                    "invalid id token",
                )
            })?;
        match value {
            Value::Object(claims) => claims,
            _ => return Err(OAuthProviderError::invalid_request("missing payload")),
        }
    } else {
        let mut jwt_options = resolved_jwt_options(context);
        jwt_options.jwt.audience = Some(vec![client_id.clone()]);
        rustauth_plugins::jwt::verify_jwt_with_options(
            context,
            token,
            &jwt_options,
            Some(&context.base_url),
        )
        .await
        .map_err(|_| {
            OAuthProviderError::new(
                http::StatusCode::UNAUTHORIZED,
                "invalid_token",
                "invalid id token",
            )
        })?
        .ok_or_else(|| {
            OAuthProviderError::new(
                http::StatusCode::UNAUTHORIZED,
                "invalid_token",
                "invalid id token",
            )
        })?
    };

    if claims.get("iss").and_then(Value::as_str) != Some(context.base_url.as_str()) {
        return Err(OAuthProviderError::invalid_request("invalid issuer"));
    }
    let audiences = audiences_from_claims(&claims)
        .ok_or_else(|| OAuthProviderError::invalid_request("id token missing audience"))?;
    if !audiences.iter().any(|audience| audience == &client_id) {
        return Err(OAuthProviderError::invalid_request("audience mismatch"));
    }
    let session_id = claims
        .get("sid")
        .and_then(Value::as_str)
        .ok_or_else(|| OAuthProviderError::invalid_request("id token missing session"))?
        .to_owned();

    Ok(ValidatedIdTokenHint { client, session_id })
}

fn sector_identifier(client: &SchemaClient) -> Result<String, RustAuthError> {
    let uri = client
        .redirect_uris
        .first()
        .ok_or_else(|| RustAuthError::Api("client has no redirect URIs".to_owned()))?;
    let url = url::Url::parse(uri).map_err(|error| RustAuthError::Api(error.to_string()))?;
    let host = url
        .host_str()
        .ok_or_else(|| RustAuthError::Api("redirect URI has no host".to_owned()))?;
    Ok(match url.port() {
        Some(port) => format!("{host}:{port}"),
        None => host.to_owned(),
    })
}