feat(auth): generic OIDC sign-in provider#370
Conversation
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.
📝 WalkthroughWalkthroughThis 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. ChangesOIDC Authentication Provider Integration
🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add 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. Comment |
There was a problem hiding this comment.
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 winWrap this server function with
Sentry.startSpan()for performance instrumentation.
getServerEnvsis a server function in theapps/startdirectory but is not instrumented with Sentry. Per the coding guidelines, all server functions inapps/start/**/*.{ts,tsx}must be wrapped withSentry.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
📒 Files selected for processing (9)
apps/api/src/controllers/oauth-callback.controller.tsxapps/api/src/routes/oauth-callback.router.tsapps/start/src/components/auth/sign-in-oidc.tsxapps/start/src/routes/_login.login.tsxapps/start/src/routes/_public.onboarding.tsxapps/start/src/routes/api/config.tsxapps/start/src/server/get-envs.tspackages/auth/src/oauth.tspackages/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']); |
There was a problem hiding this comment.
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.
|
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? |
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.
|
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:
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 |

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:
feat(auth): generic OIDC sign-in provider— the backend.packages/auth: newoidcArctic.OAuth2ClientplusOIDC_AUTHORIZATION_ENDPOINT/OIDC_TOKEN_ENDPOINTexports and anisOidcEnabled()helper.packages/trpc/src/routers/auth.ts:zProviderenum gains'oidc';signInOAuthadds a branch that builds the authorization URL using PKCE (S256), matching the existing Google flow.apps/api/src/controllers/oauth-callback.controller.tsx: newoidcCallback+fetchOidcUser. User identity comes from the ID token (sub,email,email_verified,given_name/family_name/name), decoded viaArctic.decodeIdToken— same approach the Google handler already uses.validateOAuthCallbackgeneralized to handle any PKCE provider via the existing<provider>_code_verifiercookie convention.apps/api/src/routes/oauth-callback.router.ts: registersGET /oidc/callback.feat(start): dashboard sign-in button for OIDC provider— the UI.SignInOidccomponent on/loginand/onboarding, mirroringSignInGoogle/SignInGithub.getServerEnvsnow surfacesoidc.{enabled, displayName}to the client.enabledis gated onOIDC_CLIENT_IDbeing set; the full env validation (client secret + endpoints) stays onapps/apiwhere the OAuth flow actually runs. This lets deployments with separate api/dashboard pods keepOIDC_CLIENT_SECRETconfined to the api side without losing the button.displayNamefromOIDC_DISPLAY_NAME(defaults toSingle 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
Summary by CodeRabbit