Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 103 additions & 5 deletions apps/api/src/controllers/oauth-callback.controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
generateSessionToken,
github,
google,
isOidcEnabled,
type OAuth2Tokens,
oidc,
OIDC_TOKEN_ENDPOINT,
setLastAuthProviderCookie,
setSessionTokenCookie,
} from '@openpanel/auth';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -190,6 +193,48 @@ async function fetchGithubUser(accessToken: string): Promise<OAuthUser> {
};
}

async function fetchOidcUser(tokens: OAuth2Tokens): Promise<OAuthUser> {
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<OAuthUser> {
const claims = Arctic.decodeIdToken(tokens.idToken());

Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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!
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/routes/oauth-callback.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
64 changes: 64 additions & 0 deletions apps/start/src/components/auth/sign-in-oidc.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<Button
className="w-full border border-def-300 bg-background text-foreground shadow-sm transition-all duration-200 hover:bg-def-100 hover:shadow-md [&_svg]:shrink-0"
onClick={() =>
mutation.mutate({
provider: 'oidc',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
size="lg"
>
<KeyRound className="mr-2 size-4" />
{title}
</Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 rounded-full bg-highlight px-1.5 py-0.5 font-medium text-[10px] text-white leading-none">
Used last time
</span>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions apps/start/src/routes/_login.login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,6 +73,7 @@ function LoginPage() {
)}

<div className="space-y-4">
<SignInOidc isLastUsed={lastProvider === 'oidc'} type="sign-in" />
<SignInGoogle isLastUsed={lastProvider === 'google'} type="sign-in" />
<SignInGithub isLastUsed={lastProvider === 'github'} type="sign-in" />
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/start/src/routes/_public.onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -107,6 +108,7 @@ function Component() {
)}

<div className="space-y-6">
<SignInOidc inviteId={inviteId} type="sign-up" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SignInGithub inviteId={inviteId} type="sign-up" />
<SignInGoogle inviteId={inviteId} type="sign-up" />
Expand Down
4 changes: 4 additions & 0 deletions apps/start/src/routes/api/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')({
Expand Down
11 changes: 11 additions & 0 deletions apps/start/src/server/get-envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions packages/auth/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <dashboard origin>/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
);
35 changes: 34 additions & 1 deletion packages/trpc/src/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
hashPassword,
hashRecoveryCodes,
invalidateSession,
isOidcEnabled,
oidc,
OIDC_AUTHORIZATION_ENDPOINT,
setLastAuthProviderCookie,
setSessionTokenCookie,
validateSessionToken,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, [
Expand Down