Skip to content

Commit ba12068

Browse files
committed
ui
1 parent 4217280 commit ba12068

51 files changed

Lines changed: 4974 additions & 84 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend/app/api/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter
22

33
from app.api.routes.auth.api import router as auth_router
4+
from app.api.routes.auth.passkey.api import router as auth_passkey_router
45
from app.api.routes.health import router as health_router
56
from app.api.routes.bookmarks.api import router as bookmarks_router
67
from app.api.routes.tasks.api import router as tasks_router
@@ -26,6 +27,7 @@
2627
api_router = APIRouter()
2728
api_router.include_router(health_router)
2829
api_router.include_router(auth_router)
30+
api_router.include_router(auth_passkey_router)
2931
api_router.include_router(tasks_router)
3032
api_router.include_router(bookmarks_router)
3133
api_router.include_router(passwords_router)

apps/backend/app/api/routes/auth/passkey/__init__.py

Whitespace-only changes.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
4+
5+
from app.api.routes.auth.cookie_attach import attach_auth_cookies
6+
from app.api.routes.auth.passkey import services as passkey_services
7+
from app.core.firebase import get_firebase_app
8+
9+
try:
10+
from firebase_admin import auth as firebase_auth
11+
except ModuleNotFoundError: # pragma: no cover
12+
firebase_auth = None # type: ignore[assignment]
13+
from app.api.routes.auth.passkey.schema import (
14+
LoginFinishRequest,
15+
PasskeyListResponse,
16+
PasskeySummary,
17+
RegisterFinishRequest,
18+
RenamePasskeyRequest,
19+
)
20+
from app.api.routes.auth.services import get_current_uid
21+
from app.api.routes.auth.tokens import (
22+
create_access_token,
23+
hash_refresh_token,
24+
new_refresh_token,
25+
)
26+
from app.api.routes.auth.users_repo import (
27+
list_passkeys,
28+
remove_passkey,
29+
rename_passkey,
30+
set_refresh_token_hash,
31+
)
32+
from app.core import audit
33+
from app.core.limiter import limiter
34+
35+
router = APIRouter(prefix="/auth/passkey", tags=["auth-passkey"])
36+
37+
38+
# ── Registration (auth required) ──────────────────────────────────────────────
39+
40+
41+
@router.post("/register/begin", summary="Begin passkey registration")
42+
@limiter.limit("10/minute")
43+
async def register_begin(
44+
request: Request,
45+
uid: Annotated[str, Depends(get_current_uid)],
46+
) -> dict:
47+
options = await passkey_services.begin_registration(uid)
48+
audit.set_action("auth.passkey.register_begin")
49+
audit.set_entity("user", uid)
50+
return options
51+
52+
53+
@router.post("/register/finish", summary="Finish passkey registration")
54+
@limiter.limit("10/minute")
55+
async def register_finish(
56+
request: Request,
57+
payload: RegisterFinishRequest,
58+
uid: Annotated[str, Depends(get_current_uid)],
59+
) -> dict:
60+
result = await passkey_services.finish_registration(uid, payload.credential, payload.device_name)
61+
audit.set_action("auth.passkey.register")
62+
audit.set_entity("user", uid)
63+
audit.set_summary(f"Registered passkey: {result['device_name']}")
64+
return result
65+
66+
67+
# ── Login (anonymous) ─────────────────────────────────────────────────────────
68+
69+
70+
@router.post("/login/begin", summary="Begin passkey login (resident-key)")
71+
@limiter.limit("20/minute")
72+
async def login_begin(request: Request) -> dict:
73+
return await passkey_services.begin_login()
74+
75+
76+
@router.post("/login/finish", summary="Finish passkey login → set session cookies")
77+
@limiter.limit("20/minute")
78+
async def login_finish(
79+
request: Request,
80+
payload: LoginFinishRequest,
81+
response: Response,
82+
) -> dict:
83+
uid = await passkey_services.finish_login(payload.credential)
84+
access = create_access_token(uid)
85+
raw_refresh = new_refresh_token()
86+
await set_refresh_token_hash(uid, hash_refresh_token(raw_refresh))
87+
attach_auth_cookies(response, access, raw_refresh)
88+
audit.set_action("auth.passkey.login")
89+
audit.set_entity("user", uid)
90+
audit.set_summary("Signed in with passkey")
91+
92+
# Firebase custom token so the frontend can hydrate its Firebase session.
93+
# RequireAuth gates on Firebase user; passkey-only backend cookies aren't enough.
94+
firebase_token: str | None = None
95+
if firebase_auth is not None:
96+
try:
97+
get_firebase_app()
98+
token_bytes = firebase_auth.create_custom_token(uid)
99+
firebase_token = token_bytes.decode("utf-8") if isinstance(token_bytes, bytes) else token_bytes
100+
except Exception:
101+
firebase_token = None # fail soft — backend cookies still work
102+
103+
return {"uid": uid, "firebase_custom_token": firebase_token}
104+
105+
106+
# ── Manage (auth required) ────────────────────────────────────────────────────
107+
108+
109+
@router.get("", response_model=PasskeyListResponse, summary="List user's passkeys")
110+
async def list_endpoint(uid: Annotated[str, Depends(get_current_uid)]) -> PasskeyListResponse:
111+
passkeys = await list_passkeys(uid)
112+
return PasskeyListResponse(
113+
passkeys=[
114+
PasskeySummary(
115+
credential_id=p["credential_id"],
116+
device_name=p.get("device_name"),
117+
backed_up=bool(p.get("backed_up", False)),
118+
transports=list(p.get("transports") or []),
119+
created_at=p.get("created_at"),
120+
last_used_at=p.get("last_used_at"),
121+
)
122+
for p in passkeys
123+
]
124+
)
125+
126+
127+
@router.patch("/{credential_id}", summary="Rename a passkey")
128+
@limiter.limit("20/minute")
129+
async def rename_endpoint(
130+
request: Request,
131+
credential_id: str,
132+
payload: RenamePasskeyRequest,
133+
uid: Annotated[str, Depends(get_current_uid)],
134+
) -> dict:
135+
modified = await rename_passkey(uid, credential_id, payload.device_name.strip())
136+
if not modified:
137+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Passkey not found.")
138+
audit.set_action("auth.passkey.rename")
139+
audit.set_entity("user", uid)
140+
return {"ok": True}
141+
142+
143+
@router.delete("/{credential_id}", summary="Delete a passkey")
144+
@limiter.limit("10/minute")
145+
async def delete_endpoint(
146+
request: Request,
147+
credential_id: str,
148+
uid: Annotated[str, Depends(get_current_uid)],
149+
) -> dict:
150+
modified = await remove_passkey(uid, credential_id)
151+
if not modified:
152+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Passkey not found.")
153+
audit.set_action("auth.passkey.delete")
154+
audit.set_entity("user", uid)
155+
audit.set_summary("Removed a passkey")
156+
return {"ok": True}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
import datetime as dt
4+
from typing import Literal
5+
6+
from app.database import db_manager
7+
from app.utils.collection_name import WEBAUTHN_CHALLENGES
8+
9+
ChallengeKind = Literal["register", "login"]
10+
11+
12+
def _key(kind: ChallengeKind, subject: str) -> str:
13+
return f"{kind}:{subject}"
14+
15+
16+
async def put(kind: ChallengeKind, subject: str, challenge_b64: str) -> None:
17+
"""Store a challenge keyed by (kind, subject). TTL index expires the doc."""
18+
now = dt.datetime.now(dt.timezone.utc)
19+
await db_manager.update_one(
20+
WEBAUTHN_CHALLENGES,
21+
{"_id": _key(kind, subject)},
22+
{"$set": {"challenge": challenge_b64, "kind": kind, "created_at": now}},
23+
upsert=True,
24+
)
25+
26+
27+
async def pop(kind: ChallengeKind, subject: str) -> str | None:
28+
"""Atomically fetch + delete the challenge. Returns None if missing/expired."""
29+
doc = await db_manager.find_one_and_update(
30+
WEBAUTHN_CHALLENGES,
31+
{"_id": _key(kind, subject)},
32+
{"$set": {"_consumed": True}},
33+
return_document=False,
34+
)
35+
if not doc:
36+
return None
37+
await db_manager.delete_one(WEBAUTHN_CHALLENGES, {"_id": _key(kind, subject)})
38+
return doc.get("challenge")
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel, Field
6+
7+
8+
class PublicKeyCredentialOptions(BaseModel):
9+
"""Pass-through container — server returns the WebAuthn JSON straight from the lib."""
10+
11+
options: dict[str, Any]
12+
13+
14+
class RegisterFinishRequest(BaseModel):
15+
credential: dict[str, Any]
16+
device_name: str | None = Field(default=None, max_length=80)
17+
18+
19+
class LoginFinishRequest(BaseModel):
20+
credential: dict[str, Any]
21+
22+
23+
class PasskeySummary(BaseModel):
24+
credential_id: str
25+
device_name: str | None = None
26+
backed_up: bool = False
27+
transports: list[str] = Field(default_factory=list)
28+
created_at: int | None = None
29+
last_used_at: int | None = None
30+
31+
32+
class PasskeyListResponse(BaseModel):
33+
passkeys: list[PasskeySummary]
34+
35+
36+
class RenamePasskeyRequest(BaseModel):
37+
device_name: str = Field(min_length=1, max_length=80)

0 commit comments

Comments
 (0)