Skip to content

Add optional single sign-on through OpenID Connect (authentik) - #128

Merged
MacNite merged 1 commit into
mainfrom
ccr-28b576c4-rdasjw
Oct 5, 2026
Merged

MacNite merged 1 commit into
mainfrom
ccr-28b576c4-rdasjw

Conversation

@MacNite

@MacNite MacNite commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Summary

Optional SSO through any OpenID Connect provider, written and documented for authentik. It is off by default, so an existing installation behaves exactly as before.

Decisions (from the questionnaire)

Open point Decision
Unknown email Configurable with OIDC_AUTO_CREATE (default false)
Disable password login Env switch AUTH_PASSWORD_LOGIN=false
Admin role from authentik groups No. Roles stay managed in NutriCore
When password login is off Registration hidden, password forms hidden, admin break-glass at /login?local=1, single logout

How it works

  • Flow: GET /api/auth/oidc/login → provider → GET /api/auth/oidc/callback. Uses the authorization-code flow with PKCE (S256), state and nonce. The flow cookie is HTTP-only and single-use, and the callback is rate limited.

  • Token verification: the ID token's signature is checked against the JWKS, or with HS256 and the client secret when authentik has no signing key set. Issuer, audience and expiry are checked too. If the ID token has no email, the userinfo endpoint is used, and its sub must match.

  • Matching: on the first sign-in the account is found by verified email (case-insensitive) and then bound to the provider's sub (new column User.oidcSubject). Changing the email on either side cannot move a sign-in onto another account. An account already bound to a different subject is refused (ssoConflict).

  • When no account has the email:

    • an open invitation is honoured, with its role;
    • on an empty instance the first account becomes admin, like the first password registration (unless REGISTRATION_MODE=disabled);
    • otherwise OIDC_AUTO_CREATE decides.

    Accounts created this way get an unusable password hash.

  • AUTH_PASSWORD_LOGIN=false: the password form, the sign-up page and the password form on invitation links are hidden. loginAction accepts only admins, and refuses everyone else with the same message as a wrong password. If SSO isn't fully configured, the switch is ignored and a warning is logged at start-up, so a typo can't lock everyone out.

  • Single logout: signing out of an SSO session redirects to the provider's end_session_endpoint with id_token_hint. The ID token is stored in the new column Session.oidcIdToken. The provider's origin is added to the CSP form-action.

  • An SSO session skips the password-change gate.

  • Docs: a new README section "Single sign-on" with authentik setup steps, new rows in the env table, and .env.example.

Migration

20261005120000_oidc_sso adds the two nullable columns plus a unique index. It only adds; nothing is rewritten.

Test plan

  • npm run lint, npm run typecheck, npm test: 1145 passed. New src/lib/oidc.test.ts covers config, the password switch, PKCE (RFC 7636 test vector), the flow cookie, the provisioning policy and usernames.
  • npm run build
  • End to end against a local PostgreSQL and a mock OIDC provider (RS256, PKCE check, client secret check):
    • first user on an empty instance → ADMIN, linked
    • unknown email → ssoNoAccount
    • existing password account → linked by email
    • same email, different sub → ssoConflict
    • email changed at the provider → still signs into the linked account
    • email_verified: false → ssoUnverified
    • invitation → account created and the invitation used up
    • deactivated account → ssoInactive
    • forged callback → ssoFailed
  • In Chromium (Playwright): SSO sign-in → sign out → provider end-session with id_token_hint → /login, with no CSP console errors
  • Try against a real authentik instance

Note for authentik

Since email decides which account a sign-in reaches, OIDC_REQUIRE_VERIFIED_EMAIL defaults to true. If your authentik email scope mapping reports email_verified: false, either change the mapping or set the flag to false. Only set it to false if users can't edit their own email in authentik (see the README).

🤖 Generated with Claude Code

https://claude.ai/code/session_01H9iRDbBMRtJipppd5fLRtL


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9iRDbBMRtJipppd5fLRtL
@MacNite
MacNite merged commit e97de44 into main Oct 5, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants