Manage and selectively revoke enrolled passkeys #500

Merged
timmy merged 1 commits from timmy/499-manage-enrolled-passkeys into main 2026-08-10 17:08:36 +00:00
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 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 remains the recovery fallback for browsers without WebAuthn or devices without an
enrolled credential. Registration and authentication challenges are exact-purpose, enrolled credential. Registration and authentication challenges are exact-purpose,
single-use, and short-lived. Remotely revoking an enrolled device deletes its passkey single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
as well as its active session; signing out normally keeps the passkey available for including those whose original session has expired or signed out, using only its
the next sign-in. 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 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 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-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 { 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 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-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 { 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 strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; } .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 { 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-header h3, .security-activity-header p { margin:0 0 6px; }
.security-activity-list { display:grid; gap:8px; margin:12px 0; } .security-activity-list { display:grid; gap:8px; margin:12px 0; }

View File

@ -30,7 +30,7 @@
</div> </div>
<div id="active-devices-sheet" class="active-devices-sheet" hidden> <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 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> <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> <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> <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> <button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div> <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"> <section class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header"> <div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3> <h3 id="security-activity-title">Security activity</h3>

View File

@ -38,6 +38,8 @@
const devicesSheet = root.document.getElementById('active-devices-sheet'); const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list'); const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status'); 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 closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey'); const enrollPasskey = root.document.getElementById('enroll-passkey');
const activityList = root.document.getElementById('security-activity-list'); const activityList = root.document.getElementById('security-activity-list');
@ -80,6 +82,56 @@
devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; 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) => { const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return; if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…'; activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
@ -132,6 +184,7 @@
devicesSheet.hidden = false; devicesSheet.hidden = false;
closeDevices?.focus(); closeDevices?.focus();
renderDevices(); renderDevices();
renderPasskeys();
renderSecurityActivity(); renderSecurityActivity();
}); });
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
@ -144,6 +197,7 @@
try { try {
await boundary.enrollPasskey(); await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.'; devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
await renderPasskeys();
} catch (_error) { } catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.'; devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally { } finally {
@ -547,6 +601,27 @@
return Array.isArray(payload.devices) ? payload.devices : []; 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) { async function listSecurityEvents(cursor = null) {
const query = new URLSearchParams({ limit: '25' }); const query = new URLSearchParams({ limit: '25' });
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor)); if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
@ -602,9 +677,11 @@
signOut, signOut,
signOutAllDevices, signOutAllDevices,
listActiveDevices, listActiveDevices,
listPasskeys,
listSecurityEvents, listSecurityEvents,
enrollPasskey, enrollPasskey,
revokeActiveDevice, revokeActiveDevice,
revokePasskey,
clearPrivateDeviceData, clearPrivateDeviceData,
handleServiceWorkerMessage, handleServiceWorkerMessage,
refreshOfflineLease, refreshOfflineLease,

View File

@ -208,6 +208,7 @@ class FreshAuthorization(BaseModel):
"revoke_device", "revoke_device",
"revoke_all_sessions", "revoke_all_sessions",
"enroll_passkey", "enroll_passkey",
"revoke_passkey",
] ]
target: str = Field(min_length=1, max_length=255) target: str = Field(min_length=1, max_length=255)
@ -227,7 +228,7 @@ class PasskeyAuthentication(PasskeyCeremony):
class PasskeyAuthorizationTarget(BaseModel): class PasskeyAuthorizationTarget(BaseModel):
action: Literal[ 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) 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") @app.post("/api/v1/passkeys/authentication/options")
async def create_passkey_authentication_options(request: Request): async def create_passkey_authentication_options(request: Request):
store = _passkey_store() store = _passkey_store()

View File

@ -16,6 +16,7 @@ class StoredPasskey:
sign_count: int sign_count: int
device_label: str device_label: str
management_id: str management_id: str
created_at: int
class PasskeyStore: class PasskeyStore:
@ -150,7 +151,7 @@ class PasskeyStore:
try: try:
with self._connect() as connection: with self._connect() as connection:
rows = connection.execute( 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" "FROM passkey_credentials ORDER BY created_at DESC"
).fetchall() ).fetchall()
except (OSError, sqlite3.Error) as exc: except (OSError, sqlite3.Error) as exc:
@ -161,7 +162,7 @@ class PasskeyStore:
try: try:
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( 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 = ?", "FROM passkey_credentials WHERE credential_id = ?",
(credential_id,), (credential_id,),
).fetchone() ).fetchone()
@ -169,6 +170,18 @@ class PasskeyStore:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None 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: def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
try: try:
with self._connect() as connection: with self._connect() as connection:
@ -181,12 +194,48 @@ class PasskeyStore:
except (OSError, sqlite3.Error) as exc: except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from 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: try:
with self._connect() as connection: with self._connect() as connection:
connection.execute( cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,) "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: except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from 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 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 @pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch): async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False) 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>?"] 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(): def test_security_activity_pages_are_loaded_with_stable_cursors():
result = run_session_scenario( 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="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 '<button id="active-devices" type="button">Active devices</button>' in html
assert 'id="active-devices-sheet"' 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 'aria-labelledby="security-activity-title"' in html
assert 'id="security-activity-list"' in html assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html assert '>Load older activity</button>' in html