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

@@ -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}"))

77
backend/app/auth_oidc.py Normal file
View File

@@ -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)

View File

@@ -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:

View File

@@ -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")

View File

@@ -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)

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 "",

View File

@@ -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}"

View File

@@ -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

View File

@@ -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:

View File

@@ -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 <OidcCallback />
}
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg">

View File

@@ -1,16 +1,46 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import api from '../../services/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface OidcConfig {
enabled: boolean
label: string
login_url: string
}
interface AuthConfig {
oidc: OidcConfig | null
}
export function LoginPage() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [oidc, setOidc] = useState<OidcConfig | null>(null)
// Ask the backend which login methods to show. Failure is silent —
// worst case the SSO button just doesn't appear and the user falls
// back to username/password.
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await api.get<AuthConfig>('/auth/config')
if (!cancelled) setOidc(res.data.oidc)
} catch {
/* ignore — SSO button stays hidden */
}
})()
return () => {
cancelled = true
}
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
@@ -29,10 +59,7 @@ export function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
>
<div className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl">
<h1 className="text-center text-xl font-semibold text-text">
Sign in to Mulita
</h1>
@@ -43,6 +70,23 @@ export function LoginPage() {
</Alert>
)}
{oidc?.enabled && (
<>
{/* Full-page navigation (not a fetch) — Authlib sets a
* signed session cookie in the /oidc/login response, so
* the browser needs to follow the redirect chain itself. */}
<Button asChild variant="outline" className="w-full">
<a href={oidc.login_url}>Sign in with {oidc.label}</a>
</Button>
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-text-muted">
<span className="h-px flex-1 bg-border" />
or continue with password
<span className="h-px flex-1 bg-border" />
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="login-user">Username</Label>
<Input
@@ -67,9 +111,10 @@ export function LoginPage() {
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in\u2026' : 'Sign In'}
{loading ? 'Signing in' : 'Sign In'}
</Button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
/** Landing page for the OIDC redirect.
*
* Authentik bounces the browser to /auth/callback?access_token=...&refresh_token=...
* (or ?error=oidc_xxx when something went wrong). We pull those out of
* the URL, hand them to AuthContext, then replace history so the
* tokens don't linger in the location bar, the back button, or
* whatever screen-recording the user has going.
*
* Tokens in the query string are an accepted trade-off here: they're
* short-lived, they never leave the app origin, and the alternative
* (HTTP-only cookies) would be a much larger rework of an otherwise
* JWT-in-localStorage codebase.
*/
const ERROR_MESSAGES: Record<string, string> = {
oidc_exchange_failed: 'Could not complete sign-in with the identity provider.',
oidc_missing_sub: 'Identity provider did not return a user identifier.',
oidc_signup_disabled: 'Your identity provider account has not been authorized for this instance.',
oidc_deactivated: 'This account has been deactivated.',
}
export function OidcCallback() {
const { onOidcTokens } = useAuth()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const errCode = params.get('error')
// Drop everything after the origin + root path, including the
// tokens, so refreshing or sharing the URL doesn't leak them.
const clean = () => window.history.replaceState({}, '', '/')
if (errCode) {
setError(ERROR_MESSAGES[errCode] || 'Sign-in failed. Please try again.')
clean()
return
}
if (!accessToken || !refreshToken) {
setError('The identity provider did not return the expected tokens.')
clean()
return
}
;(async () => {
try {
await onOidcTokens(accessToken, refreshToken)
} catch {
setError('Could not complete sign-in. Please try again.')
} finally {
clean()
}
})()
}, [onOidcTokens])
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-sm space-y-4 rounded-lg border border-border bg-surface p-8 shadow-xl">
<h1 className="text-center text-lg font-semibold text-text">Sign-in failed</h1>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button className="w-full" onClick={() => (window.location.href = '/')}>
Back to sign in
</Button>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg">
<div className="text-text-muted">Signing you in&hellip;</div>
</div>
)
}

View File

@@ -521,8 +521,12 @@ export function HeapsPanel() {
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<Avatar name={sh.owner_username} size="xs" />
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_username})`}>
<Avatar
name={sh.owner_username}
imageUrl={sh.owner_avatar_url}
size="xs"
/>
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_display_name || sh.owner_username})`}>
{sh.name}
</span>
<PermissionIcon

View File

