AkurAI Build
Menu

AkurAI-Build

public

Latest change 70d86cbbc0c8af02edc0ba4d8661ddcb30c30c24 - Authorize configured Build operator identities by Ólafur Búi Ólafsson

//! AkurAI ID (OIDC) browser authentication.
//!
//! Implements the platform SSO contract: authorization code + S256 PKCE against
//! `AKURAI_IDP_ISSUER`, back-channel `/token`, `/introspect`, and `/userinfo`
//! against `AKURAI_IDP_INTERNAL_URL`, and a durable opaque `akurai_session`
//! cookie backed by the encrypted database.
//!
//! Token authenticity is established by the provider's `/introspect` endpoint —
//! this crate deliberately implements no JWT signature verification.

use std::{
    env, fmt,
    io::Write,
    process::{Command, Stdio},
    time::{SystemTime, UNIX_EPOCH},
};

use serde::Deserialize;
use sha2::{Digest, Sha256};

use crate::db::{Database, LoginState, Session};

/// Opaque browser session cookie shared by every AkurAI application.
pub const SESSION_COOKIE: &str = "akurai_session";
/// Single-use CSRF cookie carrying the authorization request `state`.
pub const STATE_COOKIE: &str = "akurai_oauth_state";
/// Session lifetime in seconds.
pub const SESSION_TTL: i64 = 86_400;
/// In-flight login lifetime in seconds.
pub const STATE_TTL: i64 = 600;
/// Route that starts the login flow.
pub const LOGIN_PATH: &str = "/auth/login";

const DEFAULT_ISSUER: &str = "https://auth.olibuijr.com";
const BACK_CHANNEL_TIMEOUT: &str = "15";

/// Failure modes of the login flow, mapped to HTTP status by the server.
#[derive(Debug)]
pub enum Error {
    /// The deployment is missing AkurAI ID credentials — operator error (500).
    Configuration(String),
    /// The request failed the normative callback validation order (400).
    Invalid(String),
    /// Authenticated but not permitted by `AKURAI_IDP_ALLOWED_GROUPS` (403).
    Forbidden(String),
    /// The identity provider could not be reached or answered unusably (502).
    Upstream(String),
    /// Local persistence failed (500).
    Internal(String),
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Configuration(message)
            | Self::Invalid(message)
            | Self::Forbidden(message)
            | Self::Upstream(message)
            | Self::Internal(message) => formatter.write_str(message),
        }
    }
}

/// Resolved AkurAI ID client configuration.
#[derive(Clone, Debug)]
pub struct Config {
    pub issuer: String,
    pub internal_url: String,
    pub client_id: String,
    pub client_secret: String,
    pub redirect_uri: String,
    pub public_url: String,
    pub allowed_groups: Vec<String>,
    pub admin_emails: Vec<String>,
}

/// Read the canonical AkurAI ID environment.
///
/// `AKURAI_IDP_CLIENT_ID` and `AKURAI_IDP_CLIENT_SECRET` are mandatory; there is
/// no local-auth fallback. `AKURAI_IDP_INTERNAL_URL` defaults to the issuer, and
/// a loopback value is forced back to the issuer because this service does not
/// share a host with the provider.
pub fn config(public_url: &str) -> Result<Config, Error> {
    let public_url = public_url.trim_end_matches('/').to_owned();
    if public_url.is_empty() {
        return Err(Error::Configuration("AKURAI_PUBLIC_URL is required".into()));
    }
    let issuer = env::var("AKURAI_IDP_ISSUER")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| DEFAULT_ISSUER.into())
        .trim_end_matches('/')
        .to_owned();
    let internal_url = env::var("AKURAI_IDP_INTERNAL_URL")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| issuer.clone())
        .trim_end_matches('/')
        .to_owned();
    let internal_url = if is_loopback(&internal_url) {
        issuer.clone()
    } else {
        internal_url
    };
    let client_id = required("AKURAI_IDP_CLIENT_ID")?;
    let client_secret = required("AKURAI_IDP_CLIENT_SECRET")?;
    let redirect_uri = env::var("AKURAI_IDP_REDIRECT_URI")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| format!("{public_url}/auth/callback"));
    let allowed_groups = env::var("AKURAI_IDP_ALLOWED_GROUPS")
        .unwrap_or_default()
        .split(',')
        .map(str::trim)
        .filter(|group| !group.is_empty())
        .map(str::to_owned)
        .collect();
    let admin_emails = env::var("AKURAI_IDP_ADMIN_EMAILS")
        .unwrap_or_else(|_| "olibuijr@olibuijr.com".into())
        .split(',')
        .map(str::trim)
        .filter(|email| !email.is_empty())
        .map(str::to_owned)
        .collect();
    Ok(Config {
        issuer,
        internal_url,
        client_id,
        client_secret,
        redirect_uri,
        public_url,
        allowed_groups,
        admin_emails,
    })
}

