use std::time::{SystemTime, UNIX_EPOCH};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use super::OidcProviderConfig;
use super::types::AuthorizationCode;
pub const CODE_LIFETIME_SECS: f64 = 60.0;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct AuthorizeRequest {
pub response_type: String,
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
pub state: Option<String>,
pub nonce: Option<String>,
pub code_challenge: Option<String>,
pub code_challenge_method: Option<String>,
pub prompt: Option<String>,
pub max_age: Option<u32>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum AuthorizeValidation {
Ok {
scopes: Vec<String>,
},
Fatal { reason: String },
Redirect {
error: &'static str,
description: String,
},
}
pub fn validate(req: &AuthorizeRequest, client: &super::types::OidcClient) -> AuthorizeValidation {
if req.response_type != "code" {
return AuthorizeValidation::Redirect {
error: "unsupported_response_type",
description: format!("response_type {:?} is not supported", req.response_type),
};
}
if !client.redirect_matches(&req.redirect_uri) {
return AuthorizeValidation::Fatal {
reason: "redirect_uri does not match a registered URI".to_string(),
};
}
if !client.allows_grant("authorization_code") {
return AuthorizeValidation::Redirect {
error: "unauthorized_client",
description: "client does not allow authorization_code grant".to_string(),
};
}
let scopes: Vec<String> = req
.scope
.split_whitespace()
.map(|s| s.to_string())
.collect();
if !scopes.iter().any(|s| s == "openid") {
return AuthorizeValidation::Redirect {
error: "invalid_scope",
description: "openid scope required".to_string(),
};
}
if client.pkce_required {
let Some(method) = req.code_challenge_method.as_deref() else {
return AuthorizeValidation::Redirect {
error: "invalid_request",
description: "code_challenge_method required (S256)".to_string(),
};
};
if method != "S256" {
return AuthorizeValidation::Redirect {
error: "invalid_request",
description: "code_challenge_method must be S256".to_string(),
};
}
if req
.code_challenge
.as_deref()
.map(str::is_empty)
.unwrap_or(true)
{
return AuthorizeValidation::Redirect {
error: "invalid_request",
description: "code_challenge required for this client".to_string(),
};
}
}
AuthorizeValidation::Ok { scopes }
}
pub fn build_code(user_id: &str, req: &AuthorizeRequest, scopes: Vec<String>) -> AuthorizationCode {
let code = format!("oac_{}", random_token());
let now = now_secs();
AuthorizationCode {
code,
client_id: req.client_id.clone(),
user_id: user_id.to_string(),
redirect_uri: req.redirect_uri.clone(),
scopes,
code_challenge: req.code_challenge.clone().unwrap_or_default(),
code_challenge_method: req
.code_challenge_method
.clone()
.unwrap_or_else(|| "S256".to_string()),
nonce: req.nonce.clone(),
state: req.state.clone(),
issued_at: now,
expires_at: now + CODE_LIFETIME_SECS,
consumed: false,
}
}
pub fn redirect_with_code(redirect_uri: &str, code: &str, state: Option<&str>) -> String {
let sep = if redirect_uri.contains('?') { '&' } else { '?' };
let mut s = format!("{redirect_uri}{sep}code={}", url_encode(code));
if let Some(st) = state {
s.push_str(&format!("&state={}", url_encode(st)));
}
s
}
pub fn redirect_with_error(
redirect_uri: &str,
error: &str,
description: &str,
state: Option<&str>,
) -> String {
let sep = if redirect_uri.contains('?') { '&' } else { '?' };
let mut s = format!(
"{redirect_uri}{sep}error={}&error_description={}",
url_encode(error),
url_encode(description)
);
if let Some(st) = state {
s.push_str(&format!("&state={}", url_encode(st)));
}
s
}
pub fn return_to_for(authorize_url: &str) -> String {
format!("/auth/login?return_to={}", url_encode(authorize_url))
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
fn random_token() -> String {
let mut buf = [0u8; 32];
rand::rng().fill_bytes(&mut buf);
data_encoding::BASE64URL_NOPAD.encode(&buf)
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{:02X}", byte));
}
}
out
}
pub fn provider_issuer(p: &OidcProviderConfig) -> &str {
&p.issuer
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oidc_provider::types::{OidcClient, TokenAuthMethod};
fn confidential_client() -> OidcClient {
let mut c = OidcClient::new("c1", "App", 0.0);
c.redirect_uris = vec!["https://app.example.com/cb".to_string()];
c
}
fn req() -> AuthorizeRequest {
AuthorizeRequest {
response_type: "code".to_string(),
client_id: "c1".to_string(),
redirect_uri: "https://app.example.com/cb".to_string(),
scope: "openid email".to_string(),
state: Some("st_xyz".to_string()),
nonce: Some("n_abc".to_string()),
code_challenge: Some("ch_abc".to_string()),
code_challenge_method: Some("S256".to_string()),
prompt: None,
max_age: None,
}
}
#[test]
fn validate_happy_path() {
let r = req();
let c = confidential_client();
match validate(&r, &c) {
AuthorizeValidation::Ok { scopes } => {
assert_eq!(scopes, vec!["openid".to_string(), "email".to_string()])
}
other => panic!("expected Ok, got {other:?}"),
}
}
#[test]
fn validate_rejects_bad_response_type() {
let mut r = req();
r.response_type = "token".to_string();
let v = validate(&r, &confidential_client());
match v {
AuthorizeValidation::Redirect { error, .. } => {
assert_eq!(error, "unsupported_response_type")
}
other => panic!("expected Redirect, got {other:?}"),
}
}
#[test]
fn validate_rejects_unregistered_redirect_with_fatal() {
let mut r = req();
r.redirect_uri = "https://attacker.example.com/cb".to_string();
let v = validate(&r, &confidential_client());
assert!(matches!(v, AuthorizeValidation::Fatal { .. }));
}
#[test]
fn validate_rejects_missing_pkce_when_required() {
let mut r = req();
r.code_challenge = None;
let v = validate(&r, &confidential_client());
match v {
AuthorizeValidation::Redirect { error, .. } => {
assert_eq!(error, "invalid_request")
}
other => panic!("expected Redirect, got {other:?}"),
}
}
#[test]
fn validate_skips_pkce_check_for_non_pkce_client() {
let mut c = confidential_client();
c.pkce_required = false;
c.token_endpoint_auth_method = TokenAuthMethod::ClientSecretBasic;
let mut r = req();
r.code_challenge = None;
r.code_challenge_method = None;
assert!(matches!(validate(&r, &c), AuthorizeValidation::Ok { .. }));
}
#[test]
fn validate_rejects_missing_openid_scope() {
let mut r = req();
r.scope = "email".to_string();
let v = validate(&r, &confidential_client());
match v {
AuthorizeValidation::Redirect { error, .. } => assert_eq!(error, "invalid_scope"),
other => panic!("expected Redirect, got {other:?}"),
}
}
#[test]
fn build_code_carries_request_metadata() {
let r = req();
let scopes = vec!["openid".to_string(), "email".to_string()];
let c = build_code("user_alice", &r, scopes.clone());
assert_eq!(c.client_id, "c1");
assert_eq!(c.user_id, "user_alice");
assert_eq!(c.redirect_uri, "https://app.example.com/cb");
assert_eq!(c.scopes, scopes);
assert_eq!(c.code_challenge, "ch_abc");
assert_eq!(c.code_challenge_method, "S256");
assert_eq!(c.nonce.as_deref(), Some("n_abc"));
assert_eq!(c.state.as_deref(), Some("st_xyz"));
assert!(c.code.starts_with("oac_"));
assert!((c.expires_at - c.issued_at - CODE_LIFETIME_SECS).abs() < 1.0);
assert!(!c.consumed);
}
#[test]
fn redirect_with_code_appends_code_and_state() {
let url = redirect_with_code("https://app.example.com/cb", "oac_abc", Some("st_xyz"));
assert_eq!(url, "https://app.example.com/cb?code=oac_abc&state=st_xyz");
}
#[test]
fn redirect_with_existing_query_uses_amp() {
let url = redirect_with_code("https://app.example.com/cb?prefilled=1", "oac_a", None);
assert_eq!(url, "https://app.example.com/cb?prefilled=1&code=oac_a");
}
#[test]
fn redirect_with_error_includes_description() {
let url = redirect_with_error(
"https://app.example.com/cb",
"access_denied",
"user said no",
Some("st"),
);
assert!(url.contains("error=access_denied"));
assert!(url.contains("error_description=user%20said%20no"));
assert!(url.contains("state=st"));
}
#[test]
fn return_to_encodes_authorize_url() {
let r = return_to_for("https://idp.example.com/authorize?client_id=c1&state=x");
assert!(r.starts_with("/auth/login?return_to="));
assert!(r.contains("%3F"));
}
}