@better-auth/sso 1.4.0-beta.20 → 1.4.0-beta.22

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.4.0-beta.20",
4
+ "version": "1.4.0-beta.22",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "homepage": "https://www.better-auth.com/docs/plugins/sso",
@@ -65,10 +65,10 @@
65
65
  "express": "^5.1.0",
66
66
  "oauth2-mock-server": "^7.2.1",
67
67
  "tsdown": "^0.16.0",
68
- "better-auth": "1.4.0-beta.20"
68
+ "better-auth": "1.4.0-beta.22"
69
69
  },
70
70
  "peerDependencies": {
71
- "better-auth": "1.4.0-beta.20"
71
+ "better-auth": "1.4.0-beta.22"
72
72
  },
73
73
  "scripts": {
74
74
  "test": "vitest",
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BetterAuthClientPlugin } from "better-auth";
2
- import { sso } from "./index";
2
+ import type { sso } from "./index";
3
3
  export const ssoClient = () => {
4
4
  return {
5
5
  id: "sso-client",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type BetterAuthPlugin } from "better-auth";
1
+ import type { BetterAuthPlugin } from "better-auth";
2
2
  import { XMLValidator } from "fast-xml-parser";
3
3
  import * as saml from "samlify";
4
4
  import {
@@ -9,9 +9,9 @@ import {
9
9
  signInSSO,
10
10
  spMetadata,
11
11
  } from "./routes/sso";
12
- import type { OIDCConfig, SAMLConfig, SSOOptions } from "./types";
12
+ import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
13
13
 
14
- export type { SAMLConfig, OIDCConfig, SSOOptions };
14
+ export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
15
15
 
16
16
  const fastValidator = {
17
17
  async validate(xml: string) {
@@ -54,18 +54,22 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
54
54
  },
55
55
  schema: {
56
56
  ssoProvider: {
57
+ modelName: options?.modelName ?? "ssoProvider",
57
58
  fields: {
58
59
  issuer: {
59
60
  type: "string",
60
61
  required: true,
62
+ fieldName: options?.fields?.issuer ?? "issuer",
61
63
  },
62
64
  oidcConfig: {
63
65
  type: "string",
64
66
  required: false,
67
+ fieldName: options?.fields?.oidcConfig ?? "oidcConfig",
65
68
  },
66
69
  samlConfig: {
67
70
  type: "string",
68
71
  required: false,
72
+ fieldName: options?.fields?.samlConfig ?? "samlConfig",
69
73
  },
70
74
  userId: {
71
75
  type: "string",
@@ -73,19 +77,23 @@ export function sso<O extends SSOOptions>(options?: O | undefined): any {
73
77
  model: "user",
74
78
  field: "id",
75
79
  },
80
+ fieldName: options?.fields?.userId ?? "userId",
76
81
  },
77
82
  providerId: {
78
83
  type: "string",
79
84
  required: true,
80
85
  unique: true,
86
+ fieldName: options?.fields?.providerId ?? "providerId",
81
87
  },
82
88
  organizationId: {
83
89
  type: "string",
84
90
  required: false,
91
+ fieldName: options?.fields?.organizationId ?? "organizationId",
85
92
  },
86
93
  domain: {
87
94
  type: "string",
88
95
  required: true,
96
+ fieldName: options?.fields?.domain ?? "domain",
89
97
  },
90
98
  },
91
99
  },
package/src/routes/sso.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
2
+ import type { Account, Session, User } from "better-auth";
2
3
  import {
3
- type Account,
4
4
  createAuthorizationURL,
5
5
  generateState,
6
6
  parseState,
7
- type Session,
8
- type User,
9
7
  validateAuthorizationCode,
10
8
  validateToken,
11
9
  } from "better-auth";
@@ -63,6 +61,7 @@ export const spMetadata = () => {
63
61
  }),
64
62
  metadata: {
65
63
  openapi: {
64
+ operationId: "getSSOServiceProviderMetadata",
66
65
  summary: "Get Service Provider metadata",
67
66
  description: "Returns the SAML metadata for the Service Provider",
68
67
  responses: {
@@ -337,6 +336,7 @@ export const registerSSOProvider = (options?: SSOOptions) => {
337
336
  use: [sessionMiddleware],
338
337
  metadata: {
339
338
  openapi: {
339
+ operationId: "registerSSOProvider",
340
340
  summary: "Register an OIDC provider",
341
341
  description:
342
342
  "This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
@@ -726,6 +726,7 @@ export const signInSSO = (options?: SSOOptions) => {
726
726
  }),
727
727
  metadata: {
728
728
  openapi: {
729
+ operationId: "signInWithSSO",
729
730
  summary: "Sign in with SSO provider",
730
731
  description:
731
732
  "This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
@@ -925,6 +926,22 @@ export const signInSSO = (options?: SSOOptions) => {
925
926
  }
926
927
 
927
928
  if (provider.oidcConfig && body.providerType !== "saml") {
929
+ let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
930
+ if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
931
+ const discovery = await betterFetch<{
932
+ authorization_endpoint: string;
933
+ }>(provider.oidcConfig.discoveryEndpoint, {
934
+ method: "GET",
935
+ });
936
+ if (discovery.data) {
937
+ finalAuthUrl = discovery.data.authorization_endpoint;
938
+ }
939
+ }
940
+ if (!finalAuthUrl) {
941
+ throw new APIError("BAD_REQUEST", {
942
+ message: "Invalid OIDC configuration. Authorization URL not found.",
943
+ });
944
+ }
928
945
  const state = await generateState(ctx, undefined, false);
929
946
  const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
930
947
  const authorizationURL = await createAuthorizationURL({
@@ -946,7 +963,7 @@ export const signInSSO = (options?: SSOOptions) => {
946
963
  "offline_access",
947
964
  ],
948
965
  loginHint: ctx.body.loginHint || email,
949
- authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
966
+ authorizationEndpoint: finalAuthUrl,
950
967
  });
951
968
  return ctx.json({
952
969
  url: authorizationURL.toString(),
@@ -1014,6 +1031,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1014
1031
  metadata: {
1015
1032
  isAction: false,
1016
1033
  openapi: {
1034
+ operationId: "handleSSOCallback",
1017
1035
  summary: "Callback URL for SSO provider",
1018
1036
  description:
1019
1037
  "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
@@ -1362,6 +1380,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1362
1380
  metadata: {
1363
1381
  isAction: false,
1364
1382
  openapi: {
1383
+ operationId: "handleSAMLCallback",
1365
1384
  summary: "Callback URL for SAML provider",
1366
1385
  description:
1367
1386
  "This endpoint is used as the callback URL for SAML providers.",
@@ -1584,6 +1603,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1584
1603
  if (existingUser) {
1585
1604
  user = existingUser;
1586
1605
  } else {
1606
+ // if implicit sign up is disabled, we should not create a new user nor a new account.
1607
+ if (options?.disableImplicitSignUp) {
1608
+ throw new APIError("UNAUTHORIZED", {
1609
+ message:
1610
+ "User not found and implicit sign up is disabled for this provider",
1611
+ });
1612
+ }
1613
+
1587
1614
  user = await ctx.context.internalAdapter.createUser({
1588
1615
  email: userInfo.email,
1589
1616
  name: userInfo.name,
@@ -1687,6 +1714,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
1687
1714
  metadata: {
1688
1715
  isAction: false,
1689
1716
  openapi: {
1717
+ operationId: "handleSAMLAssertionConsumerService",
1690
1718
  summary: "SAML Assertion Consumer Service",
1691
1719
  description:
1692
1720
  "Handles SAML responses from IdP after successful authentication",
package/src/saml.test.ts CHANGED
@@ -13,7 +13,7 @@ import type {
13
13
  Response as ExpressResponse,
14
14
  } from "express";
15
15
  import express from "express";
16
- import { createServer } from "http";
16
+ import type { createServer } from "http";
17
17
  import * as saml from "samlify";
18
18
  import {
19
19
  afterAll,
@@ -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 CHANGED
@@ -177,6 +177,29 @@ export interface SSOOptions {
177
177
  * sign-in need to be called with with requestSignUp as true to create new users.
178
178
  */
179
179
  disableImplicitSignUp?: boolean | undefined;
180
+ /**
181
+ * The model name for the SSO provider table. Defaults to "ssoProvider".
182
+ */
183
+ modelName?: string;
184
+ /**
185
+ * Map fields
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * {
190
+ * samlConfig: "saml_config"
191
+ * }
192
+ * ```
193
+ */
194
+ fields?: {
195
+ issuer?: string | undefined;
196
+ oidcConfig?: string | undefined;
197
+ samlConfig?: string | undefined;
198
+ userId?: string | undefined;
199
+ providerId?: string | undefined;
200
+ organizationId?: string | undefined;
201
+ domain?: string | undefined;
202
+ };
180
203
  /**
181
204
  * Configure the maximum number of SSO providers a user can register.
182
205
  * You can also pass a function that returns a number.
@@ -0,0 +1,3 @@
1
+ import { defineProject } from "vitest/config";
2
+
3
+ export default defineProject({});