fn required(name: &str) -> Result<String, Error> {
    env::var(name)
        .ok()
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| Error::Configuration(format!("{name} is not configured")))
}

fn is_loopback(url: &str) -> bool {
    url.starts_with("http://127.0.0.1")
        || url.starts_with("http://localhost")
        || url.starts_with("http://[::1]")
}

/// Redirect that starts an authorization-code flow.
pub struct LoginRedirect {
    pub location: String,
    pub set_cookie: String,
}

/// Result of a successful callback.
pub struct CallbackOutcome {
    pub location: String,
    pub session_cookie: String,
    pub clear_state_cookie: String,
}

/// Redirect that ends both the local session and the provider session.
pub struct LogoutRedirect {
    pub location: String,
    pub clear_session_cookie: String,
    pub clear_state_cookie: String,
}

/// Query parameters accepted by `/auth/callback`.
pub struct CallbackParams<'a> {
    pub code: Option<&'a str>,
    pub state: Option<&'a str>,
    pub error: Option<&'a str>,
    pub state_cookie: Option<&'a str>,
}

/// Build the `/authorize` redirect and persist the server-side login state.
pub fn login_redirect(
    database: &Database,
    config: &Config,
    return_to: Option<&str>,
) -> Result<LoginRedirect, Error> {
    let login = LoginState {
        state: random_hex()?,
        nonce: random_hex()?,
        code_verifier: base64url(&random_bytes::<32>()?),
        return_to: same_origin_path(return_to).unwrap_or_default(),
    };
    database
        .create_login_state(&login, STATE_TTL)
        .map_err(|error| Error::Internal(format!("could not persist login state: {error}")))?;
    let challenge = base64url(Sha256::digest(login.code_verifier.as_bytes()).as_slice());
    let location = format!(
        "{issuer}/authorize?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}\
         &scope=openid%20profile%20email%20groups&state={state}&nonce={nonce}\
         &code_challenge={challenge}&code_challenge_method=S256",
        issuer = config.issuer,
        client_id = encode(&config.client_id),
        redirect_uri = encode(&config.redirect_uri),
        state = encode(&login.state),
        nonce = encode(&login.nonce),
        challenge = encode(&challenge),
    );
    let set_cookie = format!(
        "{STATE_COOKIE}={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={STATE_TTL}",
        login.state
    );
    Ok(LoginRedirect {
        location,
        set_cookie,
    })
}

