feat: manage enrolled passkeys (Closes #499)
This commit is contained in:
1 parent
7e5b966f77
commit
21a386b94f
8 files changed
+498
-11
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user