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

+116 -1
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()
+53 -4
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