feat(plugin-auth-oidc): sign in with any OpenID Connect provider

Merged
EV
14d ago
+2091-93

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.
    • start writes the signed transaction cookie (PKCE verifier, state, nonce) and returns the authorize URL.
    • complete accepts 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 when email_verified is exactly true.
    • 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 reads email and email_verified from 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 own sub.
  • OidcEmailSignInService is registered in OidcModule and exported, with its types, from the package index. public-api.spec.ts is updated to match.
  • README.md: one "Provider plugins" bullet for the new plugin.

@gauzy/plugin-auth-keycloak

  • KeycloakSignInService now delegates to OidcEmailSignInService.
  • 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 from Logger, because apps/api/src/plugins.ts calls it when the file loads.
  • AuthOidcSignInService: turns the settings into a flow for the shared service.
  • AuthOidcController: @Public() routes under /api/auth/oidc:
    • GET /config returns { enabled, displayName } and is never cached.
    • GET starts a sign-in.
    • GET /callback is where the provider returns.
  • A verified address goes through the existing social sign-in path (AuthService.validateOAuthLoginEmail, then routeRedirect), 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.json
    • apps/server-api, apps/server and apps/desktop src/package.json (file: dependency and resolution)
    • the root package.json build lists and build:package:plugin:auth-oidc[:prod|:docker] scripts
    • tsconfig.json path
  • .deploy/api/Dockerfile and .deploy/worker/Dockerfile: copy the built package, as for Keycloak.
  • .env.sample: OIDC_* settings (off, empty) and OIDC_AUTH_LINK= for the web app.
  • Web app configuration: .scripts/env.ts, .scripts/configure.ts (Docker placeholder DOCKER_OIDC_AUTH_LINK), .deploy/webapp/replacements.sed and ui-config model.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_LINK is set and GET /api/auth/oidc/config reports enabled: 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.
  • LOGIN_PAGE.SIGN_IN_WITH_SSO added 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

SettingDefaultMeaning
OIDC_ENABLEDoffLoads 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_SCOPESopenid email profileSpace or comma separated. openid is always added. A warning is logged when email is missing.
OIDC_CALLBACK_URL<API base URL>/api/auth/oidc/callbackMust be http(s), with no credentials and no fragment.
OIDC_DISPLAY_NAMESSOButton label name. Falls back to SSO, with a warning, when it is over 40 characters or has control characters.
OIDC_AUTH_LINK (web app)emptyThe 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 SSO default name and the LOGIN_PAGE.SIGN_IN_WITH_SSO key 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.

CommandResult
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/auth/jest.config.ts --rootDir packages/auth22 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-oidc2 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-keycloak3 suites, 18 passed (unchanged suites).
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/plugins/auth-zitadel/jest.config.ts --rootDir packages/plugins/auth-zitadel10 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.ts1 suite, 122 passed (17 new).
npx jest -c packages/ui-auth/jest.config.ts --rootDir packages/ui-auth social-links1 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-oidcSucceeded 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/src0 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, webapp replacements.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 set OIDC_ENABLED=false or 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