@@ -899,8 +899,12 @@ export function LeftSidebar() {
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
}
>
<Avatar name={sf.owner_username} size="xs" />
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
<Avatar
name={sf.owner_username}
imageUrl={sf.owner_avatar_url}
size="xs"
/>
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_display_name || sf.owner_username})`}>
{sf.name}
</span>
<PermissionIcon
@@ -955,8 +959,19 @@ export function LeftSidebar() {
<div className="border-t border-border p-1.5 space-y-0.5">
{/* User row */}
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
{user ? (
<Avatar
name={user.username}
imageUrl={user.avatar_url}
size="sm"
className="flex-shrink-0"
/>
) : (
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-1 truncate text-text">{user?.username}</span>
)}
<span className="flex-1 truncate text-text">
{user?.display_name || user?.username}
</span>
{isAdmin && (
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
<Shield className="inline h-2.5 w-2.5" />

View File

@@ -1,10 +1,16 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
/** Hash-tinted initial bubble used across every sharing surface
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
* isn't meaningful — it's just an identity cue so a list of names
* feels less anonymous. Using the same hash across components means
* a given user's bubble stays the same colour everywhere. */
* a given user's bubble stays the same colour everywhere.
*
* When `imageUrl` is present (Authentik `picture` claim or a Gravatar
* URL), an <img> sits on top of the tinted bubble; if the image fails
* to load we collapse back to initials, so a broken avatar never
* shows up as a white square. */
const PALETTE = [
'bg-primary/25 text-primary',
'bg-pick/25 text-pick',
@@ -23,10 +29,12 @@ export function avatarColor(name: string): string {
export function Avatar({
name,
imageUrl,
size = 'md',
className,
}: {
name: string
imageUrl?: string | null
size?: 'xs' | 'sm' | 'md'
className?: string
}) {
@@ -38,17 +46,38 @@ export function Avatar({
: size === 'sm'
? 'h-5 w-5 text-[9px]'
: 'h-8 w-8 text-[11px]'
// Reset the broken-image flag when the URL changes, e.g. after the
// user updates their Gravatar or logs in via a different provider.
const [broken, setBroken] = useState(false)
useEffect(() => {
setBroken(false)
}, [imageUrl])
const showImage = Boolean(imageUrl) && !broken
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
'relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase tracking-wide',
dims,
tint,
className,
)}
aria-hidden
>
{initials}
{showImage ? (
<img
src={imageUrl!}
alt=""
className="h-full w-full object-cover"
onError={() => setBroken(true)}
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
initials
)}
</span>
)
}

View File

@@ -90,10 +90,16 @@ export function NotificationBell() {
className="flex flex-col gap-2 px-3 py-2.5"
>
<div className="flex items-start gap-2.5">
<Avatar name={invite.owner_username} size="sm" />
<Avatar
name={invite.owner_username}
imageUrl={invite.owner_avatar_url}
size="sm"
/>
<div className="min-w-0 flex-1">
<div className="text-sm leading-tight text-text">
<span className="font-medium">{invite.owner_username}</span>
<span className="font-medium">
{invite.owner_display_name || invite.owner_username}
</span>
<span className="text-text-muted"> shared </span>
<span className="inline-flex items-center gap-1 align-baseline">
<TypeIcon className="inline h-3 w-3 text-text-muted" />

View File

@@ -180,8 +180,8 @@ export function ShareDialog({
{availableUsers.map((u) => (
<SelectItem key={u.id} value={u.username}>
<span className="flex items-center gap-2">
<Avatar name={u.username} size="sm" />
{u.username}
<Avatar name={u.username} imageUrl={u.avatar_url} size="sm" />
{u.display_name || u.username}
</span>
</SelectItem>
))}
@@ -259,11 +259,14 @@ export function ShareDialog({
key={share.id}
className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-surface-2"
>
<Avatar name={share.shared_with_username} />
<Avatar
name={share.shared_with_username}
imageUrl={share.shared_with_avatar_url}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-text">
{share.shared_with_username}
{share.shared_with_display_name || share.shared_with_username}
</span>
{share.status === 'pending' && (
<span

View File

@@ -14,6 +14,8 @@ export interface AuthUser {
email: string | null
role: 'admin' | 'user'
is_active: boolean
avatar_url: string | null
display_name: string | null
}
interface AuthContextValue {
@@ -26,6 +28,9 @@ interface AuthContextValue {
logout: () => void
/** Called after the setup endpoint creates the first admin. */
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
/** Adopt tokens received from the OIDC callback. Same effect as
* onSetupComplete but semantically distinct. */
onOidcTokens: (accessToken: string, refreshToken: string) => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
@@ -131,6 +136,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[fetchMe],
)
const onOidcTokens = useCallback(
async (accessToken: string, refreshToken: string) => {
storeToken(accessToken)
storeRefreshToken(refreshToken)
await fetchMe()
},
[fetchMe],
)
// Axios interceptor: on 401, try to refresh once using the stored
// refresh token. If that fails, sign out.
useEffect(() => {
@@ -168,7 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete, onOidcTokens }}
>
{children}
</AuthContext.Provider>

View File

@@ -790,6 +790,8 @@ export interface SharedHeap {
share_id: string
name: string
owner_username: string
owner_avatar_url: string | null
owner_display_name: string | null
permission: 'read' | 'write'
photo_count: number
}
@@ -800,6 +802,8 @@ export interface SharedFolder {
name: string
folder_type: 'folder' | 'source_root'
owner_username: string
owner_avatar_url: string | null
owner_display_name: string | null
permission: 'read' | 'write'
photo_count: number
}
@@ -808,6 +812,8 @@ export interface ShareInfo {
id: string
shared_with_id: string
shared_with_username: string
shared_with_avatar_url: string | null
shared_with_display_name: string | null
permission: string
/** 'pending' until the recipient accepts in the notification bell,
* then 'accepted'. Decline deletes the row outright. */
@@ -818,6 +824,8 @@ export interface ShareInfo {
export interface ShareableUser {
id: string
username: string
avatar_url: string | null
display_name: string | null
}
/** One entry in the combined pending-shares response. `target_id` is
@@ -828,6 +836,8 @@ export interface PendingShare {
target_id: string
target_name: string
owner_username: string
owner_avatar_url: string | null
owner_display_name: string | null
permission: 'read' | 'write'
created_at: string
}