/// Complete an authorization-code flow.
///
/// Performs, in order and failing closed: state validation against both the
/// cookie and the single-use server-side store, code exchange, ID token payload
/// checks, mandatory `/introspect`, `/userinfo`, group authorization, and
/// session creation. Blocking: run it on a blocking thread.
pub fn handle_callback(
    database: &Database,
    config: &Config,
    params: &CallbackParams<'_>,
) -> Result<CallbackOutcome, Error> {
    // 1. state present, equal to the cookie, and a live single-use store entry.
    let returned_state = params
        .state
        .filter(|value| !value.is_empty())
        .ok_or_else(|| Error::Invalid("missing authentication state".into()))?;
    let cookie_state = params
        .state_cookie
        .filter(|value| !value.is_empty())
        .ok_or_else(|| Error::Invalid("missing authentication state".into()))?;
    if !constant_time_equal(returned_state.as_bytes(), cookie_state.as_bytes()) {
        return Err(Error::Invalid("authentication state mismatch".into()));
    }
    let login = database
        .take_login_state(returned_state)
        .map_err(|error| Error::Internal(format!("could not read login state: {error}")))?
        .ok_or_else(|| Error::Invalid("authentication state is unknown or expired".into()))?;

    let code = params
        .code
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            Error::Invalid(
                params
                    .error
                    .filter(|value| !value.is_empty())
                    .unwrap_or("authentication failed")
                    .to_owned(),
            )
        })?;

    // 2. Exchange the code with client_secret_basic client authentication.
    let token = exchange_code(config, code, &login.code_verifier)?;

    // 3. ID token payload checks (authenticity is the provider's job, step 4).
    let claims = id_token_claims(&token.id_token)?;
    verify_claims(config, &claims, &login.nonce)?;

    // 4. Mandatory authenticity and revocation check.
    let introspection = introspect(config, &token.access_token)?;
    if !introspection.active {
        return Err(Error::Invalid("access token is not active".into()));
    }
    if introspection.sub.as_deref() != Some(claims.sub.as_str()) {
        return Err(Error::Invalid("token subject mismatch".into()));
    }

    // 5. /userinfo is the only source of identity.
    let identity = userinfo(config, &token.access_token)?;
    if identity.sub != claims.sub {
        return Err(Error::Invalid("userinfo subject mismatch".into()));
    }

    // 6. Authorization.
    authorize_groups(config, &identity)?;

    // 7. Durable session.
    let created_at = unix_time()?;
    let role = identity_role(config, &identity);
    let session = Session {
        id: random_hex()?,
        sub: identity.sub,
        email: identity.email,
        role,
        organization_id: identity.organization_id,
        tenant_id: identity.tenant_id,
        groups: identity.groups,
        id_token: token.id_token,
        created_at,
        expires_at: created_at + SESSION_TTL,
    };
    database
        .create_session(&session)
        .map_err(|error| Error::Internal(format!("could not persist session: {error}")))?;
    Ok(CallbackOutcome {
        location: if login.return_to.is_empty() {
            "/app".into()
        } else {
            login.return_to
        },
        session_cookie: format!(
            "{SESSION_COOKIE}={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_TTL}",
            session.id
        ),
        clear_state_cookie: expired_cookie(STATE_COOKIE),
    })
}

/// Drop the local session and hand the browser to RP-initiated provider logout.
pub fn logout_redirect(
    database: &Database,
    config: &Config,
    session_cookie: Option<&str>,
) -> Result<LogoutRedirect, Error> {
    let id_token = if let Some(id) = session_cookie {
        let session = database
            .session(id)
            .map_err(|error| Error::Internal(format!("could not read session: {error}")))?;
        database
            .delete_session(id)
            .map_err(|error| Error::Internal(format!("could not delete session: {error}")))?;
        session.map(|session| session.id_token)
    } else {
        None
    };
    let mut location = format!(
        "{}/end_session?post_logout_redirect_uri={}",
        config.issuer,
        encode(&format!("{}/", config.public_url))
    );
    if let Some(id_token) = id_token.filter(|token| !token.is_empty()) {
        location.push_str("&id_token_hint=");
        location.push_str(&encode(&id_token));
    }
    Ok(LogoutRedirect {
        location,
        clear_session_cookie: expired_cookie(SESSION_COOKIE),
        clear_state_cookie: expired_cookie(STATE_COOKIE),
    })
}

/// Resolve a live session from the cookie value. Expired records never match.
pub fn require_session(database: &Database, session_cookie: Option<&str>) -> Option<Session> {
    database.session(session_cookie?).ok().flatten()
}

/// Validate a service-scoped access token and map it into the existing
/// session principal model without persisting a browser session.
pub fn access_session(
    config: &Config,
    access_token: &str,
    audience: &str,
) -> Result<Session, Error> {
    let claims = id_token_claims(access_token)?;
    if claims.iss != config.issuer {
        return Err(Error::Invalid("access token issuer mismatch".into()));
    }
    if !claims.aud.contains(audience) {
        return Err(Error::Invalid("access token audience mismatch".into()));
    }
    if claims.exp <= unix_time()? {
        return Err(Error::Invalid("access token has expired".into()));
    }
    let identity = userinfo(config, access_token)?;
    if identity.sub.is_empty() || identity.sub != claims.sub {
        return Err(Error::Invalid("userinfo subject mismatch".into()));
    }
    authorize_groups(config, &identity)?;
    let role = identity_role(config, &identity);
    let created_at = unix_time()?;
    Ok(Session {
        id: format!("access:{}", identity.sub),
        sub: identity.sub,
        email: identity.email,
        role,
        organization_id: identity.organization_id,
        tenant_id: identity.tenant_id,
        groups: identity.groups,
        id_token: String::new(),
        created_at,
        expires_at: claims.exp,
    })
}

fn expired_cookie(name: &str) -> String {
    format!("{name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0")
}

