use serde::Deserialize;
use turnkey_jwt_verify::{JwsRules, KeySource, TimeRules, check_times, object};
use super::headers::{check_header_names, is_tchar_except_alpha};
use super::{AuthError, ConfigError};
use crate::ids;
use crate::rfc9530::is_digest;
pub(crate) const TYP: &str = "application/vnd.turnkey.http-authz-v1+jwt";
pub const MAX_LIFETIME_SECS: u64 = 300;
pub const MAX_CLOCK_SKEW_SECS: u64 = 30;
pub const DEFAULT_CLOCK_SKEW_SECS: u64 = 5;
pub(crate) const MAX_TOKEN_BYTES: usize = 12 * 1024;
pub(crate) const MAX_PATH_BYTES: usize = 2048;
pub(crate) const MAX_QUERY_BYTES: usize = 2048;
pub const ISSUER_PREFIX: &str = "https://id.turnkey.com/t/";
pub const ORG_URN_PREFIX: &str = "urn:turnkey:org:";
pub const USER_URN_PREFIX: &str = "urn:turnkey:user:";
#[derive(Debug, Clone)]
pub struct VerifierConfig {
pub audience: String,
pub organization_id: String,
pub clock_skew_secs: u64,
pub max_lifetime_secs: u64,
}
impl VerifierConfig {
pub fn new(audience: impl Into<String>, organization_id: impl Into<String>) -> Self {
Self {
audience: audience.into(),
organization_id: organization_id.into(),
clock_skew_secs: DEFAULT_CLOCK_SKEW_SECS,
max_lifetime_secs: MAX_LIFETIME_SECS,
}
}
pub(crate) fn validate(&self) -> Result<(), ConfigError> {
if self.audience.is_empty() {
return Err(ConfigError("audience must not be empty".into()));
}
if !ids::is_uuid(&self.organization_id) {
return Err(ConfigError(
"organization_id must be an organization ID: a lowercase UUID with no prefix"
.into(),
));
}
if self.clock_skew_secs > MAX_CLOCK_SKEW_SECS {
return Err(ConfigError(format!(
"clock_skew_secs must be at most {MAX_CLOCK_SKEW_SECS}"
)));
}
if self.max_lifetime_secs == 0 || self.max_lifetime_secs > MAX_LIFETIME_SECS {
return Err(ConfigError(format!(
"max_lifetime_secs must be 1 to {MAX_LIFETIME_SECS}"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RequestDescriptor {
pub method: String,
pub path: String,
pub query: String,
#[serde(default)]
pub headers: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Claims {
pub aud: String,
pub exp: u64,
pub iat: u64,
pub iss: String,
pub sub: String,
pub org: String,
#[serde(deserialize_with = "object")]
pub tk_request: RequestDescriptor,
#[serde(default)]
pub tk_body_digest: Option<String>,
#[serde(default)]
pub tk_header_digest: Option<Vec<String>>,
}
impl Claims {
#[must_use]
pub(crate) fn bound_headers(&self) -> &[String] {
self.tk_request.headers.as_deref().unwrap_or_default()
}
}
#[must_use]
fn is_method(m: &str) -> bool {
!m.is_empty()
&& m.len() <= 32
&& m.bytes()
.all(|b| b.is_ascii_uppercase() || is_tchar_except_alpha(b))
}
pub(crate) fn check_window(
claims: &Claims,
cfg: &VerifierConfig,
now: u64,
) -> Result<(), AuthError> {
check_times(
claims.iat,
claims.exp,
now,
&TimeRules::new(cfg.clock_skew_secs, cfg.max_lifetime_secs),
)?;
Ok(())
}
pub(crate) fn verify(
token: &str,
cfg: &VerifierConfig,
keys: &dyn KeySource,
now: u64,
) -> Result<Claims, AuthError> {
let jws = turnkey_jwt_verify::verify(token, &JwsRules::new(TYP, MAX_TOKEN_BYTES), keys)?;
let claims: Claims = jws.claims()?;
if claims.aud != cfg.audience {
return Err(AuthError::WrongAudience);
}
let issuer_id = ids::urn_uuid(&claims.iss, ISSUER_PREFIX).ok_or(AuthError::BadClaim("iss"))?;
if issuer_id != cfg.organization_id {
return Err(AuthError::WrongIssuer);
}
if ids::urn_uuid(&claims.org, ORG_URN_PREFIX).is_none() {
return Err(AuthError::BadClaim("org"));
}
if ids::urn_uuid(&claims.sub, USER_URN_PREFIX).is_none() {
return Err(AuthError::BadClaim("sub"));
}
check_window(&claims, cfg, now)?;
let r = &claims.tk_request;
if !is_method(&r.method) {
return Err(AuthError::BadClaim("tk_request.method"));
}
if r.path.len() > MAX_PATH_BYTES {
return Err(AuthError::BadClaim("tk_request.path"));
}
if r.query.len() > MAX_QUERY_BYTES {
return Err(AuthError::BadClaim("tk_request.query"));
}
if check_header_names(claims.bound_headers()).is_err() {
return Err(AuthError::BadClaim("tk_request.headers"));
}
match (r.headers.as_deref(), claims.tk_header_digest.as_deref()) {
(None, None) => {}
(Some(names), Some(d)) if d.len() == names.len() => {
if !d.iter().all(|x| is_digest(x)) {
return Err(AuthError::BadClaim("tk_header_digest"));
}
}
_ => {
return Err(AuthError::BadClaim(
"tk_header_digest must have one entry per name in tk_request.headers",
));
}
}
if claims
.tk_body_digest
.as_deref()
.is_some_and(|d| !is_digest(d))
{
return Err(AuthError::BadClaim("tk_body_digest"));
}
Ok(claims)
}