From e8e1adcf37268c19bb5eb3b6d769bc62d75f9d1f Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 22 Apr 2026 21:06:32 +0200 Subject: [PATCH] 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) --- .../alembic/versions/0015_oidc_and_avatar.py | 83 +++++ backend/app/auth_oidc.py | 77 +++++ backend/app/config.py | 35 +++ backend/app/main.py | 14 + backend/app/models/user.py | 16 +- backend/app/routers/auth.py | 290 +++++++++++++++++- backend/app/routers/sharing.py | 37 ++- backend/app/services/gravatar.py | 28 ++ backend/requirements.txt | 5 + docker-compose.yml | 17 + frontend/src/App.tsx | 8 + frontend/src/components/auth/LoginPage.tsx | 105 +++++-- frontend/src/components/auth/OidcCallback.tsx | 84 +++++ frontend/src/components/heaps/HeapsPanel.tsx | 8 +- .../src/components/layout/LeftSidebar.tsx | 23 +- frontend/src/components/sharing/Avatar.tsx | 35 ++- .../components/sharing/NotificationBell.tsx | 10 +- .../src/components/sharing/ShareDialog.tsx | 11 +- frontend/src/contexts/AuthContext.tsx | 16 +- frontend/src/services/api.ts | 10 + 20 files changed, 852 insertions(+), 60 deletions(-) create mode 100644 backend/alembic/versions/0015_oidc_and_avatar.py create mode 100644 backend/app/auth_oidc.py create mode 100644 backend/app/services/gravatar.py create mode 100644 frontend/src/components/auth/OidcCallback.tsx 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 + } + if (isLoading) { return (
diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index 4e73cf2..a19a3d3 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -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(null) const [loading, setLoading] = useState(false) + const [oidc, setOidc] = useState(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('/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 (
-
+

Sign in to Mulita

@@ -43,33 +70,51 @@ export function LoginPage() { )} -
- - setUsername(e.target.value)} - required - autoFocus - /> -
+ {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. */} + +
+ + or continue with password + +
+ + )} -
- - setPassword(e.target.value)} - required - /> -
+ +
+ + setUsername(e.target.value)} + required + autoFocus + /> +
- - +
+ + setPassword(e.target.value)} + required + /> +
+ + + +
) } diff --git a/frontend/src/components/auth/OidcCallback.tsx b/frontend/src/components/auth/OidcCallback.tsx new file mode 100644 index 0000000..e352d50 --- /dev/null +++ b/frontend/src/components/auth/OidcCallback.tsx @@ -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 = { + 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(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 ( +
+
+

Sign-in failed

+ + {error} + + +
+
+ ) + } + + return ( +
+
Signing you in…
+
+ ) +} diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index bb561c9..7d3e203 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -521,8 +521,12 @@ export function HeapsPanel() { navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) } > - - + + {sh.name} - - + + {sf.name} {/* User row */}
- - {user?.username} + {user ? ( + + ) : ( + + )} + + {user?.display_name || user?.username} + {isAdmin && ( diff --git a/frontend/src/components/sharing/Avatar.tsx b/frontend/src/components/sharing/Avatar.tsx index cc6a100..360a8ba 100644 --- a/frontend/src/components/sharing/Avatar.tsx +++ b/frontend/src/components/sharing/Avatar.tsx @@ -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 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 ( - {initials} + {showImage ? ( + setBroken(true)} + loading="lazy" + referrerPolicy="no-referrer" + /> + ) : ( + initials + )} ) } diff --git a/frontend/src/components/sharing/NotificationBell.tsx b/frontend/src/components/sharing/NotificationBell.tsx index a680527..a7d9e1d 100644 --- a/frontend/src/components/sharing/NotificationBell.tsx +++ b/frontend/src/components/sharing/NotificationBell.tsx @@ -90,10 +90,16 @@ export function NotificationBell() { className="flex flex-col gap-2 px-3 py-2.5" >
- +
- {invite.owner_username} + + {invite.owner_display_name || invite.owner_username} + shared diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx index 20b29f9..f508e3e 100644 --- a/frontend/src/components/sharing/ShareDialog.tsx +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -180,8 +180,8 @@ export function ShareDialog({ {availableUsers.map((u) => ( - - {u.username} + + {u.display_name || u.username} ))} @@ -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" > - +
- {share.shared_with_username} + {share.shared_with_display_name || share.shared_with_username} {share.status === 'pending' && ( void /** Called after the setup endpoint creates the first admin. */ onSetupComplete: (accessToken: string, refreshToken: string) => Promise + /** Adopt tokens received from the OIDC callback. Same effect as + * onSetupComplete but semantically distinct. */ + onOidcTokens: (accessToken: string, refreshToken: string) => Promise } const AuthContext = createContext(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 ( {children} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b40a48d..5489d34 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 }