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

@ -30,3 +30,14 @@ RIGHTMOVE_CIRCUIT_BREAKER_TIMEOUT=60.0 # Seconds to wait before recovery attemp
# Multiple schedules: # Multiple schedules:
# SCRAPE_SCHEDULES='[{"name":"RENT 2am","listing_type":"RENT","hour":"2"},{"name":"BUY 4am","listing_type":"BUY","hour":"4"}]' # SCRAPE_SCHEDULES='[{"name":"RENT 2am","listing_type":"RENT","hour":"2"},{"name":"BUY 4am","listing_type":"BUY","hour":"4"}]'
SCRAPE_SCHEDULES= SCRAPE_SCHEDULES=
# WebAuthn / Passkey configuration
WEBAUTHN_RP_ID=localhost # Relying Party ID (domain)
WEBAUTHN_RP_NAME=Wrongmove # Relying Party display name
WEBAUTHN_ORIGIN=https://localhost # Expected WebAuthn origin
# JWT configuration (for passkey-issued tokens)
JWT_SECRET=change-me-in-production # HMAC secret for HS256 signing
JWT_ALGORITHM=HS256 # JWT signing algorithm
JWT_EXPIRATION_HOURS=24 # Token expiry in hours
JWT_ISSUER=wrongmove # JWT issuer claim

View file

