diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx index f626e6491..81958d8fe 100644 --- a/apps/api/src/controllers/oauth-callback.controller.tsx +++ b/apps/api/src/controllers/oauth-callback.controller.tsx @@ -4,7 +4,10 @@ import { generateSessionToken, github, google, + isOidcEnabled, type OAuth2Tokens, + oidc, + OIDC_TOKEN_ENDPOINT, setLastAuthProviderCookie, setSessionTokenCookie, } from '@openpanel/auth'; @@ -40,7 +43,7 @@ async function getGithubEmail(githubAccessToken: string) { } // New types and interfaces -type Provider = 'github' | 'google'; +type Provider = 'github' | 'google' | 'oidc'; interface OAuthUser { id: string; email: string; @@ -190,6 +193,48 @@ async function fetchGithubUser(accessToken: string): Promise { }; } +async function fetchOidcUser(tokens: OAuth2Tokens): Promise { + const claims = Arctic.decodeIdToken(tokens.idToken()); + + // Minimal OIDC standard claims. Most IdPs surface these (sub + email + // are the only hard requirements). given_name/family_name/name are + // best-effort — Zitadel emits them; some IdPs only emit `name`. + const claimsSchema = z.object({ + sub: z.string(), + email: z.string(), + email_verified: z.boolean().optional(), + given_name: z.string().optional(), + family_name: z.string().optional(), + name: z.string().optional(), + }); + + const claimsResult = claimsSchema.safeParse(claims); + if (!claimsResult.success) { + throw new LogError('Error decoding OIDC ID token claims', { + error: claimsResult.error, + claims, + }); + } + + // Default to "verified" when the IdP doesn't surface email_verified — + // in OIDC, the IdP is the source of truth for email and platform + // operators choose which IdPs they trust. + if (claimsResult.data.email_verified === false) { + throw new LogError('Email not verified with OIDC provider'); + } + + const givenName = claimsResult.data.given_name ?? ''; + const familyName = claimsResult.data.family_name ?? ''; + const fallbackName = claimsResult.data.name ?? ''; + + return { + id: claimsResult.data.sub, + email: claimsResult.data.email, + firstName: givenName || fallbackName || claimsResult.data.email, + lastName: familyName, + }; +} + async function fetchGoogleUser(tokens: OAuth2Tokens): Promise { const claims = Arctic.decodeIdToken(tokens.idToken()); @@ -246,20 +291,22 @@ async function validateOAuthCallback( const { code, state } = query.data; const storedState = req.cookies[`${provider}_oauth_state`] ?? null; - const codeVerifier = - provider === 'google' ? (req.cookies.google_code_verifier ?? null) : null; + const usesPkce = provider === 'google' || provider === 'oidc'; + const codeVerifier = usesPkce + ? (req.cookies[`${provider}_code_verifier`] ?? null) + : null; if ( code === null || state === null || storedState === null || - (provider === 'google' && codeVerifier === null) + (usesPkce && codeVerifier === null) ) { throw new LogError('Missing oauth parameters', { code: code === null, state: state === null, storedState: storedState === null, - codeVerifier: provider === 'google' ? codeVerifier === null : undefined, + codeVerifier: usesPkce ? codeVerifier === null : undefined, provider, }); } @@ -360,6 +407,57 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) { } } +export async function oidcCallback(req: FastifyRequest, reply: FastifyReply) { + try { + if (!isOidcEnabled()) { + throw new LogError('OIDC is not configured on this instance'); + } + + const { code } = await validateOAuthCallback(req, 'oidc'); + const inviteId = req.cookies.inviteId; + const codeVerifier = req.cookies.oidc_code_verifier!; + const tokens = await oidc.validateAuthorizationCode( + OIDC_TOKEN_ENDPOINT, + code, + codeVerifier + ); + const oidcUser = await fetchOidcUser(tokens); + const existingAccount = await db.account.findFirst({ + where: { + OR: [ + { provider: 'oidc', providerId: oidcUser.id }, + // Allow operators to migrate users by matching on email + // when they flip to OIDC after initial signup. + { provider: 'oidc', providerId: null, email: oidcUser.email }, + { provider: 'oauth', user: { email: oidcUser.email } }, + ], + }, + }); + + reply.clearCookie('oidc_code_verifier'); + reply.clearCookie('oidc_oauth_state'); + + if (existingAccount) { + return await handleExistingUser({ + account: existingAccount, + oauthUser: oidcUser, + providerName: 'oidc', + reply, + }); + } + + return await handleNewUser({ + oauthUser: oidcUser, + providerName: 'oidc', + inviteId, + reply, + }); + } catch (error) { + req.log.error(error); + return redirectWithError(reply, error); + } +} + function redirectWithError(reply: FastifyReply, error: LogError | unknown) { const url = new URL( process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL! diff --git a/apps/api/src/routes/oauth-callback.router.ts b/apps/api/src/routes/oauth-callback.router.ts index 230ade477..66f264c17 100644 --- a/apps/api/src/routes/oauth-callback.router.ts +++ b/apps/api/src/routes/oauth-callback.router.ts @@ -12,6 +12,11 @@ const router: FastifyPluginCallback = async (fastify) => { url: '/google/callback', handler: controller.googleCallback, }); + fastify.route({ + method: 'GET', + url: '/oidc/callback', + handler: controller.oidcCallback, + }); }; export default router; diff --git a/apps/start/src/components/auth/sign-in-oidc.tsx b/apps/start/src/components/auth/sign-in-oidc.tsx new file mode 100644 index 000000000..fe0bf2480 --- /dev/null +++ b/apps/start/src/components/auth/sign-in-oidc.tsx @@ -0,0 +1,64 @@ +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { KeyRound } from 'lucide-react'; +import { useTRPC } from '@/integrations/trpc/react'; +import { getServerEnvsQueryOptions } from '@/server/get-envs'; +import { Button } from '../ui/button'; + +// Conditionally renders an OIDC sign-in button when the server has +// configured a generic OIDC provider (Zitadel, Keycloak, Authentik, +// Okta, etc.). The button label is driven by OIDC_DISPLAY_NAME and +// defaults to "Single Sign-On" when not set. +export function SignInOidc({ + type, + inviteId, + isLastUsed, +}: { + type: 'sign-in' | 'sign-up'; + inviteId?: string; + isLastUsed?: boolean; +}) { + const { data: envs } = useSuspenseQuery(getServerEnvsQueryOptions); + const trpc = useTRPC(); + const mutation = useMutation( + trpc.auth.signInOAuth.mutationOptions({ + onSuccess(res) { + if (res.url) { + window.location.href = res.url; + } + }, + }) + ); + + if (!envs.oidc?.enabled) { + return null; + } + + const displayName = envs.oidc.displayName || 'Single Sign-On'; + const title = + type === 'sign-in' + ? `Sign in with ${displayName}` + : `Sign up with ${displayName}`; + + return ( +
+ + {isLastUsed && ( + + Used last time + + )} +
+ ); +} diff --git a/apps/start/src/routes/_login.login.tsx b/apps/start/src/routes/_login.login.tsx index fe711c2ac..171507c06 100644 --- a/apps/start/src/routes/_login.login.tsx +++ b/apps/start/src/routes/_login.login.tsx @@ -5,6 +5,7 @@ import { Or } from '@/components/auth/or'; import { SignInEmailForm } from '@/components/auth/sign-in-email-form'; import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGoogle } from '@/components/auth/sign-in-google'; +import { SignInOidc } from '@/components/auth/sign-in-oidc'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { useCookieStore } from '@/hooks/use-cookie-store'; import { createTitle, PAGE_TITLES } from '@/utils/title'; @@ -72,6 +73,7 @@ function LoginPage() { )}
+
diff --git a/apps/start/src/routes/_public.onboarding.tsx b/apps/start/src/routes/_public.onboarding.tsx index a5515f4fb..02cfa9258 100644 --- a/apps/start/src/routes/_public.onboarding.tsx +++ b/apps/start/src/routes/_public.onboarding.tsx @@ -5,6 +5,7 @@ import { z } from 'zod'; import { Or } from '@/components/auth/or'; import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGoogle } from '@/components/auth/sign-in-google'; +import { SignInOidc } from '@/components/auth/sign-in-oidc'; import { SignUpEmailForm } from '@/components/auth/sign-up-email-form'; import FullPageLoadingState from '@/components/full-page-loading-state'; import { useTRPC } from '@/integrations/trpc/react'; @@ -107,6 +108,7 @@ function Component() { )}
+
diff --git a/apps/start/src/routes/api/config.tsx b/apps/start/src/routes/api/config.tsx index 0b1b1d08a..b6cb5f69c 100644 --- a/apps/start/src/routes/api/config.tsx +++ b/apps/start/src/routes/api/config.tsx @@ -7,6 +7,10 @@ export interface ConfigResonse { isSelfHosted: boolean; isMaintenance: boolean; isDemo: boolean; + oidc: { + enabled: boolean; + displayName: string; + }; } // Nothing sensitive here, its client environment variables which is good for debugging export const Route = createFileRoute('/api/config')({ diff --git a/apps/start/src/server/get-envs.ts b/apps/start/src/server/get-envs.ts index f1202c096..4e12d65e6 100644 --- a/apps/start/src/server/get-envs.ts +++ b/apps/start/src/server/get-envs.ts @@ -2,6 +2,13 @@ import { queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; 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( @@ -10,6 +17,10 @@ export const getServerEnvs = createServerFn().handler(() => { 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; diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts index 09f17adec..97c753a22 100644 --- a/packages/auth/src/oauth.ts +++ b/packages/auth/src/oauth.ts @@ -23,3 +23,38 @@ export const googleGsc = new Arctic.Google( process.env.GOOGLE_CLIENT_SECRET ?? '', process.env.GSC_GOOGLE_REDIRECT_URI ?? '' ); + +// Generic OIDC provider — configured via plain endpoint URLs so it works +// with any compliant Identity Provider (Zitadel, Keycloak, Authentik, +// Okta, etc.) without per-provider library support. +// +// Required env when OIDC is enabled (i.e. OIDC_CLIENT_ID is set): +// 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 — label shown on the sign-in button +// (defaults to "Single Sign-On") +export const oidc = new Arctic.OAuth2Client( + process.env.OIDC_CLIENT_ID ?? '', + process.env.OIDC_CLIENT_SECRET ?? '', + process.env.OIDC_REDIRECT_URI ?? '' +); + +export const OIDC_AUTHORIZATION_ENDPOINT = + process.env.OIDC_AUTHORIZATION_ENDPOINT ?? ''; +export const OIDC_TOKEN_ENDPOINT = process.env.OIDC_TOKEN_ENDPOINT ?? ''; +export const OIDC_DISPLAY_NAME = + process.env.OIDC_DISPLAY_NAME ?? 'Single Sign-On'; + +export const isOidcEnabled = (): boolean => + !!( + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_REDIRECT_URI && + process.env.OIDC_AUTHORIZATION_ENDPOINT && + process.env.OIDC_TOKEN_ENDPOINT + ); diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index 8d83bdf4f..50b184eb4 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -14,6 +14,9 @@ import { hashPassword, hashRecoveryCodes, invalidateSession, + isOidcEnabled, + oidc, + OIDC_AUTHORIZATION_ENDPOINT, setLastAuthProviderCookie, setSessionTokenCookie, validateSessionToken, @@ -51,7 +54,7 @@ import { const TWO_FACTOR_COOKIE = '2fa_challenge'; const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60; -const zProvider = z.enum(['email', 'google', 'github']); +const zProvider = z.enum(['email', 'google', 'github', 'oidc']); async function getIsRegistrationAllowed(inviteId?: string | null) { // ALLOW_REGISTRATION is always undefined in cloud @@ -128,6 +131,36 @@ export const authRouter = createTRPCRouter({ }; } + if (provider === 'oidc') { + if (!isOidcEnabled()) { + throw TRPCAccessError( + 'OIDC sign-in is not configured on this instance' + ); + } + + const state = Arctic.generateState(); + const codeVerifier = Arctic.generateCodeVerifier(); + const url = oidc.createAuthorizationURLWithPKCE( + OIDC_AUTHORIZATION_ENDPOINT, + state, + Arctic.CodeChallengeMethod.S256, + codeVerifier, + ['openid', 'profile', 'email'] + ); + + ctx.setCookie('oidc_oauth_state', state, { + maxAge: 60 * 10, + }); + ctx.setCookie('oidc_code_verifier', codeVerifier, { + maxAge: 60 * 10, + }); + + return { + type: 'oidc', + url: url.toString(), + }; + } + const state = Arctic.generateState(); const codeVerifier = Arctic.generateCodeVerifier(); const url = google.createAuthorizationURL(state, codeVerifier, [