mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-27 21:01:03 +08:00
feat: add custom OIDC provider support (team plan+ only) (#1594)
This commit is contained in:
parent
010c114c49
commit
4546615713
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 =======================
|
||||
|
||||
@ -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;
|
||||
|
||||
52
apps/backend/src/oauth/providers/custom-oidc.tsx
Normal file
52
apps/backend/src/oauth/providers/custom-oidc.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 "openid email profile".</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>
|
||||
|
||||
@ -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,
|
||||
}],
|
||||
}],
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
|
||||
@ -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' } });
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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> = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user