From 7af9fd9152b01b3bb6172612863ffae610a660bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:35:27 +0000 Subject: [PATCH] Add optional single sign-on through OpenID Connect (authentik) A "Sign in with authentik" button, configured entirely through OIDC_* settings and off by default. Authorization-code flow with PKCE, state and nonce; the ID token's signature (JWKS, or HS256 with the client secret as authentik uses without a signing key), issuer, audience and expiry are verified. Accounts are matched by verified email on the first sign-in and then bound to the provider's subject, so an email change on either side cannot move a sign-in onto another account. With no match, an open invitation is honoured with its role, the first account on an empty instance becomes the administrator, and OIDC_AUTO_CREATE decides everything else. AUTH_PASSWORD_LOGIN=false makes single sign-on the only way in: the password form, sign-up and password invitations disappear, and only administrators may still sign in with a password, through the unlinked break-glass at /login?local=1. The switch is ignored with a warning while SSO is not fully configured. Signing out of an SSO session also ends the provider session (OIDC_SINGLE_LOGOUT), for which the provider's origin is added to the CSP form-action. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H9iRDbBMRtJipppd5fLRtL --- .env.example | 25 ++ README.md | 91 ++++++- messages/de.json | 16 +- messages/en.json | 16 +- .../20261005120000_oidc_sso/migration.sql | 12 + prisma/schema.prisma | 4 + src/app/(auth)/login/page.tsx | 47 +++- src/app/(auth)/register/page.tsx | 4 +- src/app/api/auth/oidc/callback/route.ts | 61 +++++ src/app/api/auth/oidc/login/route.ts | 33 +++ src/app/invite/[token]/page.tsx | 38 ++- src/instrumentation.ts | 6 + src/lib/oidc-config.ts | 80 ++++++ src/lib/oidc.test.ts | 148 +++++++++++ src/lib/oidc.ts | 137 ++++++++++ src/lib/rate-limit.ts | 3 + src/lib/security-headers.test.ts | 6 + src/lib/security-headers.ts | 9 +- src/middleware.ts | 14 +- src/server/admin-actions.ts | 3 + src/server/auth-actions.ts | 29 ++- src/server/oidc.ts | 239 ++++++++++++++++++ src/server/registration.ts | 2 +- src/server/session.ts | 26 +- 24 files changed, 1013 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20261005120000_oidc_sso/migration.sql create mode 100644 src/app/api/auth/oidc/callback/route.ts create mode 100644 src/app/api/auth/oidc/login/route.ts create mode 100644 src/lib/oidc-config.ts create mode 100644 src/lib/oidc.test.ts create mode 100644 src/lib/oidc.ts create mode 100644 src/server/oidc.ts diff --git a/.env.example b/.env.example index 391e262..1155f75 100644 --- a/.env.example +++ b/.env.example @@ -189,6 +189,31 @@ SMTP_FROM_NAME=NutriCore # disabled - no self-registration at all, including the first REGISTRATION_MODE=bootstrap +# --- Single sign-on (OpenID Connect, e.g. authentik) ------------------------- +# Optional. Adds a "Sign in with ..." button; accounts are matched by email on +# the first sign-in and then bound to the provider's subject. In authentik: +# create an OAuth2/OpenID provider (confidential client) with redirect URI +# ${APP_URL}/api/auth/oidc/callback +# and, for single logout, ${APP_URL}/login as well. See README "Single sign-on". +OIDC_ENABLED=false +# The provider's issuer, e.g. https://auth.example.com/application/o/nutricore/ +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +# OIDC_SCOPES=openid email profile +# OIDC_PROVIDER_NAME=authentik +# Create an account on first sign-in when no account has the email. Off: only +# existing accounts and open invitations can sign in. +OIDC_AUTO_CREATE=false +# Refuse emails the provider does not mark as verified (email_verified). +OIDC_REQUIRE_VERIFIED_EMAIL=true +# Also end the provider session on sign-out. +OIDC_SINGLE_LOGOUT=true +# false = single sign-on only: hides the password form, registration and +# password invitations. Administrators keep a break-glass password sign-in at +# /login?local=1. Ignored (with a warning) while single sign-on is not configured. +AUTH_PASSWORD_LOGIN=true + # Retention for the records that carry no expiry of their own. Days; 0 disables # that sweep. Raw uploaded images are unaffected - they expire in minutes and are # swept every minute regardless of what is set here. diff --git a/README.md b/README.md index 2c3e909..c211d36 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Implemented and covered by tests: - **Accounts** — local email + password, Argon2id hashing, opaque session tokens stored only as SHA-256 hashes, HTTP-only cookies, logout, account - deletion. No external identity provider. + deletion. Optional single sign-on through OpenID Connect (e.g. authentik), + with password sign-in switchable off. See [Single sign-on](#single-sign-on-openid-connect--authentik). - **Onboarding and profile** — display name, language, date of birth, height, weight, biological sex, activity level and goal. - **Calorie target** — Mifflin-St Jeor. Every component (BMR, activity @@ -297,6 +298,10 @@ All variables are documented inline in [`.env.example`](.env.example). | `RETAIN_AI_JOB_DAYS` / `RETAIN_FAILED_AI_JOB_DAYS` | no | Days before finished AI jobs and their attempts are deleted; defaults 30 and 90, `0` disables | | `RETAIN_INVITATION_DAYS` | no | Days before accepted, revoked or expired invitations are deleted; default 30, `0` disables | | `REGISTRATION_MODE` | no | `bootstrap` (default), `open` or `disabled`. See [Registration policy](#registration-policy) | +| `OIDC_ENABLED` / `OIDC_ISSUER` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | no | Optional single sign-on. See [Single sign-on](#single-sign-on-openid-connect--authentik) | +| `OIDC_AUTO_CREATE` / `OIDC_REQUIRE_VERIFIED_EMAIL` / `OIDC_SINGLE_LOGOUT` | no | Defaults `false`, `true`, `true` | +| `OIDC_SCOPES` / `OIDC_PROVIDER_NAME` | no | Defaults `openid email profile` and `authentik` (the button label) | +| `AUTH_PASSWORD_LOGIN` | no | Default `true`. `false` makes single sign-on the only way in, apart from the administrators' break-glass at `/login?local=1` | | `ALLOW_INSECURE_APP_URL` | no | Default `false`. Allows a production `APP_URL` that is neither HTTPS nor local, for TLS terminated where the application cannot see it | | `TRUSTED_PROXY_HOPS` | no | Default `0`. How many reverse proxies sit in front of this deployment; `X-Forwarded-For` is ignored unless this is set | | `INVITATION_EXPIRY_HOURS` | no | Single-use invitation lifetime; default 48 hours | @@ -1281,10 +1286,94 @@ user from Settings when SMTP is configured. If that is not the membership policy you want, an administrator can disable SMTP delivery so invitations are issued only from the Administrator Panel. +## Single sign-on (OpenID Connect / authentik) + +NutriCore can sign people in through any OpenID Connect provider. It is written +and documented against [authentik](https://goauthentik.io/), but uses nothing +authentik-specific: discovery, the authorization-code flow with PKCE, a signed +ID token, and optionally the userinfo and end-session endpoints. + +### Setting it up in authentik + +1. **Applications → Providers → Create → OAuth2/OpenID Provider.** + - Client type: *Confidential*. + - Redirect URIs: `https://nutricore.example.com/api/auth/oidc/callback` + (your `APP_URL` plus that path). For single logout also add + `https://nutricore.example.com/login`. + - Scopes: the default `openid`, `email` and `profile` mappings. + - Signing key: any certificate (RS256). Leaving it empty also works; authentik + then signs with the client secret (HS256), which NutriCore accepts. +2. **Applications → Create**, slug e.g. `nutricore`, using that provider. Bind + users or groups to the application to decide who may sign in at all. +3. In `.env`: + + ```env + OIDC_ENABLED=true + OIDC_ISSUER=https://auth.example.com/application/o/nutricore/ + OIDC_CLIENT_ID= + OIDC_CLIENT_SECRET= + ``` + + The issuer is the provider's *OpenID Configuration Issuer*, shown on its + overview page. Restart the app; the sign-in page now has a + *Sign in with authentik* button. + +### How accounts are matched + +On a person's first single sign-on, the email the provider releases is matched, +case-insensitively, against NutriCore's accounts. A match is then **bound to the +provider's subject (`sub`)**: from then on the sign-in is recognised by that +subject, so changing the email on either side does not move it onto a different +account, and an account bound to one identity is never taken over by another. + +When no account has the email: + +| Situation | Result | +| --- | --- | +| An open invitation exists for the email | The account is created with the invitation's role, and the invitation is used up | +| The instance has no account yet (and `REGISTRATION_MODE` is not `disabled`) | The account is created as the administrator, exactly like the first password registration | +| `OIDC_AUTO_CREATE=true` | The account is created as a normal member | +| Otherwise | Refused: *There is no account for your email address* | + +Accounts created this way have no usable password. Administrator rights stay +managed inside NutriCore; provider groups do not change them. + +`OIDC_REQUIRE_VERIFIED_EMAIL` (default `true`) refuses to match or create an +account from an email the provider does not mark `email_verified`. Email is the +key that decides which account a sign-in reaches, so if users can edit their own +email in authentik, an unverified one could claim somebody else's not-yet-linked +account. If your authentik email mapping reports `email_verified: false` and only +administrators can change emails there, set it to `false`, or adjust the +`email` scope mapping to return `True`. + +### Single sign-on only + +`AUTH_PASSWORD_LOGIN=false` makes the provider the only way in: + +- the email + password form, the sign-up page and the password form on + invitation links disappear; an invitation link offers *Sign in with authentik* + instead and is honoured on the first sign-in with its email; +- password sign-in is refused for everyone **except administrators**, who keep a + break-glass form at `/login?local=1` (not linked anywhere) for the day the + provider is down. + +The switch is ignored, with a warning in the log at start-up, while single sign-on +is not fully configured, so a typo cannot lock everybody out. + +### Signing out + +With `OIDC_SINGLE_LOGOUT=true` (default), signing out of a single sign-on +session also ends the authentik session through its end-session endpoint and +returns to `/login`. Otherwise only the NutriCore session ends. + ## Security considerations - Argon2id password hashing with OWASP-aligned parameters - Opaque session tokens; only SHA-256 hashes are stored +- Optional OpenID Connect single sign-on: authorization-code flow with PKCE + (S256), state and nonce checked, ID token signature, issuer, audience and + expiry verified; accounts are matched by verified email once and then bound to + the provider's subject - Health sync tokens follow the same rule: 256 bits of entropy, only the SHA-256 is stored, shown to the user once, revocable per device, and fixed at issue time to one platform so a token taken off one phone cannot write as the other diff --git a/messages/de.json b/messages/de.json index cee5015..c8435d7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -61,10 +61,19 @@ "tooCommon": "Dieses Passwort ist zu leicht zu erraten.", "rateLimited": "Zu viele Versuche. Bitte in {seconds} Sekunden erneut versuchen.", "invalid": "Bitte prüfe die markierten Felder.", - "registrationClosed": "Die Registrierung ist auf diesem Server geschlossen. Neue Konten werden per Einladung erstellt." + "registrationClosed": "Die Registrierung ist auf diesem Server geschlossen. Neue Konten werden per Einladung erstellt.", + "ssoFailed": "Die Single-Sign-on-Anmeldung wurde nicht abgeschlossen. Bitte versuche es erneut.", + "ssoUnavailable": "Der Anmeldedienst ist gerade nicht erreichbar.", + "ssoNoAccount": "Für deine E-Mail-Adresse gibt es auf diesem Server kein Konto. Bitte eine Administratorin oder einen Administrator um eine Einladung.", + "ssoUnverified": "Dein Anmeldedienst hat deine E-Mail-Adresse nicht bestätigt, daher kann sie keinem Konto zugeordnet werden.", + "ssoInactive": "Dieses Konto wurde deaktiviert.", + "ssoConflict": "Ein Konto mit dieser E-Mail-Adresse ist bereits mit einer anderen Identität verknüpft. Bitte wende dich an die Administration." }, "signUpClosedTitle": "Registrierung geschlossen", - "signUpClosedSubtitle": "Auf diesem Server existiert bereits ein Konto. Neue Mitglieder kommen per Einladung dazu - bitte eine Administratorin oder einen Administrator um eine Einladung." + "signUpClosedSubtitle": "Auf diesem Server existiert bereits ein Konto. Neue Mitglieder kommen per Einladung dazu - bitte eine Administratorin oder einen Administrator um eine Einladung.", + "signInWith": "Mit {provider} anmelden", + "orPassword": "oder mit deinem Passwort", + "ssoOnlySubtitle": "Auf diesem Server meldest du dich über {provider} an." }, "onboarding": { "title": "Profil einrichten", @@ -1390,7 +1399,8 @@ "inviteError": "Die Angaben konnten nicht übernommen werden. Der Benutzername ist möglicherweise schon vergeben.", "username": "Benutzername", "password": "Passwort", - "createAccount": "Konto erstellen" + "createAccount": "Konto erstellen", + "inviteSso": "Melde dich mit {provider} als {email} an, um diese Einladung anzunehmen." }, "activity": { "title": "Sport & Aktivität", diff --git a/messages/en.json b/messages/en.json index b72e618..9eac7f4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -61,10 +61,19 @@ "tooCommon": "That password is too easy to guess.", "rateLimited": "Too many attempts. Please try again in {seconds} seconds.", "invalid": "Please check the highlighted fields.", - "registrationClosed": "Registration is closed on this server. New accounts are created by invitation." + "registrationClosed": "Registration is closed on this server. New accounts are created by invitation.", + "ssoFailed": "Single sign-on did not complete. Please try again.", + "ssoUnavailable": "The sign-in provider cannot be reached right now.", + "ssoNoAccount": "There is no account for your email address on this server. Ask an administrator for an invitation.", + "ssoUnverified": "Your sign-in provider has not confirmed your email address, so it cannot be matched to an account.", + "ssoInactive": "This account has been deactivated.", + "ssoConflict": "An account with this email address is already linked to a different identity. Ask an administrator." }, "signUpClosedTitle": "Registration is closed", - "signUpClosedSubtitle": "This server already has an account. New members join by invitation - ask an administrator to send you one." + "signUpClosedSubtitle": "This server already has an account. New members join by invitation - ask an administrator to send you one.", + "signInWith": "Sign in with {provider}", + "orPassword": "or with your password", + "ssoOnlySubtitle": "This server signs you in through {provider}." }, "onboarding": { "title": "Set up your profile", @@ -1390,7 +1399,8 @@ "inviteError": "Those details could not be accepted. The username may already be taken.", "username": "Username", "password": "Password", - "createAccount": "Create account" + "createAccount": "Create account", + "inviteSso": "Sign in with {provider} as {email} to accept this invitation." }, "activity": { "title": "Sport & Activity", diff --git a/prisma/migrations/20261005120000_oidc_sso/migration.sql b/prisma/migrations/20261005120000_oidc_sso/migration.sql new file mode 100644 index 0000000..1836900 --- /dev/null +++ b/prisma/migrations/20261005120000_oidc_sso/migration.sql @@ -0,0 +1,12 @@ +-- Optional single sign-on through an OpenID Connect provider (e.g. authentik). +-- +-- Accounts are matched by email on their first SSO sign-in and then linked to +-- the provider's stable subject, so a later email change on either side does +-- not move the sign-in to a different account. Existing accounts are unchanged +-- until they sign in through the provider. +ALTER TABLE "User" ADD COLUMN "oidcSubject" TEXT; +CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject"); + +-- The ID token is the `id_token_hint` for ending the provider session on +-- sign-out. It dies with the session row. +ALTER TABLE "Session" ADD COLUMN "oidcIdToken" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5362495..f5098be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -183,6 +183,8 @@ model User { role UserRole @default(USER) active Boolean @default(true) mustChangePassword Boolean @default(false) + /// The OIDC `sub` this account is linked to, set on its first single sign-on. + oidcSubject String? @unique profile UserProfile? sessions Session[] @@ -351,6 +353,8 @@ model Session { userId String createdAt DateTime @default(now()) expiresAt DateTime + /// The ID token of a single sign-on session, kept as the hint for RP-initiated logout. + oidcIdToken String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 798de86..5bcebba 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { getSessionUser } from "@/server/session"; +import { isOidcError, oidcConfig, passwordLoginEnabled } from "@/lib/oidc"; import { LoginForm } from "./login-form"; export async function generateMetadata() { @@ -9,9 +10,18 @@ export async function generateMetadata() { return { title: t("signIn") }; } -export default async function LoginPage() { +export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string; local?: string }> }) { if (await getSessionUser()) redirect("/"); const t = await getTranslations("auth"); + const { error, local } = await searchParams; + + const sso = oidcConfig(); + const passwords = passwordLoginEnabled(); + /* `?local=1` is the administrators' break-glass when password sign-in is + off: it shows the form, and `loginAction` accepts only administrators. It + is deliberately not linked from anywhere. */ + const showPasswordForm = passwords || local === "1"; + const ssoError = isOidcError(error) || error === "rateLimited" ? error : undefined; return (
@@ -26,14 +36,39 @@ export default async function LoginPage() {

{t("signInTitle")}

- {t("signInSubtitle")} + {showPasswordForm || !sso ? t("signInSubtitle") : t("ssoOnlySubtitle", { provider: sso.providerName })}

- + + {ssoError ? ( +
+ + {ssoError === "rateLimited" ? t("errors.rateLimited", { seconds: 60 }) : t(`errors.${ssoError}`)} +
+ ) : null} + + {sso ? ( + // A plain link, not a form: the route answers with a redirect to the provider. + + {t("signInWith", { provider: sso.providerName })} + + ) : null} + + {sso && showPasswordForm ? ( +

+ {t("orPassword")} +

+ ) : null} + + {showPasswordForm ? : null}
-

- {t("noAccount")} {t("signUp")} -

+ {passwords ? ( +

+ {t("noAccount")} {t("signUp")} +

+ ) : null}
); diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 868c8cc..085c4eb 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { getSessionUser } from "@/server/session"; import { registrationAvailable } from "@/server/registration"; +import { passwordLoginEnabled } from "@/lib/oidc"; import { RegisterForm } from "./register-form"; export async function generateMetadata() { @@ -15,7 +16,8 @@ export default async function RegisterPage() { const t = await getTranslations("auth"); // UX only. `registerAction` enforces the same policy for itself, so posting // to it directly gets nowhere even though this page never rendered a form. - const open = await registrationAvailable(); + // With password sign-in off, accounts come from the single sign-on provider. + const open = passwordLoginEnabled() && (await registrationAvailable()); return (
diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts new file mode 100644 index 0000000..bfa24fc --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { cookies, headers } from "next/headers"; +import { env } from "@/lib/env"; +import { logger } from "@/lib/logger"; +import { clientAddress } from "@/lib/client-address"; +import { safeEqual } from "@/lib/auth"; +import { RATE_LIMITS, rateLimit } from "@/lib/rate-limit"; +import { OIDC_FLOW_COOKIE, decodeFlow, oidcConfig, oidcRedirectUri } from "@/lib/oidc"; +import { OidcFlowError, accountFor, completeSignIn } from "@/server/oidc"; +import { durableRateLimitOrFallback } from "@/server/durable-rate-limit"; +import { startSession } from "@/server/session"; +import { prisma } from "@/lib/db"; + +/** + * Where the provider sends the browser back. Every failure ends on the login + * page with a code it can explain; the detail goes to the log, not the page. + */ +export async function GET(request: NextRequest) { + const { APP_URL, TRUSTED_PROXY_HOPS } = env(); + const fail = (code: string) => NextResponse.redirect(new URL(`/login?error=${code}`, APP_URL)); + + const store = await cookies(); + const flow = decodeFlow(store.get(OIDC_FLOW_COOKIE)?.value); + // Single use, whatever happens next. + store.delete({ name: OIDC_FLOW_COOKIE, path: "/api/auth/oidc" }); + + const config = oidcConfig(); + if (!config) return fail("ssoUnavailable"); + + const key = `sso:${clientAddress(await headers(), TRUSTED_PROXY_HOPS)}`; + const limit = await durableRateLimitOrFallback(key, RATE_LIMITS.sso.limit, RATE_LIMITS.sso.windowMs, rateLimit(key, RATE_LIMITS.sso.limit, RATE_LIMITS.sso.windowMs)); + if (!limit.allowed) return fail("rateLimited"); + + const params = request.nextUrl.searchParams; + const state = params.get("state"); + const code = params.get("code"); + if (params.get("error")) { + logger.warn("Single sign-on refused by provider", { error: params.get("error") ?? "" }); + return fail("ssoFailed"); + } + if (!flow || !state || !code || !safeEqual(state, flow.state)) { + logger.warn("Single sign-on callback with missing or mismatched state"); + return fail("ssoFailed"); + } + + try { + const identity = await completeSignIn(config, flow, code, oidcRedirectUri(APP_URL)); + const account = await accountFor(config, identity); + await startSession(account.id, { oidcIdToken: identity.idToken }); + const profile = await prisma.userProfile.findUnique({ where: { userId: account.id }, select: { onboardedAt: true } }); + return NextResponse.redirect(new URL(profile?.onboardedAt ? "/" : "/onboarding", APP_URL)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + if (error instanceof OidcFlowError) { + logger.warn("Single sign-on refused", { code: error.code, reason }); + return fail(error.code); + } + logger.error("Single sign-on failed", { reason }); + return fail("ssoFailed"); + } +} diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts new file mode 100644 index 0000000..9b8e787 --- /dev/null +++ b/src/app/api/auth/oidc/login/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { env } from "@/lib/env"; +import { logger } from "@/lib/logger"; +import { OIDC_FLOW_COOKIE, OIDC_FLOW_TTL_MS, authorizationUrl, createOidcFlow, encodeFlow, oidcConfig, oidcRedirectUri } from "@/lib/oidc"; +import { securityCookieOptions } from "@/lib/auth"; +import { discover } from "@/server/oidc"; + +/** + * Starts a single sign-on: state, nonce and a PKCE verifier go into a + * short-lived HTTP-only cookie, and the browser goes to the provider. + */ +export async function GET() { + const { APP_URL } = env(); + const config = oidcConfig(); + if (!config) return NextResponse.redirect(new URL("/login", APP_URL)); + + let endpoint: string; + try { + endpoint = (await discover(config)).authorization_endpoint; + } catch (error) { + logger.warn("Single sign-on provider unreachable", { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.redirect(new URL("/login?error=ssoUnavailable", APP_URL)); + } + + const flow = createOidcFlow(); + const response = NextResponse.redirect(authorizationUrl(endpoint, config, flow, oidcRedirectUri(APP_URL))); + response.headers.set("Cache-Control", "no-store"); + response.cookies.set(OIDC_FLOW_COOKIE, encodeFlow(flow), { + ...securityCookieOptions(new Date(Date.now() + OIDC_FLOW_TTL_MS), APP_URL), + path: "/api/auth/oidc", + }); + return response; +} diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx index fcf9a9d..ad58016 100644 --- a/src/app/invite/[token]/page.tsx +++ b/src/app/invite/[token]/page.tsx @@ -1,6 +1,7 @@ import { getTranslations } from "next-intl/server"; import { redeemableInvitation } from "@/server/admin"; import { acceptInvitationAction } from "@/server/admin-actions"; +import { oidcConfig, passwordLoginEnabled } from "@/lib/oidc"; export async function generateMetadata() { const t = await getTranslations("account"); @@ -16,8 +17,10 @@ export default async function InvitationPage({ }) { const { token } = await params; const t = await getTranslations("account"); + const auth = await getTranslations("auth"); const invitation = await redeemableInvitation(token); const { error } = await searchParams; + const sso = oidcConfig(); return (
@@ -29,18 +32,29 @@ export default async function InvitationPage({ <>

{t("inviteIntro", { email: invitation.email })}

{error ?
{t("inviteError")}
: null} -
- -
- - -
-
- - -
- -
+ {sso ? ( + <> + {/* The invitation is honoured on the first single sign-on with its email. */} +

{t("inviteSso", { provider: sso.providerName, email: invitation.email })}

+ + {auth("signInWith", { provider: sso.providerName })} + + + ) : null} + {passwordLoginEnabled() ? ( +
+ +
+ + +
+
+ + +
+ +
+ ) : null} )} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index b515714..26d9496 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -26,4 +26,10 @@ export async function register() { console.error(`\n${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); } + + /* Sign-in settings that would silently not do what was asked. These warn + rather than exit: each one already falls back to a configuration that + still lets people sign in. */ + const { authConfigurationProblems } = await import("@/lib/oidc-config"); + for (const problem of authConfigurationProblems()) console.warn(`[auth] ${problem}`); } diff --git a/src/lib/oidc-config.ts b/src/lib/oidc-config.ts new file mode 100644 index 0000000..792e9ff --- /dev/null +++ b/src/lib/oidc-config.ts @@ -0,0 +1,80 @@ +/** + * Single sign-on settings. Free of Node-only imports so the instrumentation + * hook, which is also compiled for the edge runtime, can read them. + */ + +const readBool = (value: string | undefined, fallback: boolean) => + value === undefined || value.trim() === "" ? fallback : value.trim() === "true" || value.trim() === "1"; + +export interface OidcConfig { + /** Issuer exactly as the provider states it, trailing slash and all. */ + issuer: string; + clientId: string; + clientSecret: string; + scopes: string; + /** What the sign-in button calls the provider. */ + providerName: string; + /** Create an account on first sign-in when no account has the email. */ + autoCreate: boolean; + /** Refuse an email the provider does not vouch for. */ + requireVerifiedEmail: boolean; + /** End the provider's session on sign-out as well (RP-initiated logout). */ + singleLogout: boolean; +} + +/** + * The OIDC settings, or null when single sign-on is not configured. + * + * Read on its own rather than through `env()` for the same reason as + * `registrationMode()`: the login page has to be able to answer "is SSO on?" + * without every other setting being valid. A half-configured provider counts + * as off - with a warning from `authConfigurationProblems` - rather than as a + * button that fails on click. + */ +export function oidcConfig(source: Record = process.env): OidcConfig | null { + if (!readBool(source.OIDC_ENABLED, false)) return null; + const issuer = source.OIDC_ISSUER?.trim(); + const clientId = source.OIDC_CLIENT_ID?.trim(); + const clientSecret = source.OIDC_CLIENT_SECRET?.trim(); + if (!issuer || !clientId || !clientSecret) return null; + try { + new URL(issuer); + } catch { + return null; + } + return { + issuer, + clientId, + clientSecret, + scopes: source.OIDC_SCOPES?.trim() || "openid email profile", + providerName: source.OIDC_PROVIDER_NAME?.trim() || "authentik", + autoCreate: readBool(source.OIDC_AUTO_CREATE, false), + requireVerifiedEmail: readBool(source.OIDC_REQUIRE_VERIFIED_EMAIL, true), + singleLogout: readBool(source.OIDC_SINGLE_LOGOUT, true), + }; +} + +/** + * Whether the email + password form is offered. + * + * `AUTH_PASSWORD_LOGIN=false` turns it off - but only when single sign-on is + * actually configured. Without a working provider that switch would leave + * nobody able to sign in, so it is ignored (and reported) instead. Even when + * it is honoured, administrators keep a break-glass password sign-in at + * `/login?local=1` for the day the provider is down. + */ +export function passwordLoginEnabled(source: Record = process.env): boolean { + if (readBool(source.AUTH_PASSWORD_LOGIN, true)) return true; + return oidcConfig(source) === null; +} + +/** Start-up warnings about the sign-in configuration. Never throws. */ +export function authConfigurationProblems(source: Record = process.env): string[] { + const problems: string[] = []; + const wantsOidc = readBool(source.OIDC_ENABLED, false); + if (wantsOidc && !oidcConfig(source)) + problems.push("OIDC_ENABLED is true but OIDC_ISSUER, OIDC_CLIENT_ID or OIDC_CLIENT_SECRET is missing or invalid; single sign-on is off."); + if (!readBool(source.AUTH_PASSWORD_LOGIN, true) && !oidcConfig(source)) + problems.push("AUTH_PASSWORD_LOGIN is false but single sign-on is not configured; password sign-in stays enabled so the instance cannot lock everyone out."); + return problems; +} diff --git a/src/lib/oidc.test.ts b/src/lib/oidc.test.ts new file mode 100644 index 0000000..16586da --- /dev/null +++ b/src/lib/oidc.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { verifyPassword } from "./auth"; +import { + SSO_ONLY_PASSWORD_HASH, + authConfigurationProblems, + authorizationUrl, + baseUsername, + createOidcFlow, + decodeFlow, + encodeFlow, + endSessionUrl, + oidcConfig, + passwordLoginEnabled, + pkceChallenge, + provisionDecision, + usernameCandidates, +} from "./oidc"; + +const configured = { + OIDC_ENABLED: "true", + OIDC_ISSUER: "https://auth.example.com/application/o/nutricore/", + OIDC_CLIENT_ID: "nutricore", + OIDC_CLIENT_SECRET: "s3cret", +}; + +describe("OIDC configuration", () => { + it("is off unless switched on and complete", () => { + expect(oidcConfig({})).toBeNull(); + expect(oidcConfig({ ...configured, OIDC_ENABLED: "false" })).toBeNull(); + expect(oidcConfig({ ...configured, OIDC_CLIENT_SECRET: "" })).toBeNull(); + expect(oidcConfig({ ...configured, OIDC_ISSUER: "not a url" })).toBeNull(); + }); + + it("reads defaults that suit authentik", () => { + expect(oidcConfig(configured)).toEqual({ + issuer: configured.OIDC_ISSUER, + clientId: "nutricore", + clientSecret: "s3cret", + scopes: "openid email profile", + providerName: "authentik", + autoCreate: false, + requireVerifiedEmail: true, + singleLogout: true, + }); + }); + + it("reads the optional switches", () => { + const config = oidcConfig({ ...configured, OIDC_AUTO_CREATE: "true", OIDC_REQUIRE_VERIFIED_EMAIL: "false", OIDC_SINGLE_LOGOUT: "0" }); + expect(config).toMatchObject({ autoCreate: true, requireVerifiedEmail: false, singleLogout: false }); + }); +}); + +describe("password sign-in switch", () => { + it("is on by default", () => { + expect(passwordLoginEnabled({})).toBe(true); + expect(passwordLoginEnabled(configured)).toBe(true); + }); + + it("turns off only when single sign-on can take over", () => { + expect(passwordLoginEnabled({ ...configured, AUTH_PASSWORD_LOGIN: "false" })).toBe(false); + // No provider: ignoring the switch is what keeps the instance reachable. + expect(passwordLoginEnabled({ AUTH_PASSWORD_LOGIN: "false" })).toBe(true); + expect(authConfigurationProblems({ AUTH_PASSWORD_LOGIN: "false" })).toHaveLength(1); + }); + + it("reports a half-configured provider", () => { + expect(authConfigurationProblems({ OIDC_ENABLED: "true" })).toHaveLength(1); + expect(authConfigurationProblems({ ...configured, AUTH_PASSWORD_LOGIN: "false" })).toEqual([]); + }); +}); + +describe("authorization request", () => { + it("carries state, nonce and an S256 PKCE challenge", () => { + const config = oidcConfig(configured)!; + const flow = createOidcFlow(); + const url = new URL(authorizationUrl("https://auth.example.com/application/o/authorize/", config, flow, "https://nc.example.com/api/auth/oidc/callback")); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe("nutricore"); + expect(url.searchParams.get("scope")).toBe("openid email profile"); + expect(url.searchParams.get("state")).toBe(flow.state); + expect(url.searchParams.get("nonce")).toBe(flow.nonce); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBe(pkceChallenge(flow.verifier)); + expect(url.searchParams.get("code_challenge")).not.toBe(flow.verifier); + }); + + it("matches the RFC 7636 test vector", () => { + expect(pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); + + it("round-trips the flow cookie and rejects tampered values", () => { + const flow = createOidcFlow(); + expect(decodeFlow(encodeFlow(flow))).toEqual(flow); + expect(decodeFlow(undefined)).toBeNull(); + expect(decodeFlow("garbage")).toBeNull(); + expect(decodeFlow(Buffer.from(JSON.stringify({ state: 1 })).toString("base64url"))).toBeNull(); + }); + + it("builds the single-logout URL with the ID token hint", () => { + const config = oidcConfig(configured)!; + const url = new URL(endSessionUrl("https://auth.example.com/application/o/nutricore/end-session/", config, "id.token", "https://nc.example.com/login")); + expect(url.searchParams.get("id_token_hint")).toBe("id.token"); + expect(url.searchParams.get("post_logout_redirect_uri")).toBe("https://nc.example.com/login"); + }); +}); + +describe("account provisioning policy", () => { + const base = { hasInvitation: false, userCount: 3, registrationMode: "bootstrap" as const, autoCreate: false }; + + it("refuses an unknown email when automatic creation is off", () => { + expect(provisionDecision(base)).toEqual({ create: false }); + }); + + it("creates a member when automatic creation is on", () => { + expect(provisionDecision({ ...base, autoCreate: true })).toEqual({ create: true, role: "USER", viaInvitation: false }); + }); + + it("honours an open invitation with its role, whatever the switch says", () => { + expect(provisionDecision({ ...base, hasInvitation: true, invitationRole: "ADMIN" })).toEqual({ create: true, role: "ADMIN", viaInvitation: true }); + }); + + it("lets the first sign-in on an empty instance become its administrator", () => { + expect(provisionDecision({ ...base, userCount: 0 })).toEqual({ create: true, role: "ADMIN", viaInvitation: false }); + expect(provisionDecision({ ...base, userCount: 0, registrationMode: "disabled" })).toEqual({ create: false }); + }); +}); + +describe("usernames from the provider", () => { + it("prefers the provider's username and strips what NutriCore does not allow", () => { + expect(baseUsername("Jörg Müller", "x@example.com")).toBe("JorgMuller"); + expect(baseUsername(undefined, "jane.doe+food@example.com")).toBe("jane.doefood"); + expect(baseUsername("ab", "a@example.com")).toMatch(/^[a-zA-Z0-9._-]{3,40}$/); + }); + + it("offers numbered alternatives within the length limit", () => { + const names = [...usernameCandidates("x".repeat(31))]; + expect(names[0]).toBe("x".repeat(31)); + expect(names[1]).toBe(`${"x".repeat(31)}-2`); + for (const name of names) expect(name).toMatch(/^[a-zA-Z0-9._-]{3,40}$/); + }); +}); + +describe("SSO-only accounts", () => { + it("cannot sign in with any password", async () => { + expect(await verifyPassword(SSO_ONLY_PASSWORD_HASH, "")).toBe(false); + expect(await verifyPassword(SSO_ONLY_PASSWORD_HASH, SSO_ONLY_PASSWORD_HASH)).toBe(false); + }); +}); diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000..0686641 --- /dev/null +++ b/src/lib/oidc.ts @@ -0,0 +1,137 @@ +/** + * Single sign-on through an OpenID Connect provider - written against + * authentik, but nothing here is authentik-specific: any provider that + * publishes `/.well-known/openid-configuration`, issues signed ID tokens and + * supports the authorization-code flow with PKCE works the same way. + * + * Everything in this module is pure so that it can be tested without a + * provider. The network half lives in `src/server/oidc.ts`. + */ + +import { createHash, randomBytes } from "node:crypto"; +import type { OidcConfig } from "./oidc-config"; + +export { authConfigurationProblems, oidcConfig, passwordLoginEnabled, type OidcConfig } from "./oidc-config"; + +/** The cookie that carries state, nonce and PKCE verifier across the redirect. */ +export const OIDC_FLOW_COOKIE = "nutricore_oidc"; +export const OIDC_FLOW_TTL_MS = 10 * 60 * 1000; + +export interface OidcFlow { + state: string; + nonce: string; + verifier: string; +} + +const random = () => randomBytes(32).toString("base64url"); + +export function createOidcFlow(): OidcFlow { + return { state: random(), nonce: random(), verifier: random() }; +} + +/** RFC 7636 S256 challenge. */ +export const pkceChallenge = (verifier: string) => createHash("sha256").update(verifier).digest("base64url"); + +export const encodeFlow = (flow: OidcFlow) => Buffer.from(JSON.stringify(flow)).toString("base64url"); + +export function decodeFlow(value: string | undefined): OidcFlow | null { + if (!value) return null; + try { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (typeof parsed?.state === "string" && typeof parsed?.nonce === "string" && typeof parsed?.verifier === "string") + return { state: parsed.state, nonce: parsed.nonce, verifier: parsed.verifier }; + } catch { + /* fall through */ + } + return null; +} + +/** Where the provider sends the browser back to. Must be registered with it. */ +export const oidcRedirectUri = (appUrl: string) => new URL("/api/auth/oidc/callback", appUrl).toString(); + +/** Where the provider sends the browser after single logout. */ +export const postLogoutRedirectUri = (appUrl: string) => new URL("/login", appUrl).toString(); + +export function authorizationUrl(endpoint: string, config: OidcConfig, flow: OidcFlow, redirectUri: string) { + const url = new URL(endpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", config.scopes); + url.searchParams.set("state", flow.state); + url.searchParams.set("nonce", flow.nonce); + url.searchParams.set("code_challenge", pkceChallenge(flow.verifier)); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export function endSessionUrl(endpoint: string, config: OidcConfig, idToken: string | null, redirectUri: string) { + const url = new URL(endpoint); + if (idToken) url.searchParams.set("id_token_hint", idToken); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("post_logout_redirect_uri", redirectUri); + return url.toString(); +} + +/** + * A username in NutriCore's alphabet (`[a-zA-Z0-9._-]{3,40}`) from whatever + * the provider offers, preferring its own username, then the email's local + * part. At most 31 characters, so the longest suffix `usernameCandidates` + * adds still fits. Uniqueness is the caller's job. + */ +export function baseUsername(preferred: string | undefined, email: string): string { + const clean = (value: string) => + value + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-zA-Z0-9._-]+/g, "") + .slice(0, 31); + let name = clean(preferred ?? ""); + if (name.length < 3) name = clean(email.split("@")[0] ?? ""); + if (name.length < 3) name = `user${name}`.padEnd(3, "0"); + return name; +} + +/** The base name first, then numbered variants - all within the 40-character limit. */ +export function* usernameCandidates(base: string, attempts = 20): Generator { + yield base; + for (let i = 2; i <= attempts; i++) yield `${base}-${i}`; + yield `${base}-${randomBytes(4).toString("hex")}`; +} + +/** + * The password hash an SSO-created account is given. It is not a valid + * Argon2 hash, so `verifyPassword` rejects every password against it: the + * account can only sign in through the provider until an administrator sets + * a password. + */ +export const SSO_ONLY_PASSWORD_HASH = "!sso-only"; + +/** + * What to do with a verified sign-in whose subject and email match no linked + * account. Kept pure so the policy reads in one place. + * + * - an open invitation for the email is honoured, with its role; + * - an instance with no account at all lets the first SSO user become its + * administrator, exactly as the first password registration would; + * - otherwise `OIDC_AUTO_CREATE` decides. + */ +export type ProvisionDecision = { create: true; role: "USER" | "ADMIN"; viaInvitation: boolean } | { create: false }; + +export function provisionDecision(input: { + hasInvitation: boolean; + invitationRole?: "USER" | "ADMIN"; + userCount: number; + registrationMode: "bootstrap" | "open" | "disabled"; + autoCreate: boolean; +}): ProvisionDecision { + if (input.hasInvitation) return { create: true, role: input.invitationRole ?? "USER", viaInvitation: true }; + if (input.userCount === 0) return input.registrationMode === "disabled" ? { create: false } : { create: true, role: "ADMIN", viaInvitation: false }; + if (input.autoCreate) return { create: true, role: "USER", viaInvitation: false }; + return { create: false }; +} + +/** The error codes the login page knows how to explain. */ +export const OIDC_ERRORS = ["ssoFailed", "ssoNoAccount", "ssoUnverified", "ssoInactive", "ssoConflict", "ssoUnavailable"] as const; +export type OidcError = (typeof OIDC_ERRORS)[number]; +export const isOidcError = (value: unknown): value is OidcError => OIDC_ERRORS.includes(value as OidcError); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 14d2eb5..4e65679 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -63,6 +63,9 @@ export const RATE_LIMITS = { because ten wrong passwords for one account is already a lot. */ loginAccount: scaled(8, 15 * 60 * 1000), register: scaled(5, 60 * 60 * 1000), + /* Single sign-on callbacks. Each one costs a token exchange with the + provider, so they are bounded per address like a password attempt. */ + sso: scaled(20, 15 * 60 * 1000), search: scaled(120, 60 * 1000), research: scaled(10, 60 * 60 * 1000), export: scaled(5, 60 * 60 * 1000), diff --git a/src/lib/security-headers.test.ts b/src/lib/security-headers.test.ts index 2dc2c3d..2a36844 100644 --- a/src/lib/security-headers.test.ts +++ b/src/lib/security-headers.test.ts @@ -38,6 +38,12 @@ describe("content security policy", () => { expect(csp).toContain("base-uri 'self'"); expect(csp).toContain("object-src 'none'"); }); + + it("lets sign-out redirect to the single sign-on provider, and only to it", () => { + expect(contentSecurityPolicy({ ...options, ssoOrigin: "https://auth.example.com" })).toContain( + "form-action 'self' https://auth.example.com;", + ); + }); }); describe("security headers", () => { diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts index 526de6e..f935d1e 100644 --- a/src/lib/security-headers.ts +++ b/src/lib/security-headers.ts @@ -20,9 +20,14 @@ export interface HeaderOptions { https: boolean; /** `next dev` needs `unsafe-eval` for hot reloading; production must not have it. */ development: boolean; + /** + * Origin of the single sign-on provider, when one is configured. Sign-out is + * a form post that may redirect there, and `form-action` covers redirects. + */ + ssoOrigin?: string; } -export function contentSecurityPolicy({ nonce, development }: HeaderOptions): string { +export function contentSecurityPolicy({ nonce, development, ssoOrigin }: HeaderOptions): string { return [ "default-src 'self'", /* `strict-dynamic` lets the nonced Next bootstrap load the chunks it needs @@ -50,7 +55,7 @@ export function contentSecurityPolicy({ nonce, development }: HeaderOptions): st "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", - "form-action 'self'", + `form-action 'self'${ssoOrigin ? ` ${ssoOrigin}` : ""}`, // Clickjacking defence; the modern replacement for X-Frame-Options. "frame-ancestors 'none'", ].join("; "); diff --git a/src/middleware.ts b/src/middleware.ts index a17ac4e..54e4797 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -25,6 +25,7 @@ export function middleware(request: NextRequest) { nonce, https: (process.env.APP_URL ?? "").startsWith("https://"), development: process.env.NODE_ENV !== "production", + ssoOrigin: ssoOrigin(), }); const requestHeaders = new Headers(request.headers); @@ -37,13 +38,24 @@ export function middleware(request: NextRequest) { return response; } +/** The provider's origin when single sign-on is switched on. */ +function ssoOrigin() { + if (process.env.OIDC_ENABLED !== "true" && process.env.OIDC_ENABLED !== "1") return undefined; + try { + return new URL(process.env.OIDC_ISSUER ?? "").origin; + } catch { + return undefined; + } +} + /** The password-change gate, which decides what kind of response this is. */ function gate(request: NextRequest, requestHeaders: Headers) { const next = () => NextResponse.next({ request: { headers: requestHeaders } }); if (request.cookies.get("nutricore_password_change")?.value !== "1") return next(); const { pathname } = request.nextUrl; - const allowed = pathname === "/change-password" || pathname === "/login" || pathname.startsWith("/_next/"); + const allowed = + pathname === "/change-password" || pathname === "/login" || pathname.startsWith("/api/auth/oidc/") || pathname.startsWith("/_next/"); return allowed ? next() : NextResponse.redirect(new URL("/change-password", request.url)); } diff --git a/src/server/admin-actions.ts b/src/server/admin-actions.ts index 88c15ad..fcc6683 100644 --- a/src/server/admin-actions.ts +++ b/src/server/admin-actions.ts @@ -18,6 +18,7 @@ import { ENRICHMENT_BATCH_LIMIT, ENRICHMENT_RETRY_MS, ENRICHMENT_SCAN_LIMIT, mis import { AI_JOB_OPERATIONS, AI_JOB_REQUEUE_DATA, AI_JOB_SELECTION_OPERATIONS, jobPriority, STUCK_RUNNING_MS, type AiJobOperation } from "./ai-types"; import { discardMealInputImages } from "./meal-image"; import { DATASET_KEYS, importAllDatasets } from "./food-datasets/import"; +import { passwordLoginEnabled } from "@/lib/oidc"; /** * The token still travels back so an administrator can copy it when mail is @@ -396,6 +397,8 @@ export async function acceptInvitationAction(formData: FormData) { if (!limit.allowed) redirect(`/invite/${encodeURIComponent(token)}?error=rateLimited`); const invitation = await redeemableInvitation(token); if (!invitation) redirect(`/invite/${encodeURIComponent(token)}?error=invalid`); + // Invitations are redeemed by signing in through the provider instead. + if (!passwordLoginEnabled()) redirect(`/invite/${encodeURIComponent(token)}`); const username = String(formData.get("username")).trim(); const password = String(formData.get("password")); diff --git a/src/server/auth-actions.ts b/src/server/auth-actions.ts index 6cc00f4..151629a 100644 --- a/src/server/auth-actions.ts +++ b/src/server/auth-actions.ts @@ -13,6 +13,8 @@ import { logger } from "@/lib/logger"; import { endSession, startSession } from "./session"; import { RegistrationClosedError, createSelfRegisteredUser } from "./registration"; import { durableRateLimitOrFallback } from "./durable-rate-limit"; +import { endSessionUrl, oidcConfig, passwordLoginEnabled, postLogoutRedirectUri } from "@/lib/oidc"; +import { discover } from "./oidc"; export interface AuthState { error?: string; @@ -81,12 +83,23 @@ export async function loginAction(_state: AuthState, formData: FormData): Promis return { error: "invalidCredentials" }; } + /* With password sign-in switched off, a password still works for + administrators - the break-glass for a provider that is down - and for + nobody else. Same answer as a wrong password, so the switch reveals + nothing about which accounts are administrators. */ + if (!passwordLoginEnabled() && user.role !== "ADMIN") { + logger.warn("Password sign-in refused: single sign-on only", { userId: user.id }); + return { error: "invalidCredentials" }; + } + await startSession(user.id); const profile = await prisma.userProfile.findUnique({ where: { userId: user.id } }); redirect(user.mustChangePassword ? "/change-password" : profile?.onboardedAt ? "/" : "/onboarding"); } export async function registerAction(_state: AuthState, formData: FormData): Promise { + // Accounts come from the provider when password sign-in is off. + if (!passwordLoginEnabled()) return { error: "registrationClosed" }; const limit = await durableLimit(`register:${await clientKey()}`, RATE_LIMITS.register); if (!limit.allowed) return { error: "rateLimited", seconds: limit.retryAfterSeconds }; @@ -131,6 +144,20 @@ export async function registerAction(_state: AuthState, formData: FormData): Pro } export async function logoutAction() { - await endSession(); + const { oidcIdToken } = await endSession(); + const config = oidcConfig(); + /* Single logout: a single sign-on session also ends at the provider, or the + next click on "Sign in with authentik" would sign straight back in. If the + provider cannot be reached the local session is still gone. */ + if (oidcIdToken && config?.singleLogout) { + let target: string | undefined; + try { + const endpoint = (await discover(config)).end_session_endpoint; + if (endpoint) target = endSessionUrl(endpoint, config, oidcIdToken, postLogoutRedirectUri(env().APP_URL)); + } catch (error) { + logger.warn("Single logout skipped: provider unreachable", { error: error instanceof Error ? error.message : String(error) }); + } + if (target) redirect(target); + } redirect("/login"); } diff --git a/src/server/oidc.ts b/src/server/oidc.ts new file mode 100644 index 0000000..8226c02 --- /dev/null +++ b/src/server/oidc.ts @@ -0,0 +1,239 @@ +import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTPayload } from "jose"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { registrationMode } from "@/lib/env"; +import { DEFAULT_LOCALE } from "@/i18n/locales"; +import { + SSO_ONLY_PASSWORD_HASH, + baseUsername, + provisionDecision, + usernameCandidates, + type OidcConfig, + type OidcError, + type OidcFlow, +} from "@/lib/oidc"; +import { BOOTSTRAP_LOCK_KEY } from "./registration"; + +/** + * The network half of single sign-on: discovery, the code exchange, ID token + * verification and turning a verified identity into a NutriCore account. + */ + +const discoverySchema = z.object({ + issuer: z.string(), + authorization_endpoint: z.string().url(), + token_endpoint: z.string().url(), + jwks_uri: z.string().url(), + userinfo_endpoint: z.string().url().optional(), + end_session_endpoint: z.string().url().optional(), +}); +export type Discovery = z.infer; + +const DISCOVERY_TTL_MS = 60 * 60 * 1000; +const TIMEOUT_MS = 10_000; +let discoveryCache: { issuer: string; at: number; value: Discovery } | undefined; +let jwksCache: { uri: string; set: ReturnType } | undefined; + +export class OidcFlowError extends Error { + constructor( + readonly code: OidcError, + message: string, + ) { + super(message); + this.name = "OidcFlowError"; + } +} + +/** The provider's metadata, cached for an hour. */ +export async function discover(config: OidcConfig): Promise { + if (discoveryCache && discoveryCache.issuer === config.issuer && Date.now() - discoveryCache.at < DISCOVERY_TTL_MS) + return discoveryCache.value; + + const url = new URL(".well-known/openid-configuration", config.issuer.endsWith("/") ? config.issuer : `${config.issuer}/`); + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: "application/json" } }); + if (!response.ok) throw new OidcFlowError("ssoUnavailable", `Discovery answered ${response.status}`); + const value = discoverySchema.parse(await response.json()); + // OIDC Discovery 4.3: the issuer in the document must be the one we asked. + // A missing or extra trailing slash in the setting is forgiven; the + // provider's own spelling is what ID tokens are then checked against. + const bare = (issuer: string) => issuer.replace(/\/+$/, ""); + if (bare(value.issuer) !== bare(config.issuer)) + throw new OidcFlowError("ssoUnavailable", `Issuer mismatch: configured ${config.issuer}, provider says ${value.issuer}`); + discoveryCache = { issuer: config.issuer, at: Date.now(), value }; + return value; +} + +const tokenSchema = z.object({ id_token: z.string(), access_token: z.string().optional() }); + +const claimsSchema = z.object({ + sub: z.string().min(1), + email: z.string().optional(), + email_verified: z.union([z.boolean(), z.string()]).optional(), + preferred_username: z.string().optional(), + name: z.string().optional(), +}); + +export interface VerifiedIdentity { + subject: string; + email: string; + emailVerified: boolean; + preferredUsername?: string; + name?: string; + idToken: string; +} + +/** Exchanges the authorization code and verifies what comes back. */ +export async function completeSignIn(config: OidcConfig, flow: OidcFlow, code: string, redirectUri: string): Promise { + const metadata = await discover(config); + + const basic = Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64"); + const response = await fetch(metadata.token_endpoint, { + method: "POST", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", authorization: `Basic ${basic}` }, + body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: flow.verifier }), + }); + if (!response.ok) throw new OidcFlowError("ssoFailed", `Token endpoint answered ${response.status}`); + const tokens = tokenSchema.parse(await response.json()); + + const payload = await verifyIdToken(config, metadata, tokens.id_token); + if (payload.nonce !== flow.nonce) throw new OidcFlowError("ssoFailed", "ID token nonce mismatch"); + + let claims = claimsSchema.parse(payload); + // The email may only be released through userinfo, depending on the provider's + // "include claims in id_token" setting. + if (!claims.email && metadata.userinfo_endpoint && tokens.access_token) { + const info = await fetch(metadata.userinfo_endpoint, { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { authorization: `Bearer ${tokens.access_token}`, accept: "application/json" }, + }); + if (info.ok) { + const extra = claimsSchema.parse(await info.json()); + // OIDC Core 5.3.2: userinfo `sub` must match the ID token's. + if (extra.sub !== claims.sub) throw new OidcFlowError("ssoFailed", "userinfo subject mismatch"); + claims = { ...claims, ...extra }; + } + } + + const email = claims.email?.trim().toLowerCase(); + if (!email || !z.email().safeParse(email).success) throw new OidcFlowError("ssoFailed", "Provider released no usable email address"); + + return { + subject: claims.sub, + email, + emailVerified: claims.email_verified === true || claims.email_verified === "true", + preferredUsername: claims.preferred_username, + name: claims.name, + idToken: tokens.id_token, + }; +} + +/** + * Asymmetric signatures are checked against the provider's JWKS. authentik + * falls back to HS256 with the client secret when a provider has no signing + * key selected, so that is accepted too - the secret is ours, which is what + * makes it a valid check. + */ +async function verifyIdToken(config: OidcConfig, metadata: Discovery, idToken: string): Promise { + const options = { issuer: metadata.issuer, audience: config.clientId, clockTolerance: 60 }; + try { + const { alg } = decodeProtectedHeader(idToken); + if (alg === "HS256" || alg === "HS384" || alg === "HS512") { + const { payload } = await jwtVerify(idToken, new TextEncoder().encode(config.clientSecret), { ...options, algorithms: [alg] }); + return payload; + } + if (!jwksCache || jwksCache.uri !== metadata.jwks_uri) + jwksCache = { uri: metadata.jwks_uri, set: createRemoteJWKSet(new URL(metadata.jwks_uri)) }; + const { payload } = await jwtVerify(idToken, jwksCache.set, { + ...options, + algorithms: ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"], + }); + return payload; + } catch (error) { + if (error instanceof OidcFlowError) throw error; + throw new OidcFlowError("ssoFailed", `ID token rejected: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * The account a verified identity signs in as - linked, matched by email, or + * created - or an `OidcFlowError` saying why there is none. + * + * Matching is by email, as configured in the provider, but only the first + * time: the account is then bound to the provider's `sub`, so a later email + * change in the provider cannot move the sign-in onto somebody else's account, + * and an account already bound to another subject is never taken over. + */ +export async function accountFor(config: OidcConfig, identity: VerifiedIdentity): Promise<{ id: string }> { + const linked = await prisma.user.findUnique({ where: { oidcSubject: identity.subject }, select: { id: true, active: true } }); + if (linked) { + if (!linked.active) throw new OidcFlowError("ssoInactive", "Linked account is deactivated"); + return linked; + } + + if (config.requireVerifiedEmail && !identity.emailVerified) + throw new OidcFlowError("ssoUnverified", "Provider did not mark the email as verified"); + + const existing = await prisma.user.findUnique({ where: { email: identity.email }, select: { id: true, active: true, oidcSubject: true } }); + if (existing) { + if (existing.oidcSubject) throw new OidcFlowError("ssoConflict", "Account with this email is linked to another identity"); + if (!existing.active) throw new OidcFlowError("ssoInactive", "Account is deactivated"); + // Conditional on still being unlinked, so two racing first sign-ins cannot both bind. + const bound = await prisma.user.updateMany({ where: { id: existing.id, oidcSubject: null }, data: { oidcSubject: identity.subject } }); + if (bound.count !== 1) throw new OidcFlowError("ssoConflict", "Account was linked concurrently"); + logger.info("Linked account to single sign-on identity", { userId: existing.id }); + return { id: existing.id }; + } + + const created = await provision(config, identity); + if (!created) throw new OidcFlowError("ssoNoAccount", "No account for this email and automatic creation is off"); + logger.info("Created account from single sign-on", { userId: created.id }); + return created; +} + +/** Creates the account under the same lock the bootstrap registration uses. */ +async function provision(config: OidcConfig, identity: VerifiedIdentity) { + return prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${BOOTSTRAP_LOCK_KEY})`; + + const userCount = await tx.user.count(); + const invitation = await tx.userInvitation.findFirst({ + where: { email: identity.email, acceptedAt: null, revokedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + }); + const decision = provisionDecision({ + hasInvitation: Boolean(invitation), + invitationRole: invitation?.role, + userCount, + registrationMode: registrationMode(), + autoCreate: config.autoCreate, + }); + if (!decision.create) return null; + + let username: string | undefined; + for (const candidate of usernameCandidates(baseUsername(identity.preferredUsername, identity.email))) { + if (!(await tx.user.findUnique({ where: { username: candidate }, select: { id: true } }))) { + username = candidate; + break; + } + } + if (!username) throw new OidcFlowError("ssoFailed", "Could not find a free username"); + + if (invitation) await tx.userInvitation.update({ where: { id: invitation.id }, data: { acceptedAt: new Date() } }); + + return tx.user.create({ + data: { + email: identity.email, + username, + passwordHash: SSO_ONLY_PASSWORD_HASH, + oidcSubject: identity.subject, + role: decision.role, + profile: { + create: { displayName: (invitation?.name || identity.name || username).slice(0, 80), language: DEFAULT_LOCALE }, + }, + }, + select: { id: true }, + }); + }); +} diff --git a/src/server/registration.ts b/src/server/registration.ts index 0fd7299..f27e643 100644 --- a/src/server/registration.ts +++ b/src/server/registration.ts @@ -27,7 +27,7 @@ import { DEFAULT_LOCALE } from "@/i18n/locales"; * it ends by rolling back, so a failed registration can never leave the next * one waiting for ever. */ -const BOOTSTRAP_LOCK_KEY = 4711001n; +export const BOOTSTRAP_LOCK_KEY = 4711001n; export class RegistrationClosedError extends Error { constructor() { diff --git a/src/server/session.ts b/src/server/session.ts index 464bdad..04206f4 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -85,29 +85,45 @@ export class UnauthorizedError extends Error { } } -export async function startSession(userId: string) { +/** + * `oidcIdToken` marks a single sign-on session. It is kept for the logout hint, + * and such a session skips the password-change gate: the person did not sign + * in with the temporary password that gate exists to retire. + */ +export async function startSession(userId: string, { oidcIdToken }: { oidcIdToken?: string } = {}) { const { token, tokenHash } = createSessionToken(); const expiresAt = new Date(Date.now() + SESSION_TTL_MS); - await prisma.session.create({ data: { userId, tokenHash, expiresAt } }); + await prisma.session.create({ data: { userId, tokenHash, expiresAt, oidcIdToken: oidcIdToken ?? null } }); (await cookies()).set(SESSION_COOKIE, token, securityCookieOptions(expiresAt)); const account = await prisma.user.findUnique({ where: { id: userId }, select: { mustChangePassword: true } }); // Same options as the session cookie. This one used to be written without // `secure`, which on an HTTPS deployment made it the one security cookie that // could travel in the clear. - if (account?.mustChangePassword) (await cookies()).set(PASSWORD_CHANGE_COOKIE, "1", securityCookieOptions(expiresAt)); + if (account?.mustChangePassword && !oidcIdToken) (await cookies()).set(PASSWORD_CHANGE_COOKIE, "1", securityCookieOptions(expiresAt)); // Opportunistic cleanup keeps the session table from growing unbounded. await prisma.session.deleteMany({ where: { userId, expiresAt: { lte: new Date() } } }); return token; } -export async function endSession() { +/** + * Ends the current session. Returns its single sign-on ID token, if it was a + * single sign-on session, so the caller can end the provider's session too. + */ +export async function endSession(): Promise<{ oidcIdToken: string | null }> { const store = await cookies(); const token = store.get(SESSION_COOKIE)?.value; - if (token) await prisma.session.deleteMany({ where: { tokenHash: hashSessionToken(token) } }); + let oidcIdToken: string | null = null; + if (token) { + const tokenHash = hashSessionToken(token); + const session = await prisma.session.findUnique({ where: { tokenHash }, select: { oidcIdToken: true } }); + oidcIdToken = session?.oidcIdToken ?? null; + await prisma.session.deleteMany({ where: { tokenHash } }); + } store.delete(SESSION_COOKIE); store.delete(PASSWORD_CHANGE_COOKIE); + return { oidcIdToken }; } /**