from __future__ import annotations from datetime import timedelta from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.security import hash_token, short_code, token_urlsafe, utc_now from app.db.base import get_db from app.models import ( DeviceLinkCode, Group, HomeDevice, HomeProfile, Member, MemberDevice, MemberInvite, RecoveryMethod, ) from app.schemas import DeviceLinkCodeIn, DeviceLinkComplete, DeviceLinkStart, InviteClaim, PasskeyStub, RecoveryConsume, RecoveryRequest from app.services.auth import ( CurrentContext, audit, create_session, ensure_home_profile, get_current_context, get_members_for_context, get_optional_context, set_session_cookies, ) from app.services.passkeys import passkey_provider from app.services.serializers import device_dict, group_dict, member_dict, profile_dict router = APIRouter(prefix="/api", tags=["auth"]) def _invite_or_404(db: Session, raw_token: str) -> MemberInvite: invite = db.scalar(select(MemberInvite).where(MemberInvite.token_hash == hash_token(raw_token))) now = utc_now() expired = invite and invite.expires_at and (invite.expires_at < (now if invite.expires_at.tzinfo else now.replace(tzinfo=None))) if not invite or invite.revoked_at or expired or invite.use_count >= invite.max_uses: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"error": {"code": "invite_unavailable", "message": "This invite is no longer available.", "details": {}}}, ) return invite @router.get("/health") def health() -> dict: return {"ok": True, "name": get_settings().app_name} @router.get("/me") def me(ctx: CurrentContext = Depends(get_optional_context), db: Session = Depends(get_db)) -> dict: memberships = [] for member in get_members_for_context(db, ctx): group = member.group memberships.append({"member": member_dict(member), "group": group_dict(group)}) return { "authenticated": ctx.authenticated, "profile": profile_dict(ctx.home_profile, ctx.member), "member": member_dict(ctx.member) if ctx.member else None, "memberships": memberships, "dev_mode": get_settings().dev_mode, } @router.post("/auth/dev/demo-session") def dev_demo_session(response: Response, request: Request, db: Session = Depends(get_db)) -> dict: settings = get_settings() if not settings.dev_mode: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Not found.", "details": {}}}) profile = db.scalar(select(HomeProfile).where(HomeProfile.primary_display_name == "Anna Müller")) if not profile: raise HTTPException(status_code=409, detail={"error": {"code": "seed_missing", "message": "Run python -m app.db.seed first.", "details": {}}}) device = HomeDevice( home_profile_id=profile.id, label="Demo browser", user_agent_summary=request.headers.get("user-agent", "")[:255], trust_level="verified", ) db.add(device) session, csrf_token = create_session(db, home_profile=profile, home_device=device) audit(db, action="dev_demo_session", resource_type="home_profile", resource_id=profile.id) db.commit() set_session_cookies(response, session, csrf_token) return {"profile": profile_dict(profile), "csrf_token": csrf_token} @router.get("/join/{token}/preview") def invite_preview(token: str, db: Session = Depends(get_db)) -> dict: invite = _invite_or_404(db, token) group = invite and db.get(Group, invite.group_id) if invite.member_id: member = db.get(Member, invite.member_id) if member and member.status == "invited": member.status = "opened" invite.opened_count += 1 from app.models import Announcement, Event from app.services.serializers import announcement_dict, event_dict announcements = [ announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True)).limit(3)).all() ] events = [event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(3)).all()] db.commit() return { "group": group_dict(group), "invite": {"label": invite.label, "expires_at": invite.expires_at.isoformat() if invite.expires_at else None, "role": invite.permission_role}, "preview": {"announcements": announcements, "events": events}, } @router.post("/auth/invite/{token}/claim") def claim_invite(token: str, payload: InviteClaim, response: Response, request: Request, db: Session = Depends(get_db)) -> dict: invite = _invite_or_404(db, token) group = db.get(Group, invite.group_id) member = db.get(Member, invite.member_id) if invite.member_id else None if member is None: member = Member( group_id=group.id, display_name=payload.display_name, role=invite.permission_role, status="joined", joined_at=utc_now(), last_seen_at=utc_now(), ) db.add(member) db.flush() else: member.display_name = payload.display_name member.role = invite.permission_role if member.role == "guest" else member.role member.status = "joined" member.joined_at = member.joined_at or utc_now() invite.use_count += 1 if invite.use_count >= invite.max_uses: invite.consumed_at = utc_now() device = MemberDevice( member_id=member.id, label=payload.device_label, user_agent_summary=request.headers.get("user-agent", "")[:255], trust_level="claimed_browser", ) db.add(device) session, csrf_token = create_session(db, home_profile=member.home_profile, member=member, member_device=device) audit(db, action="invite_claimed", resource_type="invite", resource_id=invite.id, details={"group_id": group.id}) db.commit() set_session_cookies(response, session, csrf_token) return { "member": member_dict(member), "group": group_dict(group), "csrf_token": csrf_token, "next_steps": ["save_access", "enable_notifications"], } @router.post("/auth/recovery/request") def recovery_request(payload: RecoveryRequest, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: profile = ensure_home_profile(db, ctx) raw = token_urlsafe(32) method = RecoveryMethod( home_profile_id=profile.id, kind="email", value_hash=hash_token(payload.email.lower()), display_hint=payload.email[:2] + "***" + payload.email[payload.email.rfind("@") :], recovery_token_hash=hash_token(raw), recovery_expires_at=utc_now() + timedelta(minutes=30), ) db.add(method) audit(db, ctx=ctx, action="recovery_requested", resource_type="home_profile", resource_id=profile.id) db.commit() result = {"ok": True, "message": "Check your email for a recovery link."} if get_settings().dev_mode: result.update({"dev_code": raw, "dev_link": f"{get_settings().frontend_origin}/me?recover={raw}"}) print(f"[dev recovery] {payload.email}: {raw}") return result @router.post("/auth/recovery/consume") def recovery_consume(payload: RecoveryConsume, response: Response, request: Request, db: Session = Depends(get_db)) -> dict: method = db.scalar(select(RecoveryMethod).where(RecoveryMethod.recovery_token_hash == hash_token(payload.recovery_code))) now = utc_now() expired = method and method.recovery_expires_at and method.recovery_expires_at < ( now if method.recovery_expires_at.tzinfo else now.replace(tzinfo=None) ) if not method or method.revoked_at or expired: raise HTTPException(status_code=400, detail={"error": {"code": "recovery_invalid", "message": "This recovery link has expired.", "details": {}}}) profile = db.get(HomeProfile, method.home_profile_id) method.verified_at = method.verified_at or utc_now() method.recovery_token_hash = None device = HomeDevice( home_profile_id=profile.id, label=payload.device_label, user_agent_summary=request.headers.get("user-agent", "")[:255], trust_level="verified", ) db.add(device) session, csrf_token = create_session(db, home_profile=profile, home_device=device) audit(db, action="recovery_consumed", resource_type="home_profile", resource_id=profile.id) db.commit() set_session_cookies(response, session, csrf_token) return {"profile": profile_dict(profile), "csrf_token": csrf_token} @router.post("/auth/device-link/start") def device_link_start(payload: DeviceLinkStart, request: Request, db: Session = Depends(get_db)) -> dict: code = short_code() pending = DeviceLinkCode( code_hash=hash_token(code), requested_device_label=payload.device_label, requested_user_agent=request.headers.get("user-agent", "")[:255], expires_at=utc_now() + timedelta(minutes=10), ) db.add(pending) db.commit() return {"pairing_id": pending.id, "code": code, "expires_at": pending.expires_at.isoformat()} @router.post("/auth/device-link/approve") def device_link_approve(payload: DeviceLinkCodeIn, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", "")))) now = utc_now() expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None)) if not pending or expired or pending.completed_at: raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "That link code is not active.", "details": {}}}) pending.approved_by_home_profile_id = ctx.home_profile.id if ctx.home_profile else None pending.approved_by_member_id = ctx.member.id if ctx.member else None pending.approved_at = utc_now() audit(db, ctx=ctx, action="device_link_approved", resource_type="device_link", resource_id=pending.id) db.commit() return {"ok": True, "device_label": pending.requested_device_label} @router.post("/auth/device-link/complete") def device_link_complete(payload: DeviceLinkComplete, response: Response, request: Request, db: Session = Depends(get_db)) -> dict: pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", "")))) now = utc_now() expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None)) if not pending or expired or not pending.approved_at or pending.completed_at: raise HTTPException(status_code=400, detail={"error": {"code": "pairing_not_ready", "message": "This link code has not been approved yet.", "details": {}}}) profile = db.get(HomeProfile, pending.approved_by_home_profile_id) if pending.approved_by_home_profile_id else None member = db.get(Member, pending.approved_by_member_id) if pending.approved_by_member_id else None if profile: device = HomeDevice( home_profile_id=profile.id, label=payload.device_label or pending.requested_device_label, user_agent_summary=request.headers.get("user-agent", "")[:255], trust_level="claimed_browser", ) db.add(device) session, csrf_token = create_session(db, home_profile=profile, home_device=device) elif member: member_device = MemberDevice( member_id=member.id, label=payload.device_label or pending.requested_device_label, user_agent_summary=request.headers.get("user-agent", "")[:255], trust_level="claimed_browser", ) db.add(member_device) session, csrf_token = create_session(db, member=member, member_device=member_device) else: raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "No approving device was found.", "details": {}}}) pending.completed_at = utc_now() audit(db, action="device_link_completed", resource_type="device_link", resource_id=pending.id) db.commit() set_session_cookies(response, session, csrf_token) return {"ok": True, "csrf_token": csrf_token} @router.get("/me/devices") def devices(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: items = [] current_id = ctx.home_device.id if ctx.home_device else (ctx.member_device.id if ctx.member_device else None) if ctx.home_profile: items.extend([device_dict(item, current_id) for item in db.scalars(select(HomeDevice).where(HomeDevice.home_profile_id == ctx.home_profile.id)).all()]) member_ids = [member.id for member in get_members_for_context(db, ctx)] if member_ids: items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id.in_(member_ids))).all()]) elif ctx.member: items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id == ctx.member.id)).all()]) return {"devices": items} @router.delete("/me/devices/{device_id}") def revoke_device(device_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: device = None if ctx.home_profile: device = db.scalar(select(HomeDevice).where(HomeDevice.id == device_id, HomeDevice.home_profile_id == ctx.home_profile.id)) if not device and ctx.member: device = db.scalar(select(MemberDevice).where(MemberDevice.id == device_id, MemberDevice.member_id == ctx.member.id)) if not device: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Device not found.", "details": {}}}) device.revoked_at = utc_now() audit(db, ctx=ctx, action="device_revoked", resource_type="device", resource_id=device_id) db.commit() return {"ok": True} @router.post("/auth/passkeys/register/options") def passkey_register_options(payload: PasskeyStub) -> dict: return passkey_provider.registration_options(payload.display_name) @router.post("/auth/passkeys/register/verify") def passkey_register_verify(payload: dict) -> dict: return passkey_provider.verify_registration(payload) @router.post("/auth/passkeys/login/options") def passkey_login_options() -> dict: return passkey_provider.login_options() @router.post("/auth/passkeys/login/verify") def passkey_login_verify(payload: dict) -> dict: return passkey_provider.verify_login(payload)