use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid session token: {0}")]
Token(String),
#[error("role {actor_role:?} does not permit {needed:?}")]
Forbidden {
actor_role: String,
needed: String,
},
#[error("kit error: {0}")]
Kit(String),
#[error("configuration error: {0}")]
Config(String),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Role {
Auditor,
Reviewer,
Editor,
Owner,
}
impl Role {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::Editor => "editor",
Role::Reviewer => "reviewer",
Role::Auditor => "auditor",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
match name {
"owner" => Some(Role::Owner),
"editor" => Some(Role::Editor),
"reviewer" => Some(Role::Reviewer),
"auditor" => Some(Role::Auditor),
_ => None,
}
}
#[must_use]
pub fn permits(self, capability: Capability) -> bool {
match capability {
Capability::Read => true,
Capability::ReadAudit => true,
Capability::Edit => matches!(self, Role::Owner | Role::Editor),
Capability::Redact => matches!(self, Role::Owner | Role::Editor),
Capability::Sign => matches!(self, Role::Owner | Role::Editor),
Capability::ManageMembers => matches!(self, Role::Owner),
Capability::Delete => matches!(self, Role::Owner),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Capability {
Read,
ReadAudit,
Edit,
Redact,
Sign,
ManageMembers,
Delete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChiselClaims {
#[serde(flatten)]
pub standard: tokenkit::claims::StandardClaims,
pub sid: String,
}
impl ChiselClaims {
#[must_use]
pub fn role(&self) -> Option<Role> {
self.standard.role.as_deref().and_then(Role::parse)
}
#[must_use]
pub fn permits(&self, capability: Capability) -> bool {
self.role().is_some_and(|r| r.permits(capability))
}
pub fn require(&self, capability: Capability) -> Result<()> {
if self.permits(capability) {
Ok(())
} else {
Err(Error::Forbidden {
actor_role: self.role().map_or("none".into(), |r| r.as_str().to_owned()),
needed: format!("{capability:?}"),
})
}
}
}
#[derive(Clone)]
pub struct SessionService {
inner: std::sync::Arc<tokenkit::service::JwtService>,
issuer: String,
audience: String,
session_ttl: Duration,
refresh_ttl: Duration,
}
impl std::fmt::Debug for SessionService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionService")
.field("issuer", &self.issuer)
.field("audience", &self.audience)
.field("session_ttl", &self.session_ttl)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTokens {
pub access: String,
pub refresh: String,
pub sid: String,
}
impl SessionService {
pub fn new(
signing_secret: impl Into<String>,
issuer: impl Into<String>,
audience: impl Into<String>,
) -> Result<Self> {
let secret = signing_secret.into();
let issuer = issuer.into();
let audience = audience.into();
if secret.is_empty() {
return Err(Error::Config("signing secret cannot be empty".into()));
}
if issuer.is_empty() || audience.is_empty() {
return Err(Error::Config("issuer and audience cannot be empty".into()));
}
let config = tokenkit::service::JwtConfig {
secret,
issuer: Some(issuer.clone()),
audience: Some(audience.clone()),
..tokenkit::service::JwtConfig::default()
};
Ok(Self {
inner: std::sync::Arc::new(tokenkit::service::JwtService::new(config)),
issuer,
audience,
session_ttl: Duration::hours(12),
refresh_ttl: Duration::days(30),
})
}
pub fn issue(&self, user: &chisel_kernel::UserId, role: Role) -> Result<SessionTokens> {
let sid = chisel_kernel::SessionId::try_from(format!(
"{}-{}",
user.as_str(),
Utc::now().timestamp_millis()
))
.map_err(Error::Config)?;
let access = self.encode_session(user, role, &sid, self.session_ttl)?;
let refresh = self.encode_session(user, role, &sid, self.refresh_ttl)?;
Ok(SessionTokens {
access,
refresh,
sid: sid.to_string(),
})
}
pub fn validate(&self, token: &str) -> Result<ChiselClaims> {
self.inner
.decode::<ChiselClaims>(token)
.map_err(|e| Error::Token(e.to_string()))
}
pub fn refresh(&self, refresh_token: &str) -> Result<SessionTokens> {
let claims = self.validate(refresh_token)?;
self.refresh_from_claims(&claims)
}
pub fn refresh_from_claims(&self, claims: &ChiselClaims) -> Result<SessionTokens> {
let role = claims.role().ok_or_else(|| {
Error::Token("refresh token carries no role from the current vocabulary".into())
})?;
let user = chisel_kernel::UserId::try_from(claims.standard.sub.clone().unwrap_or_default())
.map_err(Error::Token)?;
let sid = chisel_kernel::SessionId::try_from(claims.sid.clone()).map_err(Error::Token)?;
let access = self.encode_session(&user, role, &sid, self.session_ttl)?;
let refresh = self.encode_session(&user, role, &sid, self.refresh_ttl)?;
Ok(SessionTokens {
access,
refresh,
sid: sid.to_string(),
})
}
fn encode_session(
&self,
user: &chisel_kernel::UserId,
role: Role,
sid: &chisel_kernel::SessionId,
ttl: Duration,
) -> Result<String> {
let now = Utc::now();
let claims = ChiselClaims {
standard: tokenkit::claims::StandardClaims {
sub: Some(user.as_str().to_owned()),
iss: Some(self.issuer.clone()),
aud: Some(self.audience.clone()),
exp: Some(now + ttl),
iat: Some(now),
jti: None,
role: Some(role.as_str().to_owned()),
permissions: Vec::new(),
extra: std::collections::HashMap::new(),
},
sid: sid.to_string(),
};
self.inner
.encode(&claims)
.map_err(|e| Error::Kit(e.to_string()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcProviderConfig {
pub issuer: String,
pub authorization_url: String,
pub token_url: String,
pub client_id: String,
pub scopes: Vec<String>,
}
pub struct LoginStart {
flow: oauth_toolkit::loopback::LoopbackFlow,
}
impl LoginStart {
#[must_use]
pub fn url(&self) -> &str {
self.flow.authorization_url()
}
pub fn wait(self) -> Result<oauth_toolkit::loopback::CapturedCode> {
self.flow
.wait_for_code()
.map_err(|e| Error::Kit(e.to_string()))
}
}
pub trait TokenExchanger: Send + Sync {
fn exchange(
&self,
token_url: &str,
code: &str,
code_verifier: &str,
client_id: &str,
) -> Result<serde_json::Value>;
}
impl<F> TokenExchanger for F
where
F: Fn(&str, &str, &str, &str) -> Result<serde_json::Value> + Send + Sync,
{
fn exchange(
&self,
token_url: &str,
code: &str,
code_verifier: &str,
client_id: &str,
) -> Result<serde_json::Value> {
self(token_url, code, code_verifier, client_id)
}
}
pub fn start_login(
provider: &OidcProviderConfig,
scopes: &[String],
timeout: std::time::Duration,
) -> Result<LoginStart> {
let flow = oauth_toolkit::loopback::LoopbackFlow::start(
&provider.authorization_url,
&provider.client_id,
scopes,
None,
timeout,
)
.map_err(|e| Error::Kit(e.to_string()))?;
Ok(LoginStart { flow })
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use chisel_kernel::UserId;
const SECRET: &str = "test-signing-secret-do-not-use";
const ISSUER: &str = "https://auth.chisel.test";
const AUDIENCE: &str = "chisel-desktop";
fn service() -> SessionService {
SessionService::new(SECRET, ISSUER, AUDIENCE).expect("valid config")
}
#[test]
fn config_failures_are_loud_and_early() {
assert!(SessionService::new("", ISSUER, AUDIENCE).is_err());
assert!(SessionService::new(SECRET, "", AUDIENCE).is_err());
assert!(SessionService::new(SECRET, ISSUER, "").is_err());
}
#[test]
fn issue_then_validate_round_trips_role_and_identity() -> Result<()> {
let svc = service();
let user = UserId::try_from("alice").map_err(Error::Token)?;
let tokens = svc.issue(&user, Role::Editor)?;
let claims = svc.validate(&tokens.access)?;
assert_eq!(claims.standard.sub.as_deref(), Some("alice"));
assert_eq!(claims.standard.role.as_deref(), Some("editor"));
assert_eq!(claims.sid, tokens.sid);
assert!(claims.permits(Capability::Edit));
assert!(!claims.permits(Capability::ManageMembers));
Ok(())
}
#[test]
fn refresh_preserves_the_session_and_role() -> Result<()> {
let svc = service();
let user = UserId::try_from("bob").map_err(Error::Token)?;
let tokens = svc.issue(&user, Role::Auditor)?;
let fresh = svc.refresh(&tokens.refresh)?;
assert_eq!(fresh.sid, tokens.sid);
let claims = svc.validate(&fresh.access)?;
assert_eq!(claims.standard.role.as_deref(), Some("auditor"));
assert!(claims.permits(Capability::ReadAudit));
assert!(!claims.permits(Capability::Edit));
Ok(())
}
#[test]
fn a_foreign_issuer_is_rejected() -> Result<()> {
let svc = service();
let user = UserId::try_from("alice").map_err(Error::Token)?;
let tokens = svc.issue(&user, Role::Reviewer)?;
let other = SessionService::new(SECRET, "https://elsewhere.test", AUDIENCE)?;
assert!(matches!(
other.validate(&tokens.access),
Err(Error::Token(_))
));
Ok(())
}
#[test]
fn capability_checks_name_the_missing_role() -> Result<()> {
let svc = service();
let user = UserId::try_from("dan").map_err(Error::Token)?;
let tokens = svc.issue(&user, Role::Reviewer)?;
let claims = svc.validate(&tokens.access)?;
let err = claims.require(Capability::Redact).unwrap_err();
assert!(err.to_string().contains("reviewer"), "{err}");
assert!(err.to_string().contains("Redact"), "{err}");
Ok(())
}
#[test]
fn the_role_vocabulary_is_total_and_ordered() {
for (name, role) in [
("owner", Role::Owner),
("editor", Role::Editor),
("reviewer", Role::Reviewer),
("auditor", Role::Auditor),
] {
assert_eq!(Role::parse(name), Some(role));
assert_eq!(role.as_str(), name);
}
assert!(Role::Auditor < Role::Reviewer);
assert!(Role::Reviewer < Role::Editor);
assert!(Role::Editor < Role::Owner);
for r in [Role::Owner, Role::Editor, Role::Reviewer, Role::Auditor] {
assert!(r.permits(Capability::Read));
assert!(!r.permits(Capability::Delete) || r == Role::Owner);
}
}
#[test]
fn start_login_builds_a_loopback_flow_against_the_provider() -> Result<()> {
let provider = OidcProviderConfig {
issuer: "https://id.example.test".into(),
authorization_url: "https://id.example.test/authorize".into(),
token_url: "https://id.example.test/token".into(),
client_id: "chisel-desktop".into(),
scopes: vec!["openid".into(), "email".into(), "profile".into()],
};
let start = start_login(
&provider,
&provider.scopes,
std::time::Duration::from_secs(30),
)?;
assert!(
start.url().starts_with("https://id.example.test/authorize"),
"{}",
start.url()
);
assert!(
start.url().contains("code_challenge="),
"PKCE challenge present"
);
assert!(start.url().contains("state="), "CSRF state present");
Ok(())
}
#[test]
fn sign_capability_splits_editors_from_auditors() {
assert!(Role::Owner.permits(Capability::Sign));
assert!(Role::Editor.permits(Capability::Sign));
assert!(!Role::Reviewer.permits(Capability::Sign));
assert!(!Role::Auditor.permits(Capability::Sign));
}
#[test]
fn require_returns_ok_when_permitted() -> Result<()> {
let svc = service();
let user = chisel_kernel::UserId::try_from("ed").map_err(Error::Token)?;
let tokens = svc.issue(&user, Role::Editor)?;
let claims = svc.validate(&tokens.access)?;
claims.require(Capability::Edit)?;
claims.require(Capability::Read)?;
Ok(())
}
#[test]
fn debug_never_prints_the_signing_secret() {
let dbg = format!("{svc:?}", svc = service());
assert!(dbg.contains("SessionService"), "{dbg}");
assert!(!dbg.contains(SECRET), "the secret must not appear in Debug");
}
#[test]
fn login_start_wait_times_out_loudly() -> Result<()> {
let provider = OidcProviderConfig {
issuer: "https://id.example.test".into(),
authorization_url: "https://id.example.test/authorize".into(),
token_url: "https://id.example.test/token".into(),
client_id: "chisel-desktop".into(),
scopes: vec!["openid".into()],
};
let start = start_login(
&provider,
&provider.scopes,
std::time::Duration::from_millis(1),
)?;
let outcome = start.wait();
assert!(matches!(outcome, Err(Error::Kit(_))), "{outcome:?}");
Ok(())
}
#[test]
fn the_token_exchanger_trait_runs_closures() -> Result<()> {
let exchange = |token_url: &str, _code: &str, _verifier: &str, _client: &str| {
Ok(serde_json::json!({ "token_url": token_url }))
};
let v = exchange.exchange("https://token", "code", "verifier", "client")?;
assert_eq!(
v.get("token_url"),
Some(&serde_json::json!("https://token"))
);
Ok(())
}
}
pub mod passkey;