feat(sessions): show session origin in admin panel + test

This commit is contained in:
Edouard Vanbelle
2026-08-09 15:35:04 +02:00
parent 763ee82028
commit a7df46f8f8
25 changed files with 396 additions and 29 deletions
@@ -1018,7 +1018,7 @@ impl AuthApplicationService {
user_agent,
crate::domain::entities::session::SessionOrigin::Password,
)
.await
.await
}
/// Emit a fresh session for a user who has ALREADY been
+43 -1
View File
@@ -360,6 +360,48 @@ async fn main() -> ExitCode {
Err(e) => return fail(format!("/api/auth/me network: {e}")),
}
eprintln!("opaque-hurl-helper: OK — register + login + /me round-trip for '{username}'");
// SessionOrigin regression pin. The OPAQUE mint path funnels
// through `mint_session_for_authenticated_user(_, _, _, _,
// SessionOrigin::Opaque)`; a refactor that dropped that arg or
// wired the wrong variant would surface here as `unknown` (or
// any other origin) in the admin panel's row list. We can't
// check this from Hurl because /api/auth/login refuses migrated
// OPAQUE accounts (Phase 4 gate) — the OPAQUE-minted bearer is
// the ONLY credential this helper has access to at this point,
// so the assertion has to live in the same binary.
//
// No user_id filter needed: the test DB carries a single user
// (admin) at this stage, and `include_revoked=true` guarantees
// the OPAQUE row is in-frame even if a follow-up test has
// rotated it. Cheap substring check on the JSON body — we don't
// need to parse the array because "opaque" is a distinctive
// enough string that a false positive would require an
// origin-shaped `"opaque"` elsewhere in the wire payload, which
// the SessionSummaryDto shape rules out by construction.
match http
.get(format!(
"{base}/api/admin/sessions?include_revoked=true&limit=100"
))
.header("Authorization", format!("Bearer {}", auth.access_token))
.send()
.await
{
Ok(r) if r.status().is_success() => match r.text().await {
Ok(body) if body.contains("\"origin\":\"opaque\"") => {}
Ok(body) => {
return fail(format!(
"/api/admin/sessions: OPAQUE session not found in body — origin field missing or wrong variant. Body: {}",
&body[..body.len().min(512)]
));
}
Err(e) => return fail(format!("/api/admin/sessions body read: {e}")),
},
Ok(r) => {
return fail(format!("/api/admin/sessions: HTTP {}", r.status()));
}
Err(e) => return fail(format!("/api/admin/sessions network: {e}")),
}
eprintln!("opaque-hurl-helper: OK — register + login + /me + admin sessions origin=opaque for '{username}'");
ExitCode::from(EXIT_OK)
}
+10 -6
View File
@@ -42,10 +42,14 @@ impl SessionOrigin {
}
}
/// Parse from the column string. Any unrecognised value maps to
/// `Unknown` — matches the CHECK constraint's failure mode
/// (impossible on well-behaved writes, defensive on load).
pub fn from_str(s: &str) -> Self {
/// Parse from the column / wire string. Any unrecognised value
/// maps to `Unknown` — matches the CHECK constraint's failure
/// mode (impossible on well-behaved writes, defensive on load).
/// Named `from_wire` (not `from_str`) to avoid shadowing the
/// standard `std::str::FromStr::from_str` trait method, which
/// would force us to pick a meaningless `Err` type when this
/// helper is intentionally infallible.
pub fn from_wire(s: &str) -> Self {
match s {
"password" => Self::Password,
"opaque" => Self::Opaque,
@@ -321,9 +325,9 @@ mod tests {
SessionOrigin::Device,
SessionOrigin::Unknown,
] {
assert_eq!(SessionOrigin::from_str(o.as_str()), o);
assert_eq!(SessionOrigin::from_wire(o.as_str()), o);
}
// Unknown catches typos / drift-off-column-values.
assert_eq!(SessionOrigin::from_str("bogus"), SessionOrigin::Unknown);
assert_eq!(SessionOrigin::from_wire("bogus"), SessionOrigin::Unknown);
}
}
@@ -140,7 +140,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
))
}
@@ -178,7 +178,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
))
}
@@ -219,7 +219,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
)
})
.collect();
@@ -280,7 +280,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
)
})
.collect();
@@ -1263,10 +1263,18 @@ pub async fn list_sessions(
.await
.map_err(AppError::from)?;
// Also publish the current access-token TTL so the admin panel can
// render an honest "revoke takes effect within N seconds" warning
// above the table. Revoking a session flips the DB row (breaks the
// refresh path), but any in-flight JWT stays valid until its `exp`
// — which is `access_token_expiry_secs` from now. Showing this
// number keeps the UX honest instead of implying instant kill.
let access_token_expiry_secs = state.core.config.auth.access_token_expiry_secs;
Ok(Json(serde_json::json!({
"sessions": sessions,
"limit": limit,
"offset": offset,
"access_token_expiry_secs": access_token_expiry_secs,
})))
}