use super::jwt::{Algorithm, ClaimMap, Issuer, JwtAuth, read_json};
use super::seal::Sealer;
use crate::ctx::Identity;
use crate::platform::Secret;
use crate::server::Authenticator;
use axum::http::HeaderMap;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
pub(crate) const SESSION: &str = "zeyn_session";
pub(crate) const FLOW: &str = "zeyn_oidc";
pub(crate) const MAX_COOKIE: usize = 3800;
pub(crate) const FLOW_TTL: i64 = 600;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct Session {
#[serde(rename = "u")]
pub user: String,
#[serde(rename = "t", default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
#[serde(rename = "ts", default, skip_serializing_if = "Vec::is_empty")]
pub tenants: Vec<String>,
#[serde(rename = "z", default, skip_serializing_if = "Option::is_none")]
pub time_zone: Option<String>,
#[serde(rename = "r", default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
#[serde(rename = "p", default, skip_serializing_if = "Vec::is_empty")]
pub permissions: Vec<String>,
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
pub auth_time: Option<i64>,
#[serde(rename = "m", default)]
pub mfa: bool,
#[serde(rename = "e", default)]
pub expires: i64,
#[serde(rename = "x", default)]
pub check: i64,
#[serde(rename = "rt", default, skip_serializing_if = "Option::is_none")]
pub refresh: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct Flow {
#[serde(rename = "s")]
pub state: String,
#[serde(rename = "n")]
pub nonce: String,
#[serde(rename = "v")]
pub verifier: String,
#[serde(rename = "r")]
pub return_to: String,
#[serde(rename = "m", default)]
pub mfa: bool,
#[serde(rename = "e")]
pub expires: i64,
}
pub struct OidcAuth {
pub(crate) issuer: Arc<Issuer>,
pub(crate) client_id: String,
client_secret: Option<Secret<String>>,
pub(crate) sealer: Option<Sealer>,
pub(crate) public_url: String,
pub(crate) scopes: String,
pub(crate) algorithms: Vec<Algorithm>,
pub(crate) leeway: Duration,
pub(crate) claims: ClaimMap,
pub(crate) lifetime: Duration,
pub(crate) refresh: bool,
bearer: Option<String>,
bearer_auth: Option<JwtAuth>,
}
impl OidcAuth {
pub fn new(issuer: &str, client_id: &str, public_url: &str, session_key: Secret<String>) -> Self {
OidcAuth {
issuer: Arc::new(Issuer::new(issuer)),
client_id: client_id.to_string(),
client_secret: None,
sealer: Sealer::new(&session_key),
public_url: public_url.trim_end_matches('/').to_string(),
scopes: "openid profile email".into(),
algorithms: vec![Algorithm::RS256],
leeway: Duration::from_secs(60),
claims: ClaimMap::new(),
lifetime: Duration::from_secs(8 * 3600),
refresh: false,
bearer: None,
bearer_auth: None,
}
}
pub fn client_secret(mut self, secret: Secret<String>) -> Self {
self.client_secret = Some(secret);
self
}
pub fn scopes(mut self, scopes: &str) -> Self {
self.scopes = if scopes.split_whitespace().any(|s| s == "openid") {
scopes.to_string()
} else {
format!("openid {scopes}")
};
self
}
pub fn claims(mut self, claims: ClaimMap) -> Self {
self.claims = claims;
self
}
pub fn algorithms<I: IntoIterator<Item = Algorithm>>(mut self, algorithms: I) -> Self {
self.algorithms = algorithms.into_iter().collect();
self
}
pub fn leeway(mut self, leeway: Duration) -> Self {
self.leeway = leeway.min(Duration::from_secs(300));
self
}
pub fn session_lifetime(mut self, lifetime: Duration) -> Self {
self.lifetime = lifetime;
self
}
pub fn refresh_tokens(mut self, on: bool) -> Self {
self.refresh = on;
self
}
pub fn accept_bearer(mut self, audience: &str) -> Self {
self.bearer = Some(audience.to_string());
self
}
pub(crate) fn finish(&mut self) {
if let Some(audience) = &self.bearer {
self.bearer_auth = Some(JwtAuth::uses(
self.issuer.clone(),
audience,
self.algorithms.clone(),
self.leeway,
self.claims.clone(),
));
}
}
pub(crate) fn problems(&self) -> Vec<String> {
let mut problems = Vec::new();
if self.sealer.is_none() {
problems.push(format!("OidcAuth: the session key must be at least {} bytes", super::seal::MIN_SECRET));
}
if !(self.public_url.starts_with("https://") || self.public_url.starts_with("http://")) {
problems
.push("OidcAuth: the public URL must start with https:// (or http:// for local development)".into());
}
problems
}
pub(crate) fn secure(&self) -> bool {
self.public_url.starts_with("https://")
}
pub(crate) fn open_session(&self, value: &str) -> Option<Session> {
let s: Session = self.sealer.as_ref()?.open(SESSION, value)?;
(s.expires > super::now_secs()).then_some(s)
}
pub(crate) async fn authorize_url(&self, flow: &Flow, redirect_uri: &str, reauth: bool) -> Result<String, String> {
let d = self.issuer.discovery().await?;
let challenge = super::b64url(ring::digest::digest(&ring::digest::SHA256, flow.verifier.as_bytes()).as_ref());
let mut url = format!(
"{}{}response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
d.authorization_endpoint,
if d.authorization_endpoint.contains('?') { '&' } else { '?' },
pct(&self.client_id),
pct(redirect_uri),
pct(&self.scopes),
pct(&flow.state),
pct(&flow.nonce),
challenge,
);
if flow.mfa && !self.claims.mfa_acr_values().is_empty() {
url.push_str(&format!("&acr_values={}", pct(&self.claims.mfa_acr_values().join(" "))));
}
if flow.mfa || reauth {
url.push_str("&prompt=login&max_age=0");
}
Ok(url)
}
async fn token_call(&self, form: &[(&str, &str)]) -> Result<Json, String> {
let d = self.issuer.discovery().await?;
let mut body = String::new();
for (k, v) in form {
if !body.is_empty() {
body.push('&');
}
body.push_str(&format!("{}={}", pct(k), pct(v)));
}
let mut req = self
.issuer
.http()
.post(&d.token_endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.header("accept", "application/json");
match &self.client_secret {
Some(secret) => req = req.basic_auth(pct(&self.client_id), Some(pct(secret.expose()))),
None => {
body.push_str("&client_id=");
body.push_str(&pct(&self.client_id));
}
}
read_json(req.body(body).send().await.map_err(|e| format!("token endpoint: {e}"))?).await
}
pub(crate) async fn exchange(&self, code: &str, flow: &Flow, redirect_uri: &str) -> Result<Session, String> {
let tokens = self
.token_call(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &flow.verifier),
])
.await?;
self.session(&tokens, Some(&flow.nonce), None).await
}
#[cfg(feature = "accounts")]
pub(crate) async fn exchange_claims(
&self,
code: &str,
flow: &Flow,
redirect_uri: &str,
) -> Result<serde_json::Map<String, Json>, String> {
let tokens = self
.token_call(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &flow.verifier),
])
.await?;
let id_token = tokens.get("id_token").and_then(Json::as_str).ok_or("the token response has no ID token")?;
let claims = self
.issuer
.validate_id_token(id_token, &self.client_id, &self.algorithms, self.leeway)
.await
.map_err(|why| format!("ID token refused: {why:?}"))?;
if !claims.get("nonce").and_then(Json::as_str).is_some_and(|n| crate::server::same(n, &flow.nonce)) {
return Err(format!("ID token refused: {:?}", super::jwt::Refusal::Nonce));
}
Ok(claims)
}
pub(crate) async fn renew(&self, old: &Session) -> Result<Session, String> {
let rt = old.refresh.as_deref().ok_or("no refresh token")?;
let tokens = self.token_call(&[("grant_type", "refresh_token"), ("refresh_token", rt)]).await?;
self.session(&tokens, None, Some(old)).await
}
async fn session(&self, tokens: &Json, nonce: Option<&str>, old: Option<&Session>) -> Result<Session, String> {
let id_token = tokens.get("id_token").and_then(Json::as_str).ok_or("the token response has no ID token")?;
let claims = self
.issuer
.validate_id_token(id_token, &self.client_id, &self.algorithms, self.leeway)
.await
.map_err(|why| format!("ID token refused: {why:?}"))?;
if let Some(nonce) = nonce
&& !claims.get("nonce").and_then(Json::as_str).is_some_and(|n| crate::server::same(n, nonce))
{
return Err(format!("ID token refused: {:?}", super::jwt::Refusal::Nonce));
}
let mut s = self.claims.extract(&claims).ok_or("the ID token has no user claim")?;
if let Some(old) = old {
if s.user != old.user {
return Err("refreshed ID token is for another user".into());
}
s.mfa |= old.mfa;
}
let now = super::now_secs();
let exp = claims.get("exp").and_then(Json::as_i64).unwrap_or(now);
let access = tokens.get("expires_in").and_then(Json::as_i64).map(|s| now + s).unwrap_or(exp);
s.expires = old.map(|o| o.expires).unwrap_or(now + self.lifetime.as_secs() as i64);
s.check = exp.min(access);
if self.refresh {
s.refresh = tokens
.get("refresh_token")
.and_then(Json::as_str)
.map(str::to_string)
.or_else(|| old.and_then(|o| o.refresh.clone()));
}
Ok(s)
}
pub(crate) fn seal_session(&self, s: &Session) -> Result<String, String> {
let sealer = self.sealer.as_ref().ok_or("no session key")?;
let value = sealer.seal(SESSION, s);
if value.len() > MAX_COOKIE {
return Err(format!(
"the session cookie would be {} bytes (limit {MAX_COOKIE}); map fewer roles or turn off refresh tokens",
value.len()
));
}
Ok(value)
}
pub(crate) async fn logout_url(&self, back: &str) -> String {
match self.issuer.discovery().await.ok().and_then(|d| d.end_session_endpoint.clone()) {
Some(end) => format!(
"{end}{}client_id={}&post_logout_redirect_uri={}",
if end.contains('?') { '&' } else { '?' },
pct(&self.client_id),
pct(back)
),
None => back.to_string(),
}
}
}
impl Authenticator for OidcAuth {
fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
if let Some(bearer) = &self.bearer_auth
&& JwtAuth::has_bearer(headers)
{
return bearer.authenticate(headers);
}
let session = self.open_session(crate::server::cookie(headers, SESSION)?)?;
Some(self.claims.identity(&session))
}
fn prepare<'a>(&'a self, headers: &'a HeaderMap) -> Option<Pin<Box<dyn Future<Output = ()> + Send + 'a>>> {
self.bearer_auth.as_ref()?.prepare(headers)
}
}
pub(crate) fn pct(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}