use serde::{Deserialize, Serialize};
use super::cookie_signer::{CookieError, CookieSigner};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SessionClaims {
pub sub: String,
pub tenant_id: String,
pub amr: String,
pub iss: String,
pub aud: String,
pub sid: String,
pub exp: i64,
}
#[derive(Debug, thiserror::Error)]
pub enum SessionTokenError {
#[error("invalid token format")]
InvalidFormat,
#[error("invalid signature")]
InvalidSignature,
#[error("token expired")]
Expired,
#[error("invalid issuer")]
InvalidIssuer,
#[error("invalid audience")]
InvalidAudience,
#[error("session revoked")]
Revoked,
#[error("serialization failed: {0}")]
Serialization(#[from] serde_json::Error),
}
impl From<CookieError> for SessionTokenError {
fn from(err: CookieError) -> Self {
match err {
CookieError::InvalidFormat | CookieError::WeakKey => SessionTokenError::InvalidFormat,
CookieError::InvalidBase64 | CookieError::InvalidSignature => {
SessionTokenError::InvalidSignature
}
}
}
}
#[derive(Clone)]
pub struct SessionTokenSigner {
signer: CookieSigner,
ttl_seconds: i64,
issuer: String,
}
impl SessionTokenSigner {
pub fn new(secret: impl AsRef<[u8]>, ttl_seconds: i64, issuer: impl Into<String>) -> Self {
Self {
signer: CookieSigner::new(secret)
.expect("session token secret must be at least 32 bytes"),
ttl_seconds,
issuer: issuer.into(),
}
}
pub fn issue(
&self,
sub: &str,
tenant_id: &str,
amr: &str,
) -> Result<(String, SessionClaims), SessionTokenError> {
let sid = Self::generate_sid();
let claims = SessionClaims {
sub: sub.into(),
tenant_id: tenant_id.into(),
amr: amr.into(),
iss: self.issuer.clone(),
aud: tenant_id.into(),
sid,
exp: (chrono::Utc::now().timestamp() + self.ttl_seconds),
};
let payload = serde_json::to_string(&claims)?;
let token = self.signer.sign(&payload);
Ok((token, claims))
}
pub fn verify(&self, token: &str) -> Result<SessionClaims, SessionTokenError> {
let payload = self.signer.verify(token)?;
let claims: SessionClaims = serde_json::from_str(&payload)?;
if claims.exp < chrono::Utc::now().timestamp() {
return Err(SessionTokenError::Expired);
}
if claims.iss != self.issuer {
return Err(SessionTokenError::InvalidIssuer);
}
if claims.aud != claims.tenant_id {
return Err(SessionTokenError::InvalidAudience);
}
Ok(claims)
}
fn generate_sid() -> String {
use rand_core::RngCore;
let mut bytes = [0u8; 32];
rand_core::OsRng.fill_bytes(&mut bytes);
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_signer() -> SessionTokenSigner {
SessionTokenSigner::new(
"super-secret-key-that-is-at-least-32-bytes-long",
3600,
"https://gateway.example.com",
)
}
#[test]
fn issue_and_verify_round_trip() {
let signer = test_signer();
let (token, issued_claims) = signer.issue("sub-1", "tenant-1", "oidc").unwrap();
let claims = signer.verify(&token).unwrap();
assert_eq!(claims.sub, "sub-1");
assert_eq!(claims.tenant_id, "tenant-1");
assert_eq!(claims.amr, "oidc");
assert_eq!(claims.iss, "https://gateway.example.com");
assert_eq!(claims.aud, "tenant-1");
assert_eq!(claims.sid, issued_claims.sid);
assert!(!claims.sid.is_empty());
assert!(claims.exp > chrono::Utc::now().timestamp());
}
#[test]
fn verify_rejects_wrong_issuer() {
let signer = test_signer();
let (token, _) = signer.issue("sub-1", "tenant-1", "oidc").unwrap();
let signer2 = SessionTokenSigner::new(
"super-secret-key-that-is-at-least-32-bytes-long",
3600,
"https://evil.example.com",
);
let err = signer2.verify(&token).unwrap_err();
assert!(matches!(err, SessionTokenError::InvalidIssuer));
}
#[test]
fn verify_rejects_tampered_token() {
let signer = test_signer();
let (token, _) = signer.issue("sub-1", "tenant-1", "oidc").unwrap();
let mut chars: Vec<char> = token.chars().collect();
if chars[10] == 'A' {
chars[10] = 'B';
} else {
chars[10] = 'A';
}
let tampered = chars.into_iter().collect::<String>();
assert!(matches!(
signer.verify(&tampered).unwrap_err(),
SessionTokenError::InvalidSignature
));
}
#[test]
fn verify_rejects_expired_token() {
let signer = SessionTokenSigner::new(
"super-secret-key-that-is-at-least-32-bytes-long",
-1,
"https://gateway.example.com",
);
let (token, _) = signer.issue("sub-1", "tenant-1", "oidc").unwrap();
assert!(matches!(
signer.verify(&token).unwrap_err(),
SessionTokenError::Expired
));
}
}