Add explicit "link passkey to my account" flow
Signing in by passkey on a fresh install created a new empty account because the seeded admin has no email to match on. Add canonical SSO-style linking: an authenticated user starts an OIDC flow whose `state` is a signed, short-lived "link to user N" token (purpose=pocketid-link). The callback detects that state and attaches the returned identity to that account instead of creating/matching one — no reliance on emails lining up, and no group gating (the initiator is already authorised; this is identity linking, not access control). - auth.py: _make_link_state/_decode_link_state, GET /pocketid/link-url, callback handles state (rejects if the passkey is already on another account → auth_error=passkey_in_use). Expose has_passkey on /auth/me. - Profile: "Passkey sign-in" section for all users — shows linked state or a "Link a passkey to this account" button; success banner on return. - Login: messages for passkey_in_use / link_failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+85
-2
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Passkey sign-in — available to all users when PocketID is configured */}
|
||||
{pocketidAvailable?.available && (
|
||||
<Section title="🔑 Passkey sign-in">
|
||||
{user?.has_passkey ? (
|
||||
<p className="text-sm text-green-400">✓ A passkey is linked to this account — you can sign in with PocketID.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">
|
||||
Link your PocketID passkey to <strong>this</strong> account so you can sign in with a passkey
|
||||
instead of being given a separate, empty account.
|
||||
</p>
|
||||
<button onClick={handleLinkPasskey}
|
||||
className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
🔑 Link a passkey to this account
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showLinked && <p className="text-green-400 text-sm">✓ Passkey linked to your account.</p>}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Garmin Connect Sync */}
|
||||
<Section title="⌚ Garmin Connect Sync">
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Passkey sign-in — available to all users when PocketID is configured */}
|
||||
{pocketidAvailable?.available && (
|
||||
<Section title="🔑 Passkey sign-in">
|
||||
{user?.has_passkey ? (
|
||||
<p className="text-sm text-green-400">✓ A passkey is linked to this account — you can sign in with PocketID.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">
|
||||
Link your PocketID passkey to <strong>this</strong> account so you can sign in with a passkey
|
||||
instead of being given a separate, empty account.
|
||||
</p>
|
||||
<button onClick={handleLinkPasskey}
|
||||
className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
🔑 Link a passkey to this account
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showLinked && <p className="text-green-400 text-sm">✓ Passkey linked to your account.</p>}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Garmin Connect Sync */}
|
||||
<Section title="⌚ Garmin Connect Sync">
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
Reference in New Issue
Block a user