mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user