From 5e77d0c854b8bd55e06bfc2fe77208fa963d2b20 Mon Sep 17 00:00:00 2001 From: Andres Zambrano Date: Sun, 23 Aug 2026 23:32:56 -0600 Subject: [PATCH 1/2] feat(auth): OIDC observability and setup docs Adds an OIDC status card to the admin System Health section, a startup log line listing enabled sign-in providers and OIDC mode flags, and documents every OIDC_* env var introduced across the three prior PRs (.env.example, docker-compose.yml, README). --- .env.example | 29 ++++++++++++++++++ README.md | 22 ++++++++++++++ docker/docker-compose.yml | 10 +++++++ messages/de/admin.json | 6 ++++ messages/en/admin.json | 6 ++++ messages/es/admin.json | 6 ++++ messages/fr/admin.json | 6 ++++ messages/ja/admin.json | 6 ++++ messages/ko/admin.json | 6 ++++ messages/pt-BR/admin.json | 6 ++++ messages/sv/admin.json | 6 ++++ messages/zh-CN/admin.json | 6 ++++ src/app/[locale]/(app)/admin/page.tsx | 43 ++++++++++++++++++++++++++- src/instrumentation.ts | 9 ++++++ src/server/trpc/routers/admin.ts | 11 +++++++ 15 files changed, 177 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3871f4a..0bf0fad 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,35 @@ 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. +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. +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. +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..71cef23 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,27 @@ 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`. 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. | +| `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. 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`. | + +Callback URL to register with the IdP: `${NEXTAUTH_URL}/api/auth/callback/oidc` (e.g. `https://sharetab.example.com/api/auth/callback/oidc`). Some IdPs (Pocket ID among them) match callback URLs as a literal path rather than a wildcard, so register the exact path. + +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/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..ce907ab 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,36 @@ function SystemHealthSection() { + + + + + {t('systemHealth.oidc')} + + + +
+ + + {data?.oidc.configured + ? (data.oidc.name ?? t('systemHealth.oidcGeneric')) + : t('systemHealth.oidcNotConfigured')} + +
+ {data?.oidc.configured && ( +

+ {[ + data.oidc.only && t('systemHealth.oidcOnly'), + data.oidc.autoRedirect && t('systemHealth.oidcAutoRedirect'), + data.oidc.rpLogout && t('systemHealth.oidcRpLogout'), + ] + .filter(Boolean) + .join(' · ')} +

+ )} +
+
+ diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 750ed6b..3748bb8 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,6 +8,15 @@ export async function register() { const { logger } = await import('@/server/lib/logger'); logger.info('app.startup', { version, commitSha }); + const { getEnabledProviders, getOidcModes } = await import('@/server/lib/auth-providers'); + const providers = getEnabledProviders(); + if (providers.length > 0) { + logger.info('app.startup.auth_providers', { + providers: providers.map((p) => p.id), + ...(providers.some((p) => p.id === 'oidc') ? { 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, + }, }; }), From d4c7e42ea2a7c2b12033f1b8862d29e8f8f977c0 Mon Sep 17 00:00:00 2001 From: Andres Zambrano Date: Mon, 24 Aug 2026 11:35:19 -0600 Subject: [PATCH 2/2] fix(auth): document the post-logout URI and fix OIDC observability gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README/.env.example: register *two* URLs with the IdP, not one. RP-initiated logout is on by default and sends `post_logout_redirect_uri`, which most providers require pre-registered — and the docs already warn that Pocket ID matches literally rather than by wildcard. An operator following the old instructions registered only the callback URL, and their first sign-out dead-ended on the IdP's error page with the local session already cleared. `OIDC_AUTO_REDIRECT` silently changes that URI to `/login?password=1`, which the docs also never mentioned. - Document that `OIDC_TRUST_EMAIL` and `OIDC_AUTO_PROVISION` now interact with the verification requirement on the provisioning path. - instrumentation: log `app.startup.auth_providers` unconditionally, with an explicit `oidcConfigured`. It was suppressed when no provider resolved, which is exactly the case it exists to diagnose — an `OIDC_CLIENT_SECRET` missing on a public client left an operator with no line at all, indistinguishable from a deliberately password-only instance. - unraid/sharetab.xml: add all 10 `OIDC_*` Config entries. The template enumerates every other optional integration, and Unraid is the stated target; without them a Community Applications user has no field to set and the compose defaults never reach that path. - docker/entrypoint.sh: report OIDC in the startup banner, including an explicit INCOMPLETE state when only some of the three required vars are set. - admin System Health: stop asserting "Not configured" when the query failed rather than returned — `data` is undefined in both cases, and a health dashboard reporting a definite wrong state is worse than reporting none. Also skip the modes line entirely when no mode is on, instead of rendering an empty paragraph and its margin as a stray gap. --- .env.example | 7 ++ README.md | 39 ++++++++---- docker/entrypoint.sh | 15 +++++ src/app/[locale]/(app)/admin/page.tsx | 46 ++++++++++---- src/instrumentation.ts | 18 ++++-- unraid/sharetab.xml | 92 +++++++++++++++++++++++++++ 6 files changed, 183 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 0bf0fad..9d37d86 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,8 @@ OIDC_NAME="" 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. @@ -41,9 +43,14 @@ 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 ─────────────────────────────────── diff --git a/README.md b/README.md index 71cef23..5007a9e 100644 --- a/README.md +++ b/README.md @@ -292,20 +292,31 @@ AI_PROVIDER_PRIORITY="openai-codex,meridian" 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`. 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. | -| `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. 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`. | - -Callback URL to register with the IdP: `${NEXTAUTH_URL}/api/auth/callback/oidc` (e.g. `https://sharetab.example.com/api/auth/callback/oidc`). Some IdPs (Pocket ID among them) match callback URLs as a literal path rather than a wildcard, so register the exact path. +| 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. 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/src/app/[locale]/(app)/admin/page.tsx b/src/app/[locale]/(app)/admin/page.tsx index ce907ab..5572974 100644 --- a/src/app/[locale]/(app)/admin/page.tsx +++ b/src/app/[locale]/(app)/admin/page.tsx @@ -135,23 +135,24 @@ function SystemHealthSection() {
- + {/* `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?.oidc.configured - ? (data.oidc.name ?? t('systemHealth.oidcGeneric')) - : t('systemHealth.oidcNotConfigured')} + {data === undefined + ? '---' + : data.oidc.configured + ? (data.oidc.name ?? t('systemHealth.oidcGeneric')) + : t('systemHealth.oidcNotConfigured')}
- {data?.oidc.configured && ( -

- {[ - data.oidc.only && t('systemHealth.oidcOnly'), - data.oidc.autoRedirect && t('systemHealth.oidcAutoRedirect'), - data.oidc.rpLogout && t('systemHealth.oidcRpLogout'), - ] - .filter(Boolean) - .join(' · ')} -

+ {data?.oidc.configured && oidcModeLabels(data.oidc, t).length > 0 && ( +

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

)}
@@ -177,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 3748bb8..13c45e6 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,14 +8,20 @@ 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(); - if (providers.length > 0) { - logger.info('app.startup.auth_providers', { - providers: providers.map((p) => p.id), - ...(providers.some((p) => p.id === 'oidc') ? { oidcModes: getOidcModes() } : {}), - }); - } + 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(); 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"> + + + + + + + + + + + +