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:
@@ -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)
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
Reference in New Issue
Block a user