From 4546615713279af65d2584cd783f5501c07ac700 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 16 Jun 2026 16:35:11 -0700 Subject: [PATCH] feat: add custom OIDC provider support (team plan+ only) (#1594) --- .../oauth/authorize/[provider_id]/route.tsx | 2 +- .../oauth/callback/[provider_id]/route.tsx | 2 +- .../access-token/crud.tsx | 2 +- .../[provider_id]/access-token/crud.tsx | 2 +- .../neon/oauth-providers/crud.tsx | 22 +- .../app/api/latest/oauth-providers/crud.tsx | 14 +- apps/backend/src/lib/config.tsx | 4 + apps/backend/src/lib/projects.tsx | 60 ++- apps/backend/src/oauth/index.tsx | 20 + .../src/oauth/providers/custom-oidc.tsx | 52 +++ .../[projectId]/auth-methods/page-client.tsx | 430 +++++++++++++++++- .../shared/src/config/schema-fuzzer.test.ts | 3 + packages/shared/src/config/schema.ts | 13 +- packages/shared/src/schema-fields.ts | 2 + packages/shared/src/utils/oauth.tsx | 8 + .../lib/hexclave-app/project-configs/index.ts | 8 + .../src/lib/hexclave-app/projects/index.ts | 24 +- packages/ui/src/components/brand-icons.tsx | 45 +- 18 files changed, 630 insertions(+), 83 deletions(-) create mode 100644 apps/backend/src/oauth/providers/custom-oidc.tsx diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index 7d621dc09..00f72e69d 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -140,7 +140,7 @@ export const GET = createSmartRouteHandler({ // Hexclave rebrand: prefer the new query param name, accept the legacy one, // and only fall back to "redirect" when neither was provided. const responseMode = query.hexclave_response_mode ?? query.stack_response_mode ?? "redirect"; - const providerObj = await getProvider(provider); + const providerObj = await getProvider(provider, provider.id); const oauthUrl = providerObj.getAuthorizationUrl({ codeVerifier: innerCodeVerifier, state: innerState, diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 7fd915275..2b86a0d78 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -189,7 +189,7 @@ const handler = createSmartRouteHandler({ throwCheckApiKeySetError(keyCheck.error, tenancy.project.id, new KnownErrors.InvalidPublishableClientKey(tenancy.project.id)); } - const providerObj = await getProvider(provider as any); + const providerObj = await getProvider(provider as any, provider.id); let callbackResult: Awaited>; try { callbackResult = await providerObj.getCallback({ diff --git a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx index ee8891c4c..44e7dedb8 100644 --- a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx +++ b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx @@ -41,7 +41,7 @@ export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy( throw new KnownErrors.OAuthConnectionNotConnectedToUser(); } - const providerInstance = await getProvider(provider); + const providerInstance = await getProvider(provider, provider.id); const prisma = await getPrismaClientForTenancy(auth.tenancy); const oauthAccount = await prisma.projectUserOAuthAccount.findFirst({ diff --git a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx index a7fb50eef..d1058e954 100644 --- a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx +++ b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx @@ -40,7 +40,7 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre // refresh and access-token-validity methods; neither uses `redirect_uri`. // `getProvider` resolves the callback URL from the provider's own config, so // this flow doesn't need to supply one. - const providerInstance = await getProvider(provider); + const providerInstance = await getProvider(provider, provider.id); const prisma = await getPrismaClientForTenancy(auth.tenancy); // Legacy endpoint: search tokens across ALL accounts for this provider and user diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx index e9d98ae96..f991cd928 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx @@ -69,9 +69,21 @@ const oauthProvidersCrud = createCrud({ }, }); +/** + * Returns only standard/shared providers for the legacy Neon integration API. + * Custom OIDC providers are excluded since they don't fit the legacy schema. + */ +function getStandardProviders(config: Tenancy['config']) { + return Object.values(config.auth.oauth.providers).filter(p => p.type !== "custom_oidc"); +} + function oauthProviderConfigToLegacyConfig(provider: Tenancy['config']['auth']['oauth']['providers'][string]) { + const providerType = provider.type; + if (providerType === "custom_oidc") { + throwErr("oauthProviderConfigToLegacyConfig must not be called with custom_oidc providers"); + } return { - id: provider.type || throwErr('Provider type is required'), + id: providerType || throwErr('Provider type is required'), type: provider.isShared ? 'shared' : 'standard', client_id: provider.clientId, client_secret: provider.clientSecret, @@ -104,7 +116,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle data: { config: { oauth_providers: [ - ...Object.values(auth.tenancy.config.auth.oauth.providers).map(oauthProviderConfigToLegacyConfig), + ...getStandardProviders(auth.tenancy.config).map(oauthProviderConfigToLegacyConfig), { id: data.id, type: data.type ?? 'shared', @@ -130,7 +142,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle branchId: auth.branchId, data: { config: { - oauth_providers: Object.values(auth.tenancy.config.auth.oauth.providers) + oauth_providers: getStandardProviders(auth.tenancy.config) .map(provider => provider.type === params.oauth_provider_id ? { ...oauthProviderConfigToLegacyConfig(provider), ...data, @@ -144,7 +156,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle }, onList: async ({ auth }) => { return { - items: Object.values(auth.tenancy.config.auth.oauth.providers).map(oauthProviderConfigToLegacyConfig), + items: getStandardProviders(auth.tenancy.config).map(oauthProviderConfigToLegacyConfig), is_paginated: false, }; }, @@ -159,7 +171,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle branchId: auth.branchId, data: { config: { - oauth_providers: Object.values(auth.tenancy.config.auth.oauth.providers) + oauth_providers: getStandardProviders(auth.tenancy.config) .filter(provider => provider.type !== params.oauth_provider_id) .map(oauthProviderConfigToLegacyConfig), } diff --git a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx index 07957433a..bab67e7b6 100644 --- a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx @@ -119,17 +119,12 @@ function findProviderConfig(tenancy: Tenancy, providerConfigId: string) { return null; } -function getProviderConfig(tenancy: Tenancy, providerConfigId: string) { - const config = findProviderConfig(tenancy, providerConfigId); - if (!config) { - throw new StatusError(StatusError.NotFound, `OAuth provider ${providerConfigId} not found or not configured`); - } - return config; -} function resolveProviderType(tenancy: Tenancy, configOAuthProviderId: string): ProviderType | null { const config = findProviderConfig(tenancy, configOAuthProviderId); if (config?.type != null) { + // Custom OIDC providers don't have a standard ProviderType + if (config.type === "custom_oidc") return null; return config.type; } if ((allProviders as readonly string[]).includes(configOAuthProviderId)) { @@ -383,7 +378,8 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler }, async onCreate({ auth, data }) { const prismaClient = await getPrismaClientForTenancy(auth.tenancy); - const providerConfig = getProviderConfig(auth.tenancy, data.provider_config_id); + const providerType = resolveProviderType(auth.tenancy, data.provider_config_id) + ?? throwErr(new StatusError(StatusError.NotFound, `OAuth provider ${data.provider_config_id} not found or not configured`)); await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: data.user_id }); @@ -434,7 +430,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler email: data.email, id: created.id, provider_config_id: data.provider_config_id, - type: providerConfig.type as any, + type: providerType, allow_sign_in: data.allow_sign_in, allow_connected_accounts: data.allow_connected_accounts, account_id: data.account_id, diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 6cbb786a5..83c754c4a 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1116,6 +1116,10 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete if (!oauthProvider.type) { return undefined; } + // Custom OIDC providers are managed via config, not the legacy CRUD API + if (oauthProvider.type === "custom_oidc") { + return undefined; + } if (!oauthProvider.allowSignIn) { return undefined; } diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index a30be0a30..1638db5fc 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -219,6 +219,44 @@ export async function createOrUpdateProjectWithLegacyConfig( return permissions ? typedFromEntries(permissions.map((permission) => [permission.id, true])) : undefined; }; const dataOptions = options.data.config || {}; + + // When the legacy oauth_providers array is provided, it replaces the entire + // auth.oauth.providers config. Custom OIDC entries aren't representable in the + // legacy format, so we preserve them by merging them back in for update operations. + let oauthProvidersOverride: CompleteConfig['auth']['oauth']['providers'] | undefined; + if (dataOptions.oauth_providers) { + const standardEntries = typedFromEntries(dataOptions.oauth_providers.map((provider) => { + return [ + provider.id, + { + type: provider.id, + isShared: provider.type === "shared", + clientId: provider.client_id, + clientSecret: provider.client_secret, + // Injecting the hexclave-branded callback for new providers is the + // dashboard's job; this legacy path leaves it unset so providers fall + // back to the stack-auth callback. + customCallbackUrl: undefined, + facebookConfigId: provider.facebook_config_id, + microsoftTenantId: provider.microsoft_tenant_id, + appleBundles: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(bundleId => [generateUuid(), { bundleId }] as const)) : undefined, + allowSignIn: true, + allowConnectedAccounts: true, + } satisfies CompleteConfig['auth']['oauth']['providers'][string] + ]; + })); + if (options.type === "update") { + const tenancy = await getSoleTenancyFromProjectBranch(projectId, branchId); + const customOidcEntries = typedFromEntries( + Object.entries(tenancy.config.auth.oauth.providers) + .filter(([_, p]) => p.type === "custom_oidc") + ); + oauthProvidersOverride = { ...customOidcEntries, ...standardEntries }; + } else { + oauthProvidersOverride = standardEntries; + } + } + const configOverrideOverride: EnvironmentConfigOverrideOverride = filterUndefined({ // ======================= auth ======================= 'auth.allowSignUp': dataOptions.sign_up_enabled, @@ -226,27 +264,7 @@ export async function createOrUpdateProjectWithLegacyConfig( 'auth.otp.allowSignIn': dataOptions.magic_link_enabled, 'auth.passkey.allowSignIn': dataOptions.passkey_enabled, 'auth.oauth.accountMergeStrategy': dataOptions.oauth_account_merge_strategy, - 'auth.oauth.providers': dataOptions.oauth_providers ? typedFromEntries(dataOptions.oauth_providers - .map((provider) => { - return [ - provider.id, - { - type: provider.id, - isShared: provider.type === "shared", - clientId: provider.client_id, - clientSecret: provider.client_secret, - // Injecting the hexclave-branded callback for new providers is the - // dashboard's job; this legacy path leaves it unset so providers fall - // back to the stack-auth callback. - customCallbackUrl: undefined, - facebookConfigId: provider.facebook_config_id, - microsoftTenantId: provider.microsoft_tenant_id, - appleBundles: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(bundleId => [generateUuid(), { bundleId }] as const)) : undefined, - allowSignIn: true, - allowConnectedAccounts: true, - } satisfies CompleteConfig['auth']['oauth']['providers'][string] - ]; - })) : undefined, + 'auth.oauth.providers': oauthProvidersOverride, // ======================= users ======================= 'users.allowClientUserDeletion': dataOptions.client_user_deletion_enabled, // ======================= teams ======================= diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index 0d3226c39..2f7674922 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -8,6 +8,7 @@ import { OAuthModel } from "./model"; import { AppleProvider } from "./providers/apple"; import { OAuthBaseProvider } from "./providers/base"; import { BitbucketProvider } from "./providers/bitbucket"; +import { CustomOidcProvider } from "./providers/custom-oidc"; import { FacebookProvider } from "./providers/facebook"; import { GithubProvider } from "./providers/github"; import { GitlabProvider } from "./providers/gitlab"; @@ -105,8 +106,27 @@ import.meta.vitest?.test("getRedirectUri keeps existing customers on the stack-a export async function getProvider( provider: Tenancy['config']['auth']['oauth']['providers'][string], + /** The config key for this provider (e.g. "github", "my-okta"). Needed to + * build the callback URL when customCallbackUrl is absent. */ + configId?: string, ): Promise { const providerType = provider.type || throwErr("Provider type is required for shared providers"); + + // Custom OIDC providers use a generic OIDC implementation with discovery. + // The callback URL is keyed by the user-chosen config ID (not "custom_oidc"), + // so customCallbackUrl should always be set for these providers. + if (providerType === "custom_oidc") { + const issuerUrl = provider.issuerUrl ?? throwErr("Issuer URL is required for custom OIDC providers"); + const redirectUri = getRedirectUri(provider, configId ?? providerType, getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); + return await CustomOidcProvider.create({ + clientId: provider.clientId ?? throwErr("Client ID is required for custom OIDC providers"), + clientSecret: provider.clientSecret ?? throwErr("Client secret is required for custom OIDC providers"), + redirectUri, + issuerUrl, + scope: provider.scope, + }); + } + const redirectUri = getRedirectUri(provider, providerType, getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); if (provider.isShared) { const clientId = _getEnvForProvider(providerType).clientId; diff --git a/apps/backend/src/oauth/providers/custom-oidc.tsx b/apps/backend/src/oauth/providers/custom-oidc.tsx new file mode 100644 index 000000000..bbca781e3 --- /dev/null +++ b/apps/backend/src/oauth/providers/custom-oidc.tsx @@ -0,0 +1,52 @@ +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { OAuthBaseProvider, TokenSet } from "./base"; + +export class CustomOidcProvider extends OAuthBaseProvider { + private constructor( + ...args: ConstructorParameters + ) { + super(...args); + } + + static async create(options: { + clientId: string, + clientSecret: string, + redirectUri: string, + issuerUrl: string, + scope?: string, + }) { + const { redirectUri, issuerUrl, scope, ...rest } = options; + return new CustomOidcProvider(...await OAuthBaseProvider.createConstructorArgs({ + discoverFromUrl: issuerUrl, + redirectUri, + baseScope: scope || "openid email profile", + openid: true, + ...rest, + })); + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + const rawUserInfo = await this.oauthClient.userinfo(tokenSet.accessToken); + return validateUserInfo({ + accountId: rawUserInfo.sub, + displayName: rawUserInfo.name ?? rawUserInfo.preferred_username ?? null, + email: rawUserInfo.email ?? null, + profileImageUrl: rawUserInfo.picture ?? null, + emailVerified: !!rawUserInfo.email_verified, + }); + } + + async checkAccessTokenValidity(accessToken: string): Promise { + try { + const response = await this.oauthClient.userinfo(accessToken); + return !!response.sub; + } catch (error: any) { + // Only treat definitive auth failures (401/403) as "invalid token". + // Rethrow network/transient errors so callers don't persist false-negative validity. + if (error?.status === 401 || error?.status === 403 || error?.code === "invalid_token") { + return false; + } + throw error; + } + } +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 9a3f77e73..c19338952 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -1,8 +1,10 @@ "use client"; import { InlineSaveDiscard } from "@/components/inline-save-discard"; -import { ActionDialog, BrandIcons, BrowserFrame, Label, SimpleTooltip, Switch, Typography } from "@/components/ui"; +import { ActionDialog, BrandIcons, BrowserFrame, FormControl, FormField, FormItem, FormLabel, FormMessage, InlineCode, Label, SimpleTooltip, Switch, Typography } from "@/components/ui"; +import { FormDialog } from "@/components/form-dialog"; import { useUpdateConfig } from "@/lib/config-update"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { DesignAlert, DesignBadge, @@ -18,6 +20,7 @@ import { EnvelopeSimpleIcon, EyeIcon, GearSixIcon, + GlobeIcon, KeyIcon, LinkIcon, MagnifyingGlassIcon, @@ -31,16 +34,18 @@ import { import { AdminProject, AuthPage } from "@hexclave/next"; import type { CompleteConfig } from "@hexclave/shared/dist/config/schema"; import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields"; +import { urlSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; +import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors"; import { allProviders } from "@hexclave/shared/dist/utils/oauth"; -import { typedFromEntries } from "@hexclave/shared/dist/utils/objects"; +import { typedFromEntries, typedEntries } from "@hexclave/shared/dist/utils/objects"; +import { resolvePlanId } from "@hexclave/shared/dist/plans"; import { generateUuid } from "@hexclave/shared/dist/utils/uuids"; import { useId, useMemo, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; -import { getNewProviderCallbackUrl } from "./oauth-callback-url"; +import { getNewProviderCallbackUrl, resolveProviderCallbackUrl } from "./oauth-callback-url"; import { ProviderIcon, ProviderSettingDialog, ProviderSettingSwitch, TurnOffProviderDialog } from "./providers"; type AdminOAuthProviderConfig = AdminProject['config']['oauthProviders'][number]; @@ -146,12 +151,399 @@ function adminProviderToConfigProvider( allowConnectedAccounts: true, }; } + case 'custom_oidc': { + return { + // `as any` — same pattern as standard providers above; the CompleteConfig + // type field is the yup-inferred union which doesn't resolve cleanly + // when @hexclave/shared dist types are unavailable at dashboard tsc time. + type: "custom_oidc" as any, + isShared: false, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + customCallbackUrl: (existing && !existing.isShared) ? existing.customCallbackUrl : getNewProviderCallbackUrl(provider.id), + facebookConfigId: undefined, + microsoftTenantId: undefined, + appleBundles: undefined, + issuerUrl: provider.issuerUrl, + scope: provider.scope, + displayName: provider.displayName, + allowSignIn: true, + allowConnectedAccounts: true, + }; + } default: { throw new HexclaveAssertionError(`Unknown provider type: ${(provider as { type: unknown }).type}`); } } } +// ─── Plan gating ───────────────────────────────────────────────────────── + +function AddCustomOidcButton({ onClick }: { onClick: () => void }) { + const project = useAdminApp().useProject(); + const user = useDashboardInternalUser(); + const teams = user.useTeams(); + const ownerTeam = useMemo( + () => teams.find(t => t.id === project.ownerTeamId), + [teams, project.ownerTeamId], + ); + + if (ownerTeam == null) { + return ; + } + + return ; +} + +function AddCustomOidcButtonInner({ + team, + onClick, +}: { + team: { useProducts: () => Array<{ id: string | null, type?: string }> }, + onClick: () => void, +}) { + const products = team.useProducts(); + const planId = resolvePlanId(products); + const isTeamPlanOrAbove = planId === "team" || planId === "growth"; + + return ; +} + +function AddCustomOidcButtonDisabled({ onClick, isTeamPlanOrAbove }: { onClick: () => void, isTeamPlanOrAbove: boolean }) { + return ( + + + + Add Custom OIDC + {!isTeamPlanOrAbove && } + + + ); +} + +// ─── Custom OIDC types and helpers ──────────────────────────────────────── + +type CustomOidcConfigEntry = { + id: string, + type: "custom_oidc", + issuerUrl: string, + clientId: string, + clientSecret: string, + scope?: string, + displayName?: string, + customCallbackUrl?: string, +}; + +function getCustomOidcProviders(config: CompleteConfig): CustomOidcConfigEntry[] { + return typedEntries(config.auth.oauth.providers) + .filter(([, p]) => p.type === "custom_oidc") + .map(([id, p]) => ({ + id, + type: "custom_oidc" as const, + issuerUrl: p.issuerUrl ?? throwErr(`Custom OIDC provider "${id}" is missing issuerUrl`), + clientId: p.clientId ?? throwErr(`Custom OIDC provider "${id}" is missing clientId`), + clientSecret: p.clientSecret ?? throwErr(`Custom OIDC provider "${id}" is missing clientSecret`), + scope: p.scope, + displayName: p.displayName, + customCallbackUrl: p.customCallbackUrl, + })); +} + +const customOidcFormSchema = yupObject({ + providerId: yupString().defined().nonEmpty().matches( + /^[a-z0-9_-]+$/, + "Provider ID must only contain lowercase letters, numbers, hyphens, and underscores" + ).test( + "not-reserved", + "This ID is reserved for a standard provider. Choose a different name.", + (v) => !allProviders.includes(v as any), + ), + displayName: yupString().defined().nonEmpty(), + issuerUrl: urlSchema.defined().nonEmpty(), + clientId: yupString().defined().nonEmpty(), + clientSecret: yupString().defined().nonEmpty(), + scope: yupString().optional(), +}); + +type CustomOidcFormValues = { + providerId: string, + displayName: string, + issuerUrl: string, + clientId: string, + clientSecret: string, + scope?: string, +}; + +function CustomOidcProviderDialog({ + open, + onClose, + existing, +}: { + open: boolean, + onClose: () => void, + existing?: CustomOidcConfigEntry, +}) { + const hexclaveAdminApp = useAdminApp(); + const config = hexclaveAdminApp.useProject().useConfig(); + const updateConfig = useUpdateConfig(); + + const defaultValues: CustomOidcFormValues = { + providerId: existing?.id ?? "", + displayName: existing?.displayName ?? "", + issuerUrl: existing?.issuerUrl ?? "", + clientId: existing?.clientId ?? "", + clientSecret: existing?.clientSecret ?? "", + scope: existing?.scope ?? "", + }; + + const isEditing = !!existing; + + const onSubmit = async (values: CustomOidcFormValues) => { + const providerId = isEditing ? existing.id : values.providerId; + if (!isEditing && providerId in config.auth.oauth.providers) { + throw new HexclaveAssertionError(`OAuth provider ID "${providerId}" already exists`); + } + // `as any` — same rationale as adminProviderToConfigProvider + const configEntry: CompleteConfig['auth']['oauth']['providers'][string] = { + type: "custom_oidc" as any, + isShared: false, + clientId: values.clientId, + clientSecret: values.clientSecret, + customCallbackUrl: (isEditing && existing.customCallbackUrl) ? existing.customCallbackUrl : getNewProviderCallbackUrl(providerId), + facebookConfigId: undefined, + microsoftTenantId: undefined, + appleBundles: undefined, + issuerUrl: values.issuerUrl, + scope: values.scope || undefined, + displayName: values.displayName, + allowSignIn: true, + allowConnectedAccounts: true, + }; + await updateConfig({ + adminApp: hexclaveAdminApp, + configUpdate: { + [`auth.oauth.providers.${providerId}`]: configEntry, + }, + pushable: false, + }); + }; + + const callbackUrl = isEditing + ? resolveProviderCallbackUrl(existing.id, config.auth.oauth.providers[existing.id]) + : null; + + return ( + + defaultValues={defaultValues} + formSchema={customOidcFormSchema} + onSubmit={onSubmit} + open={open} + onClose={onClose} + title={isEditing ? `Edit ${existing.displayName || "Custom OIDC Provider"}` : "Add Custom OIDC Provider"} + cancelButton + okButton={{ label: isEditing ? "Save" : "Add Provider" }} + render={(form) => ( +
+
+
+ +
+
+
+ Custom OIDC Provider + +
+ Connect any OIDC-compliant identity provider +
+
+ + {!isEditing && ( + ( + + Provider ID + + + + + Unique identifier — lowercase letters, numbers, hyphens, underscores only. + + )} + /> + )} + + ( + + Display Name + + + + + + )} + /> + + ( + + Issuer URL + + + + + Must support OIDC discovery (/.well-known/openid-configuration). + + )} + /> + + ( + + Client ID + + + + + + )} + /> + + ( + + Client Secret + + + + + + )} + /> + + ( + + Scopes (optional) + + + + + Space-separated. Defaults to "openid email profile". + + )} + /> + + {callbackUrl && ( +
+ Redirect URL + + {callbackUrl} + +
+ )} +
+ )} + /> + ); +} + +function CustomOidcProviderInlineRow({ provider }: { provider: CustomOidcConfigEntry }) { + const hexclaveAdminApp = useAdminApp(); + const updateConfig = useUpdateConfig(); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [turnOffDialogOpen, setTurnOffDialogOpen] = useState(false); + + const deleteProvider = async () => { + await updateConfig({ + adminApp: hexclaveAdminApp, + configUpdate: { + [`auth.oauth.providers.${provider.id}`]: null, + }, + pushable: false, + }); + }; + + const items: DesignMenuActionItem[] = [ + { + id: "configure", + label: "Configure", + icon: , + onClick: () => setEditDialogOpen(true), + }, + { + id: "disable", + label: "Disable Provider", + icon: , + itemVariant: "destructive", + onClick: () => setTurnOffDialogOpen(true), + }, + ]; + + return ( + +
+
+
+ +
+ {provider.displayName || provider.id} + +
+ +
+ + setEditDialogOpen(false)} + existing={provider} + /> + + setTurnOffDialogOpen(false)} + danger + okButton={{ + label: `Disable ${provider.displayName || provider.id}`, + onClick: deleteProvider, + }} + cancelButton + confirmText="I understand that this will disable sign-in and sign-up for new and existing users with this provider." + > + + +
+ ); +} + function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpenChange?: (open: boolean) => void }) { const hexclaveAdminApp = useAdminApp(); const project = hexclaveAdminApp.useProject(); @@ -555,6 +947,9 @@ export default function PageClient() { const [confirmSignUpEnabled, setConfirmSignUpEnabled] = useState(false); const [confirmSignUpDisabled, setConfirmSignUpDisabled] = useState(false); const [disabledProvidersDialogOpen, setDisabledProvidersDialogOpen] = useState(false); + const [customOidcDialogOpen, setCustomOidcDialogOpen] = useState(false); + + const customOidcProviders = useMemo(() => getCustomOidcProviders(config), [config]); // ===== AUTH METHODS local state ===== const [localPasswordEnabled, setLocalPasswordEnabled] = useState(undefined); @@ -721,21 +1116,26 @@ export default function PageClient() { {enabledProvidersList.map(provider => ( ))} - {enabledProvidersList.length === 0 && ( + {customOidcProviders.map(provider => ( + + ))} + {enabledProvidersList.length === 0 && customOidcProviders.length === 0 && ( )} - setDisabledProvidersDialogOpen(true)} - variant="secondary" - > - - Add SSO providers - +
+ setDisabledProvidersDialogOpen(true)} + variant="secondary" + > + + Add SSO providers + + setCustomOidcDialogOpen(true)} /> +
setDisabledProvidersDialogOpen(x)} /> + setCustomOidcDialogOpen(false)} + /> {emailVerification.dialog} diff --git a/packages/shared/src/config/schema-fuzzer.test.ts b/packages/shared/src/config/schema-fuzzer.test.ts index 891216528..531f1c5f8 100644 --- a/packages/shared/src/config/schema-fuzzer.test.ts +++ b/packages/shared/src/config/schema-fuzzer.test.ts @@ -216,6 +216,9 @@ const environmentSchemaFuzzerConfig = [{ facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], appleBundles: [{ "some-bundle-id": [{ bundleId: ["com.example.app"] }] }], + issuerUrl: [undefined, "https://accounts.google.com"] as (string | undefined)[], + scope: [undefined, "openid email profile"] as (string | undefined)[], + displayName: [undefined, "My OIDC Provider"] as (string | undefined)[], }]]))] as const, }], }], diff --git a/packages/shared/src/config/schema.ts b/packages/shared/src/config/schema.ts index 61f80c2aa..46a49b3cd 100644 --- a/packages/shared/src/config/schema.ts +++ b/packages/shared/src/config/schema.ts @@ -11,7 +11,7 @@ import * as schemaFields from "../schema-fields"; import { productSchema, userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; import { SUPPORTED_CURRENCIES } from "../utils/currency-constants"; import { HexclaveAssertionError } from "../utils/errors"; -import { allProviders } from "../utils/oauth"; +import { allProviders, allProviderTypes } from "../utils/oauth"; import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, filterUndefined, get, getOrUndefined, has, isObjectLike, mapValues, set, typedAssign, typedEntries, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { stringCompare } from "../utils/strings"; @@ -23,6 +23,7 @@ export const configLevels = ['project', 'branch', 'environment', 'organization'] export type ConfigLevel = typeof configLevels[number]; const permissionRegex = /^\$?[a-z0-9_:]+$/; const customPermissionRegex = /^[a-z0-9_:]+$/; +const providerIdRegex = /^[a-z0-9_-]+$/; declare module "yup" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -116,7 +117,7 @@ const branchAuthSchema = yupObject({ providers: yupRecord( yupString().matches(permissionRegex), yupObject({ - type: yupString().oneOf(allProviders).optional(), + type: yupString().oneOf(allProviderTypes).optional(), allowSignIn: yupBoolean(), allowConnectedAccounts: yupBoolean(), }), @@ -376,9 +377,9 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ auth: branchConfigSchema.getNested("auth").concat(yupObject({ oauth: branchConfigSchema.getNested("auth").getNested("oauth").concat(yupObject({ providers: yupRecord( - yupString().matches(permissionRegex), + yupString().matches(providerIdRegex), yupObject({ - type: yupString().oneOf(allProviders).optional(), + type: yupString().oneOf(allProviderTypes).optional(), isShared: yupBoolean(), clientId: schemaFields.oauthClientIdSchema.optional(), clientSecret: schemaFields.oauthClientSecretSchema.optional(), @@ -395,6 +396,10 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ bundleId: schemaFields.oauthAppleBundleIdSchema, }), ).optional(), + // Custom OIDC provider fields (only used when type is "custom_oidc") + issuerUrl: schemaFields.oauthIssuerUrlSchema.optional(), + scope: schemaFields.oauthScopeSchema.optional(), + displayName: yupString().optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), diff --git a/packages/shared/src/schema-fields.ts b/packages/shared/src/schema-fields.ts index 805887474..b69be8078 100644 --- a/packages/shared/src/schema-fields.ts +++ b/packages/shared/src/schema-fields.ts @@ -605,6 +605,8 @@ export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { d export const oauthAppleBundleIdsSchema = yupArray(yupString().defined()).meta({ openapiField: { description: 'Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).', exampleValue: ['com.example.ios', 'com.example.macos'] } }); export const oauthAppleBundleIdSchema = yupString().defined().meta({ openapiField: { description: 'Apple Bundle ID for native iOS/macOS apps.', exampleValue: 'com.example.ios' } }); export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } }); +export const oauthIssuerUrlSchema = urlSchema.meta({ openapiField: { description: 'OIDC issuer URL for custom OIDC providers. Must support OIDC discovery (/.well-known/openid-configuration). Only used when type is "custom_oidc".', exampleValue: 'https://accounts.google.com' } }); +export const oauthScopeSchema = yupString().meta({ openapiField: { description: 'Space-separated OAuth scopes to request from the custom OIDC provider. Defaults to "openid email profile" if not specified.', exampleValue: 'openid email profile' } }); // Project email config export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } }); export const emailSenderNameSchema = yupString().meta({ openapiField: { description: 'Email sender name. Needs to be specified when using type="standard"', exampleValue: 'Stack' } }); diff --git a/packages/shared/src/utils/oauth.tsx b/packages/shared/src/utils/oauth.tsx index 8cf952e73..bff8e1ebe 100644 --- a/packages/shared/src/utils/oauth.tsx +++ b/packages/shared/src/utils/oauth.tsx @@ -4,6 +4,14 @@ export const sharedProviders = ["google", "github", "microsoft", "spotify"] as c export const allProviders = standardProviders; export const publishableClientKeyNotNecessarySentinel = "__stack_public_client__"; +/** + * All provider types including custom OIDC. Standard providers are the + * predefined set with first-class support; "custom_oidc" lets users bring + * any OIDC-compliant identity provider (team plan+ only). + */ +export const allProviderTypes = [...standardProviders, "custom_oidc"] as const; +export type AllProviderType = typeof allProviderTypes[number]; + export type ProviderType = typeof allProviders[number]; export type StandardProviderType = typeof standardProviders[number]; export type SharedProviderType = typeof sharedProviders[number]; diff --git a/packages/template/src/lib/hexclave-app/project-configs/index.ts b/packages/template/src/lib/hexclave-app/project-configs/index.ts index d92237064..0f54f5a66 100644 --- a/packages/template/src/lib/hexclave-app/project-configs/index.ts +++ b/packages/template/src/lib/hexclave-app/project-configs/index.ts @@ -72,6 +72,14 @@ export type AdminOAuthProviderConfig = { microsoftTenantId?: string, appleBundleIds?: string[], } + | { + type: 'custom_oidc', + clientId: string, + clientSecret: string, + issuerUrl: string, + scope?: string, + displayName?: string, + } ) & OAuthProviderConfig; export type AdminProjectConfigUpdateOptions = { diff --git a/packages/template/src/lib/hexclave-app/projects/index.ts b/packages/template/src/lib/hexclave-app/projects/index.ts index e4cff96b8..5798b23b7 100644 --- a/packages/template/src/lib/hexclave-app/projects/index.ts +++ b/packages/template/src/lib/hexclave-app/projects/index.ts @@ -176,17 +176,19 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio domain: d.domain, handler_path: d.handlerPath })), - oauth_providers: options.config?.oauthProviders?.map((p) => ({ - id: p.id as any, - type: p.type, - ...(p.type === 'standard' && { - client_id: p.clientId, - client_secret: p.clientSecret, - facebook_config_id: p.facebookConfigId, - microsoft_tenant_id: p.microsoftTenantId, - apple_bundle_ids: p.appleBundleIds, - }), - })), + oauth_providers: options.config?.oauthProviders + ?.filter((p): p is Exclude => p.type !== 'custom_oidc') + .map((p) => ({ + id: p.id as any, + type: p.type, + ...(p.type === 'standard' && { + client_id: p.clientId, + client_secret: p.clientSecret, + facebook_config_id: p.facebookConfigId, + microsoft_tenant_id: p.microsoftTenantId, + apple_bundle_ids: p.appleBundleIds, + }), + })), email_config: options.config?.emailConfig && ( options.config.emailConfig.type === 'shared' ? { type: 'shared', diff --git a/packages/ui/src/components/brand-icons.tsx b/packages/ui/src/components/brand-icons.tsx index d40bc2005..619ce4258 100644 --- a/packages/ui/src/components/brand-icons.tsx +++ b/packages/ui/src/components/brand-icons.tsx @@ -240,26 +240,39 @@ export function Mapping({ return ; } default: { - throw new HexclaveAssertionError(`Icon not found for provider: ${provider}`); + return ; } } } -export function toTitle(id: string) { - return { - github: "GitHub", - google: "Google", - facebook: "Facebook", - microsoft: "Microsoft", - spotify: "Spotify", - discord: "Discord", - gitlab: "GitLab", - apple: "Apple", - bitbucket: "Bitbucket", - linkedin: "LinkedIn", - x: "X", - twitch: "Twitch" - }[id] || throwErr(`Unknown provider: ${id}`); +function DefaultOidcIcon({ iconSize }: { iconSize: number }) { + return ( + + + + + + + ); +} + +const KNOWN_TITLES = new Map([ + ["github", "GitHub"], + ["google", "Google"], + ["facebook", "Facebook"], + ["microsoft", "Microsoft"], + ["spotify", "Spotify"], + ["discord", "Discord"], + ["gitlab", "GitLab"], + ["apple", "Apple"], + ["bitbucket", "Bitbucket"], + ["linkedin", "LinkedIn"], + ["x", "X"], + ["twitch", "Twitch"], +]); + +export function toTitle(id: string): string { + return KNOWN_TITLES.get(id) ?? id; } export const BRAND_COLORS: Record = {