feat(auth): Authentik OIDC sign-in + Gravatar avatars

Adds optional SSO via Authentik (or any OIDC provider) alongside the
existing password flow, and pulls profile images from the provider's
`picture` claim or Gravatar so the sharing UI stops looking anonymous.
Password login stays available as a recovery path; JIT provisioning and
admin-group mapping are env-configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 21:06:32 +02:00
parent 319be20389
commit e8e1adcf37
20 changed files with 852 additions and 60 deletions

View File

@@ -1,20 +1,27 @@
"""
Authentication router — login, token refresh, profile, first-run setup.
Authentication router — login, token refresh, profile, first-run setup,
and optional OIDC (Authentik) sign-in.
"""
import os
import re
import secrets
import logging
from typing import Optional
from urllib.parse import urlencode, urlparse
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from app.auth_oidc import get_client as get_oidc_client, is_enabled as oidc_is_enabled, provider_label, PROVIDER_NAME
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.folders import SourceRoot
from app.services.gravatar import gravatar_url
from app.config import settings
logger = logging.getLogger(__name__)
@@ -45,6 +52,8 @@ class UserResponse(BaseModel):
role: str
is_active: bool
created_at: Optional[str]
avatar_url: Optional[str] = None
display_name: Optional[str] = None
class SetupRequest(BaseModel):
username: str
@@ -55,10 +64,62 @@ class ChangePasswordRequest(BaseModel):
new_password: str
class OidcConfig(BaseModel):
enabled: bool
label: str
login_url: str
class AuthConfigResponse(BaseModel):
# None when OIDC is disabled / not configured — the frontend uses
# that to hide the SSO button.
oidc: Optional[OidcConfig] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def serialize_user(user: User) -> UserResponse:
"""Build the user-facing payload, computing the avatar URL with the
`provider picture > Gravatar > None` fallback chain."""
avatar = user.avatar_url or gravatar_url(user.email)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat() if user.created_at else None,
avatar_url=avatar,
display_name=user.display_name,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config():
"""Public — tells the frontend which login options to render.
Returns `oidc: null` when OIDC is disabled or not fully configured,
so the login page can hide the SSO button without a round-trip to
the IdP. The `login_url` is browser-navigable (full redirect); it
starts the Authlib flow that sets the PKCE cookie.
"""
if not oidc_is_enabled():
return AuthConfigResponse(oidc=None)
return AuthConfigResponse(
oidc=OidcConfig(
enabled=True,
label=provider_label(),
login_url="/api/v1/auth/oidc/login",
)
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate with username + password, receive JWT tokens."""
@@ -67,7 +128,14 @@ async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
)
user = result.scalar_one_or_none()
if user is None or not verify_password(body.password, user.hashed_password):
# OIDC-only users (hashed_password IS NULL) can't sign in via this
# endpoint; they must go through the SSO flow. Treat as auth failure
# so we don't leak account existence.
if (
user is None
or not user.hashed_password
or not verify_password(body.password, user.hashed_password)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
@@ -115,14 +183,7 @@ async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the authenticated user's profile."""
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
role=current_user.role,
is_active=current_user.is_active,
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
)
return serialize_user(current_user)
@router.post("/change-password")
@@ -132,7 +193,9 @@ async def change_password(
db: AsyncSession = Depends(get_db),
):
"""Change the authenticated user's password."""
if not verify_password(body.current_password, current_user.hashed_password):
if not current_user.hashed_password or not verify_password(
body.current_password, current_user.hashed_password
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
@@ -200,3 +263,206 @@ async def auth_status(db: AsyncSession = Depends(get_db)):
"""
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
return {"setup_completed": count > 0}
# ---------------------------------------------------------------------------
# OIDC (Authentik) sign-in
# ---------------------------------------------------------------------------
_USERNAME_SANITIZER = re.compile(r"[^a-zA-Z0-9_.-]+")
def _derive_username(claims: dict, existing_usernames: set[str]) -> str:
"""Pick a local username from OIDC claims.
Order of preference:
1. `preferred_username` claim (Authentik's usual choice)
2. local-part of `email`
3. `sub` claim (always present)
Strips characters the rest of the app doesn't like in paths/URLs,
trims to 50 chars (User.username column limit), and appends a short
suffix on collision so two Authentik users can't land on the same
local row.
"""
raw = (
claims.get("preferred_username")
or (claims.get("email") or "").split("@", 1)[0]
or claims.get("sub")
or "user"
)
base = _USERNAME_SANITIZER.sub("", str(raw)).strip("._-") or "user"
base = base[:40]
candidate = base
suffix = 0
while candidate in existing_usernames:
suffix += 1
candidate = f"{base}-{suffix}"[:50]
return candidate
def _frontend_origin(request: Request) -> str:
"""Best guess at where the SPA lives so the callback redirect lands
back on the app origin. Uses the configured redirect URI's scheme +
host (strips /api/... path) when available, falling back to the
request's own origin."""
if settings.oidc_redirect_uri:
parsed = urlparse(settings.oidc_redirect_uri)
return f"{parsed.scheme}://{parsed.netloc}"
return f"{request.url.scheme}://{request.url.netloc}"
@router.get("/oidc/login")
async def oidc_login(request: Request):
"""Start the OIDC flow — redirect to Authentik's authorization URL."""
if not oidc_is_enabled():
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
client = get_oidc_client()
if client is None:
raise HTTPException(status_code=500, detail="OIDC client not configured")
redirect_uri = settings.oidc_redirect_uri
return await client.authorize_redirect(request, redirect_uri)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""Handle the OIDC redirect — exchange code, provision/link user,
issue our own JWTs, bounce back to the SPA."""
if not oidc_is_enabled():
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
client = get_oidc_client()
if client is None:
raise HTTPException(status_code=500, detail="OIDC client not configured")
try:
token = await client.authorize_access_token(request)
except Exception as exc:
logger.warning("OIDC callback: authorize_access_token failed: %s", exc)
return _oidc_error_redirect(request, "oidc_exchange_failed")
# `parse_id_token` verifies signature + nonce; `userinfo` fills in
# claims some IdPs don't put in the ID token (e.g. picture). We
# merge both, preferring userinfo when both are present.
claims = dict(token.get("userinfo") or {})
if not claims:
try:
claims = dict(await client.userinfo(token=token))
except Exception:
claims = {}
id_token_claims = token.get("id_token_claims") or {}
for k, v in id_token_claims.items():
claims.setdefault(k, v)
sub = claims.get("sub")
if not sub:
logger.warning("OIDC callback: claims missing `sub` — %r", claims)
return _oidc_error_redirect(request, "oidc_missing_sub")
issuer = claims.get("iss") or (settings.oidc_issuer or "").rstrip("/")
email = claims.get("email")
display_name = claims.get("name") or claims.get("preferred_username")
picture = claims.get("picture")
groups = claims.get("groups") or []
if isinstance(groups, str):
groups = [groups]
admin_groups = set(settings.oidc_admin_group_list)
role = "admin" if admin_groups and admin_groups.intersection(groups) else "user"
# 1. Match by (issuer, sub) first — stable identity key.
user = (await db.execute(
select(User).where(
User.oidc_issuer == issuer,
User.oidc_sub == sub,
)
)).scalar_one_or_none()
# 2. Fall back to email so a pre-existing local account can be
# linked on first SSO login (homelab admin keeps their row).
if user is None and email:
user = (await db.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
if user is None:
if not settings.oidc_allow_signup:
logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email)
return _oidc_error_redirect(request, "oidc_signup_disabled")
# JIT provision.
existing = {
u for (u,) in (await db.execute(select(User.username))).all()
}
username = _derive_username(claims, existing)
media_path = os.path.join(settings.photo_dirs, username)
os.makedirs(media_path, exist_ok=True)
user = User(
username=username,
email=email,
hashed_password=None,
role=role,
is_active=True,
media_path=media_path,
oidc_issuer=issuer,
oidc_sub=sub,
avatar_url=picture,
display_name=display_name,
)
db.add(user)
await db.flush()
db.add(SourceRoot(
name=f"{user.username}'s Library",
path=media_path,
user_id=user.id,
))
await db.commit()
logger.info("OIDC JIT-created user %s (role=%s)", user.username, role)
else:
# Refresh profile bits + link identity if needed. We do *not*
# demote admins created locally; only touch role when admin
# group mapping is configured.
changed = False
if user.oidc_sub != sub or user.oidc_issuer != issuer:
user.oidc_issuer = issuer
user.oidc_sub = sub
changed = True
if email and user.email != email:
user.email = email
changed = True
if display_name and user.display_name != display_name:
user.display_name = display_name
changed = True
if picture and user.avatar_url != picture:
user.avatar_url = picture
changed = True
if admin_groups:
new_role = "admin" if admin_groups.intersection(groups) else "user"
if user.role != new_role:
user.role = new_role
changed = True
if not user.is_active:
# Don't resurrect a deactivated account — surface an error.
logger.info("OIDC login rejected — user %s is deactivated", user.username)
return _oidc_error_redirect(request, "oidc_deactivated")
if changed:
await db.commit()
# Mint our own JWTs and bounce back to the SPA. Tokens ride in the
# URL fragment-free for simplicity; the frontend callback page
# strips them from the location bar immediately.
access = create_access_token(user.id, user.role)
refresh = create_refresh_token(user.id)
params = urlencode({"access_token": access, "refresh_token": refresh})
target = f"{_frontend_origin(request)}/auth/callback?{params}"
return RedirectResponse(url=target, status_code=302)
def _oidc_error_redirect(request: Request, code: str) -> RedirectResponse:
"""Bounce back to the SPA with an `error=` query so the login page
can render something meaningful instead of a stack trace."""
target = f"{_frontend_origin(request)}/auth/callback?error={code}"
return RedirectResponse(url=target, status_code=302)

View File

@@ -21,6 +21,14 @@ from app.dependencies import (
get_user_folder,
resolve_username,
)
from app.services.gravatar import gravatar_url
def _user_avatar(user: User) -> Optional[str]:
"""OIDC `picture` claim wins, Gravatar fills the gap. Returns None
when neither source can produce a URL so the frontend can fall back
to the initials bubble."""
return user.avatar_url or gravatar_url(user.email)
logger = logging.getLogger(__name__)
@@ -38,6 +46,8 @@ class ShareResponse(BaseModel):
id: str
shared_with_id: str
shared_with_username: str
shared_with_avatar_url: Optional[str] = None
shared_with_display_name: Optional[str] = None
permission: str
status: str # 'pending' | 'accepted'
created_at: str
@@ -51,6 +61,8 @@ class SharedHeapResponse(BaseModel):
share_id: str
name: str
owner_username: str
owner_avatar_url: Optional[str] = None
owner_display_name: Optional[str] = None
permission: str
photo_count: int
@@ -61,6 +73,8 @@ class SharedFolderResponse(BaseModel):
name: str
folder_type: str
owner_username: str
owner_avatar_url: Optional[str] = None
owner_display_name: Optional[str] = None
permission: str
photo_count: int
@@ -72,6 +86,8 @@ class PendingInvite(BaseModel):
target_id: str # heap id or folder id
target_name: str
owner_username: str
owner_avatar_url: Optional[str] = None
owner_display_name: Optional[str] = None
permission: str
created_at: str
@@ -84,6 +100,8 @@ class PendingInvitesResponse(BaseModel):
class ShareableUser(BaseModel):
id: str
username: str
avatar_url: Optional[str] = None
display_name: Optional[str] = None
# ── Shareable users ──────────────────────────────────────────────────────
@@ -104,7 +122,12 @@ async def list_shareable_users(
.order_by(User.username)
)
return [
ShareableUser(id=str(u.id), username=u.username)
ShareableUser(
id=str(u.id),
username=u.username,
avatar_url=_user_avatar(u),
display_name=u.display_name,
)
for u in result.scalars().all()
]
@@ -132,6 +155,8 @@ async def list_pending_invites(
target_id=heap.id,
target_name=heap.name,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
)
@@ -165,6 +190,8 @@ async def list_pending_invites(
target_id=share.folder_id,
target_name=entity.name,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
))
@@ -205,6 +232,8 @@ async def list_shared_heaps(
share_id=share.id,
name=heap.name,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
photo_count=count,
))
@@ -230,6 +259,8 @@ async def list_heap_shares(
id=share.id,
shared_with_id=user.id,
shared_with_username=user.username,
shared_with_avatar_url=_user_avatar(user),
shared_with_display_name=user.display_name,
permission=share.permission,
status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",
@@ -414,6 +445,8 @@ async def list_shared_folders(
name=name,
folder_type=share.folder_type,
owner_username=owner.username,
owner_avatar_url=_user_avatar(owner),
owner_display_name=owner.display_name,
permission=share.permission,
photo_count=count,
))
@@ -454,6 +487,8 @@ async def list_folder_shares(
id=share.id,
shared_with_id=user.id,
shared_with_username=user.username,
shared_with_avatar_url=_user_avatar(user),
shared_with_display_name=user.display_name,
permission=share.permission,
status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",