feat(plugin-auth-oidc): sign in with any OpenID Connect provider
Summary
Adds @gauzy/plugin-auth-oidc, a generic, off by default OpenID Connect sign-in plugin. It works with any standards-compliant provider (Authentik, Authelia, Zitadel, Casdoor, Ory Hydra, Keycloak and others). When it is switched on and configured, the login page shows a "Sign in with " button.
The plugin sits on the existing OpenID Connect library in @gauzy/auth (OidcModule), the same library the Zitadel and Keycloak plugins use. The browser flow the Keycloak plugin ran is moved into a small shared service in that library. Keycloak now delegates to it, so the two plugins don't duplicate the flow.
Refs #42
Why
#42 asks for sign-in with any OpenID Connect provider, not only the ones Gauzy has a dedicated plugin for. Self-hosted installs often run Authentik, Authelia or another IdP and want their users to sign in with it. This adds that as a new sign-in method. Nothing about the existing sign-in methods changes.
What changed
@gauzy/auth (packages/auth/src/lib/oidc)
- New
OidcEmailSignInService: sign-in by verified e-mail address, built on the existing transaction, client and discovery services.startwrites the signed transaction cookie (PKCE verifier,state,nonce) and returns the authorize URL.completeaccepts the transaction once, and only for the issuer and flow that started it. It exchanges the code, validates the ID token and returns the address only whenemail_verifiedis exactlytrue.- Log lines carry the library's error code only, never a code, token or address.
- New opt-in
userinfoFallback: when the ID token carries no address, the service readsemailandemail_verifiedfrom the provider's userinfo endpoint. Zitadel and Authelia leave the address out of the ID token by default. The library accepts that answer only for the ID token's ownsub. OidcEmailSignInServiceis registered inOidcModuleand exported, with its types, from the package index.public-api.spec.tsis updated to match.README.md: one "Provider plugins" bullet for the new plugin.
@gauzy/plugin-auth-keycloak
KeycloakSignInServicenow delegates toOidcEmailSignInService.- Its public API, cookie name, scopes and redirects are unchanged.
- It does not turn on the userinfo fallback.
@gauzy/plugin-auth-oidc (new, packages/plugins/auth-oidc)
auth-oidc.config.ts: reads and validates the settings. It has no imports apart fromLogger, becauseapps/api/src/plugins.tscalls it when the file loads.AuthOidcSignInService: turns the settings into a flow for the shared service.AuthOidcController:@Public()routes under/api/auth/oidc:GET /configreturns{ enabled, displayName }and is never cached.GETstarts a sign-in.GET /callbackis where the provider returns.
- A verified address goes through the existing social sign-in path (
AuthService.validateOAuthLoginEmail, thenrouteRedirect), the same path the Google, GitHub and Microsoft sign-ins use.- When several workspaces match, the workspace picker opens.
- An unknown address goes to the register page. Nothing is provisioned automatically.
- The usual package files (project.json, jest, eslint, tsconfig, package.json with github.com URLs) and a README.
API app and deployment wiring
apps/api/src/plugins.ts:...(isOidcEnabled() ? [AuthOidcPlugin] : []), after Keycloak.apps/api/src/plugins.matrix.spec.ts: on/off cases, plus every combination of Ever ID, Keycloak and OIDC.- Dependencies and build scripts:
apps/api/package.jsonapps/server-api,apps/serverandapps/desktopsrc/package.json(file:dependency and resolution)- the root
package.jsonbuild lists andbuild:package:plugin:auth-oidc[:prod|:docker]scripts tsconfig.jsonpath
.deploy/api/Dockerfileand.deploy/worker/Dockerfile: copy the built package, as for Keycloak..env.sample:OIDC_*settings (off, empty) andOIDC_AUTH_LINK=for the web app.- Web app configuration:
.scripts/env.ts,.scripts/configure.ts(Docker placeholderDOCKER_OIDC_AUTH_LINK),.deploy/webapp/replacements.sedandui-configmodel.ts.
@gauzy/ui-auth and i18n
social-links.component: each sign-in plugin button now appears as soon as its own config check answers, so a slow check no longer holds back the others. The button order is unchanged.social-links.component: a third sign-in plugin button,oidc, after Ever ID and Keycloak.- It is shown only when
OIDC_AUTH_LINKis set andGET /api/auth/oidc/configreportsenabled: true. - The label is
LOGIN_PAGE.SIGN_IN_WITH_SSO("Sign in with {{ name }}"). The name comes from the API and is rendered as text, never markup.
- It is shown only when
LOGIN_PAGE.SIGN_IN_WITH_SSOadded to all 13 UI languages (ar, bg, de, en, es, fr, he, it, nl, pl, pt, ru, zh), each mirroring that language's "Sign in with Keycloak" string.
Flags, settings and defaults
| Setting | Default | Meaning |
|---|---|---|
OIDC_ENABLED | off | Loads the plugin only when exactly true. Unset, empty or false is silently off. Any other value logs a warning and stays off. |
OIDC_ISSUER | ā | Issuer URL exactly as the provider publishes it (a trailing slash is kept, for Authentik). Must be https://, or http:// on localhost. No query, fragment or credentials. |
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET | ā | A confidential web client at the provider (client_secret_basic). |
OIDC_SCOPES | openid email profile | Space or comma separated. openid is always added. A warning is logged when email is missing. |
OIDC_CALLBACK_URL | <API base URL>/api/auth/oidc/callback | Must be http(s), with no credentials and no fragment. |
OIDC_DISPLAY_NAME | SSO | Button label name. Falls back to SSO, with a warning, when it is over 40 characters or has control characters. |
OIDC_AUTH_LINK (web app) | empty | The button's link, usually <API base URL>/api/auth/oidc. Empty means no button and no request. |
Default off means no change: the plugin is not loaded, no route exists, the button is not shown, and the UI makes no request. Nothing goes to any provider. When OIDC_ENABLED=true but a setting is missing or invalid, the API logs which setting is wrong (names and problem only, never values) and the plugin stays off. If the module is ever loaded with unusable settings, /config reports enabled: false and sign-in answers 404 without contacting anyone.
Decisions
- Public, PKCE-only clients (no secret): not in this PR; a follow-up under #42.
- Userinfo fallback: always on in this plugin, with no extra setting. The answer counts only for the ID token's own subject, and only with
email_verified: true. - Button label: the
SSOdefault name and theLOGIN_PAGE.SIGN_IN_WITH_SSOkey stay as they are. - Account linking and provider logout: follow-ups under #42, which stays open.
Not in this PR (follow-ups under #42)
- Public, PKCE-only clients (no client secret).
- Account linking: connecting an OpenID Connect identity to an existing, signed-in Gauzy account, like the Zitadel plugin's link mode.
- Provider logout (RP-initiated logout through the provider's
end_session_endpoint).
Design notes
- Shared flow in
@gauzy/auth. The flow is shared, not copied. Keycloak delegates to it, and its behaviour for users is unchanged (its suites pass untouched). Zitadel keeps its own flow: it does more than sign-in (account linking, Ever ID modes), so it uses the library services directly as before. - Issuer kept exactly as configured. Discovery requires the exact issuer, and Authentik publishes one with a trailing slash.
- Confidential clients only. See "Not in this PR".
- Strict config parsing without imports. The issuer rule mirrors the library's (https, or http on localhost only) so a bad setting fails at start-up with a clear warning, not on the first sign-in.
- The plugin has its own social sign-in port and token. This mirrors the Keycloak plugin, so the two plugins never share mutable wiring.
- i18n in 13 languages.
ach.json(Crowdin's in-context pseudo-language, which has no Keycloak key either) is left to Crowdin. - Userinfo fallback. It is opt-in in the shared service: on for this plugin, off for Keycloak, so Keycloak keeps its exact behaviour.
- Nothing else touched. No new npm dependencies, and the Fiverr placeholder is untouched.
Tests
All commands were run from the repository root with Node 26.10.0.
| Command | Result |
|---|---|
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/auth/jest.config.ts --rootDir packages/auth | 22 suites, 199 passed. Includes the new email-sign-in.spec.ts (26 tests: verified path, unverified variants, other issuer or audience, nonce, missing or forged cookie and state, one-time use, cross-flow transaction, access_denied, unreachable issuer, log hygiene, userinfo fallback). |
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/plugins/auth-oidc/jest.config.ts --rootDir packages/plugins/auth-oidc | 2 suites, 57 passed. Config spec (43). HTTP e2e against an in-process provider (14): happy path, register redirect, unverified, other issuer, nonce, missing cookie, cancel, trailing-slash issuer, default callback, userinfo fallback, unconfigured gives 404 with no outbound request. |
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/plugins/auth-keycloak/jest.config.ts --rootDir packages/plugins/auth-keycloak | 3 suites, 18 passed (unchanged suites). |
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/plugins/auth-zitadel/jest.config.ts --rootDir packages/plugins/auth-zitadel | 10 suites, 145 passed. In the parallel run, zitadel-signin.e2e.spec.ts failed to finish loading on a busy machine (0 failed tests). Re-run alone, it passed (15/15). |
NODE_OPTIONS=--experimental-vm-modules npx jest -c apps/api/jest.config.ts --rootDir apps/api src/plugins.matrix.spec.ts | 1 suite, 122 passed (17 new). |
npx jest -c packages/ui-auth/jest.config.ts --rootDir packages/ui-auth social-links | 1 suite, 14 passed (5 new: order and payload, each button shown on its own check's answer, hidden when off, rendered label as text, name fallbacks). |
yarn nx build plugin-auth-oidc | Succeeded with the 9 projects it depends on (constants, scheduler, contracts, utils, common, config, plugin, auth, core). No specs in dist/packages/plugins/auth-oidc. |
node node_modules/eslint/bin/eslint.js packages/auth/src/lib/oidc packages/plugins/auth-oidc/src | 0 errors, no warnings in the new files. |
@nx/enforce-module-boundaries is skipped locally because there is no cached project graph. New files are formatted with the repository Prettier settings.
Manual verification still needed
- The API, server and desktop app builds were not run locally. Neither was an Angular production build of
ui-auth. The plugin package build was run (see Tests). - No sign-in against a live provider yet. A check with Authentik (trailing-slash issuer), Zitadel (userinfo fallback) and Keycloak (generic plugin pointed at a realm) is recommended.
- Docker images were not built (
.deploy/api,.deploy/worker, webappreplacements.sed). - Re-run the Keycloak sign-in end to end after this change, since it now runs on the shared service.
Risk and rollback
- Risk is low while the flag is off. The plugin is not loaded, and the only shared change is the new service in
@gauzy/auth. The Keycloak refactor keeps its public API and behaviour, and its suites pass unchanged. - Turning it off: unset
OIDC_AUTH_LINK(the button disappears), then setOIDC_ENABLED=falseor unset it (the routes disappear). The plugin stores nothing. - Rollback: revert this PR. There are no migrations and no stored data.
Refs #42
š¤ Generated with Claude Code