From 041860bc94e1cf27e85081d3a4ceda5480cac12f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 25 Jun 2026 18:46:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20admin=20session=20JWTs=20+=20adm?= =?UTF-8?q?in=5Fsessions=20queries=20[operator=20=C2=A70.3]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin tokens are cryptographically distinct from user tokens: a dedicated ADMIN_JWT_SECRET, a distinct audience (smirk-admin) plus an explicit scope, and the centralized strict Validation (HS256 pinned, zero leeway, aud required) — Validation::default() is never used on this path. Access TTL 15 min (carries a jti recorded in the session row, so a soft-revoked admin's still-valid token is rejected at the next call), refresh TTL 8 h (binds a session id). - core::admin_session: AdminSessionManager (mint/verify access+refresh) with the four required regressions + scope check: user token fails admin decode; wrong/absent aud fails; HS512 (alg confusion) fails; expired fails at zero leeway; non-admin scope fails. - core::session::strict_validation made pub(crate) and shared (no drift). - db::admin_sessions: create, find-active-by-jti (guard), find-active-by-id + rotate-jti (refresh), revoke (logout), revoke-all-for-key (key cascade), and a dead-session sweep. Lookups return only live (unrevoked, unexpired) rows. - NewAdminSession model; 2 L1 tests (lookup/rotate/logout, key->session cascade). --- src/core/admin_session.rs | 286 +++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + src/core/session.rs | 5 +- src/infra/db/admin_sessions.rs | 136 ++++++++++++++++ src/infra/db/mod.rs | 1 + src/models/db.rs | 14 ++ tests/admin_sessions.rs | 118 ++++++++++++++ 7 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 src/core/admin_session.rs create mode 100644 src/infra/db/admin_sessions.rs create mode 100644 tests/admin_sessions.rs diff --git a/src/core/admin_session.rs b/src/core/admin_session.rs new file mode 100644 index 0000000..d9a4a60 --- /dev/null +++ b/src/core/admin_session.rs @@ -0,0 +1,286 @@ +//! Admin session JWTs — cryptographically distinct from user sessions. +//! +//! Admin tokens are signed with a dedicated `ADMIN_JWT_SECRET` and carry a +//! distinct audience (`smirk-admin`) plus an explicit `scope`, so a user token +//! can never be presented on the admin plane and vice versa. Verification reuses +//! the centralized strict [`crate::core::session::strict_validation`] (HS256 +//! pinned, zero leeway, `aud` required) — `Validation::default()` is never used. +//! Access tokens are short-lived (15 min) and carry a `jti` recorded in the +//! `admin_sessions` row, so a soft-revoked admin's still-valid token is rejected +//! at the next call; refresh tokens (8 h) bind a session id. + +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::core::session::strict_validation; +use crate::error::AppError; + +const ADMIN_ACCESS_AUD: &str = "smirk-admin"; +const ADMIN_REFRESH_AUD: &str = "smirk-admin-refresh"; +const ADMIN_SCOPE: &str = "admin"; +const ACCESS_TTL_MINUTES: i64 = 15; +const REFRESH_TTL_HOURS: i64 = 8; + +/// Admin access-token claims. `sub` is the admin **pubkey** (not a UUID) so the +/// live allowlist re-check in the guard is meaningful. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminAccessClaims { + pub sub: String, + pub aud: String, + pub scope: String, + pub jti: String, + pub iat: i64, + pub exp: i64, +} + +/// Admin refresh-token claims. `sid` is the `admin_sessions` row id. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminRefreshClaims { + pub sub: String, + pub aud: String, + pub sid: String, + pub iat: i64, + pub exp: i64, +} + +/// Decoded admin access token: the pubkey to re-authorize and the `jti` to look +/// up the live session row. +#[derive(Debug, Clone)] +pub struct AdminAccessInfo { + pub pubkey: String, + pub jti: String, +} + +/// A freshly minted admin token pair. `access_jti` must be recorded in the +/// session row so the guard can match (and revocation can invalidate) it. +#[derive(Debug, Clone)] +pub struct AdminTokenPair { + pub access_token: String, + pub refresh_token: String, + pub access_jti: String, + pub expires_in: i64, +} + +/// Mints and verifies admin JWTs under a dedicated secret. +#[derive(Clone)] +pub struct AdminSessionManager { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + access_validation: jsonwebtoken::Validation, + refresh_validation: jsonwebtoken::Validation, +} + +impl AdminSessionManager { + /// `admin_jwt_secret` must be >= 32 bytes (enforced by config validation when + /// the admin surface is enabled). + pub fn new(admin_jwt_secret: &str) -> Self { + Self { + encoding_key: EncodingKey::from_secret(admin_jwt_secret.as_bytes()), + decoding_key: DecodingKey::from_secret(admin_jwt_secret.as_bytes()), + access_validation: strict_validation(ADMIN_ACCESS_AUD), + refresh_validation: strict_validation(ADMIN_REFRESH_AUD), + } + } + + /// Mint an access + refresh pair for `admin_pubkey`, binding the refresh + /// token to `session_id`. Returns the pair plus the access `jti` to persist. + pub fn create_token_pair( + &self, + admin_pubkey: &str, + session_id: Uuid, + ) -> Result { + let now = Utc::now(); + let jti = Uuid::new_v4().to_string(); + let header = Header::new(Algorithm::HS256); + + let access = AdminAccessClaims { + sub: admin_pubkey.to_string(), + aud: ADMIN_ACCESS_AUD.to_string(), + scope: ADMIN_SCOPE.to_string(), + jti: jti.clone(), + iat: now.timestamp(), + exp: (now + Duration::minutes(ACCESS_TTL_MINUTES)).timestamp(), + }; + let access_token = encode(&header, &access, &self.encoding_key) + .map_err(|e| AppError::Internal(format!("admin access token: {e}")))?; + + let refresh = AdminRefreshClaims { + sub: admin_pubkey.to_string(), + aud: ADMIN_REFRESH_AUD.to_string(), + sid: session_id.to_string(), + iat: now.timestamp(), + exp: (now + Duration::hours(REFRESH_TTL_HOURS)).timestamp(), + }; + let refresh_token = encode(&header, &refresh, &self.encoding_key) + .map_err(|e| AppError::Internal(format!("admin refresh token: {e}")))?; + + Ok(AdminTokenPair { + access_token, + refresh_token, + access_jti: jti, + expires_in: ACCESS_TTL_MINUTES * 60, + }) + } + + /// Verify an admin access token. Beyond the strict `aud` check, the `scope` + /// is re-asserted. The error is a literal (never the inner Display) so the + /// token cannot become an oracle. + pub fn verify_access(&self, token: &str) -> Result { + let data = decode::(token, &self.decoding_key, &self.access_validation) + .map_err(|_| AppError::AuthError("Invalid or expired admin token".into()))?; + if data.claims.scope != ADMIN_SCOPE { + return Err(AppError::AuthError("Invalid or expired admin token".into())); + } + Ok(AdminAccessInfo { + pubkey: data.claims.sub, + jti: data.claims.jti, + }) + } + + /// Verify an admin refresh token, returning `(admin_pubkey, session_id)`. + pub fn verify_refresh(&self, token: &str) -> Result<(String, Uuid), AppError> { + let data = + decode::(token, &self.decoding_key, &self.refresh_validation) + .map_err(|_| AppError::AuthError("Invalid or expired admin token".into()))?; + let sid = Uuid::parse_str(&data.claims.sid) + .map_err(|_| AppError::AuthError("Invalid or expired admin token".into()))?; + Ok((data.claims.sub, sid)) + } + + pub fn access_ttl_secs(&self) -> i64 { + ACCESS_TTL_MINUTES * 60 + } + + pub fn refresh_ttl(&self) -> Duration { + Duration::hours(REFRESH_TTL_HOURS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::session::{Platform, SessionManager}; + + const ADMIN_SECRET: &str = "admin-jwt-secret-at-least-32-bytes-long!!"; + const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn mint_and_verify_roundtrip() { + let m = AdminSessionManager::new(ADMIN_SECRET); + let sid = Uuid::new_v4(); + let pair = m.create_token_pair(PK, sid).unwrap(); + + let info = m.verify_access(&pair.access_token).unwrap(); + assert_eq!(info.pubkey, PK); + assert_eq!(info.jti, pair.access_jti); + + let (pk, got_sid) = m.verify_refresh(&pair.refresh_token).unwrap(); + assert_eq!(pk, PK); + assert_eq!(got_sid, sid); + } + + #[test] + fn user_access_token_fails_admin_decode() { + // A user token (distinct secret + audience) must not verify as admin. + let user = SessionManager::new("user-jwt-secret-at-least-32-bytes-long!", 24); + let pair = user + .create_token_pair(Uuid::new_v4(), Platform::Web, Uuid::new_v4()) + .unwrap(); + let admin = AdminSessionManager::new(ADMIN_SECRET); + assert!(admin.verify_access(&pair.access_token).is_err()); + } + + #[test] + fn admin_access_not_accepted_as_refresh() { + let m = AdminSessionManager::new(ADMIN_SECRET); + let pair = m.create_token_pair(PK, Uuid::new_v4()).unwrap(); + // Distinct audiences within the admin secret too. + assert!(m.verify_refresh(&pair.access_token).is_err()); + assert!(m.verify_access(&pair.refresh_token).is_err()); + } + + #[test] + fn wrong_audience_fails() { + // A token with the right secret/alg but a foreign audience must fail. + let claims = AdminAccessClaims { + sub: PK.into(), + aud: "some-other-aud".into(), + scope: ADMIN_SCOPE.into(), + jti: Uuid::new_v4().to_string(), + iat: Utc::now().timestamp(), + exp: (Utc::now() + Duration::minutes(5)).timestamp(), + }; + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(ADMIN_SECRET.as_bytes()), + ) + .unwrap(); + let m = AdminSessionManager::new(ADMIN_SECRET); + assert!(m.verify_access(&token).is_err()); + } + + #[test] + fn wrong_algorithm_fails() { + // HS512 token must be rejected (HS256 pinned, no alg confusion). + let claims = AdminAccessClaims { + sub: PK.into(), + aud: ADMIN_ACCESS_AUD.into(), + scope: ADMIN_SCOPE.into(), + jti: Uuid::new_v4().to_string(), + iat: Utc::now().timestamp(), + exp: (Utc::now() + Duration::minutes(5)).timestamp(), + }; + let token = encode( + &Header::new(Algorithm::HS512), + &claims, + &EncodingKey::from_secret(ADMIN_SECRET.as_bytes()), + ) + .unwrap(); + let m = AdminSessionManager::new(ADMIN_SECRET); + assert!(m.verify_access(&token).is_err()); + } + + #[test] + fn expired_token_fails_with_zero_leeway() { + let claims = AdminAccessClaims { + sub: PK.into(), + aud: ADMIN_ACCESS_AUD.into(), + scope: ADMIN_SCOPE.into(), + jti: Uuid::new_v4().to_string(), + iat: (Utc::now() - Duration::hours(1)).timestamp(), + exp: (Utc::now() - Duration::minutes(1)).timestamp(), + }; + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(ADMIN_SECRET.as_bytes()), + ) + .unwrap(); + let m = AdminSessionManager::new(ADMIN_SECRET); + assert!(m.verify_access(&token).is_err()); + } + + #[test] + fn scope_must_be_admin() { + // Right aud/alg/secret but a non-admin scope is rejected. + let claims = AdminAccessClaims { + sub: PK.into(), + aud: ADMIN_ACCESS_AUD.into(), + scope: "user".into(), + jti: Uuid::new_v4().to_string(), + iat: Utc::now().timestamp(), + exp: (Utc::now() + Duration::minutes(5)).timestamp(), + }; + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(ADMIN_SECRET.as_bytes()), + ) + .unwrap(); + let m = AdminSessionManager::new(ADMIN_SECRET); + assert!(m.verify_access(&token).is_err()); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index a61339f..23df108 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -3,6 +3,7 @@ //! Functions take their inputs (config, decoded bytes) as parameters, keeping //! this layer straightforward to test in isolation. +pub mod admin_session; pub mod crypto; pub mod pow; pub mod secret; diff --git a/src/core/session.rs b/src/core/session.rs index 1f1c47f..542c603 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -148,7 +148,10 @@ pub struct SessionManager { refresh_token_expiry_days: i64, } -fn strict_validation(audience: &str) -> Validation { +/// Centralized strict JWT validation: HS256 pinned (no alg-confusion), zero +/// leeway, `exp`/`sub`/`aud` required, and the audience fixed. Shared by the user +/// and admin session managers so neither can drift to `Validation::default()`. +pub(crate) fn strict_validation(audience: &str) -> Validation { let mut v = Validation::new(Algorithm::HS256); v.leeway = 0; v.validate_exp = true; diff --git a/src/infra/db/admin_sessions.rs b/src/infra/db/admin_sessions.rs new file mode 100644 index 0000000..8acb3d8 --- /dev/null +++ b/src/infra/db/admin_sessions.rs @@ -0,0 +1,136 @@ +//! Admin session queries. +//! +//! Admin sessions live in their own table (never user `sessions`). The guard +//! looks a session up by the access token's `jti`; refresh and logout act by +//! session id; revoking a key cascades to every session it owns. A lookup only +//! returns a session that is neither revoked nor expired. + +use tracing::instrument; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::db::{AdminSession, NewAdminSession}; + +use super::Database; + +const COLS: &str = "id, admin_key_id, pubkey, refresh_token_hash, access_jti, \ + device_info, ip_address, created_at, expires_at, revoked_at"; + +impl Database { + /// Persist a new admin session. + #[instrument(skip(self, input), fields(admin_key_id = %input.admin_key_id))] + pub async fn create_admin_session( + &self, + input: NewAdminSession, + ) -> Result { + let sql = format!( + "INSERT INTO admin_sessions \ + (id, admin_key_id, pubkey, refresh_token_hash, access_jti, device_info, ip_address, expires_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}" + ); + let s = sqlx::query_as::<_, AdminSession>(&sql) + .bind(input.id) + .bind(input.admin_key_id) + .bind(&input.pubkey) + .bind(&input.refresh_token_hash) + .bind(&input.access_jti) + .bind(&input.device_info) + .bind(input.ip_address) + .bind(input.expires_at) + .fetch_one(self.pool()) + .await?; + Ok(s) + } + + /// The live session whose access token carries `jti` (not revoked/expired). + #[instrument(skip(self, jti))] + pub async fn find_active_admin_session_by_jti( + &self, + jti: &str, + ) -> Result, AppError> { + let sql = format!( + "SELECT {COLS} FROM admin_sessions \ + WHERE access_jti = $1 AND revoked_at IS NULL AND expires_at > NOW()" + ); + let s = sqlx::query_as::<_, AdminSession>(&sql) + .bind(jti) + .fetch_optional(self.pool()) + .await?; + Ok(s) + } + + /// The live session with this id (used by the refresh path). + #[instrument(skip(self))] + pub async fn find_active_admin_session_by_id( + &self, + id: Uuid, + ) -> Result, AppError> { + let sql = format!( + "SELECT {COLS} FROM admin_sessions \ + WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW()" + ); + let s = sqlx::query_as::<_, AdminSession>(&sql) + .bind(id) + .fetch_optional(self.pool()) + .await?; + Ok(s) + } + + /// Rotate the access `jti` on refresh (the old access token stops matching). + /// Returns the updated row, or `None` if the session is no longer live. + #[instrument(skip(self, new_jti))] + pub async fn rotate_admin_session_jti( + &self, + id: Uuid, + new_jti: &str, + ) -> Result, AppError> { + let sql = format!( + "UPDATE admin_sessions SET access_jti = $2 \ + WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW() RETURNING {COLS}" + ); + let s = sqlx::query_as::<_, AdminSession>(&sql) + .bind(id) + .bind(new_jti) + .fetch_optional(self.pool()) + .await?; + Ok(s) + } + + /// Revoke a single session (logout). Returns true if a live row was revoked. + #[instrument(skip(self))] + pub async fn revoke_admin_session(&self, id: Uuid) -> Result { + let res = sqlx::query( + "UPDATE admin_sessions SET revoked_at = NOW() \ + WHERE id = $1 AND revoked_at IS NULL", + ) + .bind(id) + .execute(self.pool()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Revoke every live session owned by an admin key (key-revocation cascade). + /// Returns the number revoked. + #[instrument(skip(self))] + pub async fn revoke_admin_sessions_for_key(&self, admin_key_id: Uuid) -> Result { + let res = sqlx::query( + "UPDATE admin_sessions SET revoked_at = NOW() \ + WHERE admin_key_id = $1 AND revoked_at IS NULL", + ) + .bind(admin_key_id) + .execute(self.pool()) + .await?; + Ok(res.rows_affected()) + } + + /// Delete expired/revoked admin sessions older than now (hygiene sweep). + #[instrument(skip(self))] + pub async fn delete_dead_admin_sessions(&self) -> Result { + let res = sqlx::query( + "DELETE FROM admin_sessions WHERE expires_at <= NOW() OR revoked_at IS NOT NULL", + ) + .execute(self.pool()) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 338accd..07fdd71 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -7,6 +7,7 @@ //! reaches a column and the peppering cannot drift between call sites. mod admin_keys; +mod admin_sessions; mod audit; mod challenges; mod grin_slatepacks; diff --git a/src/models/db.rs b/src/models/db.rs index d8265fd..ac2ee9b 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -280,6 +280,20 @@ pub struct NewAdminKey { pub activation_deadline: Option>, } +/// Input for an admin session row. The id and access_jti are generated by the +/// caller (the refresh token embeds the id, the access token embeds the jti). +#[derive(Debug, Clone)] +pub struct NewAdminSession { + pub id: Uuid, + pub admin_key_id: Uuid, + pub pubkey: String, + pub refresh_token_hash: String, + pub access_jti: String, + pub device_info: Option, + pub ip_address: Option, + pub expires_at: DateTime, +} + #[derive(Debug, Clone)] pub struct NewAuditLog { pub user_id: Option, diff --git a/tests/admin_sessions.rs b/tests/admin_sessions.rs new file mode 100644 index 0000000..fbe0b35 --- /dev/null +++ b/tests/admin_sessions.rs @@ -0,0 +1,118 @@ +//! L1 integration: the admin_sessions lifecycle against real Postgres — +//! create, lookup-by-jti, jti rotation on refresh, logout, and key-cascade +//! revocation. + +mod common; + +use chrono::{Duration, Utc}; +use smirk_backend_core::models::db::{NewAdminKey, NewAdminSession}; +use uuid::Uuid; + +const SECRET: &str = "admin-key-integrity-secret-0123456789ab"; + +async fn make_key(db: &smirk_backend_core::infra::db::Database) -> (Uuid, String) { + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + let key = db + .create_admin_key( + NewAdminKey { + pubkey: pk.clone(), + label: None, + scope: "admin".into(), + created_by_kind: "cli".into(), + activation_deadline: None, + }, + SECRET, + ) + .await + .unwrap(); + (key.id, pk) +} + +fn new_session(id: Uuid, key_id: Uuid, pk: &str, jti: &str) -> NewAdminSession { + NewAdminSession { + id, + admin_key_id: key_id, + pubkey: pk.to_string(), + refresh_token_hash: format!("hash-{jti}"), + access_jti: jti.to_string(), + device_info: None, + ip_address: None, + expires_at: Utc::now() + Duration::hours(8), + } +} + +#[tokio::test] +async fn create_lookup_rotate_and_logout() { + let app = require_app!(); + let db = &app.state.db; + let (key_id, pk) = make_key(db).await; + let sid = Uuid::new_v4(); + + db.create_admin_session(new_session(sid, key_id, &pk, "jti-1")) + .await + .unwrap(); + + // Guard lookup by the access jti. + let found = db + .find_active_admin_session_by_jti("jti-1") + .await + .unwrap() + .expect("live session by jti"); + assert_eq!(found.id, sid); + + // Refresh rotates the jti: the old one stops matching, the new one matches. + db.rotate_admin_session_jti(sid, "jti-2") + .await + .unwrap() + .expect("rotated"); + assert!(db + .find_active_admin_session_by_jti("jti-1") + .await + .unwrap() + .is_none()); + assert!(db + .find_active_admin_session_by_jti("jti-2") + .await + .unwrap() + .is_some()); + + // Logout revokes the session. + assert!(db.revoke_admin_session(sid).await.unwrap()); + assert!(db + .find_active_admin_session_by_jti("jti-2") + .await + .unwrap() + .is_none()); + // Re-revoke is a no-op. + assert!(!db.revoke_admin_session(sid).await.unwrap()); +} + +#[tokio::test] +async fn key_revocation_cascades_to_sessions() { + let app = require_app!(); + let db = &app.state.db; + let (key_id, pk) = make_key(db).await; + + let s1 = Uuid::new_v4(); + let s2 = Uuid::new_v4(); + db.create_admin_session(new_session(s1, key_id, &pk, "c-1")) + .await + .unwrap(); + db.create_admin_session(new_session(s2, key_id, &pk, "c-2")) + .await + .unwrap(); + + // Cascade revokes both live sessions for the key. + let n = db.revoke_admin_sessions_for_key(key_id).await.unwrap(); + assert_eq!(n, 2); + assert!(db + .find_active_admin_session_by_id(s1) + .await + .unwrap() + .is_none()); + assert!(db + .find_active_admin_session_by_id(s2) + .await + .unwrap() + .is_none()); +}