alibi-plugins 0.11.0

Built-in authentication plugins and API metadata for Alibi
Documentation
//! Trusted server access-token validation without protocol client authentication.
use super::OAuthProviderPlugin;
use alibi_core::endpoint::EndpointError;
use alibi_core::store::OAuthTokenKind;
use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthSession};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde_json::{Value, json};

#[derive(Debug)]
pub(super) struct AccessTokenValidation {
    pub payload: Value,
    pub requested_user_info_claims: Vec<String>,
}
impl From<Value> for AccessTokenValidation {
    fn from(payload: Value) -> Self {
        Self {
            payload,
            requested_user_info_claims: Vec::new(),
        }
    }
}
impl OAuthProviderPlugin {
    /// Validate access authority and return its RFC 7662 payload.
    /// Unknown tokens are errors; recognized but inactive authority returns `active: false`.
    pub async fn validate_access_token<S: AuthSchema>(
        &self,
        ctx: &AuthContext<S>,
        token: &str,
        client_id: Option<&str>,
    ) -> Result<Value, EndpointError> {
        Ok(self
            .resolve_access_token_validation(ctx, token, client_id)
            .await?
            .payload)
    }
    async fn resolve_access_token_validation<S: AuthSchema>(
        &self,
        ctx: &AuthContext<S>,
        token: &str,
        client_id: Option<&str>,
    ) -> Result<AccessTokenValidation, EndpointError> {
        let decoded = self.decode_token_prefix(token, OAuthTokenKind::Access);
        // A prefix may introduce JWT separators. With prefix presentation in
        // use, non-JWS headers must still reach opaque prefix/row validation,
        // as they do after the reference's caught JWS-format error.
        let prefixed_opaque = self
            .config
            .opaque_access_token_prefix
            .as_deref()
            .is_some_and(|prefix| !prefix.is_empty())
            && token
                .split('.')
                .next()
                .and_then(|header| URL_SAFE_NO_PAD.decode(header).ok())
                .and_then(|header| serde_json::from_slice::<Value>(&header).ok())
                .is_none_or(|header| !header.is_object());
        let now = serde_json::Number::from(chrono::Utc::now().timestamp())
            .as_f64()
            .ok_or_else(|| AuthError::internal("Invalid token validation time"))?;
        let expected_issuer = issuer(ctx, self.config.disable_jwt_plugin);
        // A JWT this server cannot verify (bad signature, unknown key, or a
        // failed `iss`, `iat` or `nbf` check) may still be a stored opaque
        // token, so it falls through to the opaque lookup below. Only an
        // expired, otherwise valid JWT is recognized as inactive.
        let verified = if !self.config.disable_jwt_plugin
            && !prefixed_opaque
            && let Some(jwt) = ctx.extensions.get::<crate::jwt::JwtPlugin>()
        {
            Box::pin(jwt.verify_signed_payload_with_optional_kid(token, None, ctx))
                .await?
                .filter(|claims| {
                    claims.get("iss").and_then(Value::as_str) == Some(expected_issuer.as_str())
                        && ["iat", "nbf", "exp"]
                            .iter()
                            .all(|field| claims.get(*field).is_none_or(Value::is_number))
                        && claims
                            .get("nbf")
                            .and_then(Value::as_f64)
                            .is_none_or(|nbf| nbf <= now)
                })
        } else {
            None
        };
        if let Some(mut claims) = verified {
            if claims
                .get("exp")
                .and_then(Value::as_f64)
                .is_some_and(|exp| exp <= now)
            {
                return Ok(json!({"active":false}).into());
            }
            let Some(resources) = audience_values(claims.get("aud")) else {
                return Ok(json!({"active":false}).into());
            };
            if !audiences_allowed(self, ctx, &resources).await? {
                return Ok(json!({"active":false}).into());
            }
            let Some(issuing_id) = claims
                .get("azp")
                .and_then(Value::as_str)
                .filter(|id| !id.is_empty())
                .map(str::to_owned)
            else {
                return Ok(json!({"active":false}).into());
            };
            let Some(client) = self.get_client(ctx, &issuing_id).await? else {
                return Ok(json!({"active":false}).into());
            };
            if client.metadata.get("disabled") == Some(&Value::Bool(true))
                || !authorized(ctx, client_id, &issuing_id, &resources).await?
            {
                return Ok(json!({"active":false}).into());
            }
            if let Some(sid) = claims.get("sid").and_then(Value::as_str)
                && ctx
                    .database
                    .get_session_by_id(sid)
                    .await?
                    .is_none_or(|session| session.expires_at() < chrono::Utc::now())
            {
                return Ok(json!({"active":false}).into());
            }
            _ = claims.insert("client_id".into(), json!(issuing_id));
            _ = claims.insert("active".into(), json!(true));
            _ = claims.insert(
                "token_type".into(),
                json!(if claims
                    .get("cnf")
                    .and_then(|cnf| cnf.get("jkt"))
                    .and_then(Value::as_str)
                    .is_some_and(|jkt| !jkt.is_empty())
                {
                    "DPoP"
                } else {
                    "Bearer"
                }),
            );
            return Ok(Value::Object(claims).into());
        }
        let Some(token) = decoded else {
            return Err(invalid_access_token());
        };
        let hash = self.hash_token(token, "access_token").await?;
        let Some(row) = ctx
            .database
            .get_oauth_token(OAuthTokenKind::Access, &hash)
            .await?
        else {
            return Err(invalid_access_token());
        };
        let Some((row, client)) = super::userinfo::live_access_row(row, ctx, self).await? else {
            return Ok(json!({"active":false}).into());
        };
        let resources = row
            .metadata
            .get("resources")
            .and_then(Value::as_array)
            .map(|values| {
                values
                    .iter()
                    .filter_map(Value::as_str)
                    .map(str::to_owned)
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();
        if !audiences_allowed(self, ctx, &resources).await?
            || !authorized(ctx, client_id, &row.client_id, &resources).await?
        {
            return Ok(json!({"active":false}).into());
        }
        let scopes = row
            .metadata
            .get("scopes")
            .and_then(Value::as_array)
            .map(|values| {
                values
                    .iter()
                    .filter_map(Value::as_str)
                    .map(str::to_owned)
                    .collect::<Vec<_>>()
            });
        let mut params =
            super::OAuthTokenIssueParams::new(client, scopes.clone().unwrap_or_default());
        if let Some(user_id) = row.user_id.as_deref()
            && let Some(user) = ctx.database.get_user_by_id(user_id).await?
        {
            params.user = Some(ctx.trusted_user_view(&user));
        }
        params.reference_id = row
            .metadata
            .get("referenceId")
            .and_then(Value::as_str)
            .map(str::to_owned);
        params.resources = row
            .metadata
            .get("resources")
            .and_then(Value::as_array)
            .map(|_| resources.clone());
        // Validation has no issuance grant, session context or per-request
        // claims. Re-read both application and resource claims only after all
        // authority/liveness gates; do not re-apply new-issuance admission.
        let mut resource_claims = serde_json::Map::new();
        for resource in &resources {
            if let Some(resource) = ctx.database.get_oauth_resource(resource).await?
                && let Some(claims) = resource
                    .metadata
                    .get("customClaims")
                    .and_then(Value::as_object)
            {
                resource_claims.extend(claims.clone());
            }
        }
        let claims = self
            .resolve_access_token_claims(alibi_core::CallbackContext::new(ctx, None), &params, None)
            .await?;
        let payload = json!({"active":true,"iss":issuer(ctx,self.config.disable_jwt_plugin),"client_id":row.client_id,"azp":row.client_id,"exp":row.expires_at.timestamp(),"iat":row.created_at.timestamp()});
        let core = payload
            .as_object()
            .cloned()
            .ok_or_else(|| AuthError::internal("Invalid access-token payload"))?;
        let mut payload = super::extension_claims::compose_access_claims(claims, resource_claims);
        payload.extend(core);
        payload.extend(confirmation_claims(row.metadata.get("confirmation")));
        if let Some(scope) = scopes {
            _ = payload.insert("scope".into(), json!(scope.join(" ")));
        }
        if let Some(user) = params.user.as_ref() {
            _ = payload.insert("sub".into(), json!(user.id));
        }
        if let Some(sid) = row.session_id {
            _ = payload.insert("sid".into(), json!(sid));
        }
        if !resources.is_empty() {
            let mut audiences = resources;
            let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
            if scopes_contain(&row.metadata, "openid") && !audiences.contains(&userinfo) {
                audiences.push(userinfo);
            }
            _ = payload.insert(
                "aud".into(),
                match audiences.as_slice() {
                    [one] => json!(one),
                    _ => json!(audiences),
                },
            );
        }
        let requested_user_info_claims = row
            .metadata
            .get("requestedUserInfoClaims")
            .and_then(Value::as_array)
            .map(|values| {
                values
                    .iter()
                    .filter_map(Value::as_str)
                    .map(str::to_owned)
                    .collect()
            })
            .unwrap_or_default();
        Ok(AccessTokenValidation {
            payload: Value::Object(payload),
            requested_user_info_claims,
        })
    }
    /// Require recognized and active authority, preserving the reference Bearer challenge.
    pub async fn require_active_access_token<S: AuthSchema>(
        &self,
        ctx: &AuthContext<S>,
        token: &str,
        client_id: Option<&str>,
    ) -> Result<Value, EndpointError> {
        Ok(self
            .require_active_access_token_with_claims(ctx, token, client_id)
            .await?
            .payload)
    }
    pub(super) async fn require_active_access_token_with_claims<S: AuthSchema>(
        &self,
        ctx: &AuthContext<S>,
        token: &str,
        client_id: Option<&str>,
    ) -> Result<AccessTokenValidation, EndpointError> {
        let validation = self
            .resolve_access_token_validation(ctx, token, client_id)
            .await?;
        if validation.payload.get("active") == Some(&Value::Bool(true)) {
            Ok(validation)
        } else {
            Err(invalid_access_token())
        }
    }
}
// Opaque access and refresh projections share the same sender-constraint
// policy. Preserve truthy untrusted confirmation shapes without treating
// mTLS, arrays, or empty jkt values as DPoP.
pub(super) fn confirmation_claims(confirmation: Option<&Value>) -> serde_json::Map<String, Value> {
    let token_type = if confirmation
        .and_then(crate::oauth2::get_confirmation_jkt)
        .is_some()
    {
        "DPoP"
    } else {
        "Bearer"
    };
    let mut claims = serde_json::Map::from_iter([("token_type".into(), json!(token_type))]);
    if let Some(confirmation) = confirmation
        .filter(|value| alibi_core::utils::json::JsValue::from((**value).clone()).is_truthy())
    {
        _ = claims.insert("cnf".into(), confirmation.clone());
    }
    claims
}
fn base_url<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
    format!(
        "{}{}",
        ctx.config.base_url.trim_end_matches('/'),
        ctx.config.base_path
    )
}
fn issuer<S: AuthSchema>(ctx: &AuthContext<S>, disabled: bool) -> String {
    if !disabled
        && let Some(value) = ctx
            .extensions
            .get::<crate::jwt::JwtDiscoveryOptions>()
            .and_then(|jwt| jwt.issuer.clone())
    {
        return value;
    }
    base_url(ctx)
}
fn audience_values(value: Option<&Value>) -> Option<Vec<String>> {
    match value {
        None => Some(Vec::new()),
        Some(Value::String(value)) => Some(vec![value.clone()]),
        Some(Value::Array(values)) => values
            .iter()
            .map(|value| value.as_str().map(str::to_owned))
            .collect(),
        _ => None,
    }
}
async fn audiences_allowed<S: AuthSchema>(
    provider: &OAuthProviderPlugin,
    ctx: &AuthContext<S>,
    audiences: &[String],
) -> AuthResult<bool> {
    if audiences.len() > 64 {
        return Ok(false);
    }
    let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
    for audience in audiences {
        if audience != &userinfo && provider.get_resource(ctx, audience).await?.is_none() {
            return Ok(false);
        }
    }
    Ok(true)
}
async fn authorized<S: AuthSchema>(
    ctx: &AuthContext<S>,
    caller: Option<&str>,
    issuer: &str,
    resources: &[String],
) -> AuthResult<bool> {
    if caller.is_none_or(|caller| caller.is_empty() || caller == issuer) {
        return Ok(true);
    }
    let links = ctx
        .database
        .list_oauth_client_resources(caller.unwrap_or_default())
        .await?;
    Ok(links
        .iter()
        .any(|link| resources.contains(&link.resource_id)))
}
fn scopes_contain(metadata: &Value, scope: &str) -> bool {
    metadata
        .get("scopes")
        .and_then(Value::as_array)
        .is_some_and(|values| values.iter().any(|value| value == scope))
}
fn invalid_access_token() -> EndpointError {
    let mut headers = alibi_core::Headers::new();
    _ = headers.insert(
        "WWW-Authenticate",
        "Bearer error=\"invalid_token\", error_description=\"Invalid access token\"",
    );
    EndpointError {
        error: AuthError::Api {
            status: 401,
            code: None,
            message: String::new(),
        },
        headers: Some(headers),
        body: Some(Box::new(alibi_core::utils::json::JsValue::from(
            json!({"error":"invalid_token","error_description":"Invalid access token"}),
        ))),
        body_absent: false,
    }
}