@better-auth/sso 1.4.0-beta.9 → 1.4.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/saml.test.ts CHANGED
@@ -1,31 +1,31 @@
1
- import {
2
- afterAll,
3
- beforeAll,
4
- beforeEach,
5
- describe,
6
- expect,
7
- it,
8
- vi,
9
- } from "vitest";
1
+ import { betterFetch } from "@better-fetch/fetch";
10
2
  import { betterAuth } from "better-auth";
11
3
  import { memoryAdapter } from "better-auth/adapters/memory";
12
4
  import { createAuthClient } from "better-auth/client";
13
- import { betterFetch } from "@better-fetch/fetch";
14
5
  import { setCookieToHeader } from "better-auth/cookies";
15
6
  import { bearer } from "better-auth/plugins";
16
- import { sso } from ".";
17
- import { ssoClient } from "./client";
18
- import { createServer } from "http";
19
- import * as saml from "samlify";
7
+ import { getTestInstance } from "better-auth/test";
8
+ import bodyParser from "body-parser";
9
+ import { randomUUID } from "crypto";
20
10
  import type {
21
11
  Application as ExpressApp,
22
12
  Request as ExpressRequest,
23
13
  Response as ExpressResponse,
24
14
  } from "express";
25
15
  import express from "express";
26
- import bodyParser from "body-parser";
27
- import { randomUUID } from "crypto";
28
- import { getTestInstanceMemory } from "better-auth/test";
16
+ import type { createServer } from "http";
17
+ import * as saml from "samlify";
18
+ import {
19
+ afterAll,
20
+ beforeAll,
21
+ beforeEach,
22
+ describe,
23
+ expect,
24
+ it,
25
+ vi,
26
+ } from "vitest";
27
+ import { sso } from ".";
28
+ import { ssoClient } from "./client";
29
29
 