fn authorize_groups(config: &Config, identity: &Identity) -> Result<(), Error> {
    if config.allowed_groups.is_empty() {
        return Ok(());
    }
    if identity
        .groups
        .iter()
        .any(|group| config.allowed_groups.iter().any(|allowed| allowed == group))
    {
        return Ok(());
    }
    Err(Error::Forbidden(
        "your account is not a member of a group permitted to use AkurAI Build".into(),
    ))
}

fn identity_role(config: &Config, identity: &Identity) -> String {
    let role = normalized_role(identity.role.as_deref());
    if role == "member"
        && config
            .admin_emails
            .iter()
            .any(|email| email.eq_ignore_ascii_case(&identity.email))
    {
        "admin".into()
    } else {
        role
    }
}
fn normalized_role(role: Option<&str>) -> String {
    match role {
        Some("owner") => "owner".into(),
        Some("admin") => "admin".into(),
        _ => "member".into(),
    }
}

/// Identity as returned by `GET /userinfo` — the sole source of user facts.
#[derive(Debug, Deserialize)]
pub struct Identity {
    pub sub: String,
    #[serde(default)]
    pub email: String,
    #[serde(default)]
    pub email_verified: bool,
    #[serde(default)]
    pub organization_id: Option<String>,
    #[serde(default)]
    pub tenant_id: Option<String>,
    #[serde(default)]
    pub role: Option<String>,
    #[serde(default)]
    pub groups: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct TokenResponse {
    access_token: String,
    #[serde(default)]
    id_token: String,
}

#[derive(Debug, Deserialize)]
struct Introspection {
    #[serde(default)]
    active: bool,
    #[serde(default)]
    sub: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct IdTokenClaims {
    pub(crate) sub: String,
    #[serde(default)]
    pub(crate) iss: String,
    #[serde(default)]
    pub(crate) aud: Audience,
    #[serde(default)]
    pub(crate) exp: i64,
    #[serde(default)]
    pub(crate) nonce: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(untagged)]
pub(crate) enum Audience {
    One(String),
    Many(Vec<String>),
    #[default]
    Absent,
}

impl Audience {
    fn contains(&self, client_id: &str) -> bool {
        match self {
            Self::One(value) => value == client_id,
            Self::Many(values) => values.iter().any(|value| value == client_id),
            Self::Absent => false,
        }
    }
}

pub(crate) fn id_token_claims(id_token: &str) -> Result<IdTokenClaims, Error> {
    let payload = id_token
        .split('.')
        .nth(1)
        .ok_or_else(|| Error::Invalid("identity token is malformed".into()))?;
    let payload = base64url_decode(payload)
        .ok_or_else(|| Error::Invalid("identity token payload is not base64url".into()))?;
    serde_json::from_slice(&payload)
        .map_err(|error| Error::Invalid(format!("identity token payload is unusable: {error}")))
}

pub(crate) fn verify_claims(
    config: &Config,
    claims: &IdTokenClaims,
    nonce: &str,
) -> Result<(), Error> {
    if claims.iss != config.issuer {
        return Err(Error::Invalid("identity token issuer mismatch".into()));
    }
    if !claims.aud.contains(&config.client_id) {
        return Err(Error::Invalid("identity token audience mismatch".into()));
    }
    if claims.exp <= unix_time()? {
        return Err(Error::Invalid("identity token has expired".into()));
    }
    if claims.nonce.as_deref() != Some(nonce) {
        return Err(Error::Invalid("identity token nonce mismatch".into()));
    }
    Ok(())
}

fn exchange_code(config: &Config, code: &str, verifier: &str) -> Result<TokenResponse, Error> {
    let request = CurlRequest::new(&format!("{}/token", config.internal_url))
        .basic_auth(&config.client_id, &config.client_secret)
        .form("grant_type", "authorization_code")
        .form("code", code)
        .form("redirect_uri", &config.redirect_uri)
        .form("code_verifier", verifier);
    parse(&request.run("token exchange")?, "token exchange")
}

fn introspect(config: &Config, access_token: &str) -> Result<Introspection, Error> {
    let request = CurlRequest::new(&format!("{}/introspect", config.internal_url))
        .basic_auth(&config.client_id, &config.client_secret)
        .form("token", access_token)
        .form("token_type_hint", "access_token");
    parse(&request.run("introspection")?, "introspection")
}

fn userinfo(config: &Config, access_token: &str) -> Result<Identity, Error> {
    let request = CurlRequest::new(&format!("{}/userinfo", config.internal_url))
        .header(&format!("Authorization: Bearer {access_token}"));
    parse(&request.run("userinfo")?, "userinfo")
}

fn parse<T: for<'de> Deserialize<'de>>(body: &[u8], stage: &str) -> Result<T, Error> {
    serde_json::from_slice(body)
        .map_err(|error| Error::Upstream(format!("{stage} response is unusable: {error}")))
}

/// A back-channel call driven entirely through a curl config on stdin so that
/// the client secret and bearer tokens never appear in the process table.
struct CurlRequest {
    directives: String,
}

impl CurlRequest {
    fn new(url: &str) -> Self {
        let mut directives = String::from("silent\nshow-error\nfail\n");
        directives.push_str(&format!("max-time = {BACK_CHANNEL_TIMEOUT}\n"));
        directives.push_str(&format!("url = \"{}\"\n", quote(url)));
        Self { directives }
    }

