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
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 90 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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=<Client ID from the provider>
OIDC_CLIENT_SECRET=<Client Secret from the provider>
```

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
Expand Down
16 changes: 13 additions & 3 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions prisma/migrations/20261005120000_oidc_sso/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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])
Expand Down
47 changes: 41 additions & 6 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@ 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() {
const t = await getTranslations("auth");
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 (
<div className="auth-shell">
Expand All @@ -26,14 +36,39 @@ export default async function LoginPage() {
<div className="card">
<h1 style={{ fontSize: 21, margin: "0 0 4px" }}>{t("signInTitle")}</h1>
<p className="muted" style={{ margin: "0 0 18px", fontSize: 14 }}>
{t("signInSubtitle")}
{showPasswordForm || !sso ? t("signInSubtitle") : t("ssoOnlySubtitle", { provider: sso.providerName })}
</p>
<LoginForm />

{ssoError ? (
<div className="notice notice-error" role="alert" style={{ marginBottom: 14 }}>
<span className="notice-icon" aria-hidden="true">
!
</span>
<span>{ssoError === "rateLimited" ? t("errors.rateLimited", { seconds: 60 }) : t(`errors.${ssoError}`)}</span>
</div>
) : null}

{sso ? (
// A plain link, not a form: the route answers with a redirect to the provider.
<a href="/api/auth/oidc/login" className={showPasswordForm ? "btn btn-block" : "btn btn-primary btn-block"}>
{t("signInWith", { provider: sso.providerName })}
</a>
) : null}

{sso && showPasswordForm ? (
<p className="muted" style={{ textAlign: "center", margin: "16px 0 12px", fontSize: 13 }}>
{t("orPassword")}
</p>
) : null}

{showPasswordForm ? <LoginForm /> : null}
</div>

<p className="muted" style={{ textAlign: "center", marginTop: 16, fontSize: 14 }}>
{t("noAccount")} <Link href="/register">{t("signUp")}</Link>
</p>
{passwords ? (
<p className="muted" style={{ textAlign: "center", marginTop: 16, fontSize: 14 }}>
{t("noAccount")} <Link href="/register">{t("signUp")}</Link>
</p>
) : null}
</main>
</div>
);
Expand Down
4 changes: 3 additions & 1 deletion src/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 (
<div className="auth-shell">
Expand Down
Loading
Loading