@ -0,0 +1,66 @@
"""add passkey auth
Revision ID: b4c7d8e9f0a1
Revises: a1b2c3d4e5f6
Create Date: 2025-07-15 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'b4c7d8e9f0a1'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Make user.password nullable
op.alter_column('user', 'password',
existing_type=sqlmodel.sql.sqltypes.AutoString(),
nullable=True)
# Add created_at to user table
op.add_column('user',
sa.Column('created_at', sa.DateTime(),
nullable=True,
server_default=sa.func.now()))
# Create passkeycredential table
op.create_table('passkeycredential',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('credential_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('public_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sign_count', sa.Integer(), nullable=False),
sa.Column('transports', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_passkeycredential_credential_id'),
'passkeycredential', ['credential_id'], unique=True)
op.create_index(op.f('ix_passkeycredential_user_id'),
'passkeycredential', ['user_id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_passkeycredential_user_id'),
table_name='passkeycredential')
op.drop_index(op.f('ix_passkeycredential_credential_id'),
table_name='passkeycredential')
op.drop_table('passkeycredential')
op.drop_column('user', 'created_at')
op.alter_column('user', 'password',
existing_type=sqlmodel.sql.sqltypes.AutoString(),
nullable=False)

View file

@ -6,6 +6,7 @@ import logging.config
from typing import Annotated, Optional from typing import Annotated, Optional
from api.auth import get_current_user from api.auth import get_current_user
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS
from api.passkey_routes import passkey_router
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import Depends, FastAPI, Query from fastapi import Depends, FastAPI, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -62,6 +63,7 @@ def get_query_parameters(
app = FastAPI() app = FastAPI()
app.include_router(passkey_router)
app.mount("/metrics", metrics_app) app.mount("/metrics", metrics_app)
meter = get_meter(__name__) meter = get_meter(__name__)
request_counter = meter.create_counter( 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 cachetools import TTLCache
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi.security import OAuth2AuthorizationCodeBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from httpx import AsyncClient from httpx import AsyncClient
import jwt import jwt
from pydantic import BaseModel from pydantic import BaseModel
# OAuth2 Scheme # HTTPBearer scheme (provider-agnostic, works for both OIDC and passkey JWTs)
oauth2_scheme = OAuth2AuthorizationCodeBearer( http_bearer = HTTPBearer()
authorizationUrl=f"{AUTHENTIK_URL}/application/o/authorize/",
tokenUrl=f"{AUTHENTIK_URL}/application/o/token/",
)
JWKS_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL) JWKS_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL)
OIDC_METADATA_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 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: if "oidc_metadata" not in OIDC_METADATA_CACHE:
async with AsyncClient() as client: async with AsyncClient() as client:
resp = await client.get(OIDC_METADATA_URL, follow_redirects=True) 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"] return JWKS_CACHE["jwks_client"]
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: async def _verify_authentik_token(token: str) -> User:
try: """Verify a token issued by Authentik (RS256 via JWKS)."""
# Fetch JWKS keys from Authentik metadata = await get_oidc_metadata()
metadata = await get_oidc_metadata() signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token)
signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token)
# Decode and verify JWT payload = jwt.decode(
payload = jwt.decode( token,
token, signing_key,
signing_key, algorithms=["RS256"],
algorithms=["RS256"], audience=OIDC_CLIENT_ID,
audience=OIDC_CLIENT_ID, issuer=metadata["issuer"],
issuer=metadata["issuer"], options={"verify_exp": False},
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: except jwt.PyJWTError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {e}") raise HTTPException(status_code=401, detail=f"Invalid token: {e}")

View file

@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
import os
# Authentik OIDC Configuration # Authentik OIDC Configuration
@ -14,3 +15,14 @@ OIDC_CACHE_TTL = timedelta(
DEV_TIER_ORIGINS = ["https://localhost/"] DEV_TIER_ORIGINS = ["https://localhost/"]
PROD_TIER_ORIGINS = ["https://wrongmove.viktorbarzin.me/"] 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))

View file

@ -22,7 +22,9 @@
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@simplewebauthn/browser": "^10.0.0",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@types/crossfilter": "^0.0.38", "@types/crossfilter": "^0.0.38",
@ -1544,6 +1546,43 @@
} }
} }
}, },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area": { "node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.9", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
@ -1698,6 +1737,66 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
@ -2170,6 +2269,22 @@
"win32" "win32"
] ]
}, },
"node_modules/@simplewebauthn/browser": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/types": "^10.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/@standard-schema/utils": { "node_modules/@standard-schema/utils": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@ -3010,6 +3125,7 @@
"integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.8.0" "undici-types": "~7.8.0"
} }
@ -3026,6 +3142,7 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -3036,6 +3153,7 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
@ -3105,6 +3223,7 @@
"integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/scope-manager": "8.34.0",
"@typescript-eslint/types": "8.34.0", "@typescript-eslint/types": "8.34.0",
@ -3336,6 +3455,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3901,6 +4021,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -4134,6 +4255,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -5170,6 +5292,7 @@
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz", "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==", "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"jwt-decode": "^4.0.0" "jwt-decode": "^4.0.0"
}, },
@ -5384,6 +5507,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5414,6 +5538,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -5426,6 +5551,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz",
"integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==", "integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@ -5824,6 +5950,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5898,6 +6025,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5994,6 +6122,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@ -6082,6 +6211,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View file

@ -24,7 +24,9 @@
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@simplewebauthn/browser": "^10.0.0",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@types/crossfilter": "^0.0.38", "@types/crossfilter": "^0.0.38",

View file

@ -1,7 +1,8 @@
import type { User } from 'oidc-client-ts';
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import './App.css'; import './App.css';
import { getUser } from './auth/authService'; import { getUser } from './auth/authService';
import { getStoredPasskeyUser } from './auth/passkeyService';
import { fromOidcUser, type AuthUser } from './auth/types';
import AlertError from './components/AlertError'; import AlertError from './components/AlertError';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import AuthCallback from './components/AuthCallback'; import AuthCallback from './components/AuthCallback';
@ -20,7 +21,7 @@ import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type Streamin
function App() { function App() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null); const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
const [taskID, setTaskID] = useState<string | null>(null); const [taskID, setTaskID] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null); const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
@ -41,10 +42,23 @@ function App() {
} }
useEffect(() => { useEffect(() => {
// Load user data // Check passkey user first, then fall back to OIDC
getUser().then(setUser); const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) {
setUser(fromOidcUser(oidcUser));
}
});
}
}, []); }, []);
const handlePasskeyLogin = (passkeyUser: AuthUser) => {
setUser(passkeyUser);
};
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
return; return;
@ -122,7 +136,7 @@ function App() {
}, [user, loadListings]); }, [user, loadListings]);
if (!user) { if (!user) {
return <LoginModal isOpen={user === null} />; return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
} }
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => { const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {

View file

@ -0,0 +1,155 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import type { AuthUser } from './types';
const PASSKEY_USER_KEY = 'passkey_user';
interface RegisterBeginResponse {
options: PublicKeyCredentialCreationOptionsJSON;
session_id: string;
}
interface LoginBeginResponse {
options: PublicKeyCredentialRequestOptionsJSON;
session_id: string;
}
interface AuthTokenResponse {
token: string;
}
// WebAuthn JSON types from the spec
type PublicKeyCredentialCreationOptionsJSON = Parameters<typeof startRegistration>[0];
type PublicKeyCredentialRequestOptionsJSON = Parameters<typeof startAuthentication>[0];
function parseJwt(token: string): Record<string, unknown> {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
}
export async function registerPasskey(email: string): Promise<AuthUser> {
// Step 1: Begin registration
const beginRes = await fetch('/api/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!beginRes.ok) {
const err = await beginRes.json();
throw new Error(err.detail || 'Failed to start registration');
}
const beginData: RegisterBeginResponse = await beginRes.json();
// Step 2: Browser WebAuthn ceremony
const attResp = await startRegistration(beginData.options);
// Step 3: Complete registration
const completeRes = await fetch('/api/passkey/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: beginData.session_id,
credential: attResp,
}),
});
if (!completeRes.ok) {
const err = await completeRes.json();
throw new Error(err.detail || 'Failed to complete registration');
}
const { token }: AuthTokenResponse = await completeRes.json();
const claims = parseJwt(token);
const user: AuthUser = {
sub: claims.sub as string,
email: claims.email as string,
name: (claims.name as string) || (claims.email as string),
accessToken: token,
provider: 'passkey',
};
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
return user;
}
export async function loginWithPasskey(): Promise<AuthUser> {
// Step 1: Begin authentication
const beginRes = await fetch('/api/passkey/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!beginRes.ok) {
const err = await beginRes.json();
throw new Error(err.detail || 'Failed to start login');
}
const beginData: LoginBeginResponse = await beginRes.json();
// Step 2: Browser WebAuthn ceremony
const assertionResp = await startAuthentication(beginData.options);
// Step 3: Complete authentication
const completeRes = await fetch('/api/passkey/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: beginData.session_id,
credential: assertionResp,
}),
});
if (!completeRes.ok) {
const err = await completeRes.json();
throw new Error(err.detail || 'Failed to complete login');
}
const { token }: AuthTokenResponse = await completeRes.json();
const claims = parseJwt(token);
const user: AuthUser = {
sub: claims.sub as string,
email: claims.email as string,
name: (claims.name as string) || (claims.email as string),
accessToken: token,
provider: 'passkey',
};
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
return user;
}
export function getStoredPasskeyUser(): AuthUser | null {
const stored = localStorage.getItem(PASSKEY_USER_KEY);
if (!stored) return null;
try {
const user: AuthUser = JSON.parse(stored);
// Check JWT expiration
const claims = parseJwt(user.accessToken);
const exp = claims.exp as number | undefined;
if (exp && exp * 1000 < Date.now()) {
localStorage.removeItem(PASSKEY_USER_KEY);
return null;
}
return user;
} catch {
localStorage.removeItem(PASSKEY_USER_KEY);
return null;
}
}
export function clearPasskeyUser(): void {
localStorage.removeItem(PASSKEY_USER_KEY);
}

View file

@ -0,0 +1,19 @@
import type { User } from 'oidc-client-ts';
export interface AuthUser {
sub: string;
email: string;
name: string;
accessToken: string;
provider: 'oidc' | 'passkey';
}
export function fromOidcUser(user: User): AuthUser {
return {
sub: user.profile.sub,
email: user.profile.email ?? '',
name: user.profile.name ?? user.profile.email ?? '',
accessToken: user.access_token,
provider: 'oidc',
};
}

View file

@ -1,8 +1,9 @@
import { getUser } from '@/auth/authService'; import { getUser } from '@/auth/authService';
import { getStoredPasskeyUser } from '@/auth/passkeyService';
import { fromOidcUser, type AuthUser } from '@/auth/types';
import { POLLING_INTERVALS } from '@/constants'; import { POLLING_INTERVALS } from '@/constants';
import { fetchTaskStatus, cancelTask } from '@/services'; import { fetchTaskStatus, cancelTask } from '@/services';
import { TaskStatus, type TaskResult } from '@/types'; import { TaskStatus, type TaskResult } from '@/types';
import type { User } from 'oidc-client-ts';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import AlertError from './AlertError'; import AlertError from './AlertError';
import { Spinner } from './Spinner'; import { Spinner } from './Spinner';
@ -17,9 +18,16 @@ interface ActiveQueryProps {
} }
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => { const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => { useEffect(() => {
getUser().then(setUser); const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) setUser(fromOidcUser(oidcUser));
});
}
}, []); }, []);
const [progressPercentage, setProgressPercentage] = useState<number>(0); const [progressPercentage, setProgressPercentage] = useState<number>(0);

View file

@ -1,13 +1,14 @@
import type { User } from 'oidc-client-ts'; import type { AuthUser } from '@/auth/types';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Separator } from './ui/separator'; import { Separator } from './ui/separator';
import { LogOut, Home, Filter } from 'lucide-react'; import { LogOut, Home, Filter } from 'lucide-react';
import { logout } from '@/auth/authService'; import { logout } from '@/auth/authService';
import { clearPasskeyUser } from '@/auth/passkeyService';
import { HealthIndicator } from './HealthIndicator'; import { HealthIndicator } from './HealthIndicator';
import { TaskIndicator } from './TaskIndicator'; import { TaskIndicator } from './TaskIndicator';
interface HeaderProps { interface HeaderProps {
user: User; user: AuthUser;
activeFilterCount?: number; activeFilterCount?: number;
taskID?: string | null; taskID?: string | null;
isLoading?: boolean; isLoading?: boolean;
@ -24,6 +25,15 @@ export function Header({
showFilterToggle = false, showFilterToggle = false,
onTaskCancelled, onTaskCancelled,
}: HeaderProps) { }: HeaderProps) {
const handleLogout = async () => {
if (user.provider === 'passkey') {
clearPasskeyUser();
window.location.reload();
} else {
await logout();
}
};
return ( return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4"> <header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
{/* Logo / Brand */} {/* Logo / Brand */}
@ -63,12 +73,12 @@ export function Header({
{/* User Menu */} {/* User Menu */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground hidden md:inline"> <span className="text-sm text-muted-foreground hidden md:inline">
{user.profile.email} {user.email}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={logout} onClick={handleLogout}
className="gap-2" className="gap-2"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />

View file

@ -1,44 +1,71 @@
import { login, type AuthError } from '@/auth/authService'; import { login, type AuthError } from '@/auth/authService';
import { registerPasskey, loginWithPasskey } from '@/auth/passkeyService';
import type { AuthUser } from '@/auth/types';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DialogDescription } from '@radix-ui/react-dialog'; import { DialogDescription } from '@radix-ui/react-dialog';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Home, LogIn, AlertCircle, Loader2 } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Home, LogIn, AlertCircle, Loader2, Fingerprint, Mail } from 'lucide-react';
interface LoginModalProps { interface LoginModalProps {
isOpen: boolean; isOpen: boolean;
onPasskeyLogin: (user: AuthUser) => void;
} }
const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => { const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onPasskeyLogin }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AuthError | null>(null); const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
if (!isOpen) return null; if (!isOpen) return null;
const handleLogin = async () => { const handleSSOLogin = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
await login(); await login();
} catch (err) { } catch (err) {
setError(err as AuthError); setError((err as AuthError).message);
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleRetry = () => { const handlePasskeyLogin = async () => {
setIsLoading(true);
setError(null); setError(null);
handleLogin(); try {
const user = await loginWithPasskey();
onPasskeyLogin(user);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
}; };
const handleCancel = () => { const handlePasskeyRegister = async () => {
if (!email.trim()) {
setError('Please enter your email address');
return;
}
setIsLoading(true);
setError(null); setError(null);
setIsLoading(false); try {
const user = await registerPasskey(email.trim());
onPasskeyLogin(user);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
}; };
const clearError = () => setError(null);
return ( return (
<Dialog open={isOpen}> <Dialog open={isOpen}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]" showCloseButton={false}>
<DialogHeader className="space-y-4"> <DialogHeader className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
@ -53,72 +80,107 @@ const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="py-4 space-y-4"> <div className="py-2">
{/* Beta Notice */}
<div className="bg-muted/50 border rounded-lg p-4 text-sm">
<p className="text-muted-foreground">
We are currently in closed beta. Please contact Viktor to request an invitation.
</p>
</div>
{/* Error State */} {/* Error State */}
{error && ( {error && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3"> <div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3 mb-4">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" /> <AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<p className="text-sm text-destructive">{error.message}</p> <p className="text-sm text-destructive">{error}</p>
<div className="flex gap-2"> <Button
<Button size="sm"
size="sm" variant="ghost"
variant="outline" onClick={clearError}
onClick={handleRetry} >
className="text-destructive border-destructive/30 hover:bg-destructive/10" Dismiss
> </Button>
Try Again
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
>
Cancel
</Button>
</div>
</div> </div>
</div> </div>
)} )}
{/* Loading State */} <Tabs defaultValue="signin">
{isLoading && !error && ( <TabsList>
<div className="flex items-center justify-center gap-3 py-4 text-muted-foreground"> <TabsTrigger value="signin">Sign In</TabsTrigger>
<Loader2 className="h-5 w-5 animate-spin" /> <TabsTrigger value="signup">Sign Up</TabsTrigger>
<span>Redirecting to login...</span> </TabsList>
</div>
)}
</div>
<DialogFooter> <TabsContent value="signin" className="space-y-4 pt-2">
{!error && ( <Button
<Button onClick={handlePasskeyLogin}
onClick={handleLogin} disabled={isLoading}
disabled={isLoading} className="w-full gap-2"
className="w-full gap-2" size="lg"
size="lg" >
> {isLoading ? (
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Signing in... ) : (
</> <Fingerprint className="h-4 w-4" />
) : ( )}
<> Sign in with Passkey
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
onClick={handleSSOLogin}
disabled={isLoading}
variant="outline"
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" /> <LogIn className="h-4 w-4" />
Sign in with SSO )}
</> Sign in with SSO
)} </Button>
</Button> </TabsContent>
)}
</DialogFooter> <TabsContent value="signup" className="space-y-4 pt-2">
<div className="space-y-2">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handlePasskeyRegister();
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
</div>
<Button
onClick={handlePasskeyRegister}
disabled={isLoading || !email.trim()}
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Create account with Passkey
</Button>
<p className="text-xs text-muted-foreground text-center">
A passkey uses your device's biometrics or security key for secure, passwordless authentication.
</p>
</TabsContent>
</Tabs>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -1,8 +1,9 @@
import { getUser } from '@/auth/authService'; import { getUser } from '@/auth/authService';
import { getStoredPasskeyUser } from '@/auth/passkeyService';
import { fromOidcUser, type AuthUser } from '@/auth/types';
import { POLLING_INTERVALS } from '@/constants'; import { POLLING_INTERVALS } from '@/constants';
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services'; import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
import { TaskStatus, type TaskResult } from '@/types'; import { TaskStatus, type TaskResult } from '@/types';
import type { User } from 'oidc-client-ts';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Button } from './ui/button'; import { Button } from './ui/button';
@ -15,7 +16,7 @@ interface TaskIndicatorProps {
} }
export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) { export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [progressPercentage, setProgressPercentage] = useState<number>(0); const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [processed, setProcessed] = useState<number | null>(null); const [processed, setProcessed] = useState<number | null>(null);
const [total, setTotal] = useState<number | null>(null); const [total, setTotal] = useState<number | null>(null);
@ -26,7 +27,14 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => { useEffect(() => {
getUser().then(setUser); const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) setUser(fromOidcUser(oidcUser));
});
}
}, []); }, []);
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,60 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" {...props} />
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm flex-1",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -1,6 +1,6 @@
// Generic API client with authentication // Generic API client with authentication
import type { User } from 'oidc-client-ts'; import type { AuthUser } from '@/auth/types';
import { ApiError } from '@/types'; import { ApiError } from '@/types';
export interface RequestOptions { export interface RequestOptions {
@ -31,12 +31,11 @@ function buildQueryString(params: Record<string, string | number | boolean | Dat
* Generic authenticated API request * Generic authenticated API request
*/ */
export async function apiRequest<T>( export async function apiRequest<T>(
user: User, user: AuthUser,
endpoint: string, endpoint: string,
options: RequestOptions = {} options: RequestOptions = {}
): Promise<T> { ): Promise<T> {
const { method = 'GET', params } = options; const { method = 'GET', params } = options;
const accessToken = user.access_token;
let url = endpoint; let url = endpoint;
if (params) { if (params) {
@ -49,7 +48,7 @@ export async function apiRequest<T>(
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${user.accessToken}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });

View file

@ -1,6 +1,6 @@
// Listing service for fetching and refreshing listings // Listing service for fetching and refreshing listings
import type { User } from 'oidc-client-ts'; import type { AuthUser } from '@/auth/types';
import type { GeoJSONFeatureCollection, RefreshListingsResponse } from '@/types'; import type { GeoJSONFeatureCollection, RefreshListingsResponse } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel'; import type { ParameterValues } from '@/components/FilterPanel';
import { apiRequest } from './apiClient'; import { apiRequest } from './apiClient';
@ -31,7 +31,7 @@ function buildListingParams(parameters: ParameterValues): Record<string, string
* Fetch listing data as GeoJSON * Fetch listing data as GeoJSON
*/ */
export async function fetchListingGeoJSON( export async function fetchListingGeoJSON(
user: User, user: AuthUser,
parameters: ParameterValues parameters: ParameterValues
): Promise<GeoJSONFeatureCollection> { ): Promise<GeoJSONFeatureCollection> {
return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, { return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, {
@ -44,7 +44,7 @@ export async function fetchListingGeoJSON(
* Trigger a listing refresh task * Trigger a listing refresh task
*/ */
export async function refreshListings( export async function refreshListings(
user: User, user: AuthUser,
parameters: ParameterValues parameters: ParameterValues
): Promise<RefreshListingsResponse> { ): Promise<RefreshListingsResponse> {
return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, { return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, {

View file

@ -1,6 +1,6 @@
// Streaming service for progressive listing data loading // Streaming service for progressive listing data loading
import type { User } from 'oidc-client-ts'; import type { AuthUser } from '@/auth/types';
import type { PropertyFeature } from '@/types'; import type { PropertyFeature } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel'; import type { ParameterValues } from '@/components/FilterPanel';
import { ApiError } from '@/types'; import { ApiError } from '@/types';
@ -65,7 +65,7 @@ export interface StreamingProgress {
* Yields batches of features as they arrive from the server. * Yields batches of features as they arrive from the server.
*/ */
export async function* streamListingGeoJSON( export async function* streamListingGeoJSON(
user: User, user: AuthUser,
parameters: ParameterValues, parameters: ParameterValues,
onProgress?: (progress: StreamingProgress) => void onProgress?: (progress: StreamingProgress) => void
): AsyncGenerator<PropertyFeature[], void, unknown> { ): AsyncGenerator<PropertyFeature[], void, unknown> {
@ -77,7 +77,7 @@ export async function* streamListingGeoJSON(
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${user.access_token}`, Authorization: `Bearer ${user.accessToken}`,
}, },
}); });

View file

@ -1,6 +1,6 @@
// Task service for fetching task status // Task service for fetching task status
import type { User } from 'oidc-client-ts'; import type { AuthUser } from '@/auth/types';
import type { TaskStatusResponse } from '@/types'; import type { TaskStatusResponse } from '@/types';
import { apiRequest } from './apiClient'; import { apiRequest } from './apiClient';
import { API_ENDPOINTS } from '@/constants'; import { API_ENDPOINTS } from '@/constants';
@ -19,7 +19,7 @@ export interface ClearAllTasksResponse {
/** /**
* Fetch all active tasks for the current user * Fetch all active tasks for the current user
*/ */
export async function fetchTasksForUser(user: User): Promise<string[]> { export async function fetchTasksForUser(user: AuthUser): Promise<string[]> {
return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER); return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER);
} }
@ -27,7 +27,7 @@ export async function fetchTasksForUser(user: User): Promise<string[]> {
* Fetch the status of a specific task * Fetch the status of a specific task
*/ */
export async function fetchTaskStatus( export async function fetchTaskStatus(
user: User, user: AuthUser,
taskId: string taskId: string
): Promise<TaskStatusResponse> { ): Promise<TaskStatusResponse> {
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, { return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
@ -39,7 +39,7 @@ export async function fetchTaskStatus(
* Cancel a running task * Cancel a running task
*/ */
export async function cancelTask( export async function cancelTask(
user: User, user: AuthUser,
taskId: string taskId: string
): Promise<CancelTaskResponse> { ): Promise<CancelTaskResponse> {
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, { return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
@ -51,7 +51,7 @@ export async function cancelTask(
/** /**
* Clear all tasks for the current user * Clear all tasks for the current user
*/ */
export async function clearAllTasks(user: User): Promise<ClearAllTasksResponse> { export async function clearAllTasks(user: AuthUser): Promise<ClearAllTasksResponse> {
return apiRequest<ClearAllTasksResponse>(user, API_ENDPOINTS.CLEAR_ALL_TASKS, { return apiRequest<ClearAllTasksResponse>(user, API_ENDPOINTS.CLEAR_ALL_TASKS, {
method: 'POST', method: 'POST',
}); });

View file

@ -0,0 +1,13 @@
from datetime import datetime
from sqlmodel import SQLModel, Field
class PasskeyCredential(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
credential_id: str = Field(index=True, unique=True)
public_key: str
sign_count: int = Field(default=0)
transports: str | None = Field(default=None) # JSON-encoded list
user_id: int = Field(foreign_key="user.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)

View file

@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import EmailStr from pydantic import EmailStr
from sqlmodel import SQLModel, Field from sqlmodel import SQLModel, Field
@ -5,4 +7,5 @@ from sqlmodel import SQLModel, Field
class User(SQLModel, table=True): class User(SQLModel, table=True):
id: int = Field(primary_key=True) id: int = Field(primary_key=True)
email: EmailStr = Field(index=True, unique=True) email: EmailStr = Field(index=True, unique=True)
password: str = Field(nullable=False) password: str | None = Field(default=None, nullable=True)
created_at: datetime = Field(default_factory=datetime.utcnow)

110
crawler/poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. # This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand.
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
@ -398,6 +398,18 @@ files = [
[package.extras] [package.extras]
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
[[package]]
name = "asn1crypto"
version = "1.5.1"
description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"},
{file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"},
]
[[package]] [[package]]
name = "asttokens" name = "asttokens"
version = "3.0.0" version = "3.0.0"
@ -540,6 +552,60 @@ files = [
{file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"},
] ]
[[package]]
name = "cbor2"
version = "5.8.0"
description = "CBOR (de)serializer with extensive tag support"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "cbor2-5.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2263c0c892194f10012ced24c322d025d9d7b11b41da1c357f3b3fe06676e6b7"},
{file = "cbor2-5.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffe4ca079f6f8ed393f5c71a8de22651cb27bd50e74e2bcd6bc9c8f853a732b"},
{file = "cbor2-5.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0427bd166230fe4c4b72965c6f2b6273bf29016d97cf08b258fa48db851ea598"},
{file = "cbor2-5.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c23a04947c37964d70028ca44ea2a8709f09b8adc0090f9b5710fa957e9bc545"},
{file = "cbor2-5.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:218d5c7d2e8d13c7eded01a1b3fe2a9a1e51a7a843cefb8d38cb4bbbc6ad9bf7"},
{file = "cbor2-5.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:4ce7d907a25448af7c13415281d739634edfd417228b274309b243ca52ad71f9"},
{file = "cbor2-5.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:628d0ea850aa040921a0e50a08180e7d20cf691432cec3eabc193f643eccfbde"},
{file = "cbor2-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18ac191640093e6c7fbcb174c006ffec4106c3d8ab788e70272c1c4d933cbe11"},
{file = "cbor2-5.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fddee9103a17d7bed5753f0c7fc6663faa506eb953e50d8287804eccf7b048e6"},
{file = "cbor2-5.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d2ea26fad620aba5e88d7541be8b10c5034a55db9a23809b7cb49f36803f05b"},
{file = "cbor2-5.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:de68b4b310b072b082d317adc4c5e6910173a6d9455412e6183d72c778d1f54c"},
{file = "cbor2-5.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:418d2cf0e03e90160fa1474c05a40fe228bbb4a92d1628bdbbd13a48527cb34d"},
{file = "cbor2-5.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:453200ffa1c285ea46ab5745736a015526d41f22da09cb45594624581d959770"},
{file = "cbor2-5.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:f6615412fca973a8b472b3efc4dab01df71cc13f15d8b2c0a1cffac44500f12d"},
{file = "cbor2-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3f91fa699a5ce22470e973601c62dd9d55dc3ca20ee446516ac075fcab27c9"},
{file = "cbor2-5.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518c118a5e00001854adb51f3164e647aa99b6a9877d2a733a28cb5c0a4d6857"},
{file = "cbor2-5.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cff2a1999e49cd51c23d1b6786a012127fd8f722c5946e82bd7ab3eb307443f3"},
{file = "cbor2-5.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c4492160212374973cdc14e46f0565f2462721ef922b40f7ea11e7d613dfb2a"},
{file = "cbor2-5.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:546c7c7c4c6bcdc54a59242e0e82cea8f332b17b4465ae628718fef1fce401ca"},
{file = "cbor2-5.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:074f0fa7535dd7fdee247c2c99f679d94f3aa058ccb1ccf4126cc72d6d89cbae"},
{file = "cbor2-5.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:f95fed480b2a0d843f294d2a1ef4cc0f6a83c7922927f9f558e1f5a8dc54b7ca"},
{file = "cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015"},
{file = "cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7"},
{file = "cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32"},
{file = "cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5"},
{file = "cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd"},
{file = "cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0"},
{file = "cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea"},
{file = "cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7"},
{file = "cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718"},
{file = "cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99"},
{file = "cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6"},
{file = "cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e"},
{file = "cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1"},
{file = "cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71"},
{file = "cbor2-5.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6cda8fc407e91c4b07f1ae217332b2418096345b2f003894425bd874af445573"},
{file = "cbor2-5.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c87350cfcccfa3499413c90d62d0591c8220932c200c2a7108737d4c96c6"},
{file = "cbor2-5.8.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36afff8d8527d68cabf1b13acef15a573c0864b99017e315dcbe5710cb7e6e"},
{file = "cbor2-5.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9f197a7b33c3afa44f18d16a2f823c1c020e3eb57a79cfaa0f21435e9a7f732"},
{file = "cbor2-5.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4df31a52b20d28bf60ee35d16b2b43c2870b77c901cbc42e4151b575b20d522e"},
{file = "cbor2-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:88db454bfdfeb7c611b926e70f28d4bc37e7cbc55594141a3514cc087c8890c2"},
{file = "cbor2-5.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b0400d2c98b3137448090cd9cfa9d3ecf1b04852328339c85025b1c3acfd8b7d"},
{file = "cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c"},
{file = "cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931"},
]
[[package]] [[package]]
name = "celery" name = "celery"
version = "5.5.3" version = "5.5.3"
@ -2147,7 +2213,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
jsonschema-specifications = ">=2023.03.6" jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4" referencing = ">=0.28.4"
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
@ -4114,6 +4180,26 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pyopenssl"
version = "25.1.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab"},
{file = "pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b"},
]
[package.dependencies]
cryptography = ">=41.0.5,<46"
typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""}
[package.extras]
docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.2.3" version = "3.2.3"
@ -5886,6 +5972,24 @@ files = [
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
] ]
[[package]]
name = "webauthn"
version = "2.7.0"
description = "Pythonic WebAuthn"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "webauthn-2.7.0-py3-none-any.whl", hash = "sha256:2ecfee7959b09ebeaaffee9f8982ecdbbdc369a11766d20d4bc0637b36e235b7"},
{file = "webauthn-2.7.0.tar.gz", hash = "sha256:3c45c25e75a7d7d419220ccd10b8b899984de8012732e10d898f0a8f8c480575"},
]
[package.dependencies]
asn1crypto = ">=1.5.1"
cbor2 = ">=5.6.5"
cryptography = ">=44.0.2"
pyOpenSSL = ">=25.0.0"
[[package]] [[package]]
name = "webcolors" name = "webcolors"
version = "24.11.1" version = "24.11.1"
@ -6237,4 +6341,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">3.11" python-versions = ">3.11"
content-hash = "6f9ce2af71a995db179aa4fb682e8a9ccde59566d14e26c7b0dbf4edc8d8e583" content-hash = "208795363e3c025b8a10e5d89fdd6f052a9aba8451cb447e3f3bd6d367913caf"

View file

@ -38,6 +38,7 @@ opentelemetry-exporter-prometheus = "^0.57b0"
opentelemetry-instrumentation-fastapi = "^0.57b0" opentelemetry-instrumentation-fastapi = "^0.57b0"
opentelemetry-instrumentation-sqlalchemy = "^0.57b0" opentelemetry-instrumentation-sqlalchemy = "^0.57b0"
mysqlclient = "^2.2.7" mysqlclient = "^2.2.7"
webauthn = "^2.0.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ipdb = "^0.13.13" ipdb = "^0.13.13"
@ -80,5 +81,5 @@ strict_optional = true
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["transformers.*", "pytesseract.*", "cv2.*", "celery.*", "tqdm.*", "aiohttp.*", "aiohttp_socks.*", "tenacity.*", "geopy.*", "pandas.*", "numpy.*", "PIL.*", "sqlmodel.*", "sqlalchemy.*", "alembic.*", "apprise.*", "opentelemetry.*"] module = ["transformers.*", "pytesseract.*", "cv2.*", "celery.*", "tqdm.*", "aiohttp.*", "aiohttp_socks.*", "tenacity.*", "geopy.*", "pandas.*", "numpy.*", "PIL.*", "sqlmodel.*", "sqlalchemy.*", "alembic.*", "apprise.*", "opentelemetry.*", "webauthn.*"]
ignore_missing_imports = true ignore_missing_imports = true

View file

@ -1,3 +1,4 @@
from models.passkey_credential import PasskeyCredential
from models.user import User from models.user import User
from sqlalchemy import Engine from sqlalchemy import Engine
from sqlmodel import Session, select from sqlmodel import Session, select
@ -6,12 +7,56 @@ from sqlmodel import Session, select
class UserRepository: class UserRepository:
engine: Engine engine: Engine
def __init__(self, engine: Engine): def __init__(self, engine: Engine) -> None:
self.engine = engine self.engine = engine
async def get_user_from_token(self, token: str) -> User | None: def get_user_by_email(self, email: str) -> User | None:
raise NotImplementedError()
query = select(User)
with Session(self.engine) as session: with Session(self.engine) as session:
return session.exec(query).first() statement = select(User).where(User.email == email)
return session.exec(statement).first()
def get_user_by_id(self, user_id: int) -> User | None:
with Session(self.engine) as session:
return session.get(User, user_id)
def create_user(self, email: str) -> User:
with Session(self.engine) as session:
user = User(email=email) # type: ignore[call-arg]
session.add(user)
session.commit()
session.refresh(user)
return user
def get_credentials_for_user(self, user_id: int) -> list[PasskeyCredential]:
with Session(self.engine) as session:
statement = select(PasskeyCredential).where(
PasskeyCredential.user_id == user_id
)
return list(session.exec(statement).all())
def get_credential_by_id(self, credential_id: str) -> PasskeyCredential | None:
with Session(self.engine) as session:
statement = select(PasskeyCredential).where(
PasskeyCredential.credential_id == credential_id
)
return session.exec(statement).first()
def save_credential(self, credential: PasskeyCredential) -> PasskeyCredential:
with Session(self.engine) as session:
session.add(credential)
session.commit()
session.refresh(credential)
return credential
def update_credential_sign_count(
self, credential_id: str, new_sign_count: int
) -> None:
with Session(self.engine) as session:
statement = select(PasskeyCredential).where(
PasskeyCredential.credential_id == credential_id
)
credential = session.exec(statement).first()
if credential:
credential.sign_count = new_sign_count
session.add(credential)
session.commit()

View file

@ -0,0 +1,248 @@
import base64
import json
import logging
import uuid
from datetime import datetime, timedelta, timezone
import jwt
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
)
from webauthn.helpers import (
options_to_json,
parse_registration_credential_json,
parse_authentication_credential_json,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
AuthenticatorTransport,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from api.config import (
WEBAUTHN_RP_ID,
WEBAUTHN_RP_NAME,
WEBAUTHN_ORIGIN,
JWT_SECRET,
JWT_ALGORITHM,
JWT_EXPIRATION_HOURS,
JWT_ISSUER,
)
from models.passkey_credential import PasskeyCredential
from repositories.user_repository import UserRepository
from redis_repository import RedisRepository
logger = logging.getLogger("uvicorn")
CHALLENGE_TTL = timedelta(minutes=5)
CHALLENGE_KEY_PREFIX = "webauthn:challenge:"
def _store_challenge(session_id: str, data: dict) -> None: # type: ignore[type-arg]
redis = RedisRepository.instance()
redis.set_key(f"{CHALLENGE_KEY_PREFIX}{session_id}", data, ttl=CHALLENGE_TTL)
def _get_challenge(session_id: str) -> dict | None: # type: ignore[type-arg]
redis = RedisRepository.instance()
return redis.get_key(f"{CHALLENGE_KEY_PREFIX}{session_id}") # type: ignore[return-value]
def _issue_jwt(user_id: int, email: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"email": email,
"name": email,
"iss": JWT_ISSUER,
"iat": now,
"exp": now + timedelta(hours=JWT_EXPIRATION_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def begin_registration(
email: str, user_repo: UserRepository
) -> tuple[dict, str]: # type: ignore[type-arg]
"""Start WebAuthn registration ceremony.
Returns (options_dict, session_id).
"""
user = user_repo.get_user_by_email(email)
if user is None:
user = user_repo.create_user(email)
existing_credentials = user_repo.get_credentials_for_user(user.id)
exclude_credentials = []
for cred in existing_credentials:
transports = []
if cred.transports:
transports = [
AuthenticatorTransport(t) for t in json.loads(cred.transports)
]
exclude_credentials.append(
PublicKeyCredentialDescriptor(
id=base64.urlsafe_b64decode(cred.credential_id + "=="),
transports=transports,
)
)
options = generate_registration_options(
rp_id=WEBAUTHN_RP_ID,
rp_name=WEBAUTHN_RP_NAME,
user_id=str(user.id).encode(),
user_name=email,
user_display_name=email,
exclude_credentials=exclude_credentials,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
],
)
session_id = str(uuid.uuid4())
_store_challenge(session_id, {
"challenge": base64.urlsafe_b64encode(options.challenge).decode(),
"user_id": user.id,
"email": email,
"type": "registration",
})
options_json = json.loads(options_to_json(options))
return options_json, session_id
def complete_registration(
session_id: str,
credential: dict, # type: ignore[type-arg]
user_repo: UserRepository,
) -> str:
"""Complete WebAuthn registration ceremony.
Returns a JWT string.
"""
challenge_data = _get_challenge(session_id)
if not challenge_data or challenge_data.get("type") != "registration":
raise ValueError("Invalid or expired registration session")
expected_challenge = base64.urlsafe_b64decode(
challenge_data["challenge"] + "=="
)
registration_credential = parse_registration_credential_json(
json.dumps(credential)
)
verification = verify_registration_response(
credential=registration_credential,
expected_challenge=expected_challenge,
expected_rp_id=WEBAUTHN_RP_ID,
expected_origin=WEBAUTHN_ORIGIN,
)
credential_id_b64 = base64.urlsafe_b64encode(
verification.credential_id
).decode().rstrip("=")
public_key_b64 = base64.urlsafe_b64encode(
verification.credential_public_key
).decode().rstrip("=")
transports_json = None
if credential.get("response", {}).get("transports"):
transports_json = json.dumps(
credential["response"]["transports"]
)
passkey_cred = PasskeyCredential(
credential_id=credential_id_b64,
public_key=public_key_b64,
sign_count=verification.sign_count,
transports=transports_json,
user_id=challenge_data["user_id"],
)
user_repo.save_credential(passkey_cred)
return _issue_jwt(challenge_data["user_id"], challenge_data["email"])
def begin_authentication(
user_repo: UserRepository,
) -> tuple[dict, str]: # type: ignore[type-arg]
"""Start WebAuthn authentication ceremony (discoverable credentials).
Returns (options_dict, session_id).
"""
options = generate_authentication_options(
rp_id=WEBAUTHN_RP_ID,
user_verification=UserVerificationRequirement.PREFERRED,
)
session_id = str(uuid.uuid4())
_store_challenge(session_id, {
"challenge": base64.urlsafe_b64encode(options.challenge).decode(),
"type": "authentication",
})
options_json = json.loads(options_to_json(options))
return options_json, session_id
def complete_authentication(
session_id: str,
credential: dict, # type: ignore[type-arg]
user_repo: UserRepository,
) -> str:
"""Complete WebAuthn authentication ceremony.
Returns a JWT string.
"""
challenge_data = _get_challenge(session_id)
if not challenge_data or challenge_data.get("type") != "authentication":
raise ValueError("Invalid or expired authentication session")
expected_challenge = base64.urlsafe_b64decode(
challenge_data["challenge"] + "=="
)
# Look up the credential in the database
raw_id = credential.get("rawId") or credential.get("id", "")
stored_cred = user_repo.get_credential_by_id(raw_id)
if not stored_cred:
raise ValueError("Credential not found")
stored_public_key = base64.urlsafe_b64decode(
stored_cred.public_key + "=="
)
auth_credential = parse_authentication_credential_json(
json.dumps(credential)
)
verification = verify_authentication_response(
credential=auth_credential,
expected_challenge=expected_challenge,
expected_rp_id=WEBAUTHN_RP_ID,
expected_origin=WEBAUTHN_ORIGIN,
credential_public_key=stored_public_key,
credential_current_sign_count=stored_cred.sign_count,
)
user_repo.update_credential_sign_count(
stored_cred.credential_id, verification.new_sign_count
)
user = user_repo.get_user_by_id(stored_cred.user_id)
if not user:
raise ValueError("User not found")
return _issue_jwt(user.id, user.email)