diff --git a/.env.example b/.env.example index 3871f4a..9d37d86 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,42 @@ AUTH_TRUST_HOST=true GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" +# ─── OIDC / SSO (optional — generic OIDC provider via issuer discovery) ─ +# Works with any OIDC-compliant IdP: Pocket ID, Authentik, Keycloak, Authelia, +# Zitadel, etc. All three are required to enable the "Sign in with SSO" button. +OIDC_ISSUER="" +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +# Display name for the sign-in button, e.g. "Pocket ID". Defaults to a generic +# "Sign in with SSO" label when unset. +OIDC_NAME="" +# Link an OIDC sign-in onto an existing password/Google account with the same +# email, but only when the IdP marks that email verified. +OIDC_ALLOW_LINKING="true" +# Treat a *missing* email_verified claim as verified, for IdPs that omit it +# entirely rather than sending false. Leave false unless you trust your IdP. +# This governs both linking and creating brand-new accounts: an unverified +# address is never provisioned either. +OIDC_TRUST_EMAIL="false" +# Provision new users on first OIDC sign-in even when registrationMode is +# invite-only or closed. +OIDC_AUTO_PROVISION="false" +# Hide the password/magic-link form on /login and /register. Permanent +# break-glass access to the password form stays at /login?password=1. +OIDC_ONLY="false" +# Skip /login entirely and redirect straight to the IdP. Only flip this on +# after confirming OIDC sign-in works for every account on this instance — +# /login?password=1 is the only way back if it does not. +# Also changes the post-logout redirect URI to ${NEXTAUTH_URL}/login?password=1, +# which must be registered with the IdP instead of ${NEXTAUTH_URL}/login. +OIDC_AUTO_REDIRECT="false" +# Also end the IdP's own session on sign-out (RP-initiated logout), not just +# ShareTab's local session. Requires the IdP to advertise end_session_endpoint. +# Requires ${NEXTAUTH_URL}/login to be registered with the IdP as a post-logout +# redirect URI, alongside the callback URL — without it the first sign-out ends +# on the IdP's error page. Set to false to skip RP logout entirely. +OIDC_RP_LOGOUT="true" + # ─── AI Receipt Scanning ─────────────────────────────────── # Provider: "openai" | "openai-codex" | "claude" | "meridian" | "ollama" # - openai: requires OPENAI_API_KEY (uses GPT-4o by default) diff --git a/README.md b/README.md index a3f3546..5007a9e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ ShareTab is a free, self-hosted alternative to Splitwise for tracking shared exp - **Dark mode** -- system-aware with manual toggle - **Invite links** -- share a link to add friends to your groups - **Magic link auth** -- passwordless email sign-in +- **OIDC / SSO** -- sign in through any OIDC-compliant identity provider (Pocket ID, Authentik, Keycloak, Authelia, Zitadel, ...), with optional OIDC-only mode, auto-redirect, and RP-initiated logout - **PWA** -- installable on mobile with app-like experience - **Admin dashboard** -- user management, group overview, storage stats, AI usage, audit log, registration control, announcements, server logs, user impersonation, data export, expired guest split cleanup - **Self-hosted** -- Docker Compose deployment, designed for Unraid @@ -287,6 +288,38 @@ AI_PROVIDER_PRIORITY="openai-codex,meridian" | `GOOGLE_CLIENT_ID` | Google OAuth client ID for "Sign in with Google". | | `GOOGLE_CLIENT_SECRET` | Corresponding client secret. | +### OIDC / SSO (optional) + +One generic OIDC provider, configured via issuer discovery rather than a per-vendor preset. Works with any OIDC-compliant identity provider -- [Pocket ID](https://github.com/pocket-id/pocket-id), Authentik, Keycloak, Authelia, Zitadel, and others. Setting `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` enables a "Sign in with SSO" button on `/login` and `/register`; the rest are optional tuning knobs. + +| Variable | Default | Description | +| --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_ISSUER` | | Issuer URL, e.g. `https://auth.example.com`. ShareTab reads `${OIDC_ISSUER}/.well-known/openid-configuration` for the rest of the endpoints. | +| `OIDC_CLIENT_ID` | | Client ID registered with the IdP. | +| `OIDC_CLIENT_SECRET` | | Client secret. Leave the client's "public"/PKCE-only toggle off -- ShareTab is a confidential (server-side) client and needs a real secret. | +| `OIDC_NAME` | | Display name for the sign-in button, e.g. `Pocket ID`. Unset renders a generic "Sign in with SSO" label. | +| `OIDC_ALLOW_LINKING` | `true` | Link an OIDC sign-in onto an existing password/Google account with the same email, but only when the IdP marks that email `email_verified: true`. | +| `OIDC_TRUST_EMAIL` | `false` | Treat a _missing_ `email_verified` claim as verified, for IdPs that omit the claim rather than sending `false`. Governs both linking and provisioning -- an unverified address is never turned into a new account either. Leave off unless you trust your IdP. | +| `OIDC_AUTO_PROVISION` | `false` | Provision new users on first OIDC sign-in even when registration is invite-only or closed, bypassing the normal registration mode. It does not bypass the `email_verified` requirement. | +| `OIDC_ONLY` | `false` | Hide the password/magic-link form on `/login` and `/register`. `/login?password=1` is a permanent break-glass back to the password form. | +| `OIDC_AUTO_REDIRECT` | `false` | Skip `/login` entirely and redirect straight to the IdP. Also changes the post-logout redirect URI to `${NEXTAUTH_URL}/login?password=1`, which has to be registered too. Only enable once OIDC sign-in is confirmed working for every account on the instance. | +| `OIDC_RP_LOGOUT` | `true` | Also end the IdP's own session on sign-out (RP-initiated logout) instead of just ShareTab's local session. Requires the IdP to advertise `end_session_endpoint` **and** to have the post-logout redirect URI below registered. | + +Two URLs need registering with the IdP, not one: + +| Register as | Value | +| ------------------------ | ---------------------------------------- | +| Callback / redirect URI | `${NEXTAUTH_URL}/api/auth/callback/oidc` | +| Post-logout redirect URI | `${NEXTAUTH_URL}/login` | + +For `https://sharetab.example.com` those are `https://sharetab.example.com/api/auth/callback/oidc` and `https://sharetab.example.com/login`. + +The post-logout URI is only used when `OIDC_RP_LOGOUT` is on, which it is by default. Skip registering it and the first sign-out lands on the IdP’s "invalid post_logout_redirect_uri" error page -- after ShareTab’s own session has already been cleared. If you also enable `OIDC_AUTO_REDIRECT`, register `${NEXTAUTH_URL}/login?password=1` instead: auto-redirect changes the post-logout destination so signing out cannot bounce straight back into a still-open IdP session. Setting `OIDC_RP_LOGOUT=false` drops the requirement entirely, at the cost of leaving the IdP session open on sign-out. + +Some IdPs (Pocket ID among them) match both URLs as a literal path rather than a wildcard, so register the exact paths. + +Roll out in two steps: ship `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` first, alongside the existing password form, and confirm SSO sign-in works for every account. Only then set `OIDC_ONLY=true` and `OIDC_AUTO_REDIRECT=true` -- `/login?password=1` stays available as a fallback either way. + ### Magic Link Auth (optional) | Variable | Description | diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1569b48..50a15c8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -18,6 +18,16 @@ services: - AUTH_TRUST_HOST=${AUTH_TRUST_HOST:-false} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - OIDC_ISSUER=${OIDC_ISSUER:-} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + - OIDC_NAME=${OIDC_NAME:-} + - OIDC_ALLOW_LINKING=${OIDC_ALLOW_LINKING:-true} + - OIDC_TRUST_EMAIL=${OIDC_TRUST_EMAIL:-false} + - OIDC_AUTO_PROVISION=${OIDC_AUTO_PROVISION:-false} + - OIDC_ONLY=${OIDC_ONLY:-false} + - OIDC_AUTO_REDIRECT=${OIDC_AUTO_REDIRECT:-false} + - OIDC_RP_LOGOUT=${OIDC_RP_LOGOUT:-true} - AI_PROVIDER_PRIORITY=${AI_PROVIDER_PRIORITY:-openai} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 42fbf9d..957662c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -187,6 +187,21 @@ if [ -n "$GOOGLE_CLIENT_ID" ]; then else echo " Google OAuth: disabled" fi +# All three are required, so report the partial case explicitly: a missing +# client secret on a public IdP client is the usual reason the sign-in button +# never appears, and "disabled" alone would give an operator nothing to go on. +if [ -n "$OIDC_ISSUER" ] && [ -n "$OIDC_CLIENT_ID" ] && [ -n "$OIDC_CLIENT_SECRET" ]; then + echo " OIDC / SSO: enabled" + echo " OIDC Issuer: ${OIDC_ISSUER}" + echo " OIDC Name: ${OIDC_NAME:-not set}" + echo " OIDC Only: ${OIDC_ONLY:-false}" + echo " OIDC Redirect: ${OIDC_AUTO_REDIRECT:-false}" + echo " OIDC RP Logout: ${OIDC_RP_LOGOUT:-true}" +elif [ -n "$OIDC_ISSUER" ] || [ -n "$OIDC_CLIENT_ID" ] || [ -n "$OIDC_CLIENT_SECRET" ]; then + echo " OIDC / SSO: INCOMPLETE (needs OIDC_ISSUER + OIDC_CLIENT_ID + OIDC_CLIENT_SECRET)" +else + echo " OIDC / SSO: disabled" +fi echo " Log Level: ${LOG_LEVEL:-info}" echo "============================================" echo "" diff --git a/messages/de/admin.json b/messages/de/admin.json index c8e5c38..cee2589 100644 --- a/messages/de/admin.json +++ b/messages/de/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "Systemzustand", "database": "Datenbank", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "Nicht konfiguriert", + "oidcOnly": "Nur OIDC", + "oidcAutoRedirect": "Automatische Weiterleitung", + "oidcRpLogout": "RP-Abmeldung", "version": "Version", "uptime": "Betriebszeit", "started": "Gestartet {time}" diff --git a/messages/en/admin.json b/messages/en/admin.json index c9307e5..d3a6e53 100644 --- a/messages/en/admin.json +++ b/messages/en/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "System Health", "database": "Database", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "Not configured", + "oidcOnly": "OIDC-only", + "oidcAutoRedirect": "Auto-redirect", + "oidcRpLogout": "RP logout", "version": "Version", "uptime": "Uptime", "started": "Started {time}" diff --git a/messages/es/admin.json b/messages/es/admin.json index ad4c0cf..cb2b809 100644 --- a/messages/es/admin.json +++ b/messages/es/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "Estado del sistema", "database": "Base de datos", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "No configurado", + "oidcOnly": "Solo OIDC", + "oidcAutoRedirect": "Redirección automática", + "oidcRpLogout": "Cierre de sesión RP", "version": "Versión", "uptime": "Tiempo de actividad", "started": "Iniciado {time}" diff --git a/messages/fr/admin.json b/messages/fr/admin.json index 30d71e4..7448aba 100644 --- a/messages/fr/admin.json +++ b/messages/fr/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "État du système", "database": "Base de données", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "Non configuré", + "oidcOnly": "OIDC uniquement", + "oidcAutoRedirect": "Redirection automatique", + "oidcRpLogout": "Déconnexion RP", "version": "Version", "uptime": "Disponibilité", "started": "Démarré {time}" diff --git a/messages/ja/admin.json b/messages/ja/admin.json index a46a8f7..01a2018 100644 --- a/messages/ja/admin.json +++ b/messages/ja/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "システム状態", "database": "データベース", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "未設定", + "oidcOnly": "OIDCのみ", + "oidcAutoRedirect": "自動リダイレクト", + "oidcRpLogout": "RPログアウト", "version": "バージョン", "uptime": "稼働時間", "started": "{time}に起動" diff --git a/messages/ko/admin.json b/messages/ko/admin.json index 69c89a8..2eab1e1 100644 --- a/messages/ko/admin.json +++ b/messages/ko/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "시스템 상태", "database": "데이터베이스", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "설정되지 않음", + "oidcOnly": "OIDC 전용", + "oidcAutoRedirect": "자동 리디렉션", + "oidcRpLogout": "RP 로그아웃", "version": "버전", "uptime": "가동 시간", "started": "{time}에 시작됨" diff --git a/messages/pt-BR/admin.json b/messages/pt-BR/admin.json index b5afe6f..530306f 100644 --- a/messages/pt-BR/admin.json +++ b/messages/pt-BR/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "Saúde do sistema", "database": "Banco de dados", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "Não configurado", + "oidcOnly": "Somente OIDC", + "oidcAutoRedirect": "Redirecionamento automático", + "oidcRpLogout": "Logout RP", "version": "Versão", "uptime": "Tempo de atividade", "started": "Iniciado {time}" diff --git a/messages/sv/admin.json b/messages/sv/admin.json index bb6ded0..12cae14 100644 --- a/messages/sv/admin.json +++ b/messages/sv/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "Systemhälsa", "database": "Databas", + "oidc": "OIDC/SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "Ej konfigurerad", + "oidcOnly": "Endast OIDC", + "oidcAutoRedirect": "Automatisk omdirigering", + "oidcRpLogout": "RP-utloggning", "version": "Version", "uptime": "Drifttid", "started": "Startad {time}" diff --git a/messages/zh-CN/admin.json b/messages/zh-CN/admin.json index ced781e..07e0db8 100644 --- a/messages/zh-CN/admin.json +++ b/messages/zh-CN/admin.json @@ -3,6 +3,12 @@ "systemHealth": { "title": "系统状态", "database": "数据库", + "oidc": "OIDC / SSO", + "oidcGeneric": "SSO", + "oidcNotConfigured": "未配置", + "oidcOnly": "仅 OIDC", + "oidcAutoRedirect": "自动跳转", + "oidcRpLogout": "RP 注销", "version": "版本", "uptime": "运行时间", "started": "启动于 {time}" diff --git a/src/app/[locale]/(app)/admin/page.tsx b/src/app/[locale]/(app)/admin/page.tsx index 7032952..5572974 100644 --- a/src/app/[locale]/(app)/admin/page.tsx +++ b/src/app/[locale]/(app)/admin/page.tsx @@ -6,7 +6,18 @@ import { trpc } from '@/lib/trpc'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; -import { Shield, Database, Package, Clock, FolderOpen, HardDrive, FileWarning, Loader2, RefreshCw } from 'lucide-react'; +import { + Shield, + Database, + Package, + Clock, + FolderOpen, + HardDrive, + FileWarning, + Loader2, + RefreshCw, + KeyRound, +} from 'lucide-react'; import { AuditLogSection } from '@/components/admin/audit-log-section'; import { RegistrationControlSection } from '@/components/admin/registration-control-section'; @@ -115,6 +126,37 @@ function SystemHealthSection() { + + + + + {t('systemHealth.oidc')} + + + +
+ {/* `data` is undefined both while loading and after a failed + query. Asserting "Not configured" in the latter case would + report a definite wrong state on a health dashboard, so the + unknown case gets its own neutral rendering — matching how + the database and uptime cards degrade. */} + + + {data === undefined + ? '---' + : data.oidc.configured + ? (data.oidc.name ?? t('systemHealth.oidcGeneric')) + : t('systemHealth.oidcNotConfigured')} + +
+ {data?.oidc.configured && oidcModeLabels(data.oidc, t).length > 0 && ( +

{oidcModeLabels(data.oidc, t).join(' · ')}

+ )} +
+
+ @@ -136,6 +178,23 @@ function SystemHealthSection() { ); } +/** + * The OIDC modes that are actually on. All three can legitimately be off — + * `OIDC_RP_LOGOUT=false` with the other two unset is a supported config — in + * which case the caller must skip the line entirely rather than render an + * empty paragraph and its margin as a stray gap. + */ +function oidcModeLabels( + oidc: { only: boolean; autoRedirect: boolean; rpLogout: boolean }, + t: ReturnType>, +): string[] { + return [ + oidc.only ? t('systemHealth.oidcOnly') : null, + oidc.autoRedirect ? t('systemHealth.oidcAutoRedirect') : null, + oidc.rpLogout ? t('systemHealth.oidcRpLogout') : null, + ].filter((label): label is string => label !== null); +} + // ─── Storage Stats ───────────────────────────────────────── function StorageStatsSection() { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 750ed6b..13c45e6 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,6 +8,21 @@ export async function register() { const { logger } = await import('@/server/lib/logger'); logger.info('app.startup', { version, commitSha }); + // Logged unconditionally, including the empty case. The whole point of + // this line is diagnosing "I set the OIDC vars and no button appeared", + // and the most common cause — a missing OIDC_CLIENT_SECRET on a public + // client — resolves to no provider at all. Suppressing the line there + // would make a misconfigured instance look exactly like a deliberately + // password-only one. + const { getEnabledProviders, getOidcModes } = await import('@/server/lib/auth-providers'); + const providers = getEnabledProviders(); + const oidcConfigured = providers.some((p) => p.id === 'oidc'); + logger.info('app.startup.auth_providers', { + providers: providers.map((p) => p.id), + oidcConfigured, + ...(oidcConfigured ? { oidcModes: getOidcModes() } : {}), + }); + const { startPoller } = await import('@/server/lib/auth-health-poller'); startPoller(); } catch (error) { diff --git a/src/server/trpc/routers/admin.ts b/src/server/trpc/routers/admin.ts index ccd220a..2430ff7 100644 --- a/src/server/trpc/routers/admin.ts +++ b/src/server/trpc/routers/admin.ts @@ -37,6 +37,7 @@ import { } from '@/server/lib/openai-codex-login'; import { getBuildInfo } from '@/server/lib/build-info'; +import { getOidcConfig, getOidcModes } from '@/server/lib/auth-providers'; const serverStartTime = new Date(); const { version: cachedVersion, commitSha: cachedCommitSha } = getBuildInfo(); @@ -179,6 +180,9 @@ export const adminRouter = createTRPCRouter({ aiStatus = 'unavailable'; } + const oidcConfig = getOidcConfig(); + const oidcModes = getOidcModes(); + return { dbStatus, aiProvider, @@ -189,6 +193,13 @@ export const adminRouter = createTRPCRouter({ commitSha: cachedCommitSha, serverStartTime: serverStartTime.toISOString(), uptime: Math.floor((Date.now() - serverStartTime.getTime()) / 1000), + oidc: { + configured: oidcConfig !== null, + name: oidcConfig?.name ?? null, + only: oidcModes.only, + autoRedirect: oidcModes.autoRedirect, + rpLogout: oidcModes.rpLogout, + }, }; }), diff --git a/unraid/sharetab.xml b/unraid/sharetab.xml index 3b79789..142580b 100644 --- a/unraid/sharetab.xml +++ b/unraid/sharetab.xml @@ -309,6 +309,98 @@ Required="false" Mask="true"> + + + + + + + + + + + +