feat(sessions): show session origin in admin panel + test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user