    fn basic_auth(mut self, user: &str, password: &str) -> Self {
        self.directives.push_str("basic\n");
        self.directives
            .push_str(&format!("user = \"{}:{}\"\n", quote(user), quote(password)));
        self
    }

    fn header(mut self, header: &str) -> Self {
        self.directives
            .push_str(&format!("header = \"{}\"\n", quote(header)));
        self
    }

    /// curl joins repeated `data` directives with `&`, producing a
    /// `application/x-www-form-urlencoded` POST body.
    fn form(mut self, name: &str, value: &str) -> Self {
        self.directives.push_str(&format!(
            "data = \"{}={}\"\n",
            quote(name),
            quote(&encode(value))
        ));
        self
    }

    fn run(self, stage: &str) -> Result<Vec<u8>, Error> {
        let mut child = Command::new("curl")
            .args(["--config", "-"])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .map_err(|error| Error::Upstream(format!("{stage} could not start curl: {error}")))?;
        child
            .stdin
            .take()
            .ok_or_else(|| Error::Upstream(format!("{stage} curl stdin unavailable")))?
            .write_all(self.directives.as_bytes())
            .map_err(|error| Error::Upstream(format!("{stage} request failed: {error}")))?;
        let output = child
            .wait_with_output()
            .map_err(|error| Error::Upstream(format!("{stage} request failed: {error}")))?;
        if output.status.success() {
            Ok(output.stdout)
        } else {
            Err(Error::Upstream(format!(
                "{stage} request to the identity provider failed"
            )))
        }
    }
}

/// Escape a value for a double-quoted curl config directive.
fn quote(value: &str) -> String {
    let mut escaped = String::with_capacity(value.len());
    for character in value.chars() {
        match character {
            '\\' => escaped.push_str("\\\\"),
            '"' => escaped.push_str("\\\""),
            '\t' => escaped.push_str("\\t"),
            '\n' => escaped.push_str("\\n"),
            '\r' => escaped.push_str("\\r"),
            other => escaped.push(other),
        }
    }
    escaped
}

/// Percent-encode everything outside the unreserved set.
pub fn encode(value: &str) -> String {
    let mut encoded = String::with_capacity(value.len());
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                encoded.push(byte as char);
            }
            other => encoded.push_str(&format!("%{other:02X}")),
        }
    }
    encoded
}

/// Accept only a same-origin absolute path: starts with `/`, never `//`.
pub fn same_origin_path(value: Option<&str>) -> Option<String> {
    let value = value?;
    (value.starts_with('/')
        && !value.starts_with("//")
        && value.len() <= 512
        && !value.contains('\\')
        && !value.contains(['\r', '\n']))
    .then(|| value.to_owned())
}

fn random_bytes<const N: usize>() -> Result<[u8; N], Error> {
    let mut bytes = [0_u8; N];
    getrandom::fill(&mut bytes)
        .map_err(|error| Error::Internal(format!("random source unavailable: {error}")))?;
    Ok(bytes)
}

fn random_hex() -> Result<String, Error> {
    let bytes = random_bytes::<32>()?;
    let mut hex = String::with_capacity(64);
    for byte in bytes {
        hex.push_str(&format!("{byte:02x}"));
    }
    Ok(hex)
}

const BASE64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

