Menu
AkurAI-Build
publicLatest 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, ¶ms),
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"));
}
}