limes 0.4.0

Limes is a multi-tenant capable Authentication middleware for OAuth2.0 and Open ID Connect with support for axum.
use crate::{Subject, error::Result, introspect::IntrospectionResult};
use core::{future::Future, marker::Sync};
pub(crate) use jsonwebtoken::Header;
use std::collections::HashSet;
use std::fmt::Debug;
use typed_builder::TypedBuilder;

const ISSUER_CLAIM: &str = "iss";
const EXPIRES_CLAIM: &str = "exp";
const ISSUED_AT_CLAIM: &str = "iat";
const NOT_BEFORE_CLAIM: &str = "nbf";
/// The space-delimited OAuth `scope` claim. Shared with the JWKS authenticator so the key
/// is defined in exactly one place.
pub(crate) const SCOPE_CLAIM: &str = "scope";

pub trait Authenticator
where
    Self: Send + Sync + Clone,
{
    /// Authenticate a token. This must validate the tokens signature and claims.
    /// For opaque tokens, handlers may connect to the `IdP` to validate the token.
    ///
    /// `introspection` must be the result of [`introspect`](`crate::introspect::introspect`)
    /// for `token`. The caller computes it once and passes it in (the axum middleware does
    /// this for you). [`AuthenticatorChain`](`crate::AuthenticatorChain`) does not introspect;
    /// it forwards the value it was given to the selected authenticator. This lets
    /// implementations reuse the already-decoded header and claims instead of decoding the
    /// token a second time.
    ///
    /// # Errors
    /// - Token is not valid.
    fn authenticate(
        &self,
        token: &str,
        introspection: &IntrospectionResult,
    ) -> impl Future<Output = Result<Authentication>> + Send;

    /// Check if the authenticator can handle the token.
    /// This is used in the [`AuthenticatorChain`](`crate::AuthenticatorChain`) to determine which authenticator to use.
    /// This should be a quick check that doesn't involve cryptographic operations.
    fn can_handle_token(&self, token: &str, introspection_result: &IntrospectionResult) -> bool;

    /// Returns an id that uniquely identifies the `IdP` this authenticator is for.
    fn idp_id(&self) -> Option<&String>;

    /// Collects the IdP identifier(s) associated with this authenticator.
    ///
    /// By default this yields a single-element vector containing the result of `self.idp_id()`
    /// (converted to a `&str`) for a standalone authenticator. Implementations that represent
    /// a chain of authenticators should return one element per child authenticator in chain order.
    ///
    /// # Returns
    ///
    /// A `Vec<Option<&str>>` where each element is the IdP identifier for one authenticator in the chain,
    /// or `None` when an authenticator does not have an IdP identifier.
    fn idp_ids(&self) -> Vec<Option<&str>> {
        vec![self.idp_id().map(String::as_str)]
    }
}

#[derive(Debug, PartialEq, Eq, Clone, TypedBuilder)]
/// Information about a successful authentication.
/// Use [`Authentication::subject()`] for a unique identifier of the user.
pub struct Authentication {
    // --------- Raw token data ---------
    /// Header of the provided token if any.
    /// Not all tokens have a header. JWTs do, but opaque tokens don't.
    token_header: Option<Header>,
    /// Claims of the provided token provided as a json Value.
    /// This struct also contains some popular claims as strongly typed fields,
    /// which should be preferred over accessing the claims directly.
    claims: serde_json::Value,
    /// Subject of the token - consists of a unique identifier of the idp
    /// and the id of the subject in the idp.
    subject: Subject,
    /// Full name of the user intended for human use.
    name: Option<String>,
    /// Email of the user.
    email: Option<String>,
    /// The type of the principal making the request.
    principal_type: Option<PrincipalType>,
    /// Roles of the user extracted from the token if any.
    #[builder(default)]
    roles: Option<Vec<String>>,
    /// Audiences of the token.
    #[builder(default)]
    audiences: HashSet<String>,
}

#[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)]
/// Type of the principal making the request.
pub enum PrincipalType {
    Human,
    Application,
}

impl Authentication {
    #[must_use]
    /// Get the token header if it exists.
    pub fn token_header(&self) -> Option<&Header> {
        self.token_header.as_ref()
    }

    #[must_use]
    /// Get the content of a claim from the token.
    /// If the claim does not exist, this will return None.
    pub fn claims(&self, key: &str) -> Option<&serde_json::Value> {
        self.claims.get(key)
    }

    #[must_use]
    /// Get the subject of the user.
    /// Use this to uniquely identify the user.
    pub fn subject(&self) -> &Subject {
        &self.subject
    }

