Skip to content

feat(auth): generic OIDC sign-in provider#370

Open
patrickleet wants to merge 2 commits into
Openpanel-dev:mainfrom
hops-ops:feat/oidc-support
Open

feat(auth): generic OIDC sign-in provider#370
patrickleet wants to merge 2 commits into
Openpanel-dev:mainfrom
hops-ops:feat/oidc-support

Conversation

@patrickleet
Copy link
Copy Markdown

@patrickleet patrickleet commented May 18, 2026

Refs #368.

Adds a generic OIDC sign-in option alongside the existing Google + GitHub buttons. Works with any OIDC-compliant Identity Provider (Zitadel, Keycloak, Authentik, Okta, …) — endpoints are configured via env vars rather than per-provider library code.

Implementation

Two commits:

  1. feat(auth): generic OIDC sign-in provider — the backend.

    • packages/auth: new oidc Arctic.OAuth2Client plus OIDC_AUTHORIZATION_ENDPOINT / OIDC_TOKEN_ENDPOINT exports and an isOidcEnabled() helper.
    • packages/trpc/src/routers/auth.ts: zProvider enum gains 'oidc'; signInOAuth adds a branch that builds the authorization URL using PKCE (S256), matching the existing Google flow.
    • apps/api/src/controllers/oauth-callback.controller.tsx: new oidcCallback + fetchOidcUser. User identity comes from the ID token (sub, email, email_verified, given_name/family_name/name), decoded via Arctic.decodeIdToken — same approach the Google handler already uses. validateOAuthCallback generalized to handle any PKCE provider via the existing <provider>_code_verifier cookie convention.
    • apps/api/src/routes/oauth-callback.router.ts: registers GET /oidc/callback.
  2. feat(start): dashboard sign-in button for OIDC provider — the UI.

    • New SignInOidc component on /login and /onboarding, mirroring SignInGoogle / SignInGithub.
    • getServerEnvs now surfaces oidc.{enabled, displayName} to the client. enabled is gated on OIDC_CLIENT_ID being set; the full env validation (client secret + endpoints) stays on apps/api where the OAuth flow actually runs. This lets deployments with separate api/dashboard pods keep OIDC_CLIENT_SECRET confined to the api side without losing the button.
    • displayName from OIDC_DISPLAY_NAME (defaults to Single Sign-On) so deployments that point at one IdP can label the button with the provider name.

Required env vars

When all five are set, the OAuth flow becomes available:

```
OIDC_CLIENT_ID
OIDC_CLIENT_SECRET
OIDC_REDIRECT_URI # /api/oauth/oidc/callback
OIDC_AUTHORIZATION_ENDPOINT # e.g. https://auth.example.com/oauth/v2/authorize
OIDC_TOKEN_ENDPOINT # e.g. https://auth.example.com/oauth/v2/token
```

Optional:

```
OIDC_DISPLAY_NAME # button label override
```

The dashboard process only needs `OIDC_CLIENT_ID` (to know whether to render the button); the api process needs all five (to actually run the flow). For monolithic / docker-compose deployments where everything shares an env this is a no-op.

How to test

Spin up any OIDC IdP locally (Zitadel / Keycloak / dex), create a client with:

Export the env vars above (point at your IdP's discovery endpoints), `pnpm dev`, hit `/login`, click the new SSO button, complete IdP login, land back signed in.

I verified end-to-end against a self-hosted Zitadel install (Zitadel issued the ID token, the new `oidcCallback` decoded the claims, `handleNewUser` created the User+Account, dashboard session was set, dropped into onboarding).

Backwards compatibility

Purely additive. `isOidcEnabled()` returns false when any required var is unset; the tRPC branch refuses to start the flow; the UI returns `null` so existing installs see no change.

Not included

  • Tests — the existing auth code doesn't have OAuth flow tests so I didn't add isolated ones for OIDC specifically. Happy to add unit tests for `fetchOidcUser` claim parsing if reviewers want.
  • Discovery (.well-known/openid-configuration) — endpoints are configured directly rather than discovered. Smaller surface, no runtime fetch on every install boot. Easy to add later as an opt-in alternative.
  • Docs / .env.example — left for the reviewers' style preference. Will add as requested.

Summary by CodeRabbit

  • New Features
    • Added OIDC authentication support as an additional sign-in and sign-up provider option
    • OIDC provider display name is customizable via configuration

Review Change Stack

Adds a generic OIDC sign-in path that works with any compliant
Identity Provider (Zitadel, Keycloak, Authentik, Okta, etc.) by
configuring endpoint URLs directly rather than per-provider
library support.

Provider is enabled when these env vars are all set:
  OIDC_CLIENT_ID
  OIDC_CLIENT_SECRET
  OIDC_REDIRECT_URI                — <dashboard>/api/oauth/oidc/callback
  OIDC_AUTHORIZATION_ENDPOINT
  OIDC_TOKEN_ENDPOINT

Optional:
  OIDC_DISPLAY_NAME                — label shown on sign-in button
                                      (defaults to "Single Sign-On")

Implementation mirrors the existing Google flow:
- PKCE (S256) for the auth code exchange
- User identity from the ID token (sub, email, given_name,
  family_name; falls back to `name` claim if given_name absent)
- email_verified honoured when present; defaults to verified when
  the IdP doesn't emit the claim (operators choose trusted IdPs)

Files:
- packages/auth/src/oauth.ts — new `oidc` Arctic.OAuth2Client +
  endpoint exports + `isOidcEnabled()` helper.
- packages/trpc/src/routers/auth.ts — zProvider enum gains 'oidc';
  signInOAuth branch creates the authorization URL.
- apps/api/src/controllers/oauth-callback.controller.tsx — new
  oidcCallback + fetchOidcUser; validateOAuthCallback generalised
  to handle any PKCE provider via cookie naming convention
  (<provider>_code_verifier).
- apps/api/src/routes/oauth-callback.router.ts — registers
  GET /oidc/callback.

Dashboard sign-in button rendering still needs a small UI change
(separate commit) so end-users see an OIDC option. tRPC route is
fully wired regardless.
Adds a SignInOidc component that renders on /login and /onboarding
when the server reports `oidc.enabled=true`. Companion to the
generic OIDC auth-provider change in apps/api / packages/auth.

- New SignInOidc component (mirrors SignInGoogle / SignInGithub
  shape; calls the same `auth.signInOAuth` mutation with
  `provider: 'oidc'`).
- `getServerEnvs` surfaces `oidc.{enabled, displayName}` to the
  client. `enabled` is true when `OIDC_CLIENT_ID` is set; the
  full env validation (client secret + endpoints) stays on
  apps/api where the OAuth flow actually runs, so deployments
  can keep the OIDC client secret confined to the api process
  without losing the button.
- `displayName` comes from `OIDC_DISPLAY_NAME`, defaulting to
  "Single Sign-On" so deployments with a single IdP can label
  the button with the IdP's name ("Zitadel", "Keycloak", etc.).
- The component returns `null` when `oidc.enabled` is false so
  existing installs see no UI change.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 18, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

This PR adds OpenID Connect (OIDC) as a third authentication provider alongside existing GitHub and Google OAuth. The implementation spans auth library configuration, backend callback handling with PKCE support, frontend tRPC sign-in flow, server configuration exposure, and a new UI component for OIDC sign-in integrated into login and onboarding pages.

Changes

OIDC Authentication Provider Integration

Layer / File(s) Summary
OIDC Auth Library Setup
packages/auth/src/oauth.ts
Arctic-based OAuth2Client for OIDC initialized from environment variables (OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI); exports endpoint constants and display name (defaulting to "Single Sign-On"); isOidcEnabled() validates all required OIDC configuration is present.
Backend OIDC Callback Handler
apps/api/src/controllers/oauth-callback.controller.tsx, apps/api/src/routes/oauth-callback.router.ts
fetchOidcUser decodes OIDC ID tokens via Arctic, validates required claims, enforces email verification, and maps claims (sub, email, name, etc.) into OAuthUser shape; validateOAuthCallback adds PKCE support for OIDC and Google; oidcCallback handler validates callback, exchanges code for tokens, fetches user, performs account lookup with email migration fallback, and routes to existing/new user flows; /oidc/callback route registered.
Frontend tRPC OAuth Sign-in
packages/trpc/src/routers/auth.ts
authRouter.signInOAuth now accepts oidc provider, verifies OIDC is enabled, generates PKCE parameters (state and code challenge), sets state/verifier cookies, and returns authorization URL with type: 'oidc'.
Frontend OIDC Configuration
apps/start/src/server/get-envs.ts, apps/start/src/routes/api/config.tsx
getServerEnvs derives oidcConfigured flag and includes oidc object with enabled status and displayName (from OIDC_DISPLAY_NAME env with fallback); ConfigResponse interface updated to expose oidc: { enabled, displayName } to clients.
OIDC Sign-in UI Component & Integration
apps/start/src/components/auth/sign-in-oidc.tsx, apps/start/src/routes/_login.login.tsx, apps/start/src/routes/_public.onboarding.tsx
New SignInOidc component conditionally renders OIDC button when enabled, fetches server config via Suspense, triggers auth.signInOAuth mutation with optional inviteId, displays configurable button text and "Used last time" badge; integrated into login page (with last-provider cookie tracking) and onboarding page (with invite ID support).

🎯 3 (Moderate) | ⏱️ ~25 minutes

🐰 A hop through OAuth's garden fair,
OIDC blooms with provider care,
State and tokens dance with PKCE's grace,
Single Sign-On finds its place!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(auth): generic OIDC sign-in provider' accurately and concisely summarizes the main change—adding OIDC as a new authentication provider alongside existing Google and GitHub options.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/start/src/server/get-envs.ts (1)

4-27: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Wrap this server function with Sentry.startSpan() for performance instrumentation.

getServerEnvs is a server function in the apps/start directory but is not instrumented with Sentry. Per the coding guidelines, all server functions in apps/start/**/*.{ts,tsx} must be wrapped with Sentry.startSpan(). Add the Sentry import and wrap the handler implementation.

Suggested fix
 import { queryOptions } from '`@tanstack/react-query`';
 import { createServerFn } from '`@tanstack/react-start`';
+import * as Sentry from '`@sentry/tanstackstart-react`';

 export const getServerEnvs = createServerFn().handler(() => {
-  // The dashboard only needs OIDC_CLIENT_ID to know whether to render
-  // the SSO sign-in button. The full validation (client secret +
-  // endpoints) lives on the api pod where the OAuth flow actually
-  // runs. This lets operators keep the OIDC secret confined to the
-  // api pod without losing the button.
-  const oidcConfigured = !!process.env.OIDC_CLIENT_ID;
-
-  const envs = {
-    apiUrl: String(process.env.API_URL || process.env.NEXT_PUBLIC_API_URL),
-    dashboardUrl: String(
-      process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL
-    ),
-    isSelfHosted: process.env.SELF_HOSTED !== undefined,
-    isMaintenance: process.env.MAINTENANCE === '1',
-    isDemo: process.env.DEMO_USER_ID !== undefined,
-    oidc: {
-      enabled: oidcConfigured,
-      displayName: process.env.OIDC_DISPLAY_NAME || 'Single Sign-On',
-    },
-  };
-
-  return envs;
+  return Sentry.startSpan({ name: 'getServerEnvs', op: 'server.fn' }, () => {
+    // The dashboard only needs OIDC_CLIENT_ID to know whether to render
+    // the SSO sign-in button. The full validation (client secret +
+    // endpoints) lives on the api pod where the OAuth flow actually
+    // runs. This lets operators keep the OIDC secret confined to the
+    // api pod without losing the button.
+    const oidcConfigured = !!process.env.OIDC_CLIENT_ID;
+
+    const envs = {
+      apiUrl: String(process.env.API_URL || process.env.NEXT_PUBLIC_API_URL),
+      dashboardUrl: String(
+        process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL
+      ),
+      isSelfHosted: process.env.SELF_HOSTED !== undefined,
+      isMaintenance: process.env.MAINTENANCE === '1',
+      isDemo: process.env.DEMO_USER_ID !== undefined,
+      oidc: {
+        enabled: oidcConfigured,
+        displayName: process.env.OIDC_DISPLAY_NAME || 'Single Sign-On',
+      },
+    };
+
+    return envs;
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/server/get-envs.ts` around lines 4 - 27, Import Sentry (e.g.
from '`@sentry/node`') and wrap the body of the createServerFn().handler for
getServerEnvs in a Sentry.startSpan call: inside the handler call
Sentry.startSpan({ op: 'server.function', description: 'getServerEnvs' }) and
run the existing env-building logic within the span, ensuring you call
span.finish() (in a finally block) before returning envs; keep the function name
getServerEnvs and the createServerFn().handler usage unchanged so
instrumentation is applied only around that handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/trpc/src/routers/auth.ts`:
- Line 57: The z.enum declaration zProvider currently includes 'email' but
signInOAuth (and its related logic around lines 164-183) has no branch for
email, causing email inputs to be treated as Google OAuth; update the enum or
the handler: either remove 'email' from zProvider so only OAuth providers
('google','github','oidc') are allowed, or add an explicit email-handling branch
in signInOAuth/related functions to process email-based sign-in correctly (e.g.,
route to the email sign-in flow or return a clear error). Ensure the same change
is applied consistently where zProvider is used in the sign-in logic.

---

Outside diff comments:
In `@apps/start/src/server/get-envs.ts`:
- Around line 4-27: Import Sentry (e.g. from '`@sentry/node`') and wrap the body
of the createServerFn().handler for getServerEnvs in a Sentry.startSpan call:
inside the handler call Sentry.startSpan({ op: 'server.function', description:
'getServerEnvs' }) and run the existing env-building logic within the span,
ensuring you call span.finish() (in a finally block) before returning envs; keep
the function name getServerEnvs and the createServerFn().handler usage unchanged
so instrumentation is applied only around that handler.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66c51e29-0ce8-47b4-aa2c-45b54c575c54

📥 Commits

Reviewing files that changed from the base of the PR and between cd3bef8 and b6f2cb1.

📒 Files selected for processing (9)
  • apps/api/src/controllers/oauth-callback.controller.tsx
  • apps/api/src/routes/oauth-callback.router.ts
  • apps/start/src/components/auth/sign-in-oidc.tsx
  • apps/start/src/routes/_login.login.tsx
  • apps/start/src/routes/_public.onboarding.tsx
  • apps/start/src/routes/api/config.tsx
  • apps/start/src/server/get-envs.ts
  • packages/auth/src/oauth.ts
  • packages/trpc/src/routers/auth.ts

const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60;

const zProvider = z.enum(['email', 'google', 'github']);
const zProvider = z.enum(['email', 'google', 'github', 'oidc']);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove 'email' from OAuth provider enum (or handle it explicitly).

Line 57 currently allows provider: 'email', but signInOAuth has no email branch, so it falls through and returns a Google OAuth URL. That is an incorrect auth flow for a valid input.

Suggested fix
-const zProvider = z.enum(['email', 'google', 'github', 'oidc']);
+const zProvider = z.enum(['google', 'github', 'oidc']);
-      const state = Arctic.generateState();
-      const codeVerifier = Arctic.generateCodeVerifier();
-      const url = google.createAuthorizationURL(state, codeVerifier, [
-        'openid',
-        'profile',
-        'email',
-      ]);
+      if (provider !== 'google') {
+        throw TRPCAccessError('Unsupported OAuth provider');
+      }
+
+      const state = Arctic.generateState();
+      const codeVerifier = Arctic.generateCodeVerifier();
+      const url = google.createAuthorizationURL(state, codeVerifier, [
+        'openid',
+        'profile',
+        'email',
+      ]);

Also applies to: 164-183

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/routers/auth.ts` at line 57, The z.enum declaration
zProvider currently includes 'email' but signInOAuth (and its related logic
around lines 164-183) has no branch for email, causing email inputs to be
treated as Google OAuth; update the enum or the handler: either remove 'email'
from zProvider so only OAuth providers ('google','github','oidc') are allowed,
or add an explicit email-handling branch in signInOAuth/related functions to
process email-based sign-in correctly (e.g., route to the email sign-in flow or
return a clear error). Ensure the same change is applied consistently where
zProvider is used in the sign-in logic.

@patrickleet
Copy link
Copy Markdown
Author

image

@lindesvard
Copy link
Copy Markdown
Contributor

I like this but before I would merge this I would actually want to extend this as an "enterprise" feature. So its not hard coded with envs but rather lookup from the DB (a new scheme would be needed for this).

So SSO can be setup per organization instead of per openpanel instance. Are you up for the task or do you want me to take over here?

patrickleet added a commit to hops-ops/openpanel-app that referenced this pull request May 18, 2026
Builds on the schema/crypto/lookup foundation. The instance-level
env-driven OIDC flow from Openpanel-dev#370 is untouched on disk; per-org rides
alongside it via a cookie discriminator the callback reads on entry.

tRPC `signInOAuth` — packages/trpc/src/routers/auth.ts
------------------------------------------------------
- `zProvider` gains `'org-sso'`.
- Input gets an optional `email` field (required only when
  `provider='org-sso'`, validated at branch entry).
- New branch resolves the Org via `lookupOrgSsoByEmailDomain`,
  decrypts the stored client_secret, instantiates a per-call
  `Arctic.OAuth2Client` against the Org's `oidcAuthorizationEndpoint`,
  generates PKCE state+verifier, sets the standard `oidc_oauth_state`
  + `oidc_code_verifier` cookies AND a new `oidc_sso_config_id`
  cookie (the discriminator the callback uses).
- Returns `{ type: 'org-sso', url }`. The dashboard handles this
  identically to existing OAuth returns.

OIDC callback — apps/api/src/controllers/oauth-callback.controller.tsx
----------------------------------------------------------------------
- Detects `oidc_sso_config_id` cookie at entry. If set, loads the
  OrganizationSsoConfig, decrypts the client_secret, and exchanges
  the code via the Org's configured token endpoint with a
  per-call OAuth2Client. If not set, falls through to the existing
  env-driven validation path (Openpanel-dev#370) verbatim.
- `Provider` type extended to `'org-sso'`. Account.provider column
  carries the discriminator so future lookups can distinguish
  instance- vs per-org-issued accounts.
- JIT membership: `handleNewUser` gains a `jitMembership` param.
  When the callback runs in org-sso mode, a fresh user is created
  with a Member row binding them to the routing Org at role='member'
  in the same transaction. (Member has no compound unique on
  (userId, organizationId), so for EXISTING users we find-then-create
  rather than upsert — idempotent on retry.)

Build / typecheck
-----------------
- `@openpanel/trpc` typecheck clean.
- `@openpanel/api` typecheck clean.
- No new tests for the callback yet — the env-driven test surface
  doesn't exist either; mocking Fastify + db + cookies is its own
  chunk. Manual e2e on pat-local once the dashboard UI lands.

Remaining on this branch: dashboard /settings/sso admin page,
/login email-domain routing UI, and the password-sign-in refusal
for isRequired Orgs.
@patrickleet
Copy link
Copy Markdown
Author

I was approaching as an operator so I can set up and configure everything declaratively and programatically, including orgs.

So I think we should probably have both options - oidc for the root platform admin setup, which can manage other orgs, and SSO per org as well.

To that end I created:

  1. an updated helm chart - https://github.com/hops-ops/openpanel-chart
  • the secret management of the current chart left a lot to be desired
    • support for existing secrets, and by proxy, external secrets
    • not a giant monolithic secret
  • an init job to set up a platform admin org and credentials to allow programatic platform management, including Orgs, previously not possible
  1. updated API endpoints for platform admins that allow managing orgs - feat(api): admin JWT auth + /manage Organization endpoints + platform-admin scope hops-ops/openpanel-app#1
  2. a terraform provider - https://github.com/hops-ops/terraform-provider-openpanel
  3. a crossplane provider, created by using upjet to generate from the terraform provider - https://github.com/hops-ops/provider-openpanel

the Terraform and crossplane providers are working with my current fork

As far as per org, as well, I started that here hops-ops#2

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.

3 participants