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
+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());
}