feat: manage enrolled passkeys (Closes #499)
All checks were successful
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 17:06:36 +00:00
parent 7e5b966f77
commit 21a386b94f
8 changed files with 498 additions and 11 deletions

View File

@ -224,9 +224,15 @@ WebAuthn credential with required user verification. That device can then sign i
and authorize high-impact actions with its biometric/PIN gesture. The access token
remains the recovery fallback for browsers without WebAuthn or devices without an
enrolled credential. Registration and authentication challenges are exact-purpose,
single-use, and short-lived. Remotely revoking an enrolled device deletes its passkey
as well as its active session; signing out normally keeps the passkey available for
the next sign-in.
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
including those whose original session has expired or signed out, using only its
bounded label, enrollment time, and active/current status. Removing one requires a
single-use fresh authorization bound to that exact management ID. A remote active
session linked to the credential is revoked atomically; unrelated credentials and
sessions remain valid. Removing the current device's passkey keeps its current
session active, so the sheet warns that the recovery token will be required after
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
normally keeps the passkey available for the next sign-in.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the

View File

@ -16,11 +16,16 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button { min-height:44px; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; }
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
.security-activity-list { display:grid; gap:8px; margin:12px 0; }

View File

@ -30,7 +30,7 @@
</div>
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices">
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
<div class="active-devices-header">
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
@ -38,6 +38,15 @@
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div>
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
<div class="enrolled-passkeys-header">
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
<p class="small muted">A recovery token will be required after sign-out if you remove this devices passkey.</p>
</div>
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
</section>
<section class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3>

View File

@ -38,6 +38,8 @@
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status');
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
const closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey');
const activityList = root.document.getElementById('security-activity-list');
@ -80,6 +82,56 @@
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
const renderPasskeys = async () => {
if (!passkeysList || !passkeysStatus) return;
passkeysStatus.textContent = 'Loading enrolled passkeys…';
passkeysList.replaceChildren();
try {
const enrolled = await boundary.listPasskeys();
enrolled.forEach(passkey => {
const row = root.document.createElement('article');
row.className = 'enrolled-passkey';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = passkey.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
details.append(label, timing);
const remove = root.document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove passkey';
remove.addEventListener('click', async () => {
remove.disabled = true;
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}`;
try {
const outcome = await boundary.revokePasskey(passkey);
if (outcome) {
passkeysStatus.textContent = outcome.current_session
? 'Passkey removed. This session remains active; keep your recovery token available.'
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
}
} catch (_error) {
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
remove.disabled = false;
}
});
row.append(details, remove);
passkeysList.append(row);
});
passkeysStatus.textContent = enrolled.length
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
: 'No passkeys enrolled.';
} catch (_error) {
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
}
};
const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
@ -132,6 +184,7 @@
devicesSheet.hidden = false;
closeDevices?.focus();
renderDevices();
renderPasskeys();
renderSecurityActivity();
});
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
@ -144,6 +197,7 @@
try {
await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
await renderPasskeys();
} catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally {
@ -547,6 +601,27 @@
return Array.isArray(payload.devices) ? payload.devices : [];
}
async function listPasskeys() {
const response = await sessionFetch(base + 'api/v1/passkeys');
if (!response.ok) throw new Error('Could not load enrolled passkeys');
const payload = await response.json();
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
}
async function revokePasskey(passkey) {
if (!passkey?.management_id) return false;
const confirmed = confirmAction?.(
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
);
if (!confirmed) return false;
const response = await sessionFetch(
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error('Could not remove enrolled passkey');
return response.json();
}
async function listSecurityEvents(cursor = null) {
const query = new URLSearchParams({ limit: '25' });
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
@ -602,9 +677,11 @@
signOut,
signOutAllDevices,
listActiveDevices,
listPasskeys,
listSecurityEvents,
enrollPasskey,
revokeActiveDevice,
revokePasskey,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,

View File

@ -208,6 +208,7 @@ class FreshAuthorization(BaseModel):
"revoke_device",
"revoke_all_sessions",
"enroll_passkey",
"revoke_passkey",
]
target: str = Field(min_length=1, max_length=255)
@ -227,7 +228,7 @@ class PasskeyAuthentication(PasskeyCeremony):
class PasskeyAuthorizationTarget(BaseModel):
action: Literal[
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions", "revoke_passkey"
]
target: str = Field(min_length=1, max_length=255)
@ -1172,6 +1173,120 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
)
@app.get("/api/v1/passkeys")
async def list_enrolled_passkeys(request: Request):
try:
credentials = await asyncio.to_thread(_passkey_store().all)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Passkey registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
active = {device.management_id: device for device in devices}
return JSONResponse(
{
"passkeys": [
{
"management_id": credential.management_id,
"device_label": credential.device_label,
"created_at": credential.created_at,
"active": credential.management_id in active,
"current": bool(
credential.management_id in active
and active[credential.management_id].current
),
}
for credential in credentials
]
},
headers={"Cache-Control": "no-store"},
)
@app.delete("/api/v1/passkeys/{management_id}")
async def revoke_enrolled_passkey(
request: Request,
management_id: str = PathParam(
min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"
),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
await _require_step_up(
request,
step_up_grant,
action="revoke_passkey",
target=management_id,
)
store = _passkey_store()
try:
credential = await asyncio.to_thread(store.get_management_id, management_id)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Passkey registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if credential is None:
raise HTTPException(status_code=404, detail="Enrolled passkey not found")
active_device = next(
(device for device in devices if device.management_id == management_id), None
)
current_session = bool(active_device and active_device.current)
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"passkey_revoked",
device_label=credential.device_label,
target="passkey",
)
except SecurityEventStoreError:
return JSONResponse(
{"detail": "Security activity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
try:
revoked, session_revoked = await asyncio.to_thread(
store.revoke_access,
management_id,
preserve_session=current_session,
)
except dashboard_auth.SessionStoreError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"detail": "Passkey registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if not revoked:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise HTTPException(status_code=404, detail="Enrolled passkey not found")
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{
"revoked": True,
"current_session": current_session,
"session_revoked": session_revoked,
},
headers={"Cache-Control": "no-store"},
)
@app.post("/api/v1/passkeys/authentication/options")
async def create_passkey_authentication_options(request: Request):
store = _passkey_store()

View File

@ -16,6 +16,7 @@ class StoredPasskey:
sign_count: int
device_label: str
management_id: str
created_at: int
class PasskeyStore:
@ -150,7 +151,7 @@ class PasskeyStore:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id "
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials ORDER BY created_at DESC"
).fetchall()
except (OSError, sqlite3.Error) as exc:
@ -161,7 +162,7 @@ class PasskeyStore:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id "
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
).fetchone()
@ -169,6 +170,18 @@ class PasskeyStore:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def get_management_id(self, management_id: str) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
try:
with self._connect() as connection:
@ -181,12 +194,48 @@ class PasskeyStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_management_id(self, management_id: str) -> None:
def revoke_management_id(self, management_id: str) -> bool:
try:
with self._connect() as connection:
connection.execute(
cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_access(
self, management_id: str, *, preserve_session: bool
) -> tuple[bool, bool]:
"""Atomically remove one passkey and, when requested, its active session."""
try:
with self._connect() as connection:
credential = connection.execute(
"SELECT 1 FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
if credential is None:
return False, False
session_revoked = False
if not preserve_session:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if session is not None:
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?",
(management_id,),
)
session_revoked = cursor.rowcount == 1
cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?",
(management_id,),
)
return cursor.rowcount == 1, session_revoked
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc

View File

@ -283,6 +283,195 @@ async def test_revoking_an_enrolled_device_also_revokes_its_passkey(
assert passkey_options.status_code == 404
@pytest.mark.anyio
async def test_orphaned_passkey_is_listed_safely_and_can_be_selectively_revoked(
access_control, monkeypatch
):
class VerifiedRegistration:
credential_id = b"old-phone-credential"
credential_public_key = b"old-phone-public-key"
sign_count = 0
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as phone:
await phone.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Old phone"},
)
phone_headers = {
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
}
enrollment_grant = await fresh_grant(phone, "enroll_passkey", "current_device")
registration = await phone.post(
"/api/v1/passkeys/registration/options",
headers={**phone_headers, "X-Step-Up-Grant": enrollment_grant},
)
enrolled = await phone.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
headers=phone_headers,
)
await phone.delete("/api/v1/session", headers=phone_headers)
store = main._passkey_store()
await asyncio.to_thread(
store.register,
credential_id=b"backup-credential",
public_key=b"backup-public-key",
sign_count=0,
device_label="Backup key",
management_id="backup-management-id",
)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as laptop:
await laptop.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
listed = await laptop.get("/api/v1/passkeys")
old_phone = next(
item for item in listed.json()["passkeys"] if item["device_label"] == "Old phone"
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
}
missing_grant = await laptop.delete(
f"/api/v1/passkeys/{old_phone['management_id']}", headers=headers
)
grant = await fresh_grant(laptop, "revoke_passkey", old_phone["management_id"])
removed = await laptop.delete(
f"/api/v1/passkeys/{old_phone['management_id']}",
headers={**headers, "X-Step-Up-Grant": grant},
)
remaining = await laptop.get("/api/v1/passkeys")
sign_in_options = await laptop.post("/api/v1/passkeys/authentication/options")
assert enrolled.status_code == 201
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert set(old_phone) == {
"management_id", "device_label", "created_at", "active", "current"
}
assert old_phone["active"] is False
assert old_phone["current"] is False
assert b"old-phone-credential" not in listed.content
assert b"old-phone-public-key" not in listed.content
assert missing_grant.status_code == 428
assert missing_grant.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "revoke_passkey",
"target": old_phone["management_id"],
}
assert removed.status_code == 200
assert removed.json() == {
"revoked": True,
"current_session": False,
"session_revoked": False,
}
assert [item["device_label"] for item in remaining.json()["passkeys"]] == ["Backup key"]
assert sign_in_options.status_code == 200
assert sign_in_options.json()["allowCredentials"] == [
{"id": "YmFja3VwLWNyZWRlbnRpYWw", "type": "public-key"}
]
@pytest.mark.anyio
async def test_removing_a_remote_passkey_also_revokes_its_active_session(access_control):
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
await laptop.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
phone_device = next(item for item in devices if item["device_label"] == "Phone")
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=0,
device_label="Phone",
management_id=phone_device["management_id"],
)
grant = await fresh_grant(
laptop, "revoke_passkey", phone_device["management_id"]
)
removed = await laptop.delete(
f"/api/v1/passkeys/{phone_device['management_id']}",
headers={
"Origin": "https://test",
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
phone_status = await phone.get("/api/v1/session")
laptop_status = await laptop.get("/api/v1/session")
assert removed.json() == {
"revoked": True,
"current_session": False,
"session_revoked": True,
}
assert phone_status.status_code == 401
assert phone_status.json()["code"] == "session_revoked"
assert laptop_status.status_code == 200
@pytest.mark.anyio
async def test_removing_the_current_passkey_keeps_the_current_session_active(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
current = next(
item for item in (await client.get("/api/v1/sessions")).json()["devices"]
if item["current"]
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"current-credential",
public_key=b"current-public-key",
sign_count=0,
device_label="Laptop",
management_id=current["management_id"],
)
grant = await fresh_grant(client, "revoke_passkey", current["management_id"])
removed = await client.delete(
f"/api/v1/passkeys/{current['management_id']}",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
still_active = await client.get("/api/v1/session")
activity = await client.get("/api/v1/security-events")
assert removed.json() == {
"revoked": True,
"current_session": True,
"session_revoked": False,
}
assert still_active.status_code == 200
assert activity.json()["events"][0]["kind"] == "passkey_revoked"
assert activity.json()["events"][0]["status"] == "completed"
@pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)

View File

@ -596,6 +596,40 @@ process.stdout.write(JSON.stringify(state));
assert result["confirmations"] == ["Sign out Pixel <script>?"]
def test_enrolled_passkeys_can_be_loaded_and_selectively_removed():
result = run_session_scenario(
"""
state.responses = [
{status:200,payload:{passkeys:[
{management_id:'passkey-id-12345678', device_label:'Old phone <script>', created_at:1000, active:false, current:false},
]}},
{status:200,payload:{revoked:true,current_session:false,session_revoked:false}},
];
const passkeys = await boundary.listPasskeys();
const outcome = await boundary.revokePasskey(passkeys[0]);
state.passkeys = passkeys;
state.outcome = outcome;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["passkeys"][0]["device_label"] == "Old phone <script>"
assert result["outcome"] == {
"revoked": True,
"current_session": False,
"session_revoked": False,
}
assert result["requests"][0]["url"] == "/dashboard/api/v1/passkeys"
assert result["requests"][1]["url"] == (
"/dashboard/api/v1/passkeys/passkey-id-12345678"
)
assert result["requests"][1]["method"] == "DELETE"
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
assert result["confirmations"] == [
"Remove the passkey for Old phone <script>? This cannot be undone."
]
def test_security_activity_pages_are_loaded_with_stable_cursors():
result = run_session_scenario(
"""
@ -642,7 +676,10 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
assert '<button id="active-devices" type="button">Active devices</button>' in html
assert 'id="active-devices-sheet"' in html
assert 'aria-label="Active devices"' in html
assert 'aria-label="Active devices and passkeys"' in html
assert 'aria-labelledby="enrolled-passkeys-title"' in html
assert 'id="enrolled-passkeys-list"' in html
assert 'A recovery token will be required after sign-out if you remove this devices passkey.' in html
assert 'aria-labelledby="security-activity-title"' in html
assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html