feat(sso): support additionalFields on ssoProvider (#9445)

Signed-off-by: Gautam Manchandani <manchandanigautam@gmail.com>
Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Gautam Manchandani
2026-06-12 10:00:55 -07:00
committed by GitHub
co-authored by Gustavo Valverde
parent 445b0343ec
commit 48070ada8b
9 changed files with 690 additions and 37 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/sso": minor
---
Add `schema.ssoProvider.additionalFields` support for storing and returning custom SSO provider fields.
+44
View File
@@ -1461,6 +1461,50 @@ await authClient.signIn.sso({
The callback route supports both GET and POST methods automatically, so you don't need to create any additional route handlers in your framework.
### Additional Provider Fields
You can add custom columns to the `ssoProvider` table with `schema.ssoProvider.additionalFields`. These fields can be used to store provider metadata, such as a display name shown when an organization has multiple SSO providers.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";
export const auth = betterAuth({
plugins: [
sso({
schema: {
ssoProvider: {
additionalFields: {
displayName: {
type: "string",
required: true,
},
},
},
},
}),
],
});
```
Then include the field when registering or updating a provider:
```ts
await auth.api.registerSSOProvider({
body: {
providerId: "okta-main",
displayName: "Okta",
issuer: "https://idp.example.com",
domain: "example.com",
oidcConfig: {
clientId: "client-id",
clientSecret: "client-secret",
},
},
headers,
});
```
## Schema
The plugin requires additional fields in the `ssoProvider` table to store the provider's configuration.
+11
View File
@@ -1,4 +1,5 @@
import type { BetterAuthClientPlugin } from "better-auth/client";
import type { DBFieldAttribute } from "better-auth/db";
import type { SSOPlugin } from "./index";
import { PACKAGE_VERSION } from "./version";
@@ -8,6 +9,15 @@ interface SSOClientOptions {
enabled: boolean;
}
| undefined;
schema?:
| {
ssoProvider?: {
additionalFields?: {
[key: string]: DBFieldAttribute;
};
};
}
| undefined;
}
export const ssoClient = <CO extends SSOClientOptions>(
@@ -22,6 +32,7 @@ export const ssoClient = <CO extends SSOClientOptions>(
? true
: false;
};
schema: CO["schema"];
}>,
pathMethods: {
"/sso/providers": "GET",
+139 -13
View File
@@ -44,7 +44,14 @@ export {
validateSAMLTimestamp,
} from "./saml/timestamp";
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
import type {
InferSSOProvider,
OIDCConfig,
SAMLConfig,
SSOOptions,
SSOProvider,
SSOProviderSchema,
} from "./types";
import { PACKAGE_VERSION } from "./version";
export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
@@ -115,6 +122,11 @@ export type SSOPlugin<O extends SSOOptions> = {
(O extends { domainVerification: { enabled: true } }
? DomainVerificationEndpoints
: {});
schema: SSOProviderSchema<O>;
$Infer: {
SSOProvider: InferSSOProvider<O>;
};
options: NoInfer<O>;
};
/**
@@ -127,6 +139,76 @@ const SAML_SKIP_ORIGIN_CHECK_PATHS = [
"/sso/saml2/sp/slo", // SAML SLO endpoint (prefix matches /sp/slo/:providerId)
];
const SSO_PROVIDER_BUILT_IN_FIELD_KEYS = [
"id",
"issuer",
"oidcConfig",
"samlConfig",
"userId",
"providerId",
"organizationId",
"domain",
"domainVerified",
] as const;
const SSO_PROVIDER_RESPONSE_FIELD_KEYS = [
"type",
"spMetadataUrl",
"redirectURI",
"domainVerificationToken",
] as const;
const SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET = new Set<string>(
SSO_PROVIDER_BUILT_IN_FIELD_KEYS,
);
const SSO_PROVIDER_RESPONSE_FIELD_KEY_SET = new Set<string>(
SSO_PROVIDER_RESPONSE_FIELD_KEYS,
);
type SSOProviderBuiltInFieldKey =
(typeof SSO_PROVIDER_BUILT_IN_FIELD_KEYS)[number];
function getSSOProviderBuiltInFieldName(
options: SSOOptions | undefined,
key: SSOProviderBuiltInFieldKey,
) {
const fieldNames = options?.fields as
| Partial<Record<SSOProviderBuiltInFieldKey, string>>
| undefined;
const schemaFieldNames = options?.schema?.ssoProvider?.fields as
| Partial<Record<SSOProviderBuiltInFieldKey, string>>
| undefined;
return fieldNames?.[key] ?? schemaFieldNames?.[key] ?? key;
}
function assertNoAdditionalFieldCollisions(options?: SSOOptions) {
const additionalFields = options?.schema?.ssoProvider?.additionalFields ?? {};
const builtInFieldNames = new Set(
SSO_PROVIDER_BUILT_IN_FIELD_KEYS.map((key) =>
getSSOProviderBuiltInFieldName(options, key),
),
);
for (const [key, field] of Object.entries(additionalFields)) {
if (SSO_PROVIDER_BUILT_IN_FIELD_KEY_SET.has(key)) {
throw new Error(
`ssoProvider additional field "${key}" conflicts with a built-in field`,
);
}
if (SSO_PROVIDER_RESPONSE_FIELD_KEY_SET.has(key)) {
throw new Error(
`ssoProvider additional field "${key}" conflicts with a returned provider field`,
);
}
const fieldName = field.fieldName ?? key;
if (builtInFieldNames.has(fieldName)) {
throw new Error(
`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`,
);
}
}
}
export function sso<
O extends SSOOptions & {
domainVerification?: { enabled: true };
@@ -137,7 +219,10 @@ export function sso<
id: "sso";
version: string;
endpoints: SSOEndpoints<O> & DomainVerificationEndpoints;
schema: NonNullable<BetterAuthPlugin["schema"]>;
schema: SSOProviderSchema<O>;
$Infer: {
SSOProvider: InferSSOProvider<O>;
};
options: NoInfer<O>;
};
export function sso<O extends SSOOptions>(
@@ -146,12 +231,17 @@ export function sso<O extends SSOOptions>(
id: "sso";
version: string;
endpoints: SSOEndpoints<O>;
schema: SSOProviderSchema<O>;
$Infer: {
SSOProvider: InferSSOProvider<O>;
};
options: NoInfer<O>;
};
export function sso<O extends SSOOptions>(
options?: O | undefined,
): BetterAuthPlugin {
assertNoAdditionalFieldCollisions(options);
const optionsWithStore = options as O;
let endpoints = {
@@ -163,8 +253,8 @@ export function sso<O extends SSOOptions>(
acsEndpoint: acsEndpoint(optionsWithStore),
sloEndpoint: sloEndpoint(optionsWithStore),
initiateSLO: initiateSLO(optionsWithStore),
listSSOProviders: listSSOProviders(),
getSSOProvider: getSSOProvider(),
listSSOProviders: listSSOProviders(optionsWithStore),
getSSOProvider: getSSOProvider(optionsWithStore),
updateSSOProvider: updateSSOProvider(optionsWithStore),
deleteSSOProvider: deleteSSOProvider(),
};
@@ -253,22 +343,34 @@ export function sso<O extends SSOOptions>(
},
schema: {
ssoProvider: {
modelName: options?.modelName ?? "ssoProvider",
modelName:
options?.modelName ??
options?.schema?.ssoProvider?.modelName ??
"ssoProvider",
fields: {
issuer: {
type: "string",
required: true,
fieldName: options?.fields?.issuer ?? "issuer",
fieldName:
options?.fields?.issuer ??
options?.schema?.ssoProvider?.fields?.issuer ??
"issuer",
},
oidcConfig: {
type: "string",
required: false,
fieldName: options?.fields?.oidcConfig ?? "oidcConfig",
fieldName:
options?.fields?.oidcConfig ??
options?.schema?.ssoProvider?.fields?.oidcConfig ??
"oidcConfig",
},
samlConfig: {
type: "string",
required: false,
fieldName: options?.fields?.samlConfig ?? "samlConfig",
fieldName:
options?.fields?.samlConfig ??
options?.schema?.ssoProvider?.fields?.samlConfig ??
"samlConfig",
},
userId: {
type: "string",
@@ -276,29 +378,53 @@ export function sso<O extends SSOOptions>(
model: "user",
field: "id",
},
fieldName: options?.fields?.userId ?? "userId",
fieldName:
options?.fields?.userId ??
options?.schema?.ssoProvider?.fields?.userId ??
"userId",
},
providerId: {
type: "string",
required: true,
unique: true,
fieldName: options?.fields?.providerId ?? "providerId",
fieldName:
options?.fields?.providerId ??
options?.schema?.ssoProvider?.fields?.providerId ??
"providerId",
},
organizationId: {
type: "string",
required: false,
fieldName: options?.fields?.organizationId ?? "organizationId",
fieldName:
options?.fields?.organizationId ??
options?.schema?.ssoProvider?.fields?.organizationId ??
"organizationId",
},
domain: {
type: "string",
required: true,
fieldName: options?.fields?.domain ?? "domain",
fieldName:
options?.fields?.domain ??
options?.schema?.ssoProvider?.fields?.domain ??
"domain",
},
...(options?.domainVerification?.enabled
? { domainVerified: { type: "boolean", required: false } }
? {
domainVerified: {
type: "boolean",
required: false,
fieldName:
options?.schema?.ssoProvider?.fields?.domainVerified ??
"domainVerified",
},
}
: {}),
...(options?.schema?.ssoProvider?.additionalFields ?? {}),
},
},
} as unknown as SSOProviderSchema<O>,
$Infer: {
SSOProvider: {} as InferSSOProvider<O>,
},
options: options as NoInfer<O>,
} satisfies BetterAuthPlugin;
+249 -4
View File
@@ -3,9 +3,10 @@ import { memoryAdapter } from "better-auth/adapters/memory";
import { createAuthClient } from "better-auth/client";
import { setCookieToHeader } from "better-auth/cookies";
import { organization } from "better-auth/plugins";
import { describe, expect, it } from "vitest";
import { describe, expect, expectTypeOf, it } from "vitest";
import { sso } from ".";
import { ssoClient } from "./client";
import type { SSOOptions } from "./types";
const TEST_CERT = `MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiUMA0Gcm9markup
temporary cert for testing`;
@@ -14,6 +15,7 @@ describe("SSO provider read endpoints", () => {
type TestUser = { email: string; password: string; name: string };
interface SSOProviderData {
[key: string]: unknown;
id: string;
providerId: string;
issuer: string;
@@ -28,6 +30,7 @@ describe("SSO provider read endpoints", () => {
const createTestAuth = (
includeOrgPlugin = true,
enableDomainVerification = false,
ssoOptions: SSOOptions = {},
) => {
const data: {
user: { id: string; email: string }[];
@@ -50,8 +53,8 @@ describe("SSO provider read endpoints", () => {
const memory = memoryAdapter(data);
const ssoPlugin = enableDomainVerification
? sso({ domainVerification: { enabled: true } })
: sso();
? sso({ ...ssoOptions, domainVerification: { enabled: true } })
: sso(ssoOptions);
const plugins = includeOrgPlugin
? [ssoPlugin, organization()]
: [ssoPlugin];
@@ -121,6 +124,7 @@ describe("SSO provider read endpoints", () => {
headers: Headers,
providerId: string,
organizationId?: string,
additionalFields?: Record<string, unknown>,
) {
return auth.api.registerSSOProvider({
body: {
@@ -135,7 +139,8 @@ describe("SSO provider read endpoints", () => {
spMetadata: {},
},
organizationId,
},
...additionalFields,
} as any,
headers,
});
}
@@ -1236,6 +1241,246 @@ kBGIJYs=
});
});
describe("ssoProvider additionalFields", () => {
const ssoOptions = {
schema: {
ssoProvider: {
additionalFields: {
displayName: {
type: "string",
required: true,
},
internalCode: {
type: "string",
required: false,
returned: false,
},
source: {
type: "string",
required: false,
input: false,
defaultValue: "system",
},
},
},
},
} satisfies SSOOptions;
it("should require configured additional fields on register", async () => {
const { auth, getAuthHeaders } = createTestAuth(false, false, ssoOptions);
const headers = await getAuthHeaders({
email: "owner@example.com",
password: "password123",
name: "Owner",
});
const response = await auth.api.registerSSOProvider({
body: {
providerId: "missing-display-name",
issuer: "https://idp.example.com",
domain: "example.com",
samlConfig: {
entryPoint: "https://idp.example.com/sso",
cert: TEST_CERT,
callbackUrl: "http://localhost:3000/api/sso/callback",
spMetadata: {},
},
} as any,
headers,
asResponse: true,
});
expect(response.status).toBe(400);
});
it("should store and return visible additional fields", async () => {
const { auth, getAuthHeaders, registerSAMLProvider, data } =
createTestAuth(false, false, ssoOptions);
const headers = await getAuthHeaders({
email: "owner@example.com",
password: "password123",
name: "Owner",
});
const registered = (await registerSAMLProvider(
headers,
"okta",
undefined,
{
displayName: "Okta",
internalCode: "secret",
},
)) as any;
expect(registered.displayName).toBe("Okta");
expect(registered.source).toBe("system");
expect(registered.internalCode).toBeUndefined();
expect(data.ssoProvider[0]!.displayName).toBe("Okta");
expect(data.ssoProvider[0]!.internalCode).toBe("secret");
expect(data.ssoProvider[0]!.source).toBe("system");
const listed = (await auth.api.listSSOProviders({
headers,
})) as any;
expect(listed.providers[0].displayName).toBe("Okta");
expect(listed.providers[0].source).toBe("system");
expect(listed.providers[0].internalCode).toBeUndefined();
const fetched = (await auth.api.getSSOProvider({
query: { providerId: "okta" },
headers,
})) as any;
expect(fetched.displayName).toBe("Okta");
expect(fetched.source).toBe("system");
expect(fetched.internalCode).toBeUndefined();
});
it("should reject additional field keys that collide with built-in provider fields", () => {
expect(() =>
createTestAuth(false, false, {
schema: {
ssoProvider: {
additionalFields: {
providerId: {
type: "string",
required: false,
},
},
},
},
}),
).toThrow(
'ssoProvider additional field "providerId" conflicts with a built-in field',
);
});
it("should reject additional field keys that collide with returned provider fields", () => {
expect(() =>
createTestAuth(false, false, {
schema: {
ssoProvider: {
additionalFields: {
type: {
type: "string",
required: false,
},
},
},
},
}),
).toThrow(
'ssoProvider additional field "type" conflicts with a returned provider field',
);
});
it("should reject additional fields that map to built-in provider columns", () => {
expect(() =>
createTestAuth(false, false, {
schema: {
ssoProvider: {
additionalFields: {
displayName: {
type: "string",
required: false,
fieldName: "providerId",
},
},
},
},
}),
).toThrow(
'ssoProvider additional field "displayName" maps to built-in field "providerId"',
);
});
it("should reject input false additional fields on register", async () => {
const { getAuthHeaders, registerSAMLProvider, data } = createTestAuth(
false,
false,
ssoOptions,
);
const headers = await getAuthHeaders({
email: "owner@example.com",
password: "password123",
name: "Owner",
});
await expect(
registerSAMLProvider(headers, "okta", undefined, {
displayName: "Okta",
source: "client",
}),
).rejects.toThrow("source is not allowed to be set");
expect(data.ssoProvider).toHaveLength(0);
});
it("should update additional fields", async () => {
const { auth, getAuthHeaders, registerSAMLProvider } = createTestAuth(
false,
false,
ssoOptions,
);
const headers = await getAuthHeaders({
email: "owner@example.com",
password: "password123",
name: "Owner",
});
await registerSAMLProvider(headers, "okta", undefined, {
displayName: "Okta",
});
const updated = (await auth.api.updateSSOProvider({
body: {
providerId: "okta",
displayName: "Okta Workforce",
} as any,
headers,
})) as any;
expect(updated.displayName).toBe("Okta Workforce");
});
it("should reject input false additional fields on update", async () => {
const { auth, getAuthHeaders, registerSAMLProvider } = createTestAuth(
false,
false,
ssoOptions,
);
const headers = await getAuthHeaders({
email: "owner@example.com",
password: "password123",
name: "Owner",
});
await registerSAMLProvider(headers, "okta", undefined, {
displayName: "Okta",
});
await expect(
auth.api.updateSSOProvider({
body: {
providerId: "okta",
source: "client",
} as any,
headers,
}),
).rejects.toThrow("source is not allowed to be set");
});
it("should infer sso provider additional fields", () => {
const auth = betterAuth({
plugins: [sso(ssoOptions)],
});
ssoClient({ schema: ssoOptions.schema });
type Provider = typeof auth.$Infer.SSOProvider;
expectTypeOf<Provider>().toMatchTypeOf<{
providerId: string;
displayName: string;
source?: string | undefined;
}>();
});
});
describe("POST /sso/delete-provider", () => {
it("should return 401 when not authenticated", async () => {
const { auth } = createTestAuth();
+69 -11
View File
@@ -1,3 +1,5 @@
import type { DBFieldAttribute } from "@better-auth/core/db";
import { filterOutputFields } from "@better-auth/core/utils/db";
import type { AuthContext } from "better-auth";
import {
APIError,
@@ -18,9 +20,13 @@ import {
} from "../saml";
import type { Member, OIDCConfig, SAMLConfig, SSOOptions } from "../types";
import { maskClientId, parseCertificate, safeJsonParse } from "../utils";
import { updateSSOProviderBodySchema } from "./schemas";
import {
getUpdateSSOProviderBodySchema,
parseSSOProviderAdditionalFields,
} from "./schemas";
interface SSOProviderRecord {
[key: string]: unknown;
id: string;
providerId: string;
issuer: string;
@@ -34,6 +40,36 @@ interface SSOProviderRecord {
const ADMIN_ROLES = ["owner", "admin"];
function getSSOProviderAdditionalFields(options?: SSOOptions) {
return (options?.schema?.ssoProvider?.additionalFields ?? {}) as Record<
string,
DBFieldAttribute
>;
}
export function filterSSOProviderAdditionalFields<
T extends Record<string, unknown>,
>(provider: T, options?: SSOOptions) {
return filterOutputFields(provider, getSSOProviderAdditionalFields(options));
}
function getReturnedSSOProviderAdditionalFields(
provider: Record<string, unknown>,
options?: SSOOptions,
) {
const additionalFields = getSSOProviderAdditionalFields(options);
const result: Record<string, unknown> = {};
for (const key in additionalFields) {
if (additionalFields[key]?.returned === false) {
continue;
}
if (key in provider) {
result[key] = provider[key];
}
}
return result;
}
export function hasOrgAdminRole(member: Pick<Member, "role">): boolean {
return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
}
@@ -110,6 +146,7 @@ async function batchCheckOrgAdmin(
function sanitizeProvider(
provider: {
[key: string]: unknown;
providerId: string;
issuer: string;
domain: string;
@@ -119,6 +156,7 @@ function sanitizeProvider(
samlConfig?: string | SAMLConfig | null;
},
baseURL: string,
options?: SSOOptions,
) {
let oidcConfig: OIDCConfig | null = null;
let samlConfig: SAMLConfig | null = null;
@@ -136,8 +174,13 @@ function sanitizeProvider(
}
const type = samlConfig ? "saml" : "oidc";
const returnedAdditionalFields = getReturnedSSOProviderAdditionalFields(
provider,
options,
);
return {
...returnedAdditionalFields,
providerId: provider.providerId,
type,
issuer: provider.issuer,
@@ -173,7 +216,7 @@ function sanitizeProvider(
};
}
export const listSSOProviders = () => {
export const listSSOProviders = (options?: SSOOptions) => {
return createAuthEndpoint(
"/sso/providers",
{
@@ -246,7 +289,7 @@ export const listSSOProviders = () => {
}
const providers = accessibleProviders.map((p) =>
sanitizeProvider(p, ctx.context.baseURL),
sanitizeProvider(p, ctx.context.baseURL, options),
);
return ctx.json({ providers });
@@ -299,7 +342,7 @@ export async function checkProviderAccess(
return provider;
}
export const getSSOProvider = () => {
export const getSSOProvider = (options?: SSOOptions) => {
return createAuthEndpoint(
"/sso/get-provider",
{
@@ -331,7 +374,7 @@ export const getSSOProvider = () => {
const provider = await checkProviderAccess(ctx, providerId);
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
return ctx.json(sanitizeProvider(provider, ctx.context.baseURL, options));
},
);
};
@@ -410,14 +453,14 @@ function mergeOIDCConfig(
}
export const updateSSOProvider = (options: SSOOptions) => {
const updateBodySchema = getUpdateSSOProviderBodySchema(options);
return createAuthEndpoint(
"/sso/update-provider",
{
method: "POST",
use: [sessionMiddleware],
body: updateSSOProviderBodySchema.extend({
providerId: z.string(),
}),
body: updateBodySchema,
metadata: {
openapi: {
operationId: "updateSSOProvider",
@@ -443,7 +486,18 @@ export const updateSSOProvider = (options: SSOOptions) => {
const { providerId, ...body } = ctx.body;
const { issuer, domain, samlConfig, oidcConfig } = body;
if (!issuer && !domain && !samlConfig && !oidcConfig) {
const additionalFields = parseSSOProviderAdditionalFields(
options,
body,
"update",
);
if (
!issuer &&
!domain &&
!samlConfig &&
!oidcConfig &&
Object.keys(additionalFields).length === 0
) {
throw new APIError("BAD_REQUEST", {
message: "No fields provided for update",
});
@@ -451,7 +505,9 @@ export const updateSSOProvider = (options: SSOOptions) => {
const existingProvider = await checkProviderAccess(ctx, providerId);
const updateData: Partial<SSOProviderRecord> = {};
const updateData: Partial<SSOProviderRecord> = {
...additionalFields,
};
if (body.issuer !== undefined) {
updateData.issuer = body.issuer;
@@ -581,7 +637,9 @@ export const updateSSOProvider = (options: SSOOptions) => {
});
}
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
return ctx.json(
sanitizeProvider(fullProvider, ctx.context.baseURL, options),
);
},
);
};
+67 -2
View File
@@ -1,4 +1,56 @@
import type { DBFieldAttribute } from "@better-auth/core/db";
import { APIError } from "better-auth/api";
import { parseInputData, toZodSchema } from "better-auth/db";
import * as z from "zod";
import type { SSOOptions } from "../types";
function getSSOProviderAdditionalFields(options?: SSOOptions) {
return (options?.schema?.ssoProvider?.additionalFields ?? {}) as Record<
string,
DBFieldAttribute
>;
}
function getSSOProviderAdditionalFieldsSchema(options?: SSOOptions) {
const additionalFields = getSSOProviderAdditionalFields(options);
const schema = toZodSchema({
fields: additionalFields,
isClientSide: true,
});
const blockedInputFields: Record<string, z.ZodOptional<z.ZodAny>> = {};
for (const key in additionalFields) {
if (additionalFields[key]?.input === false) {
blockedInputFields[key] = z.any().optional();
}
}
return schema.extend(blockedInputFields);
}
function assertNoBlockedAdditionalFieldInput(
fields: Record<string, DBFieldAttribute>,
data: Record<string, unknown>,
) {
for (const key in fields) {
if (fields[key]?.input === false && key in data) {
throw new APIError("BAD_REQUEST", {
message: `${key} is not allowed to be set`,
});
}
}
}
export function parseSSOProviderAdditionalFields(
options: SSOOptions | undefined,
data: Record<string, unknown>,
action: "create" | "update",
) {
const fields = getSSOProviderAdditionalFields(options);
assertNoBlockedAdditionalFieldInput(fields, data);
return parseInputData(data, {
fields,
action,
});
}
const oidcMappingSchema = z
.object({
@@ -196,7 +248,7 @@ const samlConfigSchema = z.object({
mapping: samlMappingSchema,
});
export const registerSSOProviderBodySchema = z.object({
const registerSSOProviderBodySchema = z.object({
providerId: z.string().meta({
description:
"The ID of the provider. This is used to identify the provider during login and callback",
@@ -228,9 +280,22 @@ export const registerSSOProviderBodySchema = z.object({
.optional(),
});
export const updateSSOProviderBodySchema = z.object({
export function getRegisterSSOProviderBodySchema(options?: SSOOptions) {
return registerSSOProviderBodySchema.extend({
...getSSOProviderAdditionalFieldsSchema(options).shape,
});
}
const updateSSOProviderBodySchema = z.object({
issuer: z.string().url().optional(),
domain: z.string().optional(),
oidcConfig: oidcConfigSchema.partial().optional(),
samlConfig: samlConfigSchema.partial().optional(),
});
export function getUpdateSSOProviderBodySchema(options?: SSOOptions) {
return updateSSOProviderBodySchema.extend({
providerId: z.string(),
...getSSOProviderAdditionalFieldsSchema(options).partial().shape,
});
}
+29 -5
View File
@@ -46,12 +46,14 @@ import { SAML_ERROR_CODES } from "../saml/error-codes";
import { generateRelayState } from "../saml-state";
import type {
AuthnRequestRecord,
InferSSOProvider,
Member,
OIDCConfig,
SAMLConfig,
SAMLSessionRecord,
SSOOptions,
SSOProvider,
SSOProviderAdditionalFieldsInput,
} from "../types";
import {
domainMatches,
@@ -66,9 +68,15 @@ import {
createSP,
findSAMLProvider,
} from "./helpers";
import { hasOrgAdminRole } from "./providers";
import {
filterSSOProviderAdditionalFields,
hasOrgAdminRole,
} from "./providers";
import { getSafeRedirectUrl, processSAMLResponse } from "./saml-pipeline";
import { registerSSOProviderBodySchema } from "./schemas";
import {
getRegisterSSOProviderBodySchema,
parseSSOProviderAdditionalFields,
} from "./schemas";
/**
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
@@ -161,13 +169,20 @@ export const spMetadata = (options?: SSOOptions) => {
};
export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
const registerBodySchema = getRegisterSSOProviderBodySchema(options);
type Body = z.infer<typeof registerBodySchema> &
SSOProviderAdditionalFieldsInput<O>;
return createAuthEndpoint(
"/sso/register",
{
method: "POST",
body: registerSSOProviderBodySchema,
body: registerBodySchema,
use: [sessionMiddleware],
metadata: {
$Infer: {
body: {} as Body,
},
openapi: {
operationId: "registerSSOProvider",
summary: "Register an OIDC provider",
@@ -381,6 +396,11 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
}
const body = ctx.body;
const additionalFields = parseSSOProviderAdditionalFields(
options,
body,
"create",
);
if (body.samlConfig?.idpMetadata?.metadata) {
const maxMetadataSize =
@@ -596,6 +616,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
issuer: body.issuer,
domain: body.domain,
domainVerified: false,
...additionalFields,
oidcConfig: (() => {
const config = buildOIDCConfig();
if (config) {
@@ -671,7 +692,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
redirectURI: string;
oidcConfig: OIDCConfig | null;
samlConfig: SAMLConfig | null;
} & Omit<SSOProvider<O>, "oidcConfig" | "samlConfig">;
} & Omit<InferSSOProvider<O>, "oidcConfig" | "samlConfig">;
type SSOProviderReturn = O["domainVerification"] extends { enabled: true }
? SSOProviderResponse & {
@@ -681,7 +702,10 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
: SSOProviderResponse;
const result = {
...provider,
...filterSSOProviderAdditionalFields(
provider as unknown as Record<string, unknown>,
options,
),
oidcConfig: safeJsonParse<OIDCConfig>(
provider.oidcConfig as unknown as string,
),
+77 -2
View File
@@ -1,4 +1,10 @@
import type { Awaitable, OAuth2Tokens, User } from "better-auth";
import type {
DBFieldAttribute,
FieldAttributeToObject,
InferAdditionalFieldsFromPluginOptions,
RemoveFieldsWithReturnedFalse,
} from "better-auth/db";
import type { AlgorithmValidationOptions } from "./saml/algorithms";
export interface OIDCMapping {
@@ -193,12 +199,58 @@ type BaseSSOProvider = {
domain: string;
};
type SSOProviderAdditionalFields<
O extends SSOOptions,
IsClientSide extends boolean,
> = O["schema"] extends {
ssoProvider?: {
additionalFields: infer Field extends Record<string, DBFieldAttribute>;
};
}
? IsClientSide extends true
? FieldAttributeToObject<RemoveFieldsWithReturnedFalse<Field>>
: FieldAttributeToObject<Field>
: {};
export type SSOProviderAdditionalFieldsInput<
O extends SSOOptions,
IsClientSide extends boolean = true,
> = InferAdditionalFieldsFromPluginOptions<"ssoProvider", O, IsClientSide>;
export type InferSSOProvider<
O extends SSOOptions,
IsClientSide extends boolean = true,
> = (O["domainVerification"] extends { enabled: true }
? {
domainVerified: boolean;
} & BaseSSOProvider
: BaseSSOProvider) &
SSOProviderAdditionalFields<O, IsClientSide>;
export type SSOProvider<O extends SSOOptions> =
O["domainVerification"] extends { enabled: true }
? {
domainVerified: boolean;
} & BaseSSOProvider
: BaseSSOProvider;
} & BaseSSOProvider &
SSOProviderAdditionalFields<O, false>
: BaseSSOProvider & SSOProviderAdditionalFields<O, false>;
export type SSOProviderSchema<O extends SSOOptions> = {
ssoProvider: {
modelName: string;
fields: Record<string, DBFieldAttribute> &
(O["schema"] extends {
ssoProvider?: {
additionalFields: infer Field extends Record<
string,
DBFieldAttribute
>;
};
}
? Field
: {});
};
};
export interface SSOOptions {
/**
@@ -327,6 +379,29 @@ export interface SSOOptions {
organizationId?: string | undefined;
domain?: string | undefined;
};
/**
* The schema for the SSO plugin.
*/
schema?:
| {
ssoProvider?: {
modelName?: string | undefined;
fields?: {
issuer?: string | undefined;
oidcConfig?: string | undefined;
samlConfig?: string | undefined;
userId?: string | undefined;
providerId?: string | undefined;
organizationId?: string | undefined;
domain?: string | undefined;
domainVerified?: string | undefined;
};
additionalFields?: {
[key in string]: DBFieldAttribute;
};
};
}
| undefined;
/**
* Configure the maximum number of SSO providers a user can register.
* You can also pass a function that returns a number.