use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryDoc {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: String,
pub jwks_uri: String,
pub response_types_supported: Vec<String>,
pub subject_types_supported: Vec<String>,
pub id_token_signing_alg_values_supported: Vec<String>,
pub scopes_supported: Vec<String>,
pub token_endpoint_auth_methods_supported: Vec<String>,
pub claims_supported: Vec<String>,
}
impl DiscoveryDoc {
pub fn for_issuer(issuer: &str) -> Self {
let issuer = issuer.trim_end_matches('/').to_string();
Self {
issuer: issuer.clone(),
authorization_endpoint: format!("{issuer}/oidc/authorize"),
token_endpoint: format!("{issuer}/oidc/token"),
userinfo_endpoint: format!("{issuer}/oidc/userinfo"),
jwks_uri: format!("{issuer}/oidc/jwks"),
response_types_supported: vec!["code".into()],
subject_types_supported: vec!["public".into()],
id_token_signing_alg_values_supported: vec!["RS256".into()],
scopes_supported: vec![
"openid".into(),
"email".into(),
"profile".into(),
],
token_endpoint_auth_methods_supported: vec![
"client_secret_post".into(),
"client_secret_basic".into(),
],
claims_supported: vec![
"sub".into(),
"email".into(),
"email_verified".into(),
"name".into(),
"preferred_username".into(),
"picture".into(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Jwk {
pub kty: String,
pub alg: String,
#[serde(rename = "use")]
pub use_: String,
pub kid: String,
pub n: String,
pub e: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Jwks {
pub keys: Vec<Jwk>,
}
impl Jwks {
pub fn one(key: Jwk) -> Self {
Self { keys: vec![key] }
}
}
#[derive(Debug, Clone)]
pub struct AuthCode {
pub code: String,
pub user_id: String,
pub client_id: String,
pub redirect_uri: String,
pub scopes: Vec<String>,
pub nonce: Option<String>,
pub code_challenge: Option<String>,
pub code_challenge_method: Option<String>,
pub expires_at: u64,
}
pub struct AuthCodeStore {
codes: std::sync::Mutex<std::collections::HashMap<String, AuthCode>>,
}
impl Default for AuthCodeStore {
fn default() -> Self {
Self {
codes: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
}
impl AuthCodeStore {
pub fn new() -> Self {
Self::default()
}
pub fn put(&self, code: AuthCode) {
self.codes.lock().unwrap().insert(code.code.clone(), code);
}
pub fn take(&self, code: &str) -> Option<AuthCode> {
let mut map = self.codes.lock().unwrap();
let entry = map.remove(code)?;
if entry.expires_at <= now_secs() {
return None;
}
Some(entry)
}
}
fn now_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovery_doc_uses_issuer_for_endpoints() {
let doc = DiscoveryDoc::for_issuer("https://auth.example.com");
assert_eq!(doc.issuer, "https://auth.example.com");
assert_eq!(doc.authorization_endpoint, "https://auth.example.com/oidc/authorize");
assert_eq!(doc.token_endpoint, "https://auth.example.com/oidc/token");
assert_eq!(doc.jwks_uri, "https://auth.example.com/oidc/jwks");
assert!(doc.id_token_signing_alg_values_supported.contains(&"RS256".to_string()));
}
#[test]
fn discovery_doc_strips_trailing_slash() {
let doc = DiscoveryDoc::for_issuer("https://auth.example.com/");
assert_eq!(doc.issuer, "https://auth.example.com");
assert!(doc.token_endpoint.ends_with("/oidc/token"));
assert!(!doc.token_endpoint.contains("//oidc"));
}
#[test]
fn discovery_doc_serializes_to_json() {
let doc = DiscoveryDoc::for_issuer("https://auth.example.com");
let json = serde_json::to_string(&doc).unwrap();
assert!(json.contains("\"issuer\""));
assert!(json.contains("\"jwks_uri\""));
assert!(json.contains("\"response_types_supported\""));
}
#[test]
fn jwks_serializes_canonical_shape() {
let jwks = Jwks::one(Jwk {
kty: "RSA".into(),
alg: "RS256".into(),
use_: "sig".into(),
kid: "key-1".into(),
n: "modulus_b64url".into(),
e: "AQAB".into(),
});
let json = serde_json::to_string(&jwks).unwrap();
assert!(json.contains("\"use\":\"sig\""));
assert!(json.contains("\"kty\":\"RSA\""));
assert!(json.contains("\"alg\":\"RS256\""));
}
#[test]
fn auth_code_store_round_trip() {
let store = AuthCodeStore::new();
let code = AuthCode {
code: "tok123".into(),
user_id: "u1".into(),
client_id: "c1".into(),
redirect_uri: "https://app/cb".into(),
scopes: vec!["openid".into()],
nonce: Some("n".into()),
code_challenge: None,
code_challenge_method: None,
expires_at: 9_999_999_999,
};
store.put(code.clone());
let taken = store.take("tok123").unwrap();
assert_eq!(taken.user_id, "u1");
assert!(store.take("tok123").is_none());
}
#[test]
fn auth_code_expired_rejected() {
let store = AuthCodeStore::new();
store.put(AuthCode {
code: "old".into(),
user_id: "u1".into(),
client_id: "c1".into(),
redirect_uri: "x".into(),
scopes: vec![],
nonce: None,
code_challenge: None,
code_challenge_method: None,
expires_at: 1, });
assert!(store.take("old").is_none());
}
}