Skip to content

feat(web): support a generic OIDC login provider via env vars - #430

Open
glifocat wants to merge 3 commits into
onecli:mainfrom
glifocat:feat/generic-oidc-provider
Open

feat(web): support a generic OIDC login provider via env vars#430
glifocat wants to merge 3 commits into
onecli:mainfrom
glifocat:feat/generic-oidc-provider

Conversation

@glifocat

Copy link
Copy Markdown
Contributor

I have read the CONTRIBUTING.md file.

YES

What kind of change does this PR introduce?

Feature. Discussed in #181.

What is the current behavior?

Self-hosted OAuth mode only works with Google (checked at v1.41.0,
fe0981f0). The provider is hardcoded in three places: the NextAuth config
only builds a Google provider
(apps/web/src/lib/auth/nextauth-config.ts), the sign-in call is
nextAuthSignIn("google") (apps/web/src/lib/auth/auth-provider.tsx), and
the startup check treats "no GOOGLE_CLIENT_ID" as misconfigured
(apps/web/src/proxy.ts). So a self-hoster with Keycloak, Authentik, Pocket
ID, or Dex cannot use their own identity provider, and fully offline
deployments cannot use multi-user mode at all (#181).

What is the new behavior?

Four new env vars configure a generic OIDC provider next to Google:

  • OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET: standard OIDC
    discovery. The provider is active when all three are set.
  • OIDC_PROVIDER_NAME: login button label, defaults to "SSO".

Behavior:

  • Google is unchanged and stays first when both providers are configured, so
    existing deployments see no difference.
  • The active provider id and label flow to the client through the existing
    runtime-config mechanism (entrypoint-written runtime-config.json in
    Docker, env fallback in local dev). The login button label follows
    OIDC_PROVIDER_NAME; Google keeps its branded button.
  • The oauth-misconfigured setup error now fires only when
    NEXTAUTH_SECRET is set and neither Google nor OIDC is configured.
  • Callback URL for IdP registration: <APP_URL>/api/auth/callback/oidc.
  • The provider sets checks: ["pkce", "state", "nonce"] explicitly. Without
    it, Auth.js 5.0.0-beta.30 builds the authorize URL with only
    code_challenge, and spec-strict IdPs reject the callback with
    invalid_state (I hit this with Pocket ID before adding it). Lenient IdPs
    accept either; strict ones need this.
  • The /setup-error page copy now describes both provider options instead
    of only the Google vars, matching the updated check.
  • New vars documented in .env.example and the README, and declared in
    turbo.json so turbo/no-undeclared-env-vars stays clean.

One known limitation, kept deliberately to keep the diff small: the login
page still renders a single button. When both Google and OIDC are configured
the button starts the Google flow; the OIDC flow is still reachable at
/api/auth/signin/oidc. If you would rather have one button per configured
provider I am happy to extend the login page in this PR or a follow-up.

How I verified it

  • pnpm check-types (apps/web) passes on the branch.
  • eslint --max-warnings 0 passes on all changed files.
  • Live end-to-end test against Pocket ID 2.10.0 (pnpm dev, OIDC-only
    config, no Google vars set): the login page renders "Continue with Pocket
    ID", the full flow completes (authorize, passkey login, callback, session),
    sign-out and re-login work, and the session sync upserts the user with the
    OIDC sub as external_auth_id and the IdP email (checked in Postgres).
    With NEXTAUTH_SECRET set and no provider configured, the setup-error
    page shows as expected.
  • Not tested: a non-localhost plain-http issuer (the test issuer was
    http://localhost; https issuers are the normal case) and the
    both-providers-configured combination beyond unit-level reasoning.

Additional context

  • Cloud edition is untouched: its runtime config pins the existing Google
    behavior, and the Cognito path does not go through this code.
  • Reverse-proxy deployments need NEXTAUTH_URL set to the external origin.
    The Docker image already ships AUTH_TRUST_HOST=true, so no code change
    was needed for proxy support; .env.example notes it.
  • docker/entrypoint.sh interpolates OIDC_PROVIDER_NAME into
    runtime-config.json the same way the existing fields are written, so a
    label containing a double quote would break the JSON. That matches the
    current pattern for the other fields; I can add escaping if you want it.

Assisted by Claude; reviewed and verified by a human before submission.

@glifocat
glifocat marked this pull request as ready for review July 18, 2026 13:13
@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a generic OIDC login provider (Keycloak, Authentik, Pocket ID, etc.) alongside the existing Google OAuth path, activated by three new env vars (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET). The active provider id and label flow through runtime-config.json (Docker) or env-var fallback (local dev) to the login button, and the oauth-misconfigured setup guard is updated to accept either provider.

  • Auth config (nextauth-config.ts): dynamically builds the NextAuth provider list; the OIDC entry uses checks: ["pkce","state","nonce"] to satisfy spec-strict IdPs.
  • Runtime config (runtime-config.ts, entrypoint.sh): extends the JSON blob with authProviderId/authProviderName; the entrypoint now sed-escapes OIDC_PROVIDER_NAME before interpolation, closing the previously-flagged JSON injection path.
  • Login UI (login-content.tsx): a single button renders the Google-branded style or a neutral labelled style depending on the active provider; the known limitation (only one button shown when both providers are configured) is acknowledged in the PR description.

Confidence Score: 5/5

Safe to merge — the OIDC path is additive, existing Google behavior is unchanged, and the previously-flagged JSON escaping issue in the entrypoint has been addressed.

The change is purely additive: new env vars enable a new provider, all three detection points (nextauth config, runtime config, setup-error guard) are updated consistently, and the entrypoint correctly escapes free-form input before writing JSON. No existing auth paths are modified.

No files require special attention.

Important Files Changed

Filename Overview
apps/web/src/lib/auth/nextauth-config.ts Replaces the single-provider conditional with a dynamic providers array; adds a generic OIDC provider with pkce/state/nonce checks when the three OIDC env vars are all present. Google stays first for back-compat.
docker/entrypoint.sh Adds authProviderId and authProviderName fields to the runtime-config.json write; OIDC_PROVIDER_NAME is now properly escaped for JSON via sed before interpolation, addressing the previously-flagged injection concern.
apps/web/src/lib/runtime-config.ts Extends RuntimeConfig with authProviderId/authProviderName; both the Docker file-parse path and the local-dev env-var fallback populate these fields correctly, with Google taking priority when both providers are configured.
apps/web/src/proxy.ts Updates the oauth-misconfigured check to accept either Google or OIDC as a valid provider; oidcConfigured is now properly boolean-cast with !!().
apps/web/src/lib/auth/login-content.tsx Renders a dynamic login button based on authProviderId/authProviderName from context; Google keeps its branded styling while any other OIDC provider gets a neutral button. Minor: className merging uses a raw ternary instead of cn().
apps/web/src/lib/auth/auth-provider.tsx Threads AuthProviderInfo through OAuthInner so nextAuthSignIn is called with the correct provider id; authProviderId/authProviderName are exposed on AuthContext for the login button.
apps/web/src/lib/auth/auth-mode.ts Adds AuthProviderInfo interface and getAuthProvider() helper that reads authProviderId/authProviderName from runtime config.
apps/web/src/lib/env.ts Adds OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (defaulting to ""), and OIDC_PROVIDER_NAME (defaulting to "SSO") from process.env.
turbo.json Declares the four new OIDC env vars so turbo/no-undeclared-env-vars stays clean.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User Browser
    participant N as Next.js (proxy)
    participant L as Login Page
    participant A as NextAuth /api/auth
    participant I as OIDC IdP

    U->>N: GET /auth/login
    N->>N: "getSetupError()<br/>(checks NEXTAUTH_SECRET + Google/OIDC)"
    N-->>L: render (authProviderId, authProviderName via runtime-config)
    L-->>U: "Continue with [providerName]" button

    U->>L: click sign-in
    L->>A: nextAuthSignIn("oidc")
    A->>I: "Authorization Request<br/>(PKCE + state + nonce)"
    I-->>U: IdP login UI

    U->>I: authenticate
    I->>A: "callback /api/auth/callback/oidc<br/>(code + state)"
    A->>I: token exchange (PKCE verify)
    I-->>A: id_token (nonce verified)
    A-->>U: session cookie (JWT strategy)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as User Browser
    participant N as Next.js (proxy)
    participant L as Login Page
    participant A as NextAuth /api/auth
    participant I as OIDC IdP

    U->>N: GET /auth/login
    N->>N: "getSetupError()<br/>(checks NEXTAUTH_SECRET + Google/OIDC)"
    N-->>L: render (authProviderId, authProviderName via runtime-config)
    L-->>U: "Continue with [providerName]" button

    U->>L: click sign-in
    L->>A: nextAuthSignIn("oidc")
    A->>I: "Authorization Request<br/>(PKCE + state + nonce)"
    I-->>U: IdP login UI

    U->>I: authenticate
    I->>A: "callback /api/auth/callback/oidc<br/>(code + state)"
    A->>I: token exchange (PKCE verify)
    I-->>A: id_token (nonce verified)
    A-->>U: session cookie (JWT strategy)
Loading

Reviews (2): Last reviewed commit: "fix: escape the OIDC provider label in r..." | Re-trigger Greptile

Comment thread docker/entrypoint.sh
Comment thread apps/web/src/proxy.ts Outdated
glifocat added 2 commits July 18, 2026 13:22
Adds OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and
OIDC_PROVIDER_NAME next to the existing Google provider. Google stays
first when both are configured. The provider sets explicit
pkce/state/nonce checks so spec-strict IdPs accept the flow, and the
setup-error page copy now covers both provider options.
@mrrobertkent

Copy link
Copy Markdown

Ran this branch against Authentik on a live self-hosted instance behind TLS (merged onto current main — no conflicts, 18 commits behind). It works: the login button renders with OIDC_PROVIDER_NAME, and the sign-in redirect is correct.

302 -> https://<authentik>/application/o/authorize/
   client_id             = <client id>
   redirect_uri          = https://<instance>/api/auth/callback/oidc
   scope                 = openid profile email
   code_challenge_method = S256

runtime-config.json carried the new fields through as expected:

{"authMode":"oauth","oauthConfigured":true,"authProviderId":"oidc","authProviderName":"Authentik"}

Two notes from the integration.

OIDC_ISSUER must keep the provider's trailing slash

Worth a line in .env.example, because the failure mode doesn't point at the config. openid-client compares the issuer claim in the discovery document against the configured value verbatim. Authentik publishes:

{"issuer": "https://<authentik>/application/o/<app-slug>/"}

Configure it without that trailing slash — which looks tidier, and matches how the value is usually written elsewhere — and sign-in dies at the callback with:

[auth][error] nP: "response" body "issuer" property does not match the expected value

With the slash, discovery is requested at .../<app-slug>//.well-known/openid-configuration. That doubled slash answers 301, which is followed transparently, so it is harmless — the URL looks wrong but works, while the tidy-looking form fails. Something like "use the issuer exactly as your provider publishes it, including any trailing slash" would save people the round trip.

On TLS, this needs a gateway-side fix too

Not a problem with this PR, but they interact. On an https deployment the browser's calls to the gateway (vault pair/status, approvals, cache invalidation) return 401, because validate_oauth matches the session cookie by exact name authjs.session-token while Auth.js sends __Secure-authjs.session-token whenever the resolved AUTH_URL/NEXTAUTH_URL is https — which it must be, since providers require an https redirect URI.

That is invisible today because self-hosters overwhelmingly run local mode, where the cookie is never inspected. This PR is what changes that: it brings non-Google IdPs, and therefore more TLS multi-user deployments, into the path. I opened #475 for the gateway side; with both applied, Authentik login and the vault panels work together.

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