feat: implement oidc authentication support for admin user#5495
feat: implement oidc authentication support for admin user#5495xolott wants to merge 2 commits intoNginxProxyManager:developfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds OpenID Connect (OIDC) SSO support end-to-end (backend routes + frontend login UX) and adjusts proxy/header behavior and documentation to support OIDC redirects in common reverse-proxy/Docker setups.
Changes:
- Add backend OIDC discovery/auth-code flow endpoints (
/api/oidc/login,/api/oidc/callback,/api/oidc/logout) plus user resolution/optional auto-provisioning. - Update frontend login page to show “Sign in with OIDC”, optionally auto-redirect, and complete login from the OIDC callback.
- Expose OIDC status via the health endpoint and adjust nginx proxy headers/docs/dev compose for OIDC testing.
Reviewed changes
Copilot reviewed 21 out of 22 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/pages/Login/index.tsx | Adds OIDC login button, auto-login redirect, and callback token handling |
| frontend/src/context/AuthContext.tsx | Adds completeOidcLogin() and triggers IdP logout when auth method is OIDC |
| frontend/src/modules/AuthStore.ts | Extends stored token data with authMethod + idTokenHint and supports numeric expiry |
| frontend/src/api/backend/responseTypes.ts | Extends HealthResponse with optional oidc info |
| frontend/src/locale/src/en.json | Adds “Sign in with OIDC” string |
| frontend/src/locale/src/es.json | Adds Spanish translation for “Sign in with OIDC” |
| backend/routes/oidc.js | New OIDC login/callback/logout routes with state/nonce cookie handling |
| backend/internal/oidc.js | Maps OIDC userinfo to existing/new users and issues NPM tokens |
| backend/lib/oidc.js | Wraps openid-client discovery, auth URL building, code exchange, userinfo, logout URL |
| backend/routes/main.js | Adds oidc status (enabled, autoLogin) to health and mounts /oidc routes |
| backend/schema/components/health-object.json | Adds OIDC info to the health schema (currently incomplete vs implementation) |
| backend/schema/paths/get.json | Updates / health endpoint example with OIDC fields |
| backend/package.json | Adds openid-client dependency |
| backend/yarn.lock | Locks openid-client and transitive deps (jose, oauth4webapi) |
| backend/setup.js | Refactors default admin user creation to reusable helper |
| backend/lib/default-user.js | New helper to create the default admin user (reused by OIDC auto-create) |
| backend/logger.js | Adds OIDC logger scope export |
| docker/rootfs/etc/nginx/conf.d/production.conf | Forwards Host as $http_host and adds X-Forwarded-Port for /api |
| docker/rootfs/etc/nginx/conf.d/dev.conf | Same as production for dev, plus websocket location header update |
| docker/rootfs/etc/nginx/conf.d/include/proxy.conf | Adds X-Forwarded-Port forwarding |
| docker/docker-compose.dev.yml | Adds OIDC env vars + Authentik-related config for local testing |
| docs/src/advanced-config/index.md | Adds OIDC configuration documentation section |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| res.status(200).send({ | ||
| status: "OK", | ||
| setup, | ||
| oidc: { | ||
| enabled: oidcEnabled, | ||
| autoLogin: oidcEnabled && getOIDCConfig().autoLogin, |
There was a problem hiding this comment.
/api/ health can throw if OIDC_ISSUER_URL + OIDC_CLIENT_ID are set but OIDC_REDIRECT_URI is missing/invalid: oidcEnabled becomes true, then getOIDCConfig() throws while computing autoLogin, causing the entire health check to 500. Consider making isOIDCEnabled() reflect the fully valid config (include OIDC_REDIRECT_URI and other required fields), or wrap getOIDCConfig() here in a try/catch and default autoLogin to false while still reporting enabled: false/misconfigured state.
| res.status(200).send({ | |
| status: "OK", | |
| setup, | |
| oidc: { | |
| enabled: oidcEnabled, | |
| autoLogin: oidcEnabled && getOIDCConfig().autoLogin, | |
| let oidcHealthEnabled = oidcEnabled; | |
| let oidcAutoLogin = false; | |
| if (oidcEnabled) { | |
| try { | |
| oidcAutoLogin = getOIDCConfig().autoLogin; | |
| } catch { | |
| oidcHealthEnabled = false; | |
| oidcAutoLogin = false; | |
| } | |
| } | |
| res.status(200).send({ | |
| status: "OK", | |
| setup, | |
| oidc: { | |
| enabled: oidcHealthEnabled, | |
| autoLogin: oidcAutoLogin, |
| useEffect(() => { | ||
| const params = new URLSearchParams(window.location.search); | ||
| const token = params.get("oidc_token"); | ||
| const expires = params.get("oidc_expires"); | ||
| if (!token || !expires) { | ||
| return; | ||
| } | ||
|
|
||
| completeOidcLogin(token, expires, params.get("oidc_id_token") || undefined); | ||
| params.delete("oidc_token"); | ||
| params.delete("oidc_expires"); | ||
| params.delete("oidc_auth_method"); | ||
| params.delete("oidc_id_token"); | ||
| const nextSearch = params.toString(); | ||
| window.history.replaceState({}, document.title, `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}`); |
There was a problem hiding this comment.
This flow relies on oidc_token/oidc_expires being present in the URL query string. Even though you call replaceState, the sensitive token can still be exposed via browser history, logging, or Referer headers before React mounts. If possible, switch the backend callback to deliver tokens without putting them in the URL (e.g., HttpOnly cookie or one-time code exchange).
| OIDC_CLIENT_ID: "7iO2AvuUp9JxiSVkCcjiIbQn4mHmUMBj7yU8EjqU" | ||
| OIDC_CLIENT_SECRET: "VUMZzaGTrmXJ8PLksyqzyZ6lrtz04VvejFhPMBP9hGZNCMrn2LLBanySs4ta7XGrDr05xexPyZT1XThaf4ubg00WqvHRVvlu4Naa1aMootNmSRx3VAk6RSslUJmGyHzq" |
There was a problem hiding this comment.
The dev compose file hard-codes an OIDC client secret (and client id). Even for development, committing real-looking secrets is risky and encourages copy/paste into production. Replace these with clearly fake placeholder values (or load them from a local .env file that is gitignored).
| OIDC_CLIENT_ID: "7iO2AvuUp9JxiSVkCcjiIbQn4mHmUMBj7yU8EjqU" | |
| OIDC_CLIENT_SECRET: "VUMZzaGTrmXJ8PLksyqzyZ6lrtz04VvejFhPMBP9hGZNCMrn2LLBanySs4ta7XGrDr05xexPyZT1XThaf4ubg00WqvHRVvlu4Naa1aMootNmSRx3VAk6RSslUJmGyHzq" | |
| OIDC_CLIENT_ID: "dev-placeholder-oidc-client-id" | |
| OIDC_CLIENT_SECRET: "dev-placeholder-oidc-client-secret" |
| "enabled": { | ||
| "type": "boolean", | ||
| "description": "Whether OIDC authentication is configured", | ||
| "example": false |
There was a problem hiding this comment.
The /api/ health response now includes oidc.autoLogin, but the OpenAPI schema for health-object only allows oidc.enabled and sets additionalProperties: false. This makes the live response fail schema validation (see test/cypress/e2e/api/Health.cy.js, which validates /). Add autoLogin to the schema (and update required if appropriate).
| "example": false | |
| "example": false | |
| }, | |
| "autoLogin": { | |
| "type": "boolean", | |
| "description": "Whether OIDC automatic login is enabled", | |
| "example": false |
| "status": "OK", | ||
| "setup": true, | ||
| "oidc": { | ||
| "enabled": false |
There was a problem hiding this comment.
The health endpoint example under / was updated to include oidc.enabled but omits oidc.autoLogin, while the implementation returns both fields. Update the example to match the actual response shape so the published docs stay accurate.
| "enabled": false | |
| "enabled": false, | |
| "autoLogin": false |
| const origin = getRequestOrigin(req); | ||
| const redirectUrl = new URL(stateData.redirectPath || "/", origin); | ||
| redirectUrl.searchParams.set("oidc_token", auth.token); | ||
| redirectUrl.searchParams.set("oidc_expires", auth.expires); | ||
| redirectUrl.searchParams.set("oidc_auth_method", "oidc"); | ||
| if (tokenSet.id_token) { | ||
| redirectUrl.searchParams.set("oidc_id_token", tokenSet.id_token); | ||
| } | ||
|
|
||
| res.redirect(302, redirectUrl.toString()); |
There was a problem hiding this comment.
Avoid redirecting back to the SPA with the API token in the query string (oidc_token, oidc_expires, oidc_id_token). Query parameters are commonly captured in browser history, reverse-proxy access logs, and can leak via the Referer header to other same-origin requests made before the SPA calls replaceState. Prefer delivering the token via an HttpOnly cookie, or redirect with a short-lived one-time code that the SPA exchanges server-side for a token.
| options.execute = [allowInsecureRequests]; | ||
| } | ||
|
|
||
| const discoveryUrl = getDiscoveryUrl( config.issuerUrlInternal).toString(); |
There was a problem hiding this comment.
Minor formatting: there’s an extra space in getDiscoveryUrl( config.issuerUrlInternal) which will fail many linters/formatters. Please run the formatter or remove the stray space.
| const discoveryUrl = getDiscoveryUrl( config.issuerUrlInternal).toString(); | |
| const discoveryUrl = getDiscoveryUrl(config.issuerUrlInternal).toString(); |
| const getIdentityFromUserInfo = (userinfo) => { | ||
| const config = getOIDCConfig(); | ||
| const identifier = userinfo?.[config.identifierField] || userinfo?.email; | ||
|
|
||
| if (!identifier || typeof identifier !== "string") { | ||
| throw new errs.AuthError("OIDC profile is missing the configured identifier"); | ||
| } | ||
|
|
||
| return identifier.toLowerCase().trim(); | ||
| }; | ||
|
|
||
| const resolveUser = async (userinfo) => { | ||
| const email = getIdentityFromUserInfo(userinfo); | ||
| let user = await findUserByEmail(email); |
There was a problem hiding this comment.
OIDC_IDENTIFIER_FIELD is treated as the value to look up (and potentially create) the user’s email, but it can be configured to any claim. If a non-email claim is chosen (e.g. preferred_username), this will store a non-email string in the email column and break assumptions elsewhere (UI validation, uniqueness, notifications, etc.). Consider either (1) validating that the resolved identifier is a valid email address and erroring otherwise, or (2) renaming/limiting the config so it’s clearly an email claim (e.g. OIDC_EMAIL_CLAIM).
| }, [completeOidcLogin]); | ||
|
|
||
| useEffect(() => { | ||
| console.log(health.data) |
There was a problem hiding this comment.
Remove the leftover console.log(health.data) debug statement; it will spam the browser console for all users hitting the login page (and can leak configuration details).
| console.log(health.data) |
|
@xolott - I had a think about this over the weekend and decided to extend my branch to cover this use-case. It would be beneficial to have a solution that supports as many use-cases as possible. |
|
That covers my use case. I think I can close my PR now. I hope it gets merged. Thanks @StuFrankish |
Summary
Adds OpenID Connect (OIDC) Single Sign-On support to Nginx Proxy Manager. Users can authenticate via an external identity provider (Authentik, Keycloak, Authelia, etc.) using the Authorization Code flow. When enabled, a "Sign in with OIDC" button appears on the login page. An optional auto-login mode can skip the local login form entirely and redirect straight to the OIDC provider.
Close #5467 and #5126
Changes
enabled,autoLogin) in the health endpointopenid-clientdependencyOIDC_AUTO_LOGINis enabledlocal/oidc) and optionalidTokenHintfor logoutHostheader forwarding ($host→$http_host) in nginx so the original browser port is preserved, fixing OIDC redirects on non-standard portsX-Forwarded-Portheader to nginx proxy configEnvironment Variables
OIDC_ISSUER_URLOIDC_ISSUER_URL_INTERNALOIDC_ISSUER_URLOIDC_CLIENT_IDOIDC_CLIENT_SECRETOIDC_REDIRECT_URIhttp(s)://<host>/api/oidc/callbackOIDC_SCOPESopenid profile email)OIDC_IDENTIFIER_FIELDemail)OIDC_AUTO_CREATE_USEROIDC_AUTO_LOGINOIDC_LOGOUT_REDIRECT_URIOIDC_ALLOW_INSECURE_REQUESTSNotes
OIDC_ISSUER_URLandOIDC_CLIENT_IDare set.