diff --git a/backend/alembic/versions/0015_oidc_and_avatar.py b/backend/alembic/versions/0015_oidc_and_avatar.py
new file mode 100644
index 0000000..abdc6eb
--- /dev/null
+++ b/backend/alembic/versions/0015_oidc_and_avatar.py
@@ -0,0 +1,83 @@
+"""OIDC identity + avatar / display_name on users
+
+Revision ID: 0015_oidc_and_avatar
+Revises: 0014_share_status
+Create Date: 2026-04-22
+
+Lets users sign in via an OIDC provider (Authentik) and carry a profile
+image / display name from the provider. Password-only users are
+unaffected.
+
+ 1. Add users.oidc_issuer, users.oidc_sub (identity pair from the IdP).
+ 2. Add users.avatar_url, users.display_name (profile bits from claims
+ or manually set).
+ 3. Make users.hashed_password nullable — OIDC-only users have no local
+ password. Existing rows all have hashes so the NULLability change
+ is backwards-compatible.
+ 4. Partial unique index on (oidc_issuer, oidc_sub) WHERE oidc_sub IS
+ NOT NULL so multiple password-only users (both NULL) don't collide.
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = "0015_oidc_and_avatar"
+down_revision: Union[str, None] = "0014_share_status"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ # 1 + 2. Add new columns (idempotent — create_all on fresh installs
+ # already built them from the model).
+ for col_def in (
+ "oidc_issuer VARCHAR",
+ "oidc_sub VARCHAR",
+ "avatar_url VARCHAR",
+ "display_name VARCHAR",
+ ):
+ conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
+
+ # 3. Drop NOT NULL on hashed_password. Postgres only — SQLite can't
+ # alter column nullability in place, but the SQLite escape hatch is
+ # used for fresh local dev where create_all already wrote the new
+ # nullable definition.
+ if conn.dialect.name == "postgresql":
+ conn.execute(sa.text(
+ "ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL"
+ ))
+
+ # 4. Partial unique index — Postgres supports the WHERE clause so
+ # NULLs don't collide; SQLite treats NULLs as distinct in unique
+ # indexes already, so a plain unique index is safe there too.
+ if conn.dialect.name == "postgresql":
+ conn.execute(sa.text(
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
+ "ON users (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL"
+ ))
+ else:
+ conn.execute(sa.text(
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
+ "ON users (oidc_issuer, oidc_sub)"
+ ))
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_oidc_identity"))
+
+ if conn.dialect.name == "postgresql":
+ # Can't re-apply NOT NULL if any OIDC-only user has NULL — so
+ # only do it when safe.
+ conn.execute(sa.text(
+ "UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL"
+ ))
+ conn.execute(sa.text(
+ "ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL"
+ ))
+
+ for col in ("display_name", "avatar_url", "oidc_sub", "oidc_issuer"):
+ conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))
diff --git a/backend/app/auth_oidc.py b/backend/app/auth_oidc.py
new file mode 100644
index 0000000..1f9c72e
--- /dev/null
+++ b/backend/app/auth_oidc.py
@@ -0,0 +1,77 @@
+"""OIDC (OpenID Connect) client setup — used for Authentik SSO today,
+generic enough to register other providers later.
+
+Authlib handles the Authorization Code + PKCE flow, including discovery
+via the provider's `.well-known/openid-configuration` document. We keep
+a single registered client named "authentik" regardless of label, so the
+router code always knows where to find it.
+
+If OIDC isn't fully configured the module stays inert — `is_enabled()`
+returns False and the registry has no client. Callers must guard.
+"""
+from typing import Optional
+
+from authlib.integrations.starlette_client import OAuth
+
+from app.config import settings
+
+
+PROVIDER_NAME = "authentik"
+
+
+def is_configured() -> bool:
+ """True when every required OIDC setting is present."""
+ return bool(
+ settings.oidc_issuer
+ and settings.oidc_client_id
+ and settings.oidc_client_secret
+ and settings.oidc_redirect_uri
+ )
+
+
+def is_enabled() -> bool:
+ """True when OIDC is both configured and the admin flipped it on."""
+ return bool(settings.oidc_enabled) and is_configured()
+
+
+def provider_label() -> str:
+ return settings.oidc_provider_label or "Authentik"
+
+
+def _build_oauth() -> OAuth:
+ """Build the Authlib OAuth registry. Always safe to call; only
+ registers the provider when credentials are present so importing
+ this module never fails on a fresh install.
+ """
+ registry = OAuth()
+ if not is_configured():
+ return registry
+
+ # Authentik exposes discovery at `{issuer}/.well-known/openid-configuration`.
+ # Trailing slash handling varies by Authentik version, so normalise.
+ issuer = settings.oidc_issuer.rstrip("/")
+ discovery_url = f"{issuer}/.well-known/openid-configuration"
+
+ registry.register(
+ name=PROVIDER_NAME,
+ client_id=settings.oidc_client_id,
+ client_secret=settings.oidc_client_secret,
+ server_metadata_url=discovery_url,
+ client_kwargs={
+ "scope": settings.oidc_scopes,
+ # Force PKCE — cheap win for public clients, harmless for
+ # confidential ones.
+ "code_challenge_method": "S256",
+ },
+ )
+ return registry
+
+
+oauth: OAuth = _build_oauth()
+
+
+def get_client() -> Optional[object]:
+ """Return the registered provider client, or None when not configured."""
+ if not is_configured():
+ return None
+ return oauth.create_client(PROVIDER_NAME)
diff --git a/backend/app/config.py b/backend/app/config.py
index 2d54193..5fb83a5 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -109,6 +109,41 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = Field(default=525600, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 1 year
refresh_token_expire_days: int = Field(default=3650, env="REFRESH_TOKEN_EXPIRE_DAYS") # 10 years
+ # ── OIDC / Authentik single sign-on ────────────────────────────────
+ # Disabled by default; enable by setting OIDC_ENABLED=true and the
+ # issuer + client credentials. When enabled the login page shows a
+ # "Sign in with {label}" button alongside the username/password form.
+ oidc_enabled: bool = Field(default=False, env="OIDC_ENABLED")
+ oidc_issuer: Optional[str] = Field(default=None, env="OIDC_ISSUER")
+ oidc_client_id: Optional[str] = Field(default=None, env="OIDC_CLIENT_ID")
+ oidc_client_secret: Optional[str] = Field(default=None, env="OIDC_CLIENT_SECRET")
+ # Absolute URL the IdP redirects back to. Must match the Redirect URI
+ # configured on the Authentik side exactly.
+ oidc_redirect_uri: Optional[str] = Field(default=None, env="OIDC_REDIRECT_URI")
+ oidc_scopes: str = Field(default="openid profile email", env="OIDC_SCOPES")
+ oidc_provider_label: str = Field(default="Authentik", env="OIDC_PROVIDER_LABEL")
+ # When true, a successful OIDC login for a subject we've never seen
+ # auto-creates a local user + their /photos/{username} folder. When
+ # false, unknown subjects get 403 and must be pre-provisioned.
+ oidc_allow_signup: bool = Field(default=True, env="OIDC_ALLOW_SIGNUP")
+ # Comma-separated Authentik group names. Any group-claim match
+ # promotes the user to role=admin; otherwise role=user. Role is
+ # refreshed on every sign-in so removals demote automatically.
+ oidc_admin_groups: str = Field(default="", env="OIDC_ADMIN_GROUPS")
+ # Starlette session cookie secret — only used to hold PKCE/state
+ # during the brief OIDC round-trip. Falls back to secret_key when
+ # unset.
+ session_secret: Optional[str] = Field(default=None, env="SESSION_SECRET")
+
+ @property
+ def oidc_admin_group_list(self) -> list[str]:
+ raw = (self.oidc_admin_groups or "").strip()
+ return [g.strip() for g in raw.split(",") if g.strip()]
+
+ @property
+ def effective_session_secret(self) -> str:
+ return self.session_secret or self.secret_key
+
@property
def cors_origins(self) -> list[str]:
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
diff --git a/backend/app/main.py b/backend/app/main.py
index 0df4e4f..6704bcc 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
import logging
import os
@@ -80,6 +81,19 @@ app.add_middleware(
allow_headers=["*"],
)
+# Session middleware — only used by Authlib to hold PKCE state during
+# the OIDC round-trip. max_age is short because the cookie is only
+# meaningful between /auth/oidc/login and /auth/oidc/callback; the app
+# itself still runs on JWTs.
+app.add_middleware(
+ SessionMiddleware,
+ secret_key=settings.effective_session_secret,
+ session_cookie="mulita_oidc",
+ max_age=600,
+ same_site="lax",
+ https_only=False,
+)
+
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
if os.path.exists("/data/thumbs"):
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 917bd53..dfbfcbd 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -14,10 +14,24 @@ class User(Base):
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=True)
- hashed_password = Column(String, nullable=False)
+ # Nullable: OIDC-only users have no local password. Local accounts
+ # still always have one.
+ hashed_password = Column(String, nullable=True)
role = Column(String, nullable=False, default='user') # 'admin' | 'user'
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
media_path = Column(String, nullable=False)
+
+ # OIDC identity — populated when a user signs in via Authentik (or any
+ # other OIDC provider later). `oidc_sub` is stable per provider, so
+ # lookups key on (oidc_issuer, oidc_sub). NULL for password-only users.
+ oidc_issuer = Column(String, nullable=True)
+ oidc_sub = Column(String, nullable=True)
+
+ # Profile bits that can come from OIDC claims or be filled in later.
+ # avatar_url wins over Gravatar when set; the /auth/me response
+ # computes the final avatar URL for the frontend.
+ avatar_url = Column(String, nullable=True)
+ display_name = Column(String, nullable=True)
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index eed115f..461cf7d 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -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)
diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py
index 6eb402c..472ab92 100644
--- a/backend/app/routers/sharing.py
+++ b/backend/app/routers/sharing.py
@@ -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 "",
diff --git a/backend/app/services/gravatar.py b/backend/app/services/gravatar.py
new file mode 100644
index 0000000..38d4a3c
--- /dev/null
+++ b/backend/app/services/gravatar.py
@@ -0,0 +1,28 @@
+"""Gravatar URL helper.
+
+Pure function — no HTTP calls. The browser does the actual image
+fetch. We just build the deterministic URL from the user's email and
+let Gravatar serve an identicon when no account exists for that hash,
+so the avatar is never a broken image.
+
+Current Gravatar guidance is SHA-256 of the trimmed, lower-cased email.
+MD5 still works but is deprecated, so we prefer SHA-256.
+"""
+import hashlib
+from typing import Optional
+
+
+def gravatar_url(email: Optional[str], size: int = 240) -> Optional[str]:
+ """Return a Gravatar image URL for `email`, or None when email is empty.
+
+ The `d=identicon` fallback guarantees a deterministic placeholder when
+ the address has no Gravatar account, so callers can treat the result
+ as a valid image URL.
+ """
+ if not email:
+ return None
+ normalized = email.strip().lower()
+ if not normalized:
+ return None
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+ return f"https://gravatar.com/avatar/{digest}?d=identicon&s={size}"
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 28ea36b..011e7d5 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -55,6 +55,11 @@ aiofiles==23.2.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
+# OIDC single sign-on (Authentik, etc.). Authlib drives the Auth Code +
+# PKCE flow; itsdangerous signs the short-lived Starlette session cookie
+# that holds the PKCE state during the IdP round-trip.
+authlib==1.3.1
+itsdangerous==2.1.2
# Development
pytest==7.4.4
diff --git a/docker-compose.yml b/docker-compose.yml
index 7b0f86b..78dcc36 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -53,6 +53,23 @@ services:
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
+ # Authentik / OIDC single sign-on. Leave OIDC_ENABLED=false to
+ # hide the SSO button and stick with username/password. When
+ # enabled, set OIDC_ISSUER to the Authentik provider URL (the one
+ # that serves /.well-known/openid-configuration), and paste the
+ # client id/secret from the Authentik application. OIDC_REDIRECT_URI
+ # must match the one registered on the Authentik side exactly —
+ # e.g. https://photovault.example.com/api/v1/auth/oidc/callback.
+ - OIDC_ENABLED=${OIDC_ENABLED:-false}
+ - OIDC_ISSUER=${OIDC_ISSUER:-}
+ - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
+ - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
+ - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
+ - OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
+ - OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik}
+ - OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true}
+ - OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
+ - SESSION_SECRET=${SESSION_SECRET:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d166acc..19515f0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -24,6 +24,7 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/auth/SetupPage'
+import { OidcCallback } from './components/auth/OidcCallback'
import { TooltipProvider } from '@/components/ui/tooltip'
function MainApp() {
@@ -185,6 +186,13 @@ function App() {
function AuthGate() {
const { user, isLoading, needsSetup } = useAuth()
+ // OIDC callback lands on /auth/callback — handle it even while
+ // isLoading, so the callback page can adopt tokens and transition
+ // straight to MainApp without flashing the login screen.
+ if (window.location.pathname.startsWith('/auth/callback')) {
+ return