feat: add custom OIDC provider support (team plan+ only) (#1594)

This commit is contained in:
Konsti Wohlwend 2026-06-16 16:35:11 -07:00 committed by GitHub
parent 010c114c49
commit 4546615713
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 630 additions and 83 deletions

View File

@ -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,

View File

@ -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<ReturnType<typeof providerObj.getCallback>>;
try {
callbackResult = await providerObj.getCallback({

View File

@ -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({

View File

@ -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

View File

@ -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),
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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 =======================

View File

@ -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<OAuthBaseProvider> {
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;

View File

@ -0,0 +1,52 @@
import { OAuthUserInfo, validateUserInfo } from "../utils";
import { OAuthBaseProvider, TokenSet } from "./base";
export class CustomOidcProvider extends OAuthBaseProvider {
private constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
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<OAuthUserInfo> {
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<boolean> {
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;
}
}
}

View File

@ -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 <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />;
}
return <AddCustomOidcButtonInner team={ownerTeam} onClick={onClick} />;
}
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 <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={isTeamPlanOrAbove} />;
}
function AddCustomOidcButtonDisabled({ onClick, isTeamPlanOrAbove }: { onClick: () => void, isTeamPlanOrAbove: boolean }) {
return (
<SimpleTooltip tooltip={!isTeamPlanOrAbove ? "Custom OIDC providers require a Team plan or above." : undefined}>
<DesignButton
onClick={onClick}
variant="secondary"
disabled={!isTeamPlanOrAbove}
>
<GlobeIcon size={16} className="mr-1.5" />
Add Custom OIDC
{!isTeamPlanOrAbove && <span className="ml-1.5"><DesignBadge label="Team+" color="blue" size="sm" /></span>}
</DesignButton>
</SimpleTooltip>
);
}
// ─── 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 (
<FormDialog<CustomOidcFormValues>
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) => (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-xl ring-1 ring-black/[0.08] dark:ring-white/[0.08] shadow-sm bg-foreground/[0.04]">
<GlobeIcon size={18} className="text-foreground/70" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground">Custom OIDC Provider</span>
<DesignBadge label="OIDC" color="blue" size="sm" />
</div>
<span className="text-[11px] text-muted-foreground">Connect any OIDC-compliant identity provider</span>
</div>
</div>
{!isEditing && (
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-medium text-muted-foreground">Provider ID</FormLabel>
<FormControl>
<DesignInput {...field} value={field.value} placeholder="my-oidc-provider" size="sm" autoComplete="off" />
</FormControl>
<FormMessage />
<span className="text-[10px] text-muted-foreground">Unique identifier lowercase letters, numbers, hyphens, underscores only.</span>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-medium text-muted-foreground">Display Name</FormLabel>
<FormControl>
<DesignInput {...field} value={field.value} placeholder="My Identity Provider" size="sm" autoComplete="off" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuerUrl"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-medium text-muted-foreground">Issuer URL</FormLabel>
<FormControl>
<DesignInput {...field} value={field.value} placeholder="https://your-idp.example.com" size="sm" autoComplete="off" />
</FormControl>
<FormMessage />
<span className="text-[10px] text-muted-foreground">Must support OIDC discovery (/.well-known/openid-configuration).</span>
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-medium text-muted-foreground">Client ID</FormLabel>
<FormControl>
<DesignInput {...field} value={field.value} placeholder="Client ID" size="sm" autoComplete="off" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-medium text-muted-foreground">Client Secret</FormLabel>
<FormControl>
<DesignInput {...field} value={field.value} type="password" placeholder="Client Secret" size="sm" autoComplete="off" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scope"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-medium text-muted-foreground">Scopes (optional)</FormLabel>
<FormControl>
<DesignInput {...field} value={field.value ?? ""} placeholder="openid email profile" size="sm" autoComplete="off" />
</FormControl>
<FormMessage />
<span className="text-[10px] text-muted-foreground">Space-separated. Defaults to &quot;openid email profile&quot;.</span>
</FormItem>
)}
/>
{callbackUrl && (
<div className="flex flex-col gap-1">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Redirect URL</span>
<Typography type="footnote" className="break-all">
<InlineCode>{callbackUrl}</InlineCode>
</Typography>
</div>
)}
</div>
)}
/>
);
}
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: <GearSixIcon size={14} />,
onClick: () => setEditDialogOpen(true),
},
{
id: "disable",
label: "Disable Provider",
icon: <PowerIcon size={14} />,
itemVariant: "destructive",
onClick: () => setTurnOffDialogOpen(true),
},
];
return (
<DesignCardTint gradient="default" className="px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center w-9 h-9 rounded-xl ring-1 ring-black/[0.08] dark:ring-white/[0.08] shadow-sm bg-foreground/[0.04]">
<GlobeIcon size={18} className="text-foreground/70" />
</div>
<span className="text-sm font-semibold text-foreground truncate">{provider.displayName || provider.id}</span>
<DesignBadge label="Custom OIDC" color="blue" size="sm" />
</div>
<DesignMenu
variant="actions"
trigger="icon"
triggerLabel="Open menu"
align="end"
withIcons
items={items}
/>
</div>
<CustomOidcProviderDialog
open={editDialogOpen}
onClose={() => setEditDialogOpen(false)}
existing={provider}
/>
<ActionDialog
title={`Disable ${provider.displayName || provider.id}`}
open={turnOffDialogOpen}
onClose={() => 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."
>
<DesignAlert
variant="error"
title="This action affects existing users"
description="Disabling this provider will prevent users from signing in with it, including existing users who have already used it."
/>
</ActionDialog>
</DesignCardTint>
);
}
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<boolean | undefined>(undefined);
@ -721,21 +1116,26 @@ export default function PageClient() {
{enabledProvidersList.map(provider => (
<ProviderInlineRow key={provider.id} provider={provider} />
))}
{enabledProvidersList.length === 0 && (
{customOidcProviders.map(provider => (
<CustomOidcProviderInlineRow key={provider.id} provider={provider} />
))}
{enabledProvidersList.length === 0 && customOidcProviders.length === 0 && (
<DesignAlert
variant="info"
description="No SSO providers enabled. Add one to let users sign in with their existing accounts."
/>
)}
</div>
<DesignButton
className="mt-4 w-fit"
onClick={() => setDisabledProvidersDialogOpen(true)}
variant="secondary"
>
<PlusCircleIcon size={16} className="mr-1.5" />
Add SSO providers
</DesignButton>
<div className="flex gap-2 mt-4 flex-wrap">
<DesignButton
onClick={() => setDisabledProvidersDialogOpen(true)}
variant="secondary"
>
<PlusCircleIcon size={16} className="mr-1.5" />
Add SSO providers
</DesignButton>
<AddCustomOidcButton onClick={() => setCustomOidcDialogOpen(true)} />
</div>
</DesignCard>
<DesignCard
title="Live preview"
@ -830,6 +1230,10 @@ export default function PageClient() {
open={disabledProvidersDialogOpen}
onOpenChange={(x) => setDisabledProvidersDialogOpen(x)}
/>
<CustomOidcProviderDialog
open={customOidcDialogOpen}
onClose={() => setCustomOidcDialogOpen(false)}
/>
{emailVerification.dialog}
</PageLayout>
</AppEnabledGuard>

View File

@ -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,
}],
}],

View File

@ -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(),
}),

View File

@ -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' } });

View File

@ -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];

View File

@ -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 = {

View File

@ -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<typeof p, { type: 'custom_oidc' }> => 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',

View File

@ -240,26 +240,39 @@ export function Mapping({
return <Twitch iconSize={iconSize}/>;
}
default: {
throw new HexclaveAssertionError(`Icon not found for provider: ${provider}`);
return <DefaultOidcIcon iconSize={iconSize} />;
}
}
}
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 (
<svg xmlns="http://www.w3.org/2000/svg" width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4l2 2" />
<path d="M8 12h0" />
<path d="M16 12h0" />
</svg>
);
}
const KNOWN_TITLES = new Map<string, string>([
["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<string, string> = {