diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 70a1f73..7c60440 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -4,6 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from typing import Optional +from datetime import timedelta +from jose import jwt, JWTError import httpx from app.core.database import get_db @@ -13,6 +15,32 @@ from app.models.user import User router = APIRouter() +# Marks a short-lived OIDC `state` token as an account-link request (as opposed +# to a normal sign-in), so the callback attaches the passkey to a known user +# instead of creating/looking-up by identity. +LINK_STATE_PURPOSE = "pocketid-link" + + +def _make_link_state(user_id: int) -> str: + """Signed, short-lived token carrying 'link this passkey to user_id' intent.""" + return create_access_token( + {"sub": str(user_id), "purpose": LINK_STATE_PURPOSE}, + expires_delta=timedelta(minutes=10), + ) + + +def _decode_link_state(state: Optional[str]) -> Optional[int]: + """Return the user id from a valid link-state token, else None.""" + if not state: + return None + try: + payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm]) + if payload.get("purpose") != LINK_STATE_PURPOSE: + return None + return int(payload["sub"]) + except (JWTError, KeyError, TypeError, ValueError): + return None + async def _config_admin(db: AsyncSession): """The admin row that holds instance-wide PocketID settings. @@ -79,6 +107,7 @@ class UserOut(BaseModel): username: str email: Optional[str] is_admin: bool + has_passkey: bool = False class Config: from_attributes = True @@ -102,7 +131,13 @@ async def login( @router.get("/me", response_model=UserOut) async def get_me(current_user: User = Depends(get_current_user)): - return current_user + return UserOut( + id=current_user.id, + username=current_user.username, + email=current_user.email, + is_admin=current_user.is_admin, + has_passkey=current_user.pocketid_sub is not None, + ) @router.get("/pocketid/available") @@ -126,8 +161,32 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)): return {"url": f"{issuer}/authorize?{urlencode(params)}"} +@router.get("/pocketid/link-url") +async def pocketid_link_url( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Authenticated user starts an OIDC flow to attach a passkey to THEIR account. + + The `state` carries a signed 'link to this user' token so the callback links + the returned identity instead of creating/matching a new account. + """ + issuer, client_id, _ = await _get_pocketid_config(db) + if not issuer or not client_id: + raise HTTPException(status_code=404, detail="PocketID not configured") + from urllib.parse import urlencode + params = { + "client_id": client_id, + "redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback", + "response_type": "code", + "scope": "openid profile email groups", + "state": _make_link_state(current_user.id), + } + return {"url": f"{issuer}/authorize?{urlencode(params)}"} + + @router.get("/pocketid/callback") -async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): +async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSession = Depends(get_db)): issuer, client_id, client_secret = await _get_pocketid_config(db) if not issuer: raise HTTPException(status_code=404, detail="PocketID not configured") @@ -155,6 +214,30 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): email = userinfo.get("email") preferred_username = userinfo.get("preferred_username") or email + # ── Explicit account-link flow ────────────────────────────────────────── + # Initiated by an already-authenticated user from their profile. Attach the + # passkey to that account. No group gating here: this is identity linking, + # not access control, and the initiator is already an authorised user. + link_user_id = _decode_link_state(state) + if link_user_id is not None: + result = await db.execute(select(User).where(User.pocketid_sub == sub)) + holder = result.scalar_one_or_none() + if holder and holder.id != link_user_id: + # This passkey is already attached to a different account. + return RedirectResponse(url="/login?auth_error=passkey_in_use") + result = await db.execute(select(User).where(User.id == link_user_id)) + target = result.scalar_one_or_none() + if target is None: + return RedirectResponse(url="/login?auth_error=link_failed") + target.pocketid_sub = sub + if not target.email and email: + dup = await db.execute( + select(User).where(User.email == email, User.id != target.id) + ) + if dup.scalar_one_or_none() is None: + target.email = email + return RedirectResponse(url="/profile?linked=1") + # Group gating: if an allowed group is configured, the user must be in it. allowed_group = await _get_allowed_group(db) if allowed_group: diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index a3d5062..ca1f56a 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -4,15 +4,17 @@ import { useAuthStore } from '../hooks/useAuth' import { useQuery } from '@tanstack/react-query' import api from '../utils/api' +const AUTH_ERRORS = { + not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.", + passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.", + link_failed: "Couldn't link the passkey. Please try again.", +} + export default function LoginPage() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const authError = new URLSearchParams(window.location.search).get('auth_error') - const [error, setError] = useState( - authError === 'not_authorized' - ? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group." - : '' - ) + const [error, setError] = useState(AUTH_ERRORS[authError] || '') const { login, isLoading } = useAuthStore() const navigate = useNavigate() diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index d6aef12..ab86500 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -43,13 +43,34 @@ function SaveButton({ onClick, loading, saved, label = 'Save' }) { export default function ProfilePage() { const qc = useQueryClient() - const { user } = useAuthStore() + const { user, fetchUser } = useAuthStore() const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: () => api.get('/profile/').then(r => r.data), }) + // Passkey linking (available to all users when PocketID is configured) + const { data: pocketidAvailable } = useQuery({ + queryKey: ['pocketid-available'], + queryFn: () => api.get('/auth/pocketid/available').then(r => r.data), + }) + const [showLinked, setShowLinked] = useState( + new URLSearchParams(window.location.search).get('linked') === '1' + ) + useEffect(() => { + if (showLinked) { + fetchUser() // refresh has_passkey + window.history.replaceState({}, '', '/profile') + const t = setTimeout(() => setShowLinked(false), 6000) + return () => clearTimeout(t) + } + }, []) + const handleLinkPasskey = async () => { + const { data } = await api.get('/auth/pocketid/link-url') + window.location.href = data.url + } + const { data: pocketidConfig } = useQuery({ queryKey: ['pocketid-config'], queryFn: () => api.get('/profile/pocketid-config').then(r => r.data), @@ -325,6 +346,27 @@ export default function ProfilePage() { /> + {/* Passkey sign-in — available to all users when PocketID is configured */} + {pocketidAvailable?.available && ( +
+ {user?.has_passkey ? ( +

✓ A passkey is linked to this account — you can sign in with PocketID.

+ ) : ( + <> +

+ Link your PocketID passkey to this account so you can sign in with a passkey + instead of being given a separate, empty account. +

+ + + )} + {showLinked &&

✓ Passkey linked to your account.

} +
+ )} + {/* Garmin Connect Sync */}

diff --git a/milevault_export/backend/app/api/auth.py b/milevault_export/backend/app/api/auth.py index 70a1f73..7c60440 100644 --- a/milevault_export/backend/app/api/auth.py +++ b/milevault_export/backend/app/api/auth.py @@ -4,6 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from typing import Optional +from datetime import timedelta +from jose import jwt, JWTError import httpx from app.core.database import get_db @@ -13,6 +15,32 @@ from app.models.user import User router = APIRouter() +# Marks a short-lived OIDC `state` token as an account-link request (as opposed +# to a normal sign-in), so the callback attaches the passkey to a known user +# instead of creating/looking-up by identity. +LINK_STATE_PURPOSE = "pocketid-link" + + +def _make_link_state(user_id: int) -> str: + """Signed, short-lived token carrying 'link this passkey to user_id' intent.""" + return create_access_token( + {"sub": str(user_id), "purpose": LINK_STATE_PURPOSE}, + expires_delta=timedelta(minutes=10), + ) + + +def _decode_link_state(state: Optional[str]) -> Optional[int]: + """Return the user id from a valid link-state token, else None.""" + if not state: + return None + try: + payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm]) + if payload.get("purpose") != LINK_STATE_PURPOSE: + return None + return int(payload["sub"]) + except (JWTError, KeyError, TypeError, ValueError): + return None + async def _config_admin(db: AsyncSession): """The admin row that holds instance-wide PocketID settings. @@ -79,6 +107,7 @@ class UserOut(BaseModel): username: str email: Optional[str] is_admin: bool + has_passkey: bool = False class Config: from_attributes = True @@ -102,7 +131,13 @@ async def login( @router.get("/me", response_model=UserOut) async def get_me(current_user: User = Depends(get_current_user)): - return current_user + return UserOut( + id=current_user.id, + username=current_user.username, + email=current_user.email, + is_admin=current_user.is_admin, + has_passkey=current_user.pocketid_sub is not None, + ) @router.get("/pocketid/available") @@ -126,8 +161,32 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)): return {"url": f"{issuer}/authorize?{urlencode(params)}"} +@router.get("/pocketid/link-url") +async def pocketid_link_url( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Authenticated user starts an OIDC flow to attach a passkey to THEIR account. + + The `state` carries a signed 'link to this user' token so the callback links + the returned identity instead of creating/matching a new account. + """ + issuer, client_id, _ = await _get_pocketid_config(db) + if not issuer or not client_id: + raise HTTPException(status_code=404, detail="PocketID not configured") + from urllib.parse import urlencode + params = { + "client_id": client_id, + "redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback", + "response_type": "code", + "scope": "openid profile email groups", + "state": _make_link_state(current_user.id), + } + return {"url": f"{issuer}/authorize?{urlencode(params)}"} + + @router.get("/pocketid/callback") -async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): +async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSession = Depends(get_db)): issuer, client_id, client_secret = await _get_pocketid_config(db) if not issuer: raise HTTPException(status_code=404, detail="PocketID not configured") @@ -155,6 +214,30 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): email = userinfo.get("email") preferred_username = userinfo.get("preferred_username") or email + # ── Explicit account-link flow ────────────────────────────────────────── + # Initiated by an already-authenticated user from their profile. Attach the + # passkey to that account. No group gating here: this is identity linking, + # not access control, and the initiator is already an authorised user. + link_user_id = _decode_link_state(state) + if link_user_id is not None: + result = await db.execute(select(User).where(User.pocketid_sub == sub)) + holder = result.scalar_one_or_none() + if holder and holder.id != link_user_id: + # This passkey is already attached to a different account. + return RedirectResponse(url="/login?auth_error=passkey_in_use") + result = await db.execute(select(User).where(User.id == link_user_id)) + target = result.scalar_one_or_none() + if target is None: + return RedirectResponse(url="/login?auth_error=link_failed") + target.pocketid_sub = sub + if not target.email and email: + dup = await db.execute( + select(User).where(User.email == email, User.id != target.id) + ) + if dup.scalar_one_or_none() is None: + target.email = email + return RedirectResponse(url="/profile?linked=1") + # Group gating: if an allowed group is configured, the user must be in it. allowed_group = await _get_allowed_group(db) if allowed_group: diff --git a/milevault_export/frontend/src/pages/LoginPage.jsx b/milevault_export/frontend/src/pages/LoginPage.jsx index a3d5062..ca1f56a 100644 --- a/milevault_export/frontend/src/pages/LoginPage.jsx +++ b/milevault_export/frontend/src/pages/LoginPage.jsx @@ -4,15 +4,17 @@ import { useAuthStore } from '../hooks/useAuth' import { useQuery } from '@tanstack/react-query' import api from '../utils/api' +const AUTH_ERRORS = { + not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.", + passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.", + link_failed: "Couldn't link the passkey. Please try again.", +} + export default function LoginPage() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const authError = new URLSearchParams(window.location.search).get('auth_error') - const [error, setError] = useState( - authError === 'not_authorized' - ? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group." - : '' - ) + const [error, setError] = useState(AUTH_ERRORS[authError] || '') const { login, isLoading } = useAuthStore() const navigate = useNavigate() diff --git a/milevault_export/frontend/src/pages/ProfilePage.jsx b/milevault_export/frontend/src/pages/ProfilePage.jsx index d6aef12..ab86500 100644 --- a/milevault_export/frontend/src/pages/ProfilePage.jsx +++ b/milevault_export/frontend/src/pages/ProfilePage.jsx @@ -43,13 +43,34 @@ function SaveButton({ onClick, loading, saved, label = 'Save' }) { export default function ProfilePage() { const qc = useQueryClient() - const { user } = useAuthStore() + const { user, fetchUser } = useAuthStore() const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: () => api.get('/profile/').then(r => r.data), }) + // Passkey linking (available to all users when PocketID is configured) + const { data: pocketidAvailable } = useQuery({ + queryKey: ['pocketid-available'], + queryFn: () => api.get('/auth/pocketid/available').then(r => r.data), + }) + const [showLinked, setShowLinked] = useState( + new URLSearchParams(window.location.search).get('linked') === '1' + ) + useEffect(() => { + if (showLinked) { + fetchUser() // refresh has_passkey + window.history.replaceState({}, '', '/profile') + const t = setTimeout(() => setShowLinked(false), 6000) + return () => clearTimeout(t) + } + }, []) + const handleLinkPasskey = async () => { + const { data } = await api.get('/auth/pocketid/link-url') + window.location.href = data.url + } + const { data: pocketidConfig } = useQuery({ queryKey: ['pocketid-config'], queryFn: () => api.get('/profile/pocketid-config').then(r => r.data), @@ -325,6 +346,27 @@ export default function ProfilePage() { />

+ {/* Passkey sign-in — available to all users when PocketID is configured */} + {pocketidAvailable?.available && ( +
+ {user?.has_passkey ? ( +

✓ A passkey is linked to this account — you can sign in with PocketID.

+ ) : ( + <> +

+ Link your PocketID passkey to this account so you can sign in with a passkey + instead of being given a separate, empty account. +

+ + + )} + {showLinked &&

✓ Passkey linked to your account.

} +
+ )} + {/* Garmin Connect Sync */}