Add passkey (WebAuthn) authentication with self-registration

Enable users to sign up and sign in using passkeys (biometrics/security
keys) without needing a manually-created Authentik account. The existing
SSO login remains as an alternative.

Backend:
- Add WebAuthn registration/authentication endpoints via py-webauthn
- Issue HS256 JWTs for passkey users, with Redis-backed challenge storage
- Dual JWT verification in auth middleware (issuer-based routing: passkey
  HS256 vs Authentik RS256)
- PasskeyCredential model + migration making user.password nullable
- UserRepository with full CRUD for users and credentials

Frontend:
- AuthUser type abstraction unifying OIDC and passkey users
- Passkey service using @simplewebauthn/browser for WebAuthn ceremonies
- LoginModal redesigned with Sign In / Sign Up tabs
- Type migration from oidc-client-ts User to AuthUser across all services
  and components
This commit is contained in:
Viktor Barzin 2026-02-07 00:34:47 +00:00
parent 95c0ddc4c6
commit a8b7eace48
No known key found for this signature in database
GPG key ID: 0EB088298288D958
26 changed files with 1229 additions and 129 deletions

View file

@ -6,6 +6,7 @@ import logging.config
from typing import Annotated, Optional
from api.auth import get_current_user
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS
from api.passkey_routes import passkey_router
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, Query
from fastapi.responses import StreamingResponse
@ -62,6 +63,7 @@ def get_query_parameters(
app = FastAPI()
app.include_router(passkey_router)
app.mount("/metrics", metrics_app)
meter = get_meter(__name__)
request_counter = meter.create_counter(

View file

@ -1,17 +1,22 @@
from api.config import AUTHENTIK_URL, OIDC_CACHE_TTL, OIDC_CLIENT_ID, OIDC_METADATA_URL
from api.config import (
AUTHENTIK_URL,
OIDC_CACHE_TTL,
OIDC_CLIENT_ID,
OIDC_METADATA_URL,
JWT_SECRET,
JWT_ALGORITHM,
JWT_ISSUER,
)
from cachetools import TTLCache
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2AuthorizationCodeBearer
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from httpx import AsyncClient
import jwt
from pydantic import BaseModel
# OAuth2 Scheme
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl=f"{AUTHENTIK_URL}/application/o/authorize/",
tokenUrl=f"{AUTHENTIK_URL}/application/o/token/",
)
# HTTPBearer scheme (provider-agnostic, works for both OIDC and passkey JWTs)
http_bearer = HTTPBearer()
JWKS_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL)
OIDC_METADATA_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL)
@ -23,7 +28,7 @@ class User(BaseModel):
name: str
async def get_oidc_metadata():
async def get_oidc_metadata() -> dict: # type: ignore[type-arg]
if "oidc_metadata" not in OIDC_METADATA_CACHE:
async with AsyncClient() as client:
resp = await client.get(OIDC_METADATA_URL, follow_redirects=True)
@ -43,21 +48,51 @@ async def get_cached_jwks_client() -> jwt.PyJWKClient:
return JWKS_CACHE["jwks_client"]
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
try:
# Fetch JWKS keys from Authentik
metadata = await get_oidc_metadata()
signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token)
async def _verify_authentik_token(token: str) -> User:
"""Verify a token issued by Authentik (RS256 via JWKS)."""
metadata = await get_oidc_metadata()
signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token)
# Decode and verify JWT
payload = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=OIDC_CLIENT_ID,
issuer=metadata["issuer"],
options={"verify_exp": False},
payload = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=OIDC_CLIENT_ID,
issuer=metadata["issuer"],
options={"verify_exp": False},
)
return User(**payload)
def _verify_passkey_token(token: str) -> User:
"""Verify a token issued by the passkey service (HS256)."""
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
issuer=JWT_ISSUER,
)
return User(
sub=payload["sub"],
email=payload["email"],
name=payload.get("name", payload["email"]),
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(http_bearer),
) -> User:
token = credentials.credentials
try:
# Peek at unverified issuer to route verification
unverified = jwt.decode(
token, options={"verify_signature": False, "verify_exp": False}
)
return User(**payload)
issuer = unverified.get("iss", "")
if issuer == JWT_ISSUER:
return _verify_passkey_token(token)
else:
return await _verify_authentik_token(token)
except jwt.PyJWTError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")

View file

@ -1,4 +1,5 @@
from datetime import timedelta
import os
# Authentik OIDC Configuration
@ -14,3 +15,14 @@ OIDC_CACHE_TTL = timedelta(
DEV_TIER_ORIGINS = ["https://localhost/"]
PROD_TIER_ORIGINS = ["https://wrongmove.viktorbarzin.me/"]
# WebAuthn / Passkey Configuration
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Wrongmove")
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://localhost")
# JWT Configuration (for passkey-issued tokens)
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", "24"))
JWT_ISSUER = os.getenv("JWT_ISSUER", "wrongmove")

View file

@ -0,0 +1,93 @@
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
from database import engine
from repositories.user_repository import UserRepository
from services import passkey_service
logger = logging.getLogger("uvicorn")
passkey_router = APIRouter(prefix="/api/passkey", tags=["passkey"])
class RegisterBeginRequest(BaseModel):
email: EmailStr
class RegisterBeginResponse(BaseModel):
options: dict # type: ignore[type-arg]
session_id: str
class CeremonyCompleteRequest(BaseModel):
session_id: str
credential: dict # type: ignore[type-arg]
class AuthTokenResponse(BaseModel):
token: str
class LoginBeginResponse(BaseModel):
options: dict # type: ignore[type-arg]
session_id: str
@passkey_router.post("/register/begin", response_model=RegisterBeginResponse)
async def register_begin(body: RegisterBeginRequest) -> RegisterBeginResponse:
"""Start passkey registration ceremony."""
try:
user_repo = UserRepository(engine)
options, session_id = passkey_service.begin_registration(
body.email, user_repo
)
return RegisterBeginResponse(options=options, session_id=session_id)
except Exception as e:
logger.error(f"Registration begin failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
@passkey_router.post("/register/complete", response_model=AuthTokenResponse)
async def register_complete(body: CeremonyCompleteRequest) -> AuthTokenResponse:
"""Complete passkey registration ceremony."""
try:
user_repo = UserRepository(engine)
token = passkey_service.complete_registration(
body.session_id, body.credential, user_repo
)
return AuthTokenResponse(token=token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Registration complete failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
@passkey_router.post("/login/begin", response_model=LoginBeginResponse)
async def login_begin() -> LoginBeginResponse:
"""Start passkey authentication ceremony."""
try:
user_repo = UserRepository(engine)
options, session_id = passkey_service.begin_authentication(user_repo)
return LoginBeginResponse(options=options, session_id=session_id)
except Exception as e:
logger.error(f"Login begin failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
@passkey_router.post("/login/complete", response_model=AuthTokenResponse)
async def login_complete(body: CeremonyCompleteRequest) -> AuthTokenResponse:
"""Complete passkey authentication ceremony."""
try:
user_repo = UserRepository(engine)
token = passkey_service.complete_authentication(
body.session_id, body.credential, user_repo
)
return AuthTokenResponse(token=token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Login complete failed: {e}")
raise HTTPException(status_code=400, detail=str(e))