Manage and selectively revoke enrolled passkeys #500
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 device’s 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
117
src/main.py
117
src/main.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user