From ea5b486287d7cf876601010c08e29fe11f64a358 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 9 Sep 2026 08:58:24 +0200 Subject: [PATCH 1/2] feat(auth): add a generic OIDC login provider Lets a self-hosted installation sign in against any standards-compliant OIDC provider, alongside the existing Google and GitHub options. - packages/auth: build an OIDC client from OIDC_* environment variables. Leaving them unset is fine; setting only some of them throws at boot rather than failing at the first login attempt. - packages/auth: report which providers are actually configured, so the dashboard only renders buttons that will work. - apps/api: handle the /oauth/oidc/callback leg, map claims to a user, and reject an unverified email however the provider spells that claim. - packages/trpc: accept 'oidc' in signInOAuth and expose an authProviders query. - apps/start: show the provider button on the login and onboarding pages, and hide the whole OAuth section when nothing is configured. - AUTH_AUTO_REDIRECT sends /login straight to the provider when exactly one provider is configured, and logs a warning at boot when it cannot apply. /login?noredirect=1 always renders the normal page, which is where logging out and deleting an account now land. - docs: environment variable reference for all of the above. Claude-Session: https://claude.ai/code/session_01RrUhKh3Zrk7Z1G5PjMFW5H --- .../oauth-callback.controller.test.ts | 283 ++++++++++++++++++ .../controllers/oauth-callback.controller.tsx | 140 ++++++++- apps/api/src/index.ts | 4 + apps/api/src/routes/oauth-callback.router.ts | 5 + .../self-hosting/environment-variables.mdx | 136 +++++++++ .../src/components/auth/sign-in-oidc.tsx | 53 ++++ apps/start/src/hooks/use-logout.ts | 3 +- .../src/modals/confirm-delete-account.tsx | 4 +- apps/start/src/routes/_login.login.tsx | 85 +++++- apps/start/src/routes/_public.onboarding.tsx | 33 +- packages/auth/src/index.ts | 2 + packages/auth/src/oidc.test.ts | 71 +++++ packages/auth/src/oidc.ts | 68 +++++ packages/auth/src/providers.test.ts | 76 +++++ packages/auth/src/providers.ts | 69 +++++ packages/trpc/src/routers/auth.ts | 34 ++- 16 files changed, 1038 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/controllers/oauth-callback.controller.test.ts create mode 100644 apps/start/src/components/auth/sign-in-oidc.tsx create mode 100644 packages/auth/src/oidc.test.ts create mode 100644 packages/auth/src/oidc.ts create mode 100644 packages/auth/src/providers.test.ts create mode 100644 packages/auth/src/providers.ts diff --git a/apps/api/src/controllers/oauth-callback.controller.test.ts b/apps/api/src/controllers/oauth-callback.controller.test.ts new file mode 100644 index 000000000..9a7ef8003 --- /dev/null +++ b/apps/api/src/controllers/oauth-callback.controller.test.ts @@ -0,0 +1,283 @@ +/** + * Security properties of the OIDC callback: state and PKCE verifier are both + * required before a code exchange, an unverified email is refused, and + * account matching never falls back to email. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + accountFindFirst, + userCreate, + userFindFirst, + createSessionMock, + setSessionTokenCookieMock, + validateAuthorizationCodeMock, + getIsRegistrationAllowedMock, +} = vi.hoisted(() => ({ + accountFindFirst: vi.fn(), + userCreate: vi.fn(), + userFindFirst: vi.fn(), + createSessionMock: vi.fn(), + setSessionTokenCookieMock: vi.fn(), + validateAuthorizationCodeMock: vi.fn(), + getIsRegistrationAllowedMock: vi.fn(), +})); + +vi.mock('@openpanel/auth', () => ({ + Arctic: { decodeIdToken: vi.fn() }, + createSession: createSessionMock, + generateSessionToken: () => 'session-token', + github: {}, + google: {}, + oidc: { validateAuthorizationCode: validateAuthorizationCodeMock }, + oidcConfig: { + clientId: 'openpanel', + clientSecret: 'secret', + redirectUri: 'http://localhost:3333/oauth/oidc/callback', + authorizationEndpoint: 'http://idp.test/authorize', + tokenEndpoint: 'http://idp.test/token', + userinfoEndpoint: 'http://idp.test/userinfo', + scopes: ['openid', 'profile', 'email'], + name: 'Keycloak', + }, + setLastAuthProviderCookie: vi.fn(), + setSessionTokenCookie: setSessionTokenCookieMock, +})); + +vi.mock('@openpanel/db', () => ({ + db: { + account: { findFirst: accountFindFirst, update: vi.fn() }, + user: { create: userCreate, findFirst: userFindFirst }, + }, + connectUserToOrganization: vi.fn(), + getIsRegistrationAllowed: getIsRegistrationAllowedMock, +})); + +const { mapOidcUser, oidcCallback } = await import( + './oauth-callback.controller' +); + +function makeReply() { + const redirect = vi.fn(); + const reply = { + redirect, + clearCookie: vi.fn(), + setCookie: vi.fn(), + log: { error: vi.fn() }, + request: { id: 'req-1' }, + }; + return { reply: reply as unknown as FastifyReply, redirect }; +} + +function makeReq(cookies: Record = {}) { + return { + query: { code: 'auth-code', state: 'state-1' }, + cookies: { + oidc_oauth_state: 'state-1', + oidc_code_verifier: 'verifier-1', + ...cookies, + }, + log: { error: vi.fn() }, + } as unknown as FastifyRequest; +} + +function userInfoResponse(body: unknown) { + return { + ok: true, + json: () => Promise.resolve(body), + } as unknown as Response; +} + +function redirectedError(redirect: ReturnType) { + const url = new URL(redirect.mock.calls[0]?.[0] as string); + return { pathname: url.pathname, error: url.searchParams.get('error') }; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Otherwise the fetch stub outlives its test. + vi.unstubAllGlobals(); + vi.stubEnv('DASHBOARD_URL', 'http://localhost:3000'); + validateAuthorizationCodeMock.mockResolvedValue({ + accessToken: () => 'access-token', + }); + getIsRegistrationAllowedMock.mockResolvedValue(true); + accountFindFirst.mockResolvedValue(null); + userFindFirst.mockResolvedValue(null); + userCreate.mockResolvedValue({ id: 'user-1', email: 'ada@example.com' }); + createSessionMock.mockResolvedValue({ expiresAt: new Date() }); +}); + +describe('mapOidcUser', () => { + it('maps a full userinfo payload', () => { + expect( + mapOidcUser({ + sub: 'sub-1', + email: 'ada@example.com', + email_verified: true, + given_name: 'Ada', + family_name: 'Lovelace', + name: 'Ada Lovelace', + }) + ).toEqual({ + id: 'sub-1', + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + }); + }); + + it('falls back from given_name to name to the email local part', () => { + const base = { sub: 'sub-1', email: 'ada@example.com' }; + expect(mapOidcUser({ ...base, name: 'Ada Lovelace' }).firstName).toBe( + 'Ada Lovelace' + ); + expect(mapOidcUser(base).firstName).toBe('ada'); + }); + + it.each([ + ['sub', { email: 'ada@example.com' }], + ['email', { sub: 'sub-1' }], + ])('throws when %s is missing', (_claim, payload) => { + expect(() => mapOidcUser(payload)).toThrow(/userinfo/i); + }); + + it.each([false, 'false'])( + 'rejects email_verified=%o however the provider spells it', + (claim) => { + expect(() => + mapOidcUser({ + sub: 'sub-1', + email: 'ada@example.com', + email_verified: claim, + }) + ).toThrow(/verified/i); + } + ); + + it.each([true, 'true'])( + 'accepts email_verified=%o however the provider spells it', + (claim) => { + // Cognito sends this claim as a string. + expect( + mapOidcUser({ + sub: 'sub-1', + email: 'ada@example.com', + email_verified: claim, + }).id + ).toBe('sub-1'); + } + ); + + it('treats an unrecognised email_verified value as absent', () => { + expect( + mapOidcUser({ sub: 'sub-1', email: 'ada@example.com', email_verified: 2 }) + .id + ).toBe('sub-1'); + }); + + it('accepts a payload with no email_verified claim', () => { + expect( + mapOidcUser({ sub: 'sub-1', email: 'ada@example.com' }).id + ).toBe('sub-1'); + }); +}); + +describe('oidcCallback', () => { + it('refuses a state that does not match the cookie', async () => { + const { reply, redirect } = makeReply(); + + await oidcCallback(makeReq({ oidc_oauth_state: 'other-state' }), reply); + + expect(validateAuthorizationCodeMock).not.toHaveBeenCalled(); + expect(createSessionMock).not.toHaveBeenCalled(); + expect(setSessionTokenCookieMock).not.toHaveBeenCalled(); + expect(redirectedError(redirect)).toEqual({ + pathname: '/login', + error: 'OAuth state mismatch', + }); + }); + + it('refuses a callback with no PKCE verifier cookie', async () => { + const { reply, redirect } = makeReply(); + const req = makeReq(); + (req.cookies as Record).oidc_code_verifier = + undefined; + + await oidcCallback(req, reply); + + expect(validateAuthorizationCodeMock).not.toHaveBeenCalled(); + expect(setSessionTokenCookieMock).not.toHaveBeenCalled(); + expect(redirectedError(redirect).pathname).toBe('/login'); + }); + + it('creates no user when the provider reports an unverified email', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + userInfoResponse({ + sub: 'sub-1', + email: 'ada@example.com', + email_verified: false, + }) + ) + ); + const { reply, redirect } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(userCreate).not.toHaveBeenCalled(); + expect(setSessionTokenCookieMock).not.toHaveBeenCalled(); + expect(redirectedError(redirect).error).toMatch(/verified/i); + }); + + it('matches on provider and subject only, never on email', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + userInfoResponse({ sub: 'sub-1', email: 'ada@example.com' }) + ) + ); + const { reply } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(accountFindFirst).toHaveBeenCalledTimes(1); + expect(accountFindFirst).toHaveBeenCalledWith({ + where: { provider: 'oidc', providerId: 'sub-1' }, + }); + expect(JSON.stringify(accountFindFirst.mock.calls[0])).not.toContain( + 'ada@example.com' + ); + }); + + it('sends the access token to the userinfo endpoint as a bearer token', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + userInfoResponse({ sub: 'sub-1', email: 'ada@example.com' }) + ); + vi.stubGlobal('fetch', fetchMock); + const { reply } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(validateAuthorizationCodeMock).toHaveBeenCalledWith( + 'http://idp.test/token', + 'auth-code', + 'verifier-1' + ); + expect(fetchMock).toHaveBeenCalledWith( + 'http://idp.test/userinfo', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }) + ); + }); +}); diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx index ff311a352..b61611679 100644 --- a/apps/api/src/controllers/oauth-callback.controller.tsx +++ b/apps/api/src/controllers/oauth-callback.controller.tsx @@ -1,10 +1,13 @@ import { Arctic, + COOKIE_OPTIONS, createSession, generateSessionToken, github, google, type OAuth2Tokens, + oidc, + oidcConfig, setLastAuthProviderCookie, setSessionTokenCookie, } from '@openpanel/auth'; @@ -45,7 +48,10 @@ async function getGithubEmail(githubAccessToken: string) { } // New types and interfaces -type Provider = 'github' | 'google'; +type Provider = 'github' | 'google' | 'oidc'; + +// Providers that carry a `_code_verifier` cookie into the callback. +const PKCE_PROVIDERS = new Set(['google', 'oidc']); interface OAuthUser { id: string; email: string; @@ -260,6 +266,80 @@ async function fetchGoogleUser(tokens: OAuth2Tokens): Promise { }; } +const oidcUserInfoSchema = z.object({ + sub: z.string().min(1), + email: z.string().min(1), + // Unknown because some providers (Cognito) send this as a string. + email_verified: z.unknown().optional(), + name: z.string().nullish(), + given_name: z.string().nullish(), + family_name: z.string().nullish(), +}); + +/** Only an explicit false rejects a login; anything else counts as absent. */ +function isEmailExplicitlyUnverified(claim: unknown): boolean { + return claim === false || claim === 'false'; +} + +/** + * Claims come from userinfo rather than the id_token: a compliant server may + * put only `sub` in the token, which would then need a merge step. + */ +export function mapOidcUser(payload: unknown): OAuthUser { + const result = oidcUserInfoSchema.safeParse(payload); + if (!result.success) { + // Field names only: the userinfo body must not reach the logs. + throw new LogError('Invalid userinfo response from the login provider', { + fieldErrors: result.error.flatten().fieldErrors, + }); + } + + const claims = result.data; + + // An unverified email is an account-takeover vector, so refuse outright. + if (isEmailExplicitlyUnverified(claims.email_verified)) { + throw new LogError('Your login provider has not verified this email'); + } + + const emailLocalPart = claims.email.split('@')[0]; + + return { + id: claims.sub, + email: claims.email, + firstName: + claims.given_name || claims.name || emailLocalPart || claims.email, + lastName: claims.family_name || '', + }; +} + +async function fetchOidcUser(accessToken: string): Promise { + if (!oidcConfig) { + throw new LogError('OIDC login is not configured'); + } + + const response = await fetch(oidcConfig.userinfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new LogError('Could not reach the login provider', { + status: response.status, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new LogError('The login provider returned an invalid response'); + } + + return mapOidcUser(payload); +} + interface ValidatedOAuthQuery { code: string; state: string; @@ -285,20 +365,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 = PKCE_PROVIDERS.has(provider); + 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, }); } @@ -401,6 +483,54 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) { } } +export async function oidcCallback(req: FastifyRequest, reply: FastifyReply) { + try { + if (!(oidc && oidcConfig)) { + throw new LogError('OIDC login is not configured'); + } + + const { code } = await validateOAuthCallback(req, 'oidc'); + const inviteId = req.cookies.inviteId; + const codeVerifier = req.cookies.oidc_code_verifier!; + const tokens = await oidc.validateAuthorizationCode( + oidcConfig.tokenEndpoint, + code, + codeVerifier + ); + const oidcUser = await fetchOidcUser(tokens.accessToken()); + + // Subject only, never email: matching on email would hand an account to + // anyone who can get the IdP to issue them that address. + const account = await db.account.findFirst({ + where: { provider: 'oidc', providerId: oidcUser.id }, + }); + + // Must match the options they were set with, or the clear misses them. + reply.clearCookie('oidc_code_verifier', COOKIE_OPTIONS); + reply.clearCookie('oidc_oauth_state', COOKIE_OPTIONS); + + if (account) { + return await handleExistingUser({ + account, + oauthUser: oidcUser, + providerName: 'oidc', + inviteId, + 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/index.ts b/apps/api/src/index.ts index 903e1401a..e1fe6bb7b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ process.env.TZ = 'UTC'; import './utils/observability'; +import { getAuthProviderWarnings } from '@openpanel/auth'; import { rawStderrWrite } from '@openpanel/logger'; import sourceMapSupport from 'source-map-support'; import { buildApp } from './app'; @@ -19,6 +20,9 @@ const host = const startServer = async () => { logger.info('Starting server'); + for (const warning of getAuthProviderWarnings()) { + logger.warn(warning); + } try { const fastify = await buildApp(); 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/public/content/docs/self-hosting/environment-variables.mdx b/apps/public/content/docs/self-hosting/environment-variables.mdx index bb4442ebf..8f1c1d8ce 100644 --- a/apps/public/content/docs/self-hosting/environment-variables.mdx +++ b/apps/public/content/docs/self-hosting/environment-variables.mdx @@ -560,6 +560,142 @@ Secret for signing Slack OAuth state parameter. SLACK_STATE_SECRET=your-state-secret ``` +## Single sign-on (OIDC) + +OpenPanel also support any standard OIDC provider. Fill in the env vars below to set it up. + +When configured a new "Sign in with ..." button will appear on the login screen. + +### OIDC_CLIENT_ID + +**Type**: `string` +**Required**: No +**Default**: None + +Client ID from your identity provider. Setting it enables the SSO button. + +**Example**: +```bash +OIDC_CLIENT_ID=openpanel +``` + +### OIDC_CLIENT_SECRET + +**Type**: `string` +**Required**: Yes, when `OIDC_CLIENT_ID` is set +**Default**: None + +Client secret from your identity provider. + +**Example**: +```bash +OIDC_CLIENT_SECRET=your-client-secret +``` + +### OIDC_REDIRECT_URI + +**Type**: `string` +**Required**: Yes, when `OIDC_CLIENT_ID` is set +**Default**: None + +Where the provider sends the user back. Must be `{API_URL}/oauth/oidc/callback`, +and must be registered as a valid redirect URI on the provider side. + +**Example**: +```bash +OIDC_REDIRECT_URI=https://analytics.example.com/api/oauth/oidc/callback +``` + +### OIDC_AUTHORIZATION_ENDPOINT + +**Type**: `string` +**Required**: Yes, when `OIDC_CLIENT_ID` is set +**Default**: None + +The provider's authorization endpoint. The **browser** opens this, so it has to +be an address your users can reach. + +**Example**: +```bash +OIDC_AUTHORIZATION_ENDPOINT=https://auth.example.com/realms/main/protocol/openid-connect/auth +``` + +### OIDC_TOKEN_ENDPOINT + +**Type**: `string` +**Required**: Yes, when `OIDC_CLIENT_ID` is set +**Default**: None + +The provider's token endpoint. The **API container** calls this, so an internal +address is fine and often preferable. + +**Example**: +```bash +OIDC_TOKEN_ENDPOINT=http://keycloak:8080/realms/main/protocol/openid-connect/token +``` + +### OIDC_USERINFO_ENDPOINT + +**Type**: `string` +**Required**: Yes, when `OIDC_CLIENT_ID` is set +**Default**: None + +The provider's userinfo endpoint, also called by the API container. OpenPanel +reads `sub`, `email`, `name`, `given_name` and `family_name` from the top level +of the response. + +**Example**: +```bash +OIDC_USERINFO_ENDPOINT=http://keycloak:8080/realms/main/protocol/openid-connect/userinfo +``` + +### OIDC_SCOPES + +**Type**: `string` +**Required**: No +**Default**: `openid profile email` + +Space-separated scopes to request. Set it to an empty string on OAuth2-only +servers that reject the `openid` scope. + +**Example**: +```bash +OIDC_SCOPES="openid profile email groups" +``` + +### OIDC_NAME + +**Type**: `string` +**Required**: No +**Default**: `SSO` + +Label for the login button, rendered as "Sign in with {name}". + +**Example**: +```bash +OIDC_NAME=Keycloak +``` + +### AUTH_AUTO_REDIRECT + +**Type**: `boolean` +**Required**: No +**Default**: `false` + +Send `/login` straight to the provider instead of showing a single lone button. +Only applies when exactly one login provider is configured; otherwise the login +page renders normally and a warning is logged at startup. + +**Example**: +```bash +AUTH_AUTO_REDIRECT=true +``` + + +`/login?noredirect=1` always renders the normal login page. +Logging out and deleting an account would send user to this url, so that login doesn't start automatically if `AUTH_AUTO_REDIRECT` is on. + + ## Self-hosting ### SELF_HOSTED 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..a12a8aad8 --- /dev/null +++ b/apps/start/src/components/auth/sign-in-oidc.tsx @@ -0,0 +1,53 @@ +import { useMutation } from '@tanstack/react-query'; +import { KeyRoundIcon } from 'lucide-react'; +import { useTRPC } from '@/integrations/trpc/react'; +import { Button } from '../ui/button'; + +export function SignInOidc({ + type, + name, + inviteId, + isLastUsed, +}: { + type: 'sign-in' | 'sign-up'; + name: string; + inviteId?: string; + isLastUsed?: boolean; +}) { + const trpc = useTRPC(); + const mutation = useMutation( + trpc.auth.signInOAuth.mutationOptions({ + onSuccess(res) { + if (res.url) { + window.location.href = res.url; + } + }, + }) + ); + + const title = type === 'sign-up' ? `Sign up with ${name}` : `Sign in with ${name}`; + + return ( +
+ + {isLastUsed && ( + + Used last time + + )} +
+ ); +} diff --git a/apps/start/src/hooks/use-logout.ts b/apps/start/src/hooks/use-logout.ts index 9fd38e578..933b8c820 100644 --- a/apps/start/src/hooks/use-logout.ts +++ b/apps/start/src/hooks/use-logout.ts @@ -6,7 +6,8 @@ export function useLogout() { const signOut = useMutation( trpc.auth.signOut.mutationOptions({ onSuccess() { - window.location.href = '/'; + // `noredirect` so AUTH_AUTO_REDIRECT doesn't sign them back in. + window.location.href = '/login?noredirect=1'; }, }), ); diff --git a/apps/start/src/modals/confirm-delete-account.tsx b/apps/start/src/modals/confirm-delete-account.tsx index 473a69d44..9eb689b0f 100644 --- a/apps/start/src/modals/confirm-delete-account.tsx +++ b/apps/start/src/modals/confirm-delete-account.tsx @@ -19,8 +19,8 @@ export default function ConfirmDeleteAccount() { onError: handleError, onSuccess: () => { toast.success('Your account has been deleted'); - // The session is now gone server-side; send the user back to the start. - window.location.href = '/'; + // `noredirect` so AUTH_AUTO_REDIRECT doesn't recreate the account. + window.location.href = '/login?noredirect=1'; }, }), ); diff --git a/apps/start/src/routes/_login.login.tsx b/apps/start/src/routes/_login.login.tsx index ef36777ed..29a6eb928 100644 --- a/apps/start/src/routes/_login.login.tsx +++ b/apps/start/src/routes/_login.login.tsx @@ -1,12 +1,16 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { AlertCircle } from 'lucide-react'; +import { useEffect, useRef } from 'react'; import { z } from 'zod'; 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 { useTRPC } from '@/integrations/trpc/react'; import { createTitle, PAGE_TITLES } from '@/utils/title'; export const Route = createFileRoute('/_login/login')({ @@ -21,15 +25,52 @@ export const Route = createFileRoute('/_login/login')({ error: z.string().optional(), correlationId: z.string().optional(), inviteId: z.string().optional(), + noredirect: z.unknown().optional(), }), + loader: async ({ context }) => { + await context.queryClient.prefetchQuery( + context.trpc.auth.authProviders.queryOptions() + ); + }, }); function LoginPage() { - const { error, correlationId, inviteId } = Route.useSearch(); + const { error, correlationId, inviteId, noredirect } = Route.useSearch(); + const trpc = useTRPC(); const [lastProvider] = useCookieStore( 'last-auth-provider', null ); + const { data: providers } = useQuery( + trpc.auth.authProviders.queryOptions() + ); + const signInOAuth = useMutation( + trpc.auth.signInOAuth.mutationOptions({ + onSuccess(res) { + if (res.url) { + window.location.href = res.url; + } + }, + }) + ); + + const autoRedirect = providers?.autoRedirect ?? null; + // A failed login must not bounce back to the provider, or it loops. + const skipAutoRedirect = error !== undefined || noredirect !== undefined; + const hasStartedRedirect = useRef(false); + const startRedirect = signInOAuth.mutate; + + useEffect(() => { + if (!autoRedirect || skipAutoRedirect || hasStartedRedirect.current) { + return; + } + hasStartedRedirect.current = true; + startRedirect({ provider: autoRedirect, inviteId }); + }, [autoRedirect, skipAutoRedirect, inviteId, startRedirect]); + + const hasOAuthProviders = Boolean( + providers?.google || providers?.github || providers?.oidc + ); return (
@@ -72,19 +113,35 @@ function LoginPage() { )} -
- - -
- + {hasOAuthProviders && ( + <> +
+ {providers?.google && ( + + )} + {providers?.github && ( + + )} + {providers?.oidc && ( + + )} +
+ + + )}
); diff --git a/apps/start/src/routes/_public.onboarding.tsx b/apps/start/src/routes/_public.onboarding.tsx index e854db824..c8dbfed0c 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'; @@ -29,6 +30,9 @@ export const Route = createFileRoute('/_public/onboarding')({ validateSearch, loader: async ({ context, location }) => { const search = validateSearch.safeParse(location.search); + await context.queryClient.prefetchQuery( + context.trpc.auth.authProviders.queryOptions() + ); if (search.success && search.data.inviteId) { await context.queryClient.prefetchQuery( context.trpc.organization.getInvite.queryOptions({ @@ -53,6 +57,12 @@ function Component() { } ) ); + const { data: providers } = useQuery( + trpc.auth.authProviders.queryOptions() + ); + const hasOAuthProviders = Boolean( + providers?.google || providers?.github || providers?.oidc + ); return (
@@ -119,15 +129,28 @@ function Component() { )}
-
- - -
+ {hasOAuthProviders && ( +
+ {providers?.github && ( + + )} + {providers?.google && ( + + )} + {providers?.oidc && ( + + )} +
+ )}

No credit card required · Free 30-day trial · Cancel anytime

- + {hasOAuthProviders && }
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 1f170515a..81ce32266 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,5 +1,7 @@ export * from './cookie'; export * from './oauth'; +export * from './oidc'; +export * from './providers'; export * from './password'; export * from './session'; export * from './totp'; diff --git a/packages/auth/src/oidc.test.ts b/packages/auth/src/oidc.test.ts new file mode 100644 index 000000000..6e7a1c87c --- /dev/null +++ b/packages/auth/src/oidc.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { loadOidcConfig, parseOidcScopes } from './oidc'; + +const FULL_ENV = { + OIDC_CLIENT_ID: 'openpanel', + OIDC_CLIENT_SECRET: 'secret', + OIDC_REDIRECT_URI: 'http://localhost:3333/oauth/oidc/callback', + OIDC_AUTHORIZATION_ENDPOINT: 'https://auth.example.com/authorize', + OIDC_TOKEN_ENDPOINT: 'http://keycloak:8080/token', + OIDC_USERINFO_ENDPOINT: 'http://keycloak:8080/userinfo', +}; + +describe('loadOidcConfig', () => { + it('is disabled when OIDC_CLIENT_ID is unset', () => { + expect(loadOidcConfig({})).toBeNull(); + expect(loadOidcConfig({ OIDC_CLIENT_SECRET: 'secret' })).toBeNull(); + }); + + it('is disabled when OIDC_CLIENT_ID is empty', () => { + expect(loadOidcConfig({ ...FULL_ENV, OIDC_CLIENT_ID: '' })).toBeNull(); + }); + + it.each([ + 'OIDC_CLIENT_SECRET', + 'OIDC_REDIRECT_URI', + 'OIDC_AUTHORIZATION_ENDPOINT', + 'OIDC_TOKEN_ENDPOINT', + 'OIDC_USERINFO_ENDPOINT', + ])('throws and names %s when it is missing', (missing) => { + const env = { ...FULL_ENV, [missing]: undefined }; + expect(() => loadOidcConfig(env)).toThrow(missing); + }); + + it('reads every endpoint, allowing internal token/userinfo urls', () => { + const config = loadOidcConfig(FULL_ENV); + expect(config).toMatchObject({ + clientId: 'openpanel', + clientSecret: 'secret', + authorizationEndpoint: 'https://auth.example.com/authorize', + tokenEndpoint: 'http://keycloak:8080/token', + userinfoEndpoint: 'http://keycloak:8080/userinfo', + }); + }); + + it('defaults the button name to SSO', () => { + expect(loadOidcConfig(FULL_ENV)?.name).toBe('SSO'); + expect(loadOidcConfig({ ...FULL_ENV, OIDC_NAME: 'Keycloak' })?.name).toBe( + 'Keycloak' + ); + }); +}); + +describe('parseOidcScopes', () => { + it('defaults to the standard OIDC scopes', () => { + expect(parseOidcScopes(undefined)).toEqual(['openid', 'profile', 'email']); + }); + + it('splits on whitespace', () => { + expect(parseOidcScopes('openid email\tgroups')).toEqual([ + 'openid', + 'email', + 'groups', + ]); + }); + + it('yields no scopes when explicitly empty', () => { + // OAuth2-only servers reject the `openid` scope. + expect(parseOidcScopes('')).toEqual([]); + expect(parseOidcScopes(' ')).toEqual([]); + }); +}); diff --git a/packages/auth/src/oidc.ts b/packages/auth/src/oidc.ts new file mode 100644 index 000000000..03f71f939 --- /dev/null +++ b/packages/auth/src/oidc.ts @@ -0,0 +1,68 @@ +import * as Arctic from 'arctic'; + +/** + * Generic OIDC login provider, configured from the environment. + */ + +export interface OidcConfig { + clientId: string; + clientSecret: string; + redirectUri: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + scopes: string[]; + name: string; +} + +type AuthEnv = Record; + +const DEFAULT_OIDC_SCOPES = ['openid', 'profile', 'email']; +const DEFAULT_OIDC_NAME = 'SSO'; +const WHITESPACE = /\s+/; + +/** An empty value yields no scopes, which OAuth2-only servers need. */ +export function parseOidcScopes(raw: string | undefined): string[] { + if (raw === undefined) { + return [...DEFAULT_OIDC_SCOPES]; + } + return raw.split(WHITESPACE).filter((scope) => scope.length > 0); +} + +function requireVar(env: AuthEnv, name: string): string { + const value = env[name]; + if (!value) { + throw new Error(`${name} is required when OIDC_CLIENT_ID is set`); + } + return value; +} + +/** + * Null when unconfigured, throws when half-configured so a broken setup never boots. + */ +export function loadOidcConfig(env: AuthEnv = process.env): OidcConfig | null { + if (!env.OIDC_CLIENT_ID) { + return null; + } + + return { + clientId: env.OIDC_CLIENT_ID, + clientSecret: requireVar(env, 'OIDC_CLIENT_SECRET'), + redirectUri: requireVar(env, 'OIDC_REDIRECT_URI'), + authorizationEndpoint: requireVar(env, 'OIDC_AUTHORIZATION_ENDPOINT'), + tokenEndpoint: requireVar(env, 'OIDC_TOKEN_ENDPOINT'), + userinfoEndpoint: requireVar(env, 'OIDC_USERINFO_ENDPOINT'), + scopes: parseOidcScopes(env.OIDC_SCOPES), + name: env.OIDC_NAME || DEFAULT_OIDC_NAME, + }; +} + +export const oidcConfig = loadOidcConfig(); + +export const oidc = oidcConfig + ? new Arctic.OAuth2Client( + oidcConfig.clientId, + oidcConfig.clientSecret, + oidcConfig.redirectUri + ) + : null; diff --git a/packages/auth/src/providers.test.ts b/packages/auth/src/providers.test.ts new file mode 100644 index 000000000..ca134f53c --- /dev/null +++ b/packages/auth/src/providers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { loadOidcConfig } from './oidc'; +import { + getAuthProviderWarnings, + getConfiguredAuthProviders, +} from './providers'; + +const FULL_OIDC_ENV = { + OIDC_CLIENT_ID: 'openpanel', + OIDC_CLIENT_SECRET: 'secret', + OIDC_REDIRECT_URI: 'http://localhost:3333/oauth/oidc/callback', + OIDC_AUTHORIZATION_ENDPOINT: 'https://auth.example.com/authorize', + OIDC_TOKEN_ENDPOINT: 'http://keycloak:8080/token', + OIDC_USERINFO_ENDPOINT: 'http://keycloak:8080/userinfo', +}; + +describe('getConfiguredAuthProviders', () => { + it('reports nothing configured on a default self-hosted install', () => { + expect(getConfiguredAuthProviders({}, null)).toEqual({ + google: false, + github: false, + oidc: false, + autoRedirect: null, + }); + }); + + it('reports google and github from their own env vars', () => { + const providers = getConfiguredAuthProviders( + { GOOGLE_CLIENT_ID: 'g', GITHUB_CLIENT_ID: 'gh' }, + null + ); + expect(providers).toMatchObject({ google: true, github: true, oidc: false }); + }); + + it('exposes the oidc button name and never the secret', () => { + const config = loadOidcConfig({ ...FULL_OIDC_ENV, OIDC_NAME: 'Keycloak' }); + const providers = getConfiguredAuthProviders({}, config); + expect(providers.oidc).toEqual({ name: 'Keycloak' }); + expect(JSON.stringify(providers)).not.toContain('secret'); + }); + + it('auto-redirects when the flag is on and one provider is configured', () => { + const config = loadOidcConfig(FULL_OIDC_ENV); + const env = { AUTH_AUTO_REDIRECT: 'true' }; + expect(getConfiguredAuthProviders(env, config).autoRedirect).toBe('oidc'); + expect(getAuthProviderWarnings(env, config)).toEqual([]); + }); + + it('auto-redirects to google when it is the only provider', () => { + const env = { AUTH_AUTO_REDIRECT: 'true', GOOGLE_CLIENT_ID: 'g' }; + expect(getConfiguredAuthProviders(env, null).autoRedirect).toBe('google'); + }); + + it('does not auto-redirect when two providers are configured', () => { + const config = loadOidcConfig(FULL_OIDC_ENV); + const env = { AUTH_AUTO_REDIRECT: 'true', GOOGLE_CLIENT_ID: 'google' }; + expect(getConfiguredAuthProviders(env, config).autoRedirect).toBeNull(); + expect(getAuthProviderWarnings(env, config)).toHaveLength(1); + }); + + it('does not auto-redirect when no provider is configured', () => { + const env = { AUTH_AUTO_REDIRECT: 'true' }; + expect(getConfiguredAuthProviders(env, null).autoRedirect).toBeNull(); + expect(getAuthProviderWarnings(env, null)).toHaveLength(1); + }); + + it.each(['false', '0', 'no', ''])( + 'treats AUTH_AUTO_REDIRECT=%o as off', + (value) => { + const config = loadOidcConfig(FULL_OIDC_ENV); + const env = { AUTH_AUTO_REDIRECT: value }; + expect(getConfiguredAuthProviders(env, config).autoRedirect).toBeNull(); + expect(getAuthProviderWarnings(env, config)).toEqual([]); + } + ); +}); diff --git a/packages/auth/src/providers.ts b/packages/auth/src/providers.ts new file mode 100644 index 000000000..7b99ef917 --- /dev/null +++ b/packages/auth/src/providers.ts @@ -0,0 +1,69 @@ +import { type OidcConfig, oidcConfig } from './oidc'; + +/** Which login providers are configured, and whether to skip straight to one. */ + +type AuthEnv = Record; + +export type AuthProviderId = 'google' | 'github' | 'oidc'; + +export interface ConfiguredAuthProviders { + google: boolean; + github: boolean; + oidc: false | { name: string }; + /** Set only when AUTH_AUTO_REDIRECT is on and exactly one provider exists. */ + autoRedirect: AuthProviderId | null; +} + +// Explicit comparison, so the string "false" is not read as on. +function isFlagEnabled(value: string | undefined): boolean { + return value === 'true' || value === '1'; +} + +function resolveAuthProviders(env: AuthEnv, config: OidcConfig | null) { + const enabled: AuthProviderId[] = []; + if (env.GOOGLE_CLIENT_ID) { + enabled.push('google'); + } + if (env.GITHUB_CLIENT_ID) { + enabled.push('github'); + } + if (config) { + enabled.push('oidc'); + } + + const warnings: string[] = []; + let autoRedirect: AuthProviderId | null = null; + + if (isFlagEnabled(env.AUTH_AUTO_REDIRECT)) { + if (enabled.length === 1) { + autoRedirect = enabled[0]!; + } else { + warnings.push( + `AUTH_AUTO_REDIRECT is enabled but ${enabled.length} login providers are configured (${enabled.join(', ') || 'none'}). It only applies when exactly one is configured, so the login page will render normally.` + ); + } + } + + const providers: ConfiguredAuthProviders = { + google: enabled.includes('google'), + github: enabled.includes('github'), + oidc: config ? { name: config.name } : false, + autoRedirect, + }; + + return { providers, warnings }; +} + +export function getConfiguredAuthProviders( + env: AuthEnv = process.env, + config: OidcConfig | null = oidcConfig +): ConfiguredAuthProviders { + return resolveAuthProviders(env, config).providers; +} + +export function getAuthProviderWarnings( + env: AuthEnv = process.env, + config: OidcConfig | null = oidcConfig +): string[] { + return resolveAuthProviders(env, config).warnings; +} diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index 5f7dec314..0e4c59c3e 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -9,11 +9,14 @@ import { generateRecoveryCodes, generateSessionToken, generateTotpSecret, + getConfiguredAuthProviders, github, google, hashPassword, hashRecoveryCodes, invalidateSession, + oidc, + oidcConfig, setLastAuthProviderCookie, setSessionTokenCookie, validateSessionToken, @@ -53,7 +56,7 @@ const TWO_FACTOR_COOKIE = '2fa_challenge'; const TWO_FACTOR_CHALLENGE_TTL_SECONDS = 5 * 60; const INVITE_COOKIE = 'inviteId'; -const zProvider = z.enum(['email', 'google', 'github']); +const zProvider = z.enum(['email', 'google', 'github', 'oidc']); /** * Best-effort consumption of an invite for a user that just authenticated. @@ -116,6 +119,34 @@ export const authRouter = createTRPCRouter({ }; } + if (provider === 'oidc') { + if (!(oidc && oidcConfig)) { + throw new TRPCNotFoundError('OIDC login is not configured'); + } + + const state = Arctic.generateState(); + const codeVerifier = Arctic.generateCodeVerifier(); + const url = oidc.createAuthorizationURLWithPKCE( + oidcConfig.authorizationEndpoint, + state, + Arctic.CodeChallengeMethod.S256, + codeVerifier, + oidcConfig.scopes + ); + + 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, [ @@ -136,6 +167,7 @@ export const authRouter = createTRPCRouter({ url: url.toString(), }; }), + authProviders: publicProcedure.query(() => getConfiguredAuthProviders()), signUpEmail: publicProcedure .use( rateLimitMiddleware({ From 849a63482491ad7451797db5b6a894748e5827a3 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 9 Sep 2026 09:42:35 +0200 Subject: [PATCH 2/2] coderabit pr fixes --- .../oauth-callback.controller.test.ts | 89 ++++++++++++++++++- apps/start/src/routes/_public.onboarding.tsx | 21 ++--- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/apps/api/src/controllers/oauth-callback.controller.test.ts b/apps/api/src/controllers/oauth-callback.controller.test.ts index 9a7ef8003..3f2440c0e 100644 --- a/apps/api/src/controllers/oauth-callback.controller.test.ts +++ b/apps/api/src/controllers/oauth-callback.controller.test.ts @@ -15,6 +15,7 @@ const { setSessionTokenCookieMock, validateAuthorizationCodeMock, getIsRegistrationAllowedMock, + cookieOptions, } = vi.hoisted(() => ({ accountFindFirst: vi.fn(), userCreate: vi.fn(), @@ -23,10 +24,18 @@ const { setSessionTokenCookieMock: vi.fn(), validateAuthorizationCodeMock: vi.fn(), getIsRegistrationAllowedMock: vi.fn(), + cookieOptions: { + domain: undefined, + secure: false, + sameSite: 'lax', + httpOnly: true, + path: '/', + }, })); vi.mock('@openpanel/auth', () => ({ Arctic: { decodeIdToken: vi.fn() }, + COOKIE_OPTIONS: cookieOptions, createSession: createSessionMock, generateSessionToken: () => 'session-token', github: {}, @@ -61,14 +70,15 @@ const { mapOidcUser, oidcCallback } = await import( function makeReply() { const redirect = vi.fn(); + const clearCookie = vi.fn(); const reply = { redirect, - clearCookie: vi.fn(), + clearCookie, setCookie: vi.fn(), log: { error: vi.fn() }, request: { id: 'req-1' }, }; - return { reply: reply as unknown as FastifyReply, redirect }; + return { reply: reply as unknown as FastifyReply, redirect, clearCookie }; } function makeReq(cookies: Record = {}) { @@ -90,6 +100,12 @@ function userInfoResponse(body: unknown) { } as unknown as Response; } +function stubUserInfo(body: unknown) { + const fetchMock = vi.fn().mockResolvedValue(userInfoResponse(body)); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + function redirectedError(redirect: ReturnType) { const url = new URL(redirect.mock.calls[0]?.[0] as string); return { pathname: url.pathname, error: url.searchParams.get('error') }; @@ -280,4 +296,73 @@ describe('oidcCallback', () => { }) ); }); + + it('creates the user, the session and the session cookie for a new subject', async () => { + stubUserInfo({ sub: 'sub-1', email: 'ada@example.com' }); + const { reply, redirect } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(userCreate).toHaveBeenCalledWith({ + data: { + email: 'ada@example.com', + firstName: 'ada', + lastName: '', + accounts: { create: { provider: 'oidc', providerId: 'sub-1' } }, + }, + }); + expect(createSessionMock).toHaveBeenCalledWith('session-token', 'user-1'); + expect(setSessionTokenCookieMock).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith('http://localhost:3000'); + }); + + it('signs a known subject in without creating a second user', async () => { + accountFindFirst.mockResolvedValue({ id: 'account-1', userId: 'user-9' }); + stubUserInfo({ sub: 'sub-1', email: 'ada@example.com' }); + const { reply, redirect } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(userCreate).not.toHaveBeenCalled(); + expect(createSessionMock).toHaveBeenCalledWith('session-token', 'user-9'); + expect(setSessionTokenCookieMock).toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith('http://localhost:3000'); + }); + + it('refuses a new subject whose email already belongs to another login method', async () => { + userFindFirst.mockResolvedValue({ id: 'user-9', email: 'ada@example.com' }); + stubUserInfo({ sub: 'sub-1', email: 'ada@example.com' }); + const { reply, redirect } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(userCreate).not.toHaveBeenCalled(); + expect(setSessionTokenCookieMock).not.toHaveBeenCalled(); + expect(redirectedError(redirect).error).toMatch(/original authentication/i); + }); + + it('creates no user when registration is closed', async () => { + getIsRegistrationAllowedMock.mockResolvedValue(false); + stubUserInfo({ sub: 'sub-1', email: 'ada@example.com' }); + const { reply, redirect } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(userCreate).not.toHaveBeenCalled(); + expect(setSessionTokenCookieMock).not.toHaveBeenCalled(); + expect(redirectedError(redirect).error).toMatch(/not allowed/i); + }); + + it('clears the state and verifier cookies with the options they were set with', async () => { + stubUserInfo({ sub: 'sub-1', email: 'ada@example.com' }); + const { reply, clearCookie } = makeReply(); + + await oidcCallback(makeReq(), reply); + + expect(clearCookie).toHaveBeenCalledWith( + 'oidc_code_verifier', + cookieOptions + ); + expect(clearCookie).toHaveBeenCalledWith('oidc_oauth_state', cookieOptions); + }); }); diff --git a/apps/start/src/routes/_public.onboarding.tsx b/apps/start/src/routes/_public.onboarding.tsx index c8dbfed0c..6c804e80f 100644 --- a/apps/start/src/routes/_public.onboarding.tsx +++ b/apps/start/src/routes/_public.onboarding.tsx @@ -30,16 +30,17 @@ export const Route = createFileRoute('/_public/onboarding')({ validateSearch, loader: async ({ context, location }) => { const search = validateSearch.safeParse(location.search); - await context.queryClient.prefetchQuery( - context.trpc.auth.authProviders.queryOptions() - ); - if (search.success && search.data.inviteId) { - await context.queryClient.prefetchQuery( - context.trpc.organization.getInvite.queryOptions({ - inviteId: search.data.inviteId, - }) - ); - } + const inviteId = search.success ? search.data.inviteId : undefined; + await Promise.all([ + context.queryClient.prefetchQuery( + context.trpc.auth.authProviders.queryOptions() + ), + inviteId + ? context.queryClient.prefetchQuery( + context.trpc.organization.getInvite.queryOptions({ inviteId }) + ) + : null, + ]); }, pendingComponent: FullPageLoadingState, });