feat: manage enrolled passkeys (Closes #499)
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 committed 2026-08-10 17:06:36 +00:00
1 parent 7e5b966f77
commit 21a386b94f
8 files changed
+498 -11

No files matched your search

+189
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)
+38 -1
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 device’s 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