30
30
  const spMetadata = `
31
31
  <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3001/api/sso/saml2/sp/metadata">
@@ -926,7 +926,7 @@ describe("SAML SSO", async () => {
926
926
  });
927
927
 
928
928
  it("should not allow creating a provider if limit is set to 0", async () => {
929
- const { auth, signInWithTestUser } = await getTestInstanceMemory({
929
+ const { auth, signInWithTestUser } = await getTestInstance({
930
930
  plugins: [sso({ providersLimit: 0 })],
931
931
  });
932
932
  const { headers } = await signInWithTestUser();
@@ -957,7 +957,7 @@ describe("SAML SSO", async () => {
957
957
  });
958
958
 
959
959
  it("should not allow creating a provider if limit is reached", async () => {
960
- const { auth, signInWithTestUser } = await getTestInstanceMemory({
960
+ const { auth, signInWithTestUser } = await getTestInstance({
961
961
  plugins: [sso({ providersLimit: 1 })],
962
962
  });
963
963
  const { headers } = await signInWithTestUser();
@@ -1011,7 +1011,7 @@ describe("SAML SSO", async () => {
1011
1011
  });
1012
1012
 
1013
1013
  it("should not allow creating a provider if limit from function is reached", async () => {
1014
- const { auth, signInWithTestUser } = await getTestInstanceMemory({
1014
+ const { auth, signInWithTestUser } = await getTestInstance({
1015
1015
  plugins: [
1016
1016
  sso({
1017
1017
  providersLimit: async (user) => {
@@ -1118,4 +1118,210 @@ describe("SAML SSO", async () => {
1118
1118
  },
1119
1119
  });
1120
1120
  });
1121
+
1122
+ it("should reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
1123
+ const { auth: authWithDisabledSignUp, signInWithTestUser } =
1124
+ await getTestInstance({
1125
+ plugins: [sso({ disableImplicitSignUp: true })],
1126
+ });
1127
+
1128
+ const { headers } = await signInWithTestUser();
1129
+
1130
+ // Register SAML provider
1131
+ await authWithDisabledSignUp.api.registerSSOProvider({
1132
+ body: {
1133
+ providerId: "saml-test-provider",
1134
+ issuer: "http://localhost:8081",
1135
+ domain: "http://localhost:8081",
1136
+ samlConfig: {
1137
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1138
+ cert: certificate,
1139
+ callbackUrl: "http://localhost:3000/dashboard",
1140
+ wantAssertionsSigned: false,
1141
+ signatureAlgorithm: "sha256",
1142
+ digestAlgorithm: "sha256",
1143
+ idpMetadata: {
1144
+ metadata: idpMetadata,
1145
+ },
1146
+ spMetadata: {
1147
+ metadata: spMetadata,
1148
+ },
1149
+ identifierFormat:
1150
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1151
+ },
1152
+ },
1153
+ headers: headers,
1154
+ });
1155
+
1156
+ // Identity Provider-initiated: Get SAML response directly from IdP
1157
+ // The mock IdP will return test@email.com, which doesn't exist in the DB
1158
+ let samlResponse: any;
1159
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1160
+ onSuccess: async (context) => {
1161
+ samlResponse = await context.data;
1162
+ },
1163
+ });
1164
+
1165
+ // Attempt to complete SAML callback - should fail because test@email.com doesn't exist
1166
+ // and disableImplicitSignUp is true
1167
+ await expect(
1168
+ authWithDisabledSignUp.api.callbackSSOSAML({
1169
+ body: {
1170
+ SAMLResponse: samlResponse.samlResponse,
1171
+ RelayState: "http://localhost:3000/dashboard",
1172
+ },
1173
+ params: {
1174
+ providerId: "saml-test-provider",
1175
+ },
1176
+ }),
1177
+ ).rejects.toMatchObject({
1178
+ status: "UNAUTHORIZED",
1179
+ body: {
1180
+ message:
1181
+ "User not found and implicit sign up is disabled for this provider",
1182
+ },
1183
+ });
1184
+ });
1185
+ });
1186
+
1187
+ describe("SAML SSO with custom fields", () => {
1188
+ const ssoOptions = {
1189
+ modelName: "sso_provider",
1190
+ fields: {
1191
+ issuer: "the_issuer",
1192
+ oidcConfig: "oidc_config",
1193
+ samlConfig: "saml_config",
1194
+ userId: "user_id",
1195
+ providerId: "provider_id",
1196
+ organizationId: "organization_id",
1197
+ domain: "the_domain",
1198
+ },
1199
+ };
1200
+
1201
+ const data = {
1202
+ user: [],
1203
+ session: [],
1204
+ verification: [],
1205
+ account: [],
1206
+ sso_provider: [],
1207
+ };
1208
+
1209
+ const memory = memoryAdapter(data);
1210
+ const mockIdP = createMockSAMLIdP(8081); // Different port from your main app
1211
+
1212
+ const auth = betterAuth({
1213
+ database: memory,
1214
+ baseURL: "http://localhost:3000",
1215
+ emailAndPassword: {
1216
+ enabled: true,
1217
+ },
1218
+ plugins: [sso(ssoOptions)],
1219
+ });
1220
+
1221
+ const authClient = createAuthClient({
1222
+ baseURL: "http://localhost:3000",
1223
+ plugins: [bearer(), ssoClient()],
1224
+ fetchOptions: {
1225
+ customFetchImpl: async (url, init) => {
1226
+ return auth.handler(new Request(url, init));
1227
+ },
1228
+ },
1229
+ });
1230
+
1231
+ const testUser = {
1232
+ email: "test@email.com",
1233
+ password: "password",
1234
+ name: "Test User",
1235
+ };
1236
+
1237
+ beforeAll(async () => {
1238
+ await mockIdP.start();
1239
+ const res = await authClient.signUp.email({
1240
+ email: testUser.email,
1241
+ password: testUser.password,
1242
+ name: testUser.name,
1243
+ });
1244
+ });
1245
+
1246
+ afterAll(async () => {
1247
+ await mockIdP.stop();
1248
+ });
1249
+
1250
+ beforeEach(() => {
1251
+ data.user = [];
1252
+ data.session = [];
1253
+ data.verification = [];
1254
+ data.account = [];
1255
+ data.sso_provider = [];
1256
+
1257
+ vi.clearAllMocks();
1258
+ });
1259
+
1260
+ async function getAuthHeaders() {
1261
+ const headers = new Headers();
1262
+ await authClient.signUp.email({
1263
+ email: testUser.email,
1264
+ password: testUser.password,
1265
+ name: testUser.name,
1266
+ });
1267
+ await authClient.signIn.email(testUser, {
1268
+ throw: true,
1269
+ onSuccess: setCookieToHeader(headers),
1270
+ });
1271
+ return headers;
1272
+ }
1273
+
1274
+ it("should register a new SAML provider", async () => {
1275
+ const headers = await getAuthHeaders();
1276
+
1277
+ const provider = await auth.api.registerSSOProvider({
1278
+ body: {
1279
+ providerId: "saml-provider-1",
1280
+ issuer: "http://localhost:8081",
1281
+ domain: "http://localhost:8081",
1282
+ samlConfig: {
1283
+ entryPoint: mockIdP.metadataUrl,
1284
+ cert: certificate,
1285
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1286
+ wantAssertionsSigned: false,
1287
+ signatureAlgorithm: "sha256",
1288
+ digestAlgorithm: "sha256",
1289
+ idpMetadata: {
1290
+ metadata: idpMetadata,
1291
+ privateKey: idpPrivateKey,
1292
+ privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
1293
+ isAssertionEncrypted: true,
1294
+ encPrivateKey: idpEncryptionKey,
1295
+ encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
1296
+ },
1297
+ spMetadata: {
1298
+ metadata: spMetadata,
1299
+ binding: "post",
1300
+ privateKey: spPrivateKey,
1301
+ privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
1302
+ isAssertionEncrypted: true,
1303
+ encPrivateKey: spEncryptionKey,
1304
+ encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
1305
+ },
1306
+ identifierFormat:
1307
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1308
+ },
1309
+ },
1310
+ headers,
1311
+ });
1312
+ expect(provider).toMatchObject({
1313
+ id: expect.any(String),
1314
+ issuer: "http://localhost:8081",
1315
+ samlConfig: {
1316
+ entryPoint: mockIdP.metadataUrl,
1317
+ cert: expect.any(String),
1318
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
1319
+ wantAssertionsSigned: false,
1320
+ signatureAlgorithm: "sha256",
1321
+ digestAlgorithm: "sha256",
1322
+ identifierFormat:
1323
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1324
+ },
1325
+ });
1326
+ });
1121
1327
  });
package/src/types.ts ADDED
@@ -0,0 +1,256 @@
1
+ import type { OAuth2Tokens, User } from "better-auth";
2
+
3
+ export interface OIDCMapping {
4
+ id?: string | undefined;
5
+ email?: string | undefined;
6
+ emailVerified?: string | undefined;
7
+ name?: string | undefined;
8
+ image?: string | undefined;
9
+ extraFields?: Record<string, string> | undefined;
10
+ }
11
+
12
+ export interface SAMLMapping {
13
+ id?: string | undefined;
14
+ email?: string | undefined;
15
+ emailVerified?: string | undefined;
16
+ name?: string | undefined;
17
+ firstName?: string | undefined;
18
+ lastName?: string | undefined;
19
+ extraFields?: Record<string, string> | undefined;
20
+ }
21
+
22
+ export interface OIDCConfig {
23
+ issuer: string;
24
+ pkce: boolean;
25
+ clientId: string;
26
+ clientSecret: string;
27
+ authorizationEndpoint?: string | undefined;
28
+ discoveryEndpoint: string;
29
+ userInfoEndpoint?: string | undefined;
30
+ scopes?: string[] | undefined;
31
+ overrideUserInfo?: boolean | undefined;
32
+ tokenEndpoint?: string | undefined;
33
+ tokenEndpointAuthentication?:
34
+ | ("client_secret_post" | "client_secret_basic")
35
+ | undefined;
36
+ jwksEndpoint?: string | undefined;
37
+ mapping?: OIDCMapping | undefined;
38
+ }
39
+
40
+ export interface SAMLConfig {
41
+ issuer: string;
42
+ entryPoint: string;
43
+ cert: string;
44
+ callbackUrl: string;
45
+ audience?: string | undefined;
46
+ idpMetadata?:
47
+ | {
48
+ metadata?: string;
49
+ entityID?: string;
50
+ entityURL?: string;
51
+ redirectURL?: string;
52
+ cert?: string;
53
+ privateKey?: string;
54
+ privateKeyPass?: string;
55
+ isAssertionEncrypted?: boolean;
56
+ encPrivateKey?: string;
57
+ encPrivateKeyPass?: string;
58
+ singleSignOnService?: Array<{
59
+ Binding: string;
60
+ Location: string;
61
+ }>;
62
+ }
63
+ | undefined;
64
+ spMetadata: {
65
+ metadata?: string | undefined;
66
+ entityID?: string | undefined;
67
+ binding?: string | undefined;
68
+ privateKey?: string | undefined;
69
+ privateKeyPass?: string | undefined;
70
+ isAssertionEncrypted?: boolean | undefined;
71
+ encPrivateKey?: string | undefined;
72
+ encPrivateKeyPass?: string | undefined;
73
+ };
74
+ wantAssertionsSigned?: boolean | undefined;
75
+ signatureAlgorithm?: string | undefined;
76
+ digestAlgorithm?: string | undefined;
77
+ identifierFormat?: string | undefined;
78
+ privateKey?: string | undefined;
79
+ decryptionPvk?: string | undefined;
80
+ additionalParams?: Record<string, any> | undefined;
81
+ mapping?: SAMLMapping | undefined;
82
+ }
83
+
84
+ type BaseSSOProvider = {
85
+ issuer: string;
86
+ oidcConfig?: OIDCConfig | undefined;
87
+ samlConfig?: SAMLConfig | undefined;
88
+ userId: string;
89
+ providerId: string;
90
+ organizationId?: string | undefined;
91
+ domain: string;
92
+ };
93
+
94
+ export type SSOProvider<O extends SSOOptions> =
95
+ O["domainVerification"] extends { enabled: true }
96
+ ? {
97
+ domainVerified: boolean;
98
+ } & BaseSSOProvider
99
+ : BaseSSOProvider;
100
+
101
+ export interface SSOOptions {
102
+ /**
103
+ * custom function to provision a user when they sign in with an SSO provider.
104
+ */
105
+ provisionUser?:
106
+ | ((data: {
107
+ /**
108
+ * The user object from the database
109
+ */
110
+ user: User & Record<string, any>;
111
+ /**
112
+ * The user info object from the provider
113
+ */
114
+ userInfo: Record<string, any>;
115
+ /**
116
+ * The OAuth2 tokens from the provider
117
+ */
118
+ token?: OAuth2Tokens;
119
+ /**
120
+ * The SSO provider
121
+ */
122
+ provider: SSOProvider<SSOOptions>;
123
+ }) => Promise<void>)
124
+ | undefined;
125
+ /**
126
+ * Organization provisioning options
127
+ */
128
+ organizationProvisioning?:
129
+ | {
130
+ disabled?: boolean;
131
+ defaultRole?: "member" | "admin";
132
+ getRole?: (data: {
133
+ /**
134
+ * The user object from the database
135
+ */
136
+ user: User & Record<string, any>;
137
+ /**
138
+ * The user info object from the provider
139
+ */
140
+ userInfo: Record<string, any>;
141
+ /**
142
+ * The OAuth2 tokens from the provider
143
+ */
144
+ token?: OAuth2Tokens;
145
+ /**
146
+ * The SSO provider
147
+ */
148
+ provider: SSOProvider<SSOOptions>;
149
+ }) => Promise<"member" | "admin">;
150
+ }
151
+ | undefined;
152
+ /**
153
+ * Default SSO provider configurations for testing.
154
+ * These will take the precedence over the database providers.
155
+ */
156
+ defaultSSO?:
157
+ | Array<{
158
+ /**
159
+ * The domain to match for this default provider.
160
+ * This is only used to match incoming requests to this default provider.
161
+ */
162
+ domain: string;
163
+ /**
164
+ * The provider ID to use
165
+ */
166
+ providerId: string;
167
+ /**
168
+ * SAML configuration
169
+ */
170
+ samlConfig?: SAMLConfig;
171
+ /**
172
+ * OIDC configuration
173
+ */
174
+ oidcConfig?: OIDCConfig;
175
+ }>
176
+ | undefined;
177
+ /**
178
+ * Override user info with the provider info.
179
+ * @default false
180
+ */
181
+ defaultOverrideUserInfo?: boolean | undefined;
182
+ /**
183
+ * Disable implicit sign up for new users. When set to true for the provider,
184
+ * sign-in need to be called with with requestSignUp as true to create new users.
185
+ */
186
+ disableImplicitSignUp?: boolean | undefined;
187
+ /**
188
+ * The model name for the SSO provider table. Defaults to "ssoProvider".
189
+ */
190
+ modelName?: string;
191
+ /**
192
+ * Map fields
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * {
197
+ * samlConfig: "saml_config"
198
+ * }
199
+ * ```
200
+ */
201
+ fields?: {
202
+ issuer?: string | undefined;
203
+ oidcConfig?: string | undefined;
204
+ samlConfig?: string | undefined;
205
+ userId?: string | undefined;
206
+ providerId?: string | undefined;
207
+ organizationId?: string | undefined;
208
+ domain?: string | undefined;
209
+ };
210
+ /**
211
+ * Configure the maximum number of SSO providers a user can register.
212
+ * You can also pass a function that returns a number.
213
+ * Set to 0 to disable SSO provider registration.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * providersLimit: async (user) => {
218
+ * const plan = await getUserPlan(user);
219
+ * return plan.name === "pro" ? 10 : 1;
220
+ * }
221
+ * ```
222
+ * @default 10
223
+ */
224
+ providersLimit?:
225
+ | (number | ((user: User) => Promise<number> | number))
226
+ | undefined;
227
+ /**
228
+ * Trust the email verified flag from the provider.
229
+ *
230
+ * ⚠️ Use this with caution — it can lead to account takeover if misused. Only enable it if users **cannot freely register new providers**. You can
231
+ * prevent that by using `disabledPaths` or other safeguards to block provider registration from the client.
232
+ *
233
+ * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
234
+ * providers in the `trustedProviders` list.
235
+ * @default false
236
+ */
237
+ trustEmailVerified?: boolean | undefined;
238
+ /**
239
+ * Enable domain verification on SSO providers
240
+ *
241
+ * When this option is enabled, new SSO providers will require the associated domain to be verified by the owner
242
+ * prior to allowing sign-ins.
243
+ */
244
+ domainVerification?: {
245
+ /**
246
+ * Enables or disables the domain verification feature
247
+ */
248
+ enabled?: boolean;
249
+ /**
250
+ * Prefix used to generate the domain verification token
251
+ *
252
+ * @default "better-auth-token-"
253
+ */
254
+ tokenPrefix?: string;
255
+ };
256
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,10 @@
1
+ export const validateEmailDomain = (email: string, domain: string) => {
2
+ const emailDomain = email.split("@")[1]?.toLowerCase();
3
+ const providerDomain = domain.toLowerCase();
4
+ if (!emailDomain || !providerDomain) {
5
+ return false;
6
+ }
7
+ return (
8
+ emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`)
9
+ );
10
+ };
package/tsconfig.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
- "extends": "../../tsconfig.json",
2
+ "extends": "../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
- "rootDir": "./src",
5
- "outDir": "./dist",
6
4
  "lib": ["esnext", "dom", "dom.iterable"]
7
5
  },
8
- "include": ["src"]
6
+ "references": [
7
+ {
8
+ "path": "../better-auth/tsconfig.json"
9
+ }
10
+ ]
9
11
  }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ dts: { build: true, incremental: true },
5
+ format: ["esm"],
6
+ entry: ["./src/index.ts", "./src/client.ts"],
7
+ external: ["better-auth", "better-call", "@better-fetch/fetch", "stripe"],
8
+ });
@@ -0,0 +1,3 @@
1
+ import { defineProject } from "vitest/config";
2
+
3
+ export default defineProject({});
package/build.config.ts DELETED
@@ -1,12 +0,0 @@
1
- import { defineBuildConfig } from "unbuild";
2
-
3
- export default defineBuildConfig({
4
- declaration: true,
5
- rollup: {
6
- emitCJS: true,
7
- },
8
- outDir: "dist",
9
- clean: false,
10
- failOnWarn: false,
11
- externals: ["better-auth", "better-call", "@better-fetch/fetch", "stripe"],
12
- });
package/dist/client.cjs DELETED
@@ -1,10 +0,0 @@
1
- 'use strict';
2
-
3
- const ssoClient = () => {
4
- return {
5
- id: "sso-client",
6
- $InferServerPlugin: {}
7
- };
8
- };
9
-
10
- exports.ssoClient = ssoClient;
package/dist/client.d.cts DELETED
@@ -1,11 +0,0 @@
1
- import { sso } from './index.cjs';
2
- import 'better-call';
3
- import 'better-auth';
4
- import 'zod/v4';
5
-
6
- declare const ssoClient: () => {
7
- id: "sso-client";
8
- $InferServerPlugin: ReturnType<typeof sso>;
9
- };
10
-
11
- export { ssoClient };
package/dist/client.d.ts DELETED
@@ -1,11 +0,0 @@
1
- import { sso } from './index.js';
2
- import 'better-call';
3
- import 'better-auth';
4
- import 'zod/v4';
5
-
6
- declare const ssoClient: () => {
7
- id: "sso-client";
8
- $InferServerPlugin: ReturnType<typeof sso>;
9
- };
10
-
11
- export { ssoClient };