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
+116
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user