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:
parent
95c0ddc4c6
commit
a8b7eace48
26 changed files with 1229 additions and 129 deletions
|
|
@ -30,3 +30,14 @@ RIGHTMOVE_CIRCUIT_BREAKER_TIMEOUT=60.0 # Seconds to wait before recovery attemp
|
|||
# Multiple schedules:
|
||||
# SCRAPE_SCHEDULES='[{"name":"RENT 2am","listing_type":"RENT","hour":"2"},{"name":"BUY 4am","listing_type":"BUY","hour":"4"}]'
|
||||
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
|
||||
|
|
|
|||
66
crawler/alembic/versions/b4c7d8e9f0a1_add_passkey_auth.py
Normal file
66
crawler/alembic/versions/b4c7d8e9f0a1_add_passkey_auth.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
93
crawler/api/passkey_routes.py
Normal file
93
crawler/api/passkey_routes.py
Normal 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))
|
||||
130
crawler/frontend/package-lock.json
generated
130
crawler/frontend/package-lock.json
generated
|
|
@ -22,7 +22,9 @@
|
|||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@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": {
|
||||
"version": "1.2.9",
|
||||
"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": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
|
||||
|
|
@ -2170,6 +2269,22 @@
|
|||
"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": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
|
|
@ -3010,6 +3125,7 @@
|
|||
"integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
}
|
||||
|
|
@ -3026,6 +3142,7 @@
|
|||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -3036,6 +3153,7 @@
|
|||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
|
|
@ -3105,6 +3223,7 @@
|
|||
"integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.34.0",
|
||||
"@typescript-eslint/types": "8.34.0",
|
||||
|
|
@ -3336,6 +3455,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3901,6 +4021,7 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -4134,6 +4255,7 @@
|
|||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@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",
|
||||
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
|
|
@ -5384,6 +5507,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -5414,6 +5538,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -5426,6 +5551,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz",
|
||||
"integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -5824,6 +5950,7 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5898,6 +6025,7 @@
|
|||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -5994,6 +6122,7 @@
|
|||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -6082,6 +6211,7 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@
|
|||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/crossfilter": "^0.0.38",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { User } from 'oidc-client-ts';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import './App.css';
|
||||
import { getUser } from './auth/authService';
|
||||
import { getStoredPasskeyUser } from './auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from './auth/types';
|
||||
import AlertError from './components/AlertError';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import AuthCallback from './components/AuthCallback';
|
||||
|
|
@ -20,7 +21,7 @@ import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type Streamin
|
|||
function App() {
|
||||
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | 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 [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
||||
|
|
@ -41,10 +42,23 @@ function App() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Load user data
|
||||
getUser().then(setUser);
|
||||
// Check passkey user first, then fall back to OIDC
|
||||
const passkeyUser = getStoredPasskeyUser();
|
||||
if (passkeyUser) {
|
||||
setUser(passkeyUser);
|
||||
} else {
|
||||
getUser().then((oidcUser) => {
|
||||
if (oidcUser) {
|
||||
setUser(fromOidcUser(oidcUser));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePasskeyLogin = (passkeyUser: AuthUser) => {
|
||||
setUser(passkeyUser);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
|
|
@ -122,7 +136,7 @@ function App() {
|
|||
}, [user, loadListings]);
|
||||
|
||||
if (!user) {
|
||||
return <LoginModal isOpen={user === null} />;
|
||||
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
||||
}
|
||||
|
||||
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
||||
|
|
|
|||
155
crawler/frontend/src/auth/passkeyService.ts
Normal file
155
crawler/frontend/src/auth/passkeyService.ts
Normal 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);
|
||||
}
|
||||
19
crawler/frontend/src/auth/types.ts
Normal file
19
crawler/frontend/src/auth/types.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { getUser } from '@/auth/authService';
|
||||
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { fetchTaskStatus, cancelTask } from '@/services';
|
||||
import { TaskStatus, type TaskResult } from '@/types';
|
||||
import type { User } from 'oidc-client-ts';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AlertError from './AlertError';
|
||||
import { Spinner } from './Spinner';
|
||||
|
|
@ -17,9 +18,16 @@ interface ActiveQueryProps {
|
|||
}
|
||||
|
||||
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import type { User } from 'oidc-client-ts';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { LogOut, Home, Filter } from 'lucide-react';
|
||||
import { logout } from '@/auth/authService';
|
||||
import { clearPasskeyUser } from '@/auth/passkeyService';
|
||||
import { HealthIndicator } from './HealthIndicator';
|
||||
import { TaskIndicator } from './TaskIndicator';
|
||||
|
||||
interface HeaderProps {
|
||||
user: User;
|
||||
user: AuthUser;
|
||||
activeFilterCount?: number;
|
||||
taskID?: string | null;
|
||||
isLoading?: boolean;
|
||||
|
|
@ -24,6 +25,15 @@ export function Header({
|
|||
showFilterToggle = false,
|
||||
onTaskCancelled,
|
||||
}: HeaderProps) {
|
||||
const handleLogout = async () => {
|
||||
if (user.provider === 'passkey') {
|
||||
clearPasskeyUser();
|
||||
window.location.reload();
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
|
||||
{/* Logo / Brand */}
|
||||
|
|
@ -63,12 +73,12 @@ export function Header({
|
|||
{/* User Menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline">
|
||||
{user.profile.email}
|
||||
{user.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,44 +1,71 @@
|
|||
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 { DialogDescription } from '@radix-ui/react-dialog';
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Home, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Home, LogIn, AlertCircle, Loader2, Fingerprint, Mail } from 'lucide-react';
|
||||
|
||||
interface LoginModalProps {
|
||||
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 [error, setError] = useState<AuthError | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleLogin = async () => {
|
||||
const handleSSOLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login();
|
||||
} catch (err) {
|
||||
setError(err as AuthError);
|
||||
setError((err as AuthError).message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
const handlePasskeyLogin = async () => {
|
||||
setIsLoading(true);
|
||||
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);
|
||||
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 (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[425px]" showCloseButton={false}>
|
||||
<DialogHeader className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
|
|
@ -53,72 +80,107 @@ const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* 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>
|
||||
|
||||
<div className="py-2">
|
||||
{/* Error State */}
|
||||
{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" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
className="text-destructive border-destructive/30 hover:bg-destructive/10"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={clearError}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && !error && (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Redirecting to login...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tabs defaultValue="signin">
|
||||
<TabsList>
|
||||
<TabsTrigger value="signin">Sign In</TabsTrigger>
|
||||
<TabsTrigger value="signup">Sign Up</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<DialogFooter>
|
||||
{!error && (
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TabsContent value="signin" className="space-y-4 pt-2">
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<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" />
|
||||
Sign in with SSO
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { getUser } from '@/auth/authService';
|
||||
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
|
||||
import { TaskStatus, type TaskResult } from '@/types';
|
||||
import type { User } from 'oidc-client-ts';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { Button } from './ui/button';
|
||||
|
|
@ -15,7 +16,7 @@ interface 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 [processed, setProcessed] = 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);
|
||||
|
||||
useEffect(() => {
|
||||
getUser().then(setUser);
|
||||
const passkeyUser = getStoredPasskeyUser();
|
||||
if (passkeyUser) {
|
||||
setUser(passkeyUser);
|
||||
} else {
|
||||
getUser().then((oidcUser) => {
|
||||
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
60
crawler/frontend/src/components/ui/tabs.tsx
Normal file
60
crawler/frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Generic API client with authentication
|
||||
|
||||
import type { User } from 'oidc-client-ts';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
export interface RequestOptions {
|
||||
|
|
@ -31,12 +31,11 @@ function buildQueryString(params: Record<string, string | number | boolean | Dat
|
|||
* Generic authenticated API request
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
user: User,
|
||||
user: AuthUser,
|
||||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', params } = options;
|
||||
const accessToken = user.access_token;
|
||||
|
||||
let url = endpoint;
|
||||
if (params) {
|
||||
|
|
@ -49,7 +48,7 @@ export async function apiRequest<T>(
|
|||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// 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 { ParameterValues } from '@/components/FilterPanel';
|
||||
import { apiRequest } from './apiClient';
|
||||
|
|
@ -31,7 +31,7 @@ function buildListingParams(parameters: ParameterValues): Record<string, string
|
|||
* Fetch listing data as GeoJSON
|
||||
*/
|
||||
export async function fetchListingGeoJSON(
|
||||
user: User,
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues
|
||||
): Promise<GeoJSONFeatureCollection> {
|
||||
return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, {
|
||||
|
|
@ -44,7 +44,7 @@ export async function fetchListingGeoJSON(
|
|||
* Trigger a listing refresh task
|
||||
*/
|
||||
export async function refreshListings(
|
||||
user: User,
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues
|
||||
): Promise<RefreshListingsResponse> {
|
||||
return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// 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 { ParameterValues } from '@/components/FilterPanel';
|
||||
import { ApiError } from '@/types';
|
||||
|
|
@ -65,7 +65,7 @@ export interface StreamingProgress {
|
|||
* Yields batches of features as they arrive from the server.
|
||||
*/
|
||||
export async function* streamListingGeoJSON(
|
||||
user: User,
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues,
|
||||
onProgress?: (progress: StreamingProgress) => void
|
||||
): AsyncGenerator<PropertyFeature[], void, unknown> {
|
||||
|
|
@ -77,7 +77,7 @@ export async function* streamListingGeoJSON(
|
|||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.access_token}`,
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// 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 { apiRequest } from './apiClient';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
|
@ -19,7 +19,7 @@ export interface ClearAllTasksResponse {
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ export async function fetchTasksForUser(user: User): Promise<string[]> {
|
|||
* Fetch the status of a specific task
|
||||
*/
|
||||
export async function fetchTaskStatus(
|
||||
user: User,
|
||||
user: AuthUser,
|
||||
taskId: string
|
||||
): Promise<TaskStatusResponse> {
|
||||
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
|
||||
|
|
@ -39,7 +39,7 @@ export async function fetchTaskStatus(
|
|||
* Cancel a running task
|
||||
*/
|
||||
export async function cancelTask(
|
||||
user: User,
|
||||
user: AuthUser,
|
||||
taskId: string
|
||||
): Promise<CancelTaskResponse> {
|
||||
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
|
||||
|
|
@ -51,7 +51,7 @@ export async function cancelTask(
|
|||
/**
|
||||
* 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, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
|
|
|||
13
crawler/models/passkey_credential.py
Normal file
13
crawler/models/passkey_credential.py
Normal 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)
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import EmailStr
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
|
@ -5,4 +7,5 @@ from sqlmodel import SQLModel, Field
|
|||
class User(SQLModel, table=True):
|
||||
id: int = Field(primary_key=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
110
crawler/poetry.lock
generated
|
|
@ -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]]
|
||||
name = "aiohappyeyeballs"
|
||||
|
|
@ -398,6 +398,18 @@ files = [
|
|||
[package.extras]
|
||||
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]]
|
||||
name = "asttokens"
|
||||
version = "3.0.0"
|
||||
|
|
@ -540,6 +552,60 @@ files = [
|
|||
{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]]
|
||||
name = "celery"
|
||||
version = "5.5.3"
|
||||
|
|
@ -2147,7 +2213,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
|||
idna = {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\""}
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rfc3339-validator = {version = "*", 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"]
|
||||
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]]
|
||||
name = "pyparsing"
|
||||
version = "3.2.3"
|
||||
|
|
@ -5886,6 +5972,24 @@ files = [
|
|||
{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]]
|
||||
name = "webcolors"
|
||||
version = "24.11.1"
|
||||
|
|
@ -6237,4 +6341,4 @@ type = ["pytest-mypy"]
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">3.11"
|
||||
content-hash = "6f9ce2af71a995db179aa4fb682e8a9ccde59566d14e26c7b0dbf4edc8d8e583"
|
||||
content-hash = "208795363e3c025b8a10e5d89fdd6f052a9aba8451cb447e3f3bd6d367913caf"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ opentelemetry-exporter-prometheus = "^0.57b0"
|
|||
opentelemetry-instrumentation-fastapi = "^0.57b0"
|
||||
opentelemetry-instrumentation-sqlalchemy = "^0.57b0"
|
||||
mysqlclient = "^2.2.7"
|
||||
webauthn = "^2.0.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ipdb = "^0.13.13"
|
||||
|
|
@ -80,5 +81,5 @@ strict_optional = true
|
|||
plugins = ["pydantic.mypy"]
|
||||
|
||||
[[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
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from models.passkey_credential import PasskeyCredential
|
||||
from models.user import User
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session, select
|
||||
|
|
@ -6,12 +7,56 @@ from sqlmodel import Session, select
|
|||
class UserRepository:
|
||||
engine: Engine
|
||||
|
||||
def __init__(self, engine: Engine):
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
async def get_user_from_token(self, token: str) -> User | None:
|
||||
raise NotImplementedError()
|
||||
query = select(User)
|
||||
|
||||
def get_user_by_email(self, email: str) -> User | None:
|
||||
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()
|
||||
|
|
|
|||
248
crawler/services/passkey_service.py
Normal file
248
crawler/services/passkey_service.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue