Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
15 changes: 15 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
6 changes: 6 additions & 0 deletions messages/de/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions messages/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions messages/es/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions messages/fr/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions messages/ja/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
"systemHealth": {
"title": "システム状態",
"database": "データベース",
"oidc": "OIDC / SSO",
"oidcGeneric": "SSO",
"oidcNotConfigured": "未設定",
"oidcOnly": "OIDCのみ",
"oidcAutoRedirect": "自動リダイレクト",
"oidcRpLogout": "RPログアウト",
"version": "バージョン",
"uptime": "稼働時間",
"started": "{time}に起動"
Expand Down
6 changes: 6 additions & 0 deletions messages/ko/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
"systemHealth": {
"title": "시스템 상태",
"database": "데이터베이스",
"oidc": "OIDC / SSO",
"oidcGeneric": "SSO",
"oidcNotConfigured": "설정되지 않음",
"oidcOnly": "OIDC 전용",
"oidcAutoRedirect": "자동 리디렉션",
"oidcRpLogout": "RP 로그아웃",
"version": "버전",
"uptime": "가동 시간",
"started": "{time}에 시작됨"
Expand Down
6 changes: 6 additions & 0 deletions messages/pt-BR/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions messages/sv/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions messages/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
"systemHealth": {
"title": "系统状态",
"database": "数据库",
"oidc": "OIDC / SSO",
"oidcGeneric": "SSO",
"oidcNotConfigured": "未配置",
"oidcOnly": "仅 OIDC",
"oidcAutoRedirect": "自动跳转",
"oidcRpLogout": "RP 注销",
"version": "版本",
"uptime": "运行时间",
"started": "启动于 {time}"
Expand Down
61 changes: 60 additions & 1 deletion src/app/[locale]/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -115,6 +126,37 @@ function SystemHealthSection() {
</CardContent>
</Card>

<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<KeyRound className="h-4 w-4" />
{t('systemHealth.oidc')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
{/* `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. */}
<span
className={`h-2.5 w-2.5 rounded-full ${data === undefined ? 'bg-gray-400' : data.oidc.configured ? 'bg-green-500' : 'bg-gray-400'}`}
/>
<span className="text-sm font-medium">
{data === undefined
? '---'
: data.oidc.configured
? (data.oidc.name ?? t('systemHealth.oidcGeneric'))
: t('systemHealth.oidcNotConfigured')}
</span>
</div>
{data?.oidc.configured && oidcModeLabels(data.oidc, t).length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">{oidcModeLabels(data.oidc, t).join(' · ')}</p>
)}
</CardContent>
</Card>

<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Expand All @@ -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<typeof useTranslations<'admin'>>,
): 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() {
Expand Down
15 changes: 15 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions src/server/trpc/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -179,6 +180,9 @@ export const adminRouter = createTRPCRouter({
aiStatus = 'unavailable';
}

const oidcConfig = getOidcConfig();
const oidcModes = getOidcModes();

return {
dbStatus,
aiProvider,
Expand All @@ -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,
},
};
}),

Expand Down
Loading
Loading