Files
smirk-backend-core/tests/admin_auth.rs
T
jwinterm 4489c1c540 feat(admin): admin auth flow + guard + loopback plane [operator §1.1-1.4, §2.1]
"Sign in with Smirk" for operators. Auth is a NIP-98 signed action over a
single-use nonce (AUTHN) + the MAC'd allowlist (AUTHZ), composed in admin_guard
so a protected route physically cannot run without both. All rejections collapse
to one opaque 401 (no enumeration oracle).

- src/api/admin.rs: admin_guard (verify access token -> live session by jti ->
  live MAC-verified, activated allowlist entry) + AdminContext; handlers:
  - challenge: mint a 120s admin_login nonce + return the {url, instance_id} the
    wallet must bind.
  - verify: verify_signed_action (purpose/nonce/descriptor/instance) THEN consume
    the nonce (forged sig can't burn a victim's nonce; replay loses the race) ->
    authorize (pending key allowed for first login, which activates it) -> mint +
    persist session AND the login audit row in ONE tx (fail-closed) -> bump
    last_used.
  - refresh: re-run FULL authorization before minting (closes the "8h refresh is
    the real blast radius" gap) + constant-time refresh-hash bind + rotate jti so
    the old access token dies.
  - logout: revoke the session; audit. me: guarded whoami.
- admin_router (lib): served on a SEPARATE loopback socket in main (confidentiality
  by socket, not middleware); no CORS layer (cross-origin browser calls blocked);
  warns if bound non-loopback (hard bind-guard lands with admin posture). Routes
  are intentionally absent from the public OpenAPI.
- config: ADMIN_PUBLIC_URL (the signed-action u-tag base; validated when enabled).
  AppState.admin_sessions (Some only when enabled). AdminSessionManager.mint_access
  (refresh) + db helpers (create_admin_session_audited, touch_admin_key_last_used).
- test harness: admin_router() + request_on(router); 1 L1 test exercising the full
  flow end-to-end incl. user-token rejection, replay rejection, jti rotation, and
  post-logout revocation.

NOTE (for the upcoming S1 review): admin-login timing-oracle hardening (fixed-cost
allowlist probe + jittered 401 floor, spec §1.3) is deferred — the loopback/onion
exposure + single-use nonce + signature gate are the v1 boundary. Per-pubkey
backoff (§0.5) also pending.
2026-06-26 06:36:55 -04:00

208 lines
6.8 KiB
Rust

//! L1 integration: the admin auth flow on the loopback plane — challenge → sign
//! → verify → guarded /admin/me → refresh (jti rotation) → logout → replay.
//!
//! One sequential test: it enables the admin surface via process env, so running
//! it alongside others in the same binary would race the config.
mod common;
use axum::http::StatusCode;
use base64::Engine;
use k256::schnorr::SigningKey;
use sha2::{Digest, Sha256};
use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descriptor};
use smirk_backend_core::models::db::NewAdminKey;
const NIP98_KIND: u32 = 27235;
/// Build a `Nostr <base64(event)>` admin_login signed-action token.
#[allow(clippy::too_many_arguments)]
fn sign_admin_login(
sk: &SigningKey,
url: &str,
challenge: &str,
payload: &str,
instance_id: &str,
created_at: i64,
) -> String {
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
let tags = vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), "POST".to_string()],
vec!["purpose".to_string(), "admin_login".to_string()],
vec!["challenge".to_string(), challenge.to_string()],
vec!["payload".to_string(), payload.to_string()],
vec!["instance_id".to_string(), instance_id.to_string()],
];
let serial = serde_json::to_string(&serde_json::json!([
0, pk_hex, created_at, NIP98_KIND, tags, ""
]))
.unwrap();
let id = hex::encode(Sha256::digest(serial.as_bytes()));
let sig = sk.sign_raw(&hex::decode(&id).unwrap(), &[0u8; 32]).unwrap();
let ev = serde_json::json!({
"id": id, "pubkey": pk_hex, "created_at": created_at,
"kind": NIP98_KIND, "tags": tags, "content": "", "sig": hex::encode(sig.to_bytes())
});
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&ev).unwrap())
)
}
#[tokio::test]
async fn admin_login_guard_refresh_logout_flow() {
// Enable the admin surface (must precede try_app's config load).
std::env::set_var("ADMIN_ENABLED", "true");
std::env::set_var(
"ADMIN_JWT_SECRET",
"admin-jwt-secret-at-least-32-bytes-long!!",
);
std::env::set_var(
"ADMIN_KEY_INTEGRITY_SECRET",
"admin-integrity-secret-at-least-32-bytes!",
);
std::env::set_var("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081");
let app = require_app!();
let admin = app.admin_router();
let secret = app.state.config.admin.key_integrity_secret.clone();
// Seed a PENDING admin key for our signer.
let sk = SigningKey::from_bytes(&[9u8; 32]).unwrap();
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
app.state
.db
.create_admin_key(
NewAdminKey {
pubkey: pk_hex.clone(),
label: Some("test".into()),
scope: "admin".into(),
created_by_kind: "cli".into(),
activation_deadline: None,
},
&secret,
)
.await
.unwrap();
// 1. Challenge.
let (st, body) = app
.request_on(admin.clone(), "POST", "/admin/auth/challenge", None, None)
.await;
assert_eq!(st, StatusCode::OK);
let challenge = body["challenge"].as_str().unwrap().to_string();
let url = body["url"].as_str().unwrap().to_string();
let instance_id = body["instance_id"].as_str().unwrap().to_string();
// 2. Sign the admin_login action over the verify descriptor.
let payload = descriptor_sha256(&request_descriptor("POST", "/admin/auth/verify", "", b""));
let token = sign_admin_login(
&sk,
&url,
&challenge,
&payload,
&instance_id,
chrono::Utc::now().timestamp(),
);
// 3. Verify -> tokens.
let (st, body) = app
.request_on(
admin.clone(),
"POST",
"/admin/auth/verify",
None,
Some(serde_json::json!({ "admin_token": token, "challenge": challenge })),
)
.await;
assert_eq!(st, StatusCode::OK, "verify: {body:?}");
let access = body["access_token"].as_str().unwrap().to_string();
let refresh = body["refresh_token"].as_str().unwrap().to_string();
// 4. Guard: /admin/me works with the access token, fails without / with a user token.
let (st, body) = app
.request_on(admin.clone(), "GET", "/admin/me", Some(&access), None)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(body["pubkey"].as_str().unwrap(), pk_hex);
let (st, _) = app
.request_on(admin.clone(), "GET", "/admin/me", None, None)
.await;
assert_eq!(st, StatusCode::UNAUTHORIZED, "no token rejected");
let (_, user_access, _) = app.mint_session().await;
let (st, _) = app
.request_on(admin.clone(), "GET", "/admin/me", Some(&user_access), None)
.await;
assert_eq!(
st,
StatusCode::UNAUTHORIZED,
"user token rejected on admin plane"
);
// 5. Replay: the consumed challenge cannot be reused.
let (st, _) = app
.request_on(
admin.clone(),
"POST",
"/admin/auth/verify",
None,
Some(serde_json::json!({ "admin_token": token, "challenge": challenge })),
)
.await;
assert_eq!(st, StatusCode::UNAUTHORIZED, "replayed challenge rejected");
// 6. Refresh rotates the access jti: old access token stops working, new one works.
let (st, body) = app
.request_on(
admin.clone(),
"POST",
"/admin/auth/refresh",
None,
Some(serde_json::json!({ "refresh_token": refresh })),
)
.await;
assert_eq!(st, StatusCode::OK);
let access2 = body["access_token"].as_str().unwrap().to_string();
let (st, _) = app
.request_on(admin.clone(), "GET", "/admin/me", Some(&access), None)
.await;
assert_eq!(
st,
StatusCode::UNAUTHORIZED,
"old access token invalid after refresh"
);
let (st, _) = app
.request_on(admin.clone(), "GET", "/admin/me", Some(&access2), None)
.await;
assert_eq!(st, StatusCode::OK, "new access token valid");
// 7. Logout revokes the session: the access token no longer authenticates.
let (st, _) = app
.request_on(
admin.clone(),
"POST",
"/admin/auth/logout",
Some(&access2),
None,
)
.await;
assert_eq!(st, StatusCode::OK);
let (st, _) = app
.request_on(admin.clone(), "GET", "/admin/me", Some(&access2), None)
.await;
assert_eq!(st, StatusCode::UNAUTHORIZED, "session revoked after logout");
for k in [
"ADMIN_ENABLED",
"ADMIN_JWT_SECRET",
"ADMIN_KEY_INTEGRITY_SECRET",
"ADMIN_PUBLIC_URL",
] {
std::env::remove_var(k);
}
}