mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-02 06:44:07 -05:00
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:
co-authored by
Gustavo Valverde
parent
445b0343ec
commit
48070ada8b
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@better-auth/sso": minor
|
||||
---
|
||||
|
||||
Add `schema.ssoProvider.additionalFields` support for storing and returning custom SSO provider fields.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user