/// Unpadded base64url, as required for PKCE challenges and JWT segments.
pub fn base64url(bytes: &[u8]) -> String {
    let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
    for chunk in bytes.chunks(3) {
        let block = chunk
            .iter()
            .enumerate()
            .fold(0_u32, |block, (index, byte)| {
                block | (u32::from(*byte) << (16 - 8 * index))
            });
        for index in 0..=chunk.len() {
            let position = (block >> (18 - 6 * index)) & 0x3f;
            encoded.push(char::from(BASE64URL[position as usize]));
        }
    }
    encoded
}

/// Decode unpadded (or padded) base64url. Returns `None` on any invalid input.
pub fn base64url_decode(value: &str) -> Option<Vec<u8>> {
    let value = value.trim_end_matches('=');
    let mut bytes = Vec::with_capacity(value.len() * 3 / 4);
    let mut block = 0_u32;
    let mut bits = 0_u32;
    for character in value.bytes() {
        let position = BASE64URL.iter().position(|entry| *entry == character)?;
        block = (block << 6) | position as u32;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            bytes.push(((block >> bits) & 0xff) as u8);
        }
    }
    Some(bytes)
}

fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
    use subtle::ConstantTimeEq;
    left.len() == right.len() && left.ct_eq(right).into()
}

fn unix_time() -> Result<i64, Error> {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs() as i64)
        .map_err(|error| Error::Internal(format!("system clock is before the epoch: {error}")))
}

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

    fn test_config() -> Config {
        Config {
            issuer: "https://auth.example".into(),
            internal_url: "https://auth.example".into(),
            client_id: "build client".into(),
            client_secret: "secret".into(),
            redirect_uri: "https://build.example/auth/callback".into(),
            public_url: "https://build.example".into(),
            allowed_groups: Vec::new(),
            admin_emails: Vec::new(),
        }
    }

    fn database() -> Database {
        Database::memory("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
            .expect("database")
    }

    #[test]
    fn login_redirect_carries_pkce_state_nonce_and_group_scope() {
        let database = database();
        let config = test_config();
        let redirect = login_redirect(&database, &config, Some("/app/runs")).expect("redirect");
        assert!(
            redirect
                .location
                .starts_with("https://auth.example/authorize?response_type=code")
        );
        assert!(redirect.location.contains("client_id=build%20client"));
        assert!(
            redirect
                .location
                .contains("redirect_uri=https%3A%2F%2Fbuild.example%2Fauth%2Fcallback")
        );
        assert!(
            redirect
                .location
                .contains("scope=openid%20profile%20email%20groups")
        );
        assert!(redirect.location.contains("code_challenge_method=S256"));
        assert!(redirect.location.contains("&state="));
        assert!(redirect.location.contains("&nonce="));
        assert!(redirect.set_cookie.starts_with("akurai_oauth_state="));
        assert!(redirect.set_cookie.contains("HttpOnly"));
        assert!(redirect.set_cookie.contains("Secure"));
        assert!(redirect.set_cookie.contains("SameSite=Lax"));
        assert!(redirect.set_cookie.contains("Max-Age=600"));
    }

    #[test]
    fn login_state_is_single_use_and_bound_to_the_cookie() {
        let database = database();
        let config = test_config();
        let redirect = login_redirect(&database, &config, None).expect("redirect");
        let state = redirect
            .set_cookie
            .trim_start_matches("akurai_oauth_state=")
            .split(';')
            .next()
            .expect("state value")
            .to_owned();

        // A returned state that does not match the cookie is refused before any
        // back-channel call happens.
        let mismatch = handle_callback(
            &database,
            &config,
            &CallbackParams {
                code: Some("code"),
                state: Some(&state),
                error: None,
                state_cookie: Some("something-else"),
            },
        );
        assert!(matches!(mismatch, Err(Error::Invalid(_))));

        // The store entry is consumed on first use; replay is refused.
        assert!(
            database
                .take_login_state(&state)
                .expect("take")
                .is_some_and(|login| login.state == state)
        );
        assert!(database.take_login_state(&state).expect("take").is_none());
    }

    #[test]
    fn callback_rejects_unknown_state() {
        let database = database();
        let config = test_config();
        let result = handle_callback(
            &database,
            &config,
            &CallbackParams {
                code: Some("code"),
                state: Some("deadbeef"),
                error: None,
                state_cookie: Some("deadbeef"),
            },
        );
        assert!(matches!(result, Err(Error::Invalid(_))));
    }

    #[test]
    fn callback_requires_a_state_at_all() {
        let database = database();
        let config = test_config();
        for params in [
            CallbackParams {
                code: Some("code"),
                state: None,
                error: None,
                state_cookie: Some("abc"),
            },
            CallbackParams {
                code: Some("code"),
                state: Some("abc"),
                error: None,
                state_cookie: None,
            },
        ] {
            assert!(matches!(
                handle_callback(&database, &config, &params),
                Err(Error::Invalid(_))
            ));
        }
    }

    #[test]
    fn id_token_claims_are_validated_against_issuer_audience_expiry_and_nonce() {
        let config = test_config();
        let future = unix_time().expect("clock") + 300;
        let claims = |issuer: &str, audience: &str, exp: i64, nonce: &str| {
            let payload = serde_json::json!({
                "sub": "user-1",
                "iss": issuer,
                "aud": audience,
                "exp": exp,
                "nonce": nonce,
            });
            let token = format!(
                "header.{}.signature",
                base64url(payload.to_string().as_bytes())
            );
            id_token_claims(&token).expect("claims")
        };

        let good = claims("https://auth.example", "build client", future, "n1");
        assert!(verify_claims(&config, &good, "n1").is_ok());
        assert!(verify_claims(&config, &good, "other").is_err());

        let wrong_issuer = claims("https://evil.example", "build client", future, "n1");
        assert!(verify_claims(&config, &wrong_issuer, "n1").is_err());

        let wrong_audience = claims("https://auth.example", "other-client", future, "n1");
        assert!(verify_claims(&config, &wrong_audience, "n1").is_err());

        let expired = claims("https://auth.example", "build client", 1, "n1");
        assert!(verify_claims(&config, &expired, "n1").is_err());
    }

    #[test]
    fn audience_accepts_an_array_containing_the_client() {
        let claims: IdTokenClaims = serde_json::from_value(serde_json::json!({
            "sub": "user-1",
            "iss": "https://auth.example",
            "aud": ["other", "build client"],
            "exp": 4_102_444_800_i64,
            "nonce": "n1",
        }))
        .expect("claims");
        assert!(verify_claims(&test_config(), &claims, "n1").is_ok());
    }

    #[test]
    fn allowed_groups_gate_membership() {
        let mut config = test_config();
        let identity = |groups: &[&str]| Identity {
            sub: "user-1".into(),
            email: "user@example.com".into(),
            email_verified: true,
            organization_id: None,
            tenant_id: None,
            role: Some("member".into()),
            groups: groups.iter().map(|group| (*group).to_owned()).collect(),
        };
        assert!(authorize_groups(&config, &identity(&[])).is_ok());
        config.allowed_groups = vec!["builders".into()];
        assert!(authorize_groups(&config, &identity(&["builders"])).is_ok());
        assert!(matches!(
            authorize_groups(&config, &identity(&["other"])),
            Err(Error::Forbidden(_))
        ));
    }

    #[test]
    fn configured_admin_email_maps_existing_identity_to_admin() {
        let mut config = test_config();
        config.admin_emails = vec!["owner@example.com".into()];
        let identity = Identity {
            sub: "user-1".into(),
            email: "OWNER@example.com".into(),
            email_verified: true,
            organization_id: None,
            tenant_id: None,
            role: Some("member".into()),
            groups: Vec::new(),
        };
        assert_eq!(identity_role(&config, &identity), "admin");
        config.admin_emails.clear();
        assert_eq!(identity_role(&config, &identity), "member");
    }

    #[test]
    fn expired_sessions_are_never_returned_and_are_deleted() {
        let database = database();
        let now = unix_time().expect("clock");
        let expired = Session {
            id: "a".repeat(64),
            sub: "user-1".into(),
            email: "user@example.com".into(),
            role: "member".into(),
            organization_id: None,
            tenant_id: None,
            groups: Vec::new(),
            id_token: String::new(),
            created_at: now - SESSION_TTL - 60,
            expires_at: now - 1,
        };
        database.create_session(&expired).expect("insert");
        assert!(require_session(&database, Some(&expired.id)).is_none());
        // Enforcement also purges the row rather than leaving it readable.
        assert!(database.session(&expired.id).expect("lookup").is_none());

        let live = Session {
            id: "b".repeat(64),
            expires_at: now + SESSION_TTL,
            created_at: now,
            ..expired.clone()
        };
        database.create_session(&live).expect("insert");
        let found = require_session(&database, Some(&live.id)).expect("live session");
        assert_eq!(found.email, "user@example.com");
        assert!(!found.is_superuser());
    }

    #[test]
    fn logout_clears_the_session_and_asks_the_provider_to_end_it() {
        let database = database();
        let config = test_config();
        let now = unix_time().expect("clock");
        let session = Session {
            id: "c".repeat(64),
            sub: "user-1".into(),
            email: "user@example.com".into(),
            role: "owner".into(),
            organization_id: None,
            tenant_id: None,
            groups: Vec::new(),
            id_token: "header.payload.signature".into(),
            created_at: now,
            expires_at: now + SESSION_TTL,
        };
        database.create_session(&session).expect("insert");
        let redirect =
            logout_redirect(&database, &config, Some(&session.id)).expect("logout redirect");
        assert!(
            redirect
                .location
                .starts_with("https://auth.example/end_session?")
        );
        assert!(
            redirect
                .location
                .contains("post_logout_redirect_uri=https%3A%2F%2Fbuild.example%2F")
        );
        assert!(
            redirect
                .location
                .contains("id_token_hint=header.payload.signature")
        );
        assert!(redirect.clear_session_cookie.contains("Max-Age=0"));
        assert!(require_session(&database, Some(&session.id)).is_none());
    }

    #[test]
    fn superuser_roles_are_owner_and_admin() {
        for (role, expected) in [("owner", true), ("admin", true), ("member", false)] {
            let session = Session {
                id: "d".repeat(64),
                sub: "s".into(),
                email: "e@example.com".into(),
                role: role.into(),
                organization_id: None,
                tenant_id: None,
                groups: Vec::new(),
                id_token: String::new(),
                created_at: 0,
                expires_at: 0,
            };
            assert_eq!(session.is_superuser(), expected);
        }
        assert_eq!(normalized_role(Some("owner")), "owner");
        assert_eq!(normalized_role(Some("admin")), "admin");
        assert_eq!(normalized_role(Some("nonsense")), "member");
        assert_eq!(normalized_role(None), "member");
    }

    #[test]
    fn return_to_accepts_only_same_origin_paths() {
        assert_eq!(
            same_origin_path(Some("/app/runs")),
            Some("/app/runs".into())
        );
        assert_eq!(same_origin_path(Some("//evil.example")), None);
        assert_eq!(same_origin_path(Some("https://evil.example")), None);
        assert_eq!(same_origin_path(Some("app/runs")), None);
        assert_eq!(same_origin_path(Some("/app\\runs")), None);
        assert_eq!(same_origin_path(Some("/app\nSet-Cookie: x")), None);
        assert_eq!(same_origin_path(None), None);
    }

    #[test]
    fn base64url_round_trips_and_matches_the_pkce_vector() {
        // RFC 7636 appendix B.
        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
        assert_eq!(
            base64url(Sha256::digest(verifier.as_bytes()).as_slice()),
            "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
        );
        for payload in [
            b"".as_slice(),
            b"a",
            b"ab",
            b"abc",
            b"abcd",
            b"\x00\xff\x10",
        ] {
            let encoded = base64url(payload);
            assert!(!encoded.contains('='));
            assert_eq!(base64url_decode(&encoded).as_deref(), Some(payload));
        }
        assert_eq!(base64url_decode("!!!"), None);
    }

    #[test]
    fn percent_encoding_preserves_unreserved_bytes_only() {
        assert_eq!(encode("aZ0-._~"), "aZ0-._~");
        assert_eq!(encode("a b/c?d&e=f"), "a%20b%2Fc%3Fd%26e%3Df");
        assert_eq!(encode("\u{00e9}"), "%C3%A9");
    }

    #[test]
    fn curl_config_quoting_neutralises_directive_injection() {
        assert_eq!(quote("plain"), "plain");
        assert_eq!(quote("a\"b"), "a\\\"b");
        assert_eq!(quote("a\\b"), "a\\\\b");
        assert_eq!(
            quote("a\nurl = \"http://evil\""),
            "a\\nurl = \\\"http://evil\\\""
        );
    }

    #[test]
    fn loopback_internal_urls_fall_back_to_the_issuer() {
        assert!(is_loopback("http://127.0.0.1:3500"));
        assert!(is_loopback("http://localhost:3500"));
        assert!(!is_loopback("https://auth.olibuijr.com"));
    }
}