    #[must_use]
    /// Get the full name of the user.
    /// This is intended for human use. It is not guaranteed to be unique and may change.
    pub fn full_name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    #[must_use]
    /// Get the type of the principal making the request.
    /// This is estimated by the [`Authenticator`] implementation and may not be accurate in all cases.
    pub fn principal_type(&self) -> Option<PrincipalType> {
        self.principal_type
    }

    #[must_use]
    /// Get the email of the user.
    pub fn email(&self) -> Option<&str> {
        self.email.as_deref()
    }

    /// Get the roles of the user that were extracted from the token if any.
    #[must_use]
    pub fn roles(&self) -> Option<&[String]> {
        self.roles.as_deref()
    }

    #[must_use]
    /// Get the audiences of the token.
    pub fn audiences(&self) -> &HashSet<String> {
        &self.audiences
    }

    #[must_use]
    /// Get the IdP identifier of the subject, if present.
    /// This is a convenience accessor that maps the `Option<&String>` returned by
    /// [`Subject::idp_id`] to `Option<&str>`.
    pub fn idp_id(&self) -> Option<&str> {
        self.subject().idp_id().map(std::string::String::as_str)
    }

    #[must_use]
    /// Get the full set of claims as a JSON value.
    ///
    /// For JWTs this is the decoded payload; for the Kubernetes authenticator it is the
    /// `user.extra` map returned by the `TokenReview` API. Prefer the typed accessors where one
    /// exists; use [`claims`](Self::claims) to read a single claim by key.
    pub fn all_claims(&self) -> &serde_json::Value {
        &self.claims
    }

    #[must_use]
    /// Get the issuer (`iss`) of the token, if present in the claims.
    pub fn issuer(&self) -> Option<&str> {
        self.claims
            .get(ISSUER_CLAIM)
            .and_then(serde_json::Value::as_str)
    }

    /// Get the scopes of the token, parsed from the space-delimited `scope` claim.
    ///
    /// Returns an empty iterator if the `scope` claim is absent. Derived on demand from the
    /// claims so unused scopes cost nothing on the authentication path.
    pub fn scopes(&self) -> impl Iterator<Item = &str> {
        self.claims
            .get(SCOPE_CLAIM)
            .and_then(serde_json::Value::as_str)
            .unwrap_or_default()
            .split_whitespace()
    }

    #[must_use]
    /// Get the expiry (`exp`) as seconds since the Unix epoch, if present in the claims.
    pub fn expires_at(&self) -> Option<i64> {
        self.claims
            .get(EXPIRES_CLAIM)
            .and_then(serde_json::Value::as_i64)
    }

    #[must_use]
    /// Get the issued-at time (`iat`) as seconds since the Unix epoch, if present in the claims.
    pub fn issued_at(&self) -> Option<i64> {
        self.claims
            .get(ISSUED_AT_CLAIM)
            .and_then(serde_json::Value::as_i64)
    }

    #[must_use]
    /// Get the not-before time (`nbf`) as seconds since the Unix epoch, if present in the claims.
    pub fn not_before(&self) -> Option<i64> {
        self.claims
            .get(NOT_BEFORE_CLAIM)
            .and_then(serde_json::Value::as_i64)
    }
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::Subject;

    fn auth_with_claims(claims: serde_json::Value) -> Authentication {
        Authentication::builder()
            .token_header(None)
            .claims(claims)
            .name(None)
            .email(None)
            .subject(Subject::new(None, "sub".to_string()))
            .principal_type(None)
            .build()
    }

    #[test]
    fn test_metadata_accessors_present() {
        let auth = auth_with_claims(serde_json::json!({
            "iss": "https://issuer.example.com",
            "exp": 1_730_052_519,
            "iat": 1_730_048_619,
            "nbf": 1_730_048_619,
            "scope": "openid profile email",
        }));

        assert_eq!(auth.issuer(), Some("https://issuer.example.com"));
        assert_eq!(auth.expires_at(), Some(1_730_052_519));
        assert_eq!(auth.issued_at(), Some(1_730_048_619));
        assert_eq!(auth.not_before(), Some(1_730_048_619));
        assert_eq!(
            auth.scopes().collect::<Vec<_>>(),
            ["openid", "profile", "email"]
        );
        assert!(auth.all_claims().get("iss").is_some());
    }

    #[test]
    fn test_metadata_accessors_absent() {
        let auth = auth_with_claims(serde_json::json!({ "sub": "x" }));

        assert_eq!(auth.issuer(), None);
        assert_eq!(auth.expires_at(), None);
        assert_eq!(auth.issued_at(), None);
        assert_eq!(auth.not_before(), None);
        assert_eq!(auth.scopes().count(), 0);
    }
}