feat(admin): admin session JWTs + admin_sessions queries [operator §0.3]

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).
This commit is contained in:
jwinterm
2026-06-25 18:46:50 -04:00
parent a03dfc6253
commit 041860bc94
7 changed files with 560 additions and 1 deletions
+286
View File
@@ -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<AdminTokenPair, AppError> {
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<AdminAccessInfo, AppError> {
let data = decode::<AdminAccessClaims>(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::<AdminRefreshClaims>(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());
}
}
+1
View File
@@ -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;
+4 -1
View File
@@ -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;
+136
View File
@@ -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<AdminSession, AppError> {
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<Option<AdminSession>, 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<Option<AdminSession>, 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<Option<AdminSession>, 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<bool, AppError> {
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<u64, AppError> {
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<u64, AppError> {
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())
}
}
+1
View File
@@ -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;
+14
View File
@@ -280,6 +280,20 @@ pub struct NewAdminKey {
pub activation_deadline: Option<DateTime<Utc>>,
}
/// 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<String>,
pub ip_address: Option<IpNetwork>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct NewAuditLog {
pub user_id: Option<Uuid>,
+118
View File
@@ -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());
}