@better-auth/sso 1.4.6-beta.3 → 1.4.6-beta.6

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/sso@1.4.6-beta.3 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.4.6-beta.6 build /home/runner/work/better-auth/better-auth/packages/sso
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.0 powered by rolldown v1.0.0-beta.53
@@ -7,10 +7,10 @@
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs 58.73 kB │ gzip: 10.41 kB
10
+ ℹ dist/index.mjs 59.70 kB │ gzip: 10.49 kB
11
11
  ℹ dist/client.mjs  0.15 kB │ gzip: 0.14 kB
12
- ℹ dist/client.d.mts  0.49 kB │ gzip: 0.30 kB
12
+ ℹ dist/client.d.mts  0.49 kB │ gzip: 0.29 kB
13
13
  ℹ dist/index.d.mts  0.21 kB │ gzip: 0.15 kB
14
- ℹ dist/index-D-JmJR9N.d.mts 25.42 kB │ gzip: 3.95 kB
15
- ℹ 5 files, total: 85.01 kB
16
- ✔ Build complete in 10982ms
14
+ ℹ dist/index-CYgzSZS4.d.mts 25.84 kB │ gzip: 4.13 kB
15
+ ℹ 5 files, total: 86.39 kB
16
+ ✔ Build complete in 11274ms
package/bump.config.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "bumpp";
2
+
3
+ export default defineConfig({
4
+ files: ["package.json"],
5
+ });
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-D-JmJR9N.mjs";
1
+ import { t as SSOPlugin } from "./index-CYgzSZS4.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -216,7 +216,13 @@ interface SSOOptions {
216
216
  *
217
217
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
218
218
  * providers in the `trustedProviders` list.
219
+ *
219
220
  * @default false
221
+ *
222
+ * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
223
+ * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
224
+ * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
225
+ * This option may be removed in a future major version.
220
226
  */
221
227
  trustEmailVerified?: boolean | undefined;
222
228
  /**
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as SSOOptions, i as SAMLConfig, n as sso, o as SSOProvider, r as OIDCConfig, t as SSOPlugin } from "./index-D-JmJR9N.mjs";
1
+ import { a as SSOOptions, i as SAMLConfig, n as sso, o as SSOProvider, r as OIDCConfig, t as SSOPlugin } from "./index-CYgzSZS4.mjs";
2
2
  export { OIDCConfig, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, sso };
package/dist/index.mjs CHANGED
@@ -185,19 +185,14 @@ const verifyDomain = (options) => {
185
185
 
186
186
  //#endregion
187
187
  //#region src/utils.ts
188
- const validateEmailDomain = (email, domain) => {
189
- const emailDomain = email.split("@")[1]?.toLowerCase();
190
- const providerDomain = domain.toLowerCase();
191
- if (!emailDomain || !providerDomain) return false;
192
- return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
193
- };
194
-
195
- //#endregion
196
- //#region src/routes/sso.ts
197
188
  /**
198
- * Safely parses a value that might be a JSON string or already a parsed object
189
+ * Safely parses a value that might be a JSON string or already a parsed object.
199
190
  * This handles cases where ORMs like Drizzle might return already parsed objects
200
- * instead of JSON strings from TEXT/JSON columns
191
+ * instead of JSON strings from TEXT/JSON columns.
192
+ *
193
+ * @param value - The value to parse (string, object, null, or undefined)
194
+ * @returns The parsed object or null
195
+ * @throws Error if string parsing fails
201
196
  */
202
197
  function safeJsonParse(value) {
203
198
  if (!value) return null;
@@ -209,6 +204,15 @@ function safeJsonParse(value) {
209
204
  }
210
205
  return null;
211
206
  }
207
+ const validateEmailDomain = (email, domain) => {
208
+ const emailDomain = email.split("@")[1]?.toLowerCase();
209
+ const providerDomain = domain.toLowerCase();
210
+ if (!emailDomain || !providerDomain) return false;
211
+ return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
212
+ };
213
+
214
+ //#endregion
215
+ //#region src/routes/sso.ts
212
216
  const spMetadataQuerySchema = z.object({
213
217
  providerId: z.string(),
214
218
  format: z.enum(["xml", "json"]).default("xml")
@@ -587,8 +591,8 @@ const registerSSOProvider = (options) => {
587
591
  }
588
592
  return ctx.json({
589
593
  ...provider,
590
- oidcConfig: JSON.parse(provider.oidcConfig),
591
- samlConfig: JSON.parse(provider.samlConfig),
594
+ oidcConfig: safeJsonParse(provider.oidcConfig),
595
+ samlConfig: safeJsonParse(provider.samlConfig),
592
596
  redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
593
597
  ...options?.domainVerification?.enabled ? { domainVerified } : {},
594
598
  ...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
@@ -897,6 +901,7 @@ const callbackSSO = (options) => {
897
901
  userInfo = userInfoResponse.data;
898
902
  }
899
903
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
904
+ const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
900
905
  const linked = await handleOAuthUserInfo(ctx, {
901
906
  userInfo: {
902
907
  email: userInfo.email,
@@ -917,7 +922,8 @@ const callbackSSO = (options) => {
917
922
  },
918
923
  callbackURL,
919
924
  disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
920
- overrideUserInfo: config.overrideUserInfo
925
+ overrideUserInfo: config.overrideUserInfo,
926
+ isTrustedProvider
921
927
  });
922
928
  if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
923
929
  const { session, user } = linked.data;
@@ -1117,38 +1123,52 @@ const callbackSSOSAML = (options) => {
1117
1123
  value: userInfo.email
1118
1124
  }]
1119
1125
  });
1120
- if (existingUser) user = existingUser;
1121
- else {
1126
+ if (existingUser) {
1127
+ if (!await ctx.context.adapter.findOne({
1128
+ model: "account",
1129
+ where: [
1130
+ {
1131
+ field: "userId",
1132
+ value: existingUser.id
1133
+ },
1134
+ {
1135
+ field: "providerId",
1136
+ value: provider.providerId
1137
+ },
1138
+ {
1139
+ field: "accountId",
1140
+ value: userInfo.id
1141
+ }
1142
+ ]
1143
+ })) {
1144
+ if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) {
1145
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1146
+ throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
1147
+ }
1148
+ await ctx.context.internalAdapter.createAccount({
1149
+ userId: existingUser.id,
1150
+ providerId: provider.providerId,
1151
+ accountId: userInfo.id,
1152
+ accessToken: "",
1153
+ refreshToken: ""
1154
+ });
1155
+ }
1156
+ user = existingUser;
1157
+ } else {
1122
1158
  if (options?.disableImplicitSignUp) throw new APIError("UNAUTHORIZED", { message: "User not found and implicit sign up is disabled for this provider" });
1123
1159
  user = await ctx.context.internalAdapter.createUser({
1124
1160
  email: userInfo.email,
1125
1161
  name: userInfo.name,
1126
1162
  emailVerified: userInfo.emailVerified
1127
1163
  });
1164
+ await ctx.context.internalAdapter.createAccount({
1165
+ userId: user.id,
1166
+ providerId: provider.providerId,
1167
+ accountId: userInfo.id,
1168
+ accessToken: "",
1169
+ refreshToken: ""
1170
+ });
1128
1171
  }
1129
- if (!await ctx.context.adapter.findOne({
1130
- model: "account",
1131
- where: [
1132
- {
1133
- field: "userId",
1134
- value: user.id
1135
- },
1136
- {
1137
- field: "providerId",
1138
- value: provider.providerId
1139
- },
1140
- {
1141
- field: "accountId",
1142
- value: userInfo.id
1143
- }
1144
- ]
1145
- })) await ctx.context.internalAdapter.createAccount({
1146
- userId: user.id,
1147
- providerId: provider.providerId,
1148
- accountId: userInfo.id,
1149
- accessToken: "",
1150
- refreshToken: ""
1151
- });
1152
1172
  if (options?.provisionUser) await options.provisionUser({
1153
1173
  user,
1154
1174
  userInfo,
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.4.6-beta.3",
4
+ "version": "1.4.6-beta.6",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
+ "types": "dist/index.d.mts",
7
8
  "homepage": "https://www.better-auth.com/docs/plugins/sso",
8
9
  "repository": {
9
10
  "type": "git",
10
- "url": "https://github.com/better-auth/better-auth",
11
+ "url": "git+https://github.com/better-auth/better-auth.git",
11
12
  "directory": "packages/sso"
12
13
  },
13
14
  "license": "MIT",
@@ -60,18 +61,19 @@
60
61
  "devDependencies": {
61
62
  "@types/body-parser": "^1.19.6",
62
63
  "@types/express": "^5.0.5",
63
- "better-call": "1.1.4",
64
+ "better-call": "1.1.5",
64
65
  "body-parser": "^2.2.1",
65
66
  "express": "^5.1.0",
66
67
  "oauth2-mock-server": "^8.2.0",
67
68
  "tsdown": "^0.17.0",
68
- "better-auth": "1.4.6-beta.3"
69
+ "better-auth": "1.4.6-beta.6"
69
70
  },
70
71
  "peerDependencies": {
71
- "better-auth": "1.4.6-beta.3"
72
+ "better-auth": "1.4.6-beta.6"
72
73
  },
73
74
  "scripts": {
74
75
  "test": "vitest",
76
+ "coverage": "vitest run --coverage",
75
77
  "lint:package": "publint run --strict",
76
78
  "build": "tsdown",
77
79
  "dev": "tsdown --watch",
package/src/oidc.test.ts CHANGED
@@ -571,3 +571,167 @@ describe("provisioning", async (ctx) => {
571
571
  expect(res.url).toContain("http://localhost:8080/authorize");
572
572
  });
573
573
  });
574
+
575
+ describe("OIDC account linking with domainVerified", async () => {
576
+ const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
577
+ await getTestInstance({
578
+ account: {
579
+ accountLinking: {
580
+ enabled: true,
581
+ trustedProviders: [],
582
+ },
583
+ },
584
+ plugins: [
585
+ sso({
586
+ domainVerification: {
587
+ enabled: true,
588
+ },
589
+ }),
590
+ ],
591
+ });
592
+
593
+ const authClient = createAuthClient({
594
+ plugins: [ssoClient()],
595
+ baseURL: "http://localhost:3000",
596
+ fetchOptions: {
597
+ customFetchImpl,
598
+ },
599
+ });
600
+
601
+ beforeAll(async () => {
602
+ await server.issuer.keys.generate("RS256");
603
+ await server.start(8080, "localhost");
604
+ });
605
+
606
+ afterAll(async () => {
607
+ await server.stop().catch(() => {});
608
+ });
609
+
610
+ async function simulateOAuthFlow(authUrl: string, headers: Headers) {
611
+ let location: string | null = null;
612
+ await betterFetch(authUrl, {
613
+ method: "GET",
614
+ redirect: "manual",
615
+ onError(context) {
616
+ location = context.response.headers.get("location");
617
+ },
618
+ });
619
+
620
+ if (!location) throw new Error("No redirect location found");
621
+
622
+ let callbackURL = "";
623
+ const newHeaders = new Headers();
624
+ await betterFetch(location, {
625
+ method: "GET",
626
+ customFetchImpl,
627
+ headers,
628
+ onError(context) {
629
+ callbackURL = context.response.headers.get("location") || "";
630
+ cookieSetter(newHeaders)(context);
631
+ },
632
+ });
633
+
634
+ return { callbackURL, headers: newHeaders };
635
+ }
636
+
637
+ it("should allow account linking when domain is verified and email domain matches", async () => {
638
+ const testEmail = "linking-test@verified-oidc.com";
639
+ const testDomain = "verified-oidc.com";
640
+
641
+ server.service.on("beforeTokenSigning", (token) => {
642
+ token.payload.email = testEmail;
643
+ token.payload.email_verified = false;
644
+ token.payload.name = "Domain Verified User";
645
+ token.payload.sub = "oidc-domain-verified-user";
646
+ });
647
+
648
+ const { headers } = await signInWithTestUser();
649
+
650
+ const provider = await auth.api.registerSSOProvider({
651
+ body: {
652
+ providerId: "domain-verified-oidc",
653
+ issuer: server.issuer.url!,
654
+ domain: testDomain,
655
+ oidcConfig: {
656
+ clientId: "test",
657
+ clientSecret: "test",
658
+ authorizationEndpoint: `${server.issuer.url}/authorize`,
659
+ tokenEndpoint: `${server.issuer.url}/token`,
660
+ jwksEndpoint: `${server.issuer.url}/jwks`,
661
+ discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
662
+ mapping: {
663
+ id: "sub",
664
+ email: "email",
665
+ emailVerified: "email_verified",
666
+ name: "name",
667
+ },
668
+ },
669
+ },
670
+ headers,
671
+ });
672
+
673
+ expect(provider.domainVerified).toBe(false);
674
+
675
+ const ctx = await auth.$context;
676
+ await ctx.adapter.update({
677
+ model: "ssoProvider",
678
+ where: [{ field: "providerId", value: provider.providerId }],
679
+ update: {
680
+ domainVerified: true,
681
+ },
682
+ });
683
+
684
+ const updatedProvider = await ctx.adapter.findOne<{
685
+ domainVerified: boolean;
686
+ domain: string;
687
+ }>({
688
+ model: "ssoProvider",
689
+ where: [{ field: "providerId", value: provider.providerId }],
690
+ });
691
+ expect(updatedProvider?.domainVerified).toBe(true);
692
+
693
+ await ctx.adapter.create({
694
+ model: "user",
695
+ data: {
696
+ id: "existing-oidc-domain-user",
697
+ email: testEmail,
698
+ name: "Existing User",
699
+ emailVerified: true,
700
+ createdAt: new Date(),
701
+ updatedAt: new Date(),
702
+ },
703
+ forceAllowId: true,
704
+ });
705
+
706
+ const newHeaders = new Headers();
707
+ const res = await authClient.signIn.sso({
708
+ providerId: "domain-verified-oidc",
709
+ callbackURL: "/dashboard",
710
+ fetchOptions: {
711
+ throw: true,
712
+ onSuccess: cookieSetter(newHeaders),
713
+ },
714
+ });
715
+
716
+ expect(res.url).toContain("http://localhost:8080/authorize");
717
+
718
+ const { callbackURL } = await simulateOAuthFlow(res.url, newHeaders);
719
+
720
+ expect(callbackURL).toContain("/dashboard");
721
+ expect(callbackURL).not.toContain("error");
722
+
723
+ const accounts = await ctx.adapter.findMany<{
724
+ providerId: string;
725
+ accountId: string;
726
+ userId: string;
727
+ }>({
728
+ model: "account",
729
+ where: [{ field: "userId", value: "existing-oidc-domain-user" }],
730
+ });
731
+ const linkedAccount = accounts.find(
732
+ (a) => a.providerId === "domain-verified-oidc",
733
+ );
734
+ expect(linkedAccount).toBeTruthy();
735
+ expect(linkedAccount?.accountId).toBe("oidc-domain-verified-user");
736
+ });
737
+ });
package/src/routes/sso.ts CHANGED
@@ -22,35 +22,7 @@ import type { IdentityProvider } from "samlify/types/src/entity-idp";
22
22
  import type { FlowResult } from "samlify/types/src/flow";
23
23
  import * as z from "zod/v4";
24
24
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
25
- import { validateEmailDomain } from "../utils";
26
-
27
- /**
28
- * Safely parses a value that might be a JSON string or already a parsed object
29
- * This handles cases where ORMs like Drizzle might return already parsed objects
30
- * instead of JSON strings from TEXT/JSON columns
31
- */
32
- function safeJsonParse<T>(value: string | T | null | undefined): T | null {
33
- if (!value) return null;
34
-
35
- // If it's already an object (not a string), return it as-is
36
- if (typeof value === "object") {
37
- return value as T;
38
- }
39
-
40
- // If it's a string, try to parse it
41
- if (typeof value === "string") {
42
- try {
43
- return JSON.parse(value) as T;
44
- } catch (error) {
45
- // If parsing fails, this might indicate the string is not valid JSON
46
- throw new Error(
47
- `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
48
- );
49
- }
50
- }
51
-
52
- return null;
53
- }
25
+ import { safeJsonParse, validateEmailDomain } from "../utils";
54
26
 
55
27
  const spMetadataQuerySchema = z.object({
56
28
  providerId: z.string(),
@@ -683,12 +655,12 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
683
655
 
684
656
  return ctx.json({
685
657
  ...provider,
686
- oidcConfig: JSON.parse(
658
+ oidcConfig: safeJsonParse<OIDCConfig>(
687
659
  provider.oidcConfig as unknown as string,
688
- ) as OIDCConfig,
689
- samlConfig: JSON.parse(
660
+ ),
661
+ samlConfig: safeJsonParse<SAMLConfig>(
690
662
  provider.samlConfig as unknown as string,
691
- ) as SAMLConfig,
663
+ ),
692
664
  redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
693
665
  ...(options?.domainVerification?.enabled ? { domainVerified } : {}),
694
666
  ...(options?.domainVerification?.enabled
@@ -1377,6 +1349,11 @@ export const callbackSSO = (options?: SSOOptions) => {
1377
1349
  }/error?error=invalid_provider&error_description=missing_user_info`,
1378
1350
  );
1379
1351
  }
1352
+ const isTrustedProvider =
1353
+ "domainVerified" in provider &&
1354
+ (provider as { domainVerified?: boolean }).domainVerified === true &&
1355
+ validateEmailDomain(userInfo.email, provider.domain);
1356
+
1380
1357
  const linked = await handleOAuthUserInfo(ctx, {
1381
1358
  userInfo: {
1382
1359
  email: userInfo.email,
@@ -1400,6 +1377,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1400
1377
  callbackURL,
1401
1378
  disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
1402
1379
  overrideUserInfo: config.overrideUserInfo,
1380
+ isTrustedProvider,
1403
1381
  });
1404
1382
  if (linked.error) {
1405
1383
  throw ctx.redirect(
@@ -1722,6 +1700,35 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1722
1700
  });
1723
1701
 
1724
1702
  if (existingUser) {
1703
+ const account = await ctx.context.adapter.findOne<Account>({
1704
+ model: "account",
1705
+ where: [
1706
+ { field: "userId", value: existingUser.id },
1707
+ { field: "providerId", value: provider.providerId },
1708
+ { field: "accountId", value: userInfo.id },
1709
+ ],
1710
+ });
1711
+ if (!account) {
1712
+ const isTrustedProvider =
1713
+ ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
1714
+ provider.providerId,
1715
+ ) ||
1716
+ ("domainVerified" in provider &&
1717
+ provider.domainVerified &&
1718
+ validateEmailDomain(userInfo.email, provider.domain));
1719
+ if (!isTrustedProvider) {
1720
+ const redirectUrl =
1721
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1722
+ throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
1723
+ }
1724
+ await ctx.context.internalAdapter.createAccount({
1725
+ userId: existingUser.id,
1726
+ providerId: provider.providerId,
1727
+ accountId: userInfo.id,
1728
+ accessToken: "",
1729
+ refreshToken: "",
1730
+ });
1731
+ }
1725
1732
  user = existingUser;
1726
1733
  } else {
1727
1734
  // if implicit sign up is disabled, we should not create a new user nor a new account.
@@ -1737,19 +1744,6 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1737
1744
  name: userInfo.name,
1738
1745
  emailVerified: userInfo.emailVerified,
1739
1746
  });
1740
- }
1741
-
1742
- // Create or update account link
1743
- const account = await ctx.context.adapter.findOne<Account>({
1744
- model: "account",
1745
- where: [
1746
- { field: "userId", value: user.id },
1747
- { field: "providerId", value: provider.providerId },
1748
- { field: "accountId", value: userInfo.id },
1749
- ],
1750
- });
1751
-
1752
- if (!account) {
1753
1747
  await ctx.context.internalAdapter.createAccount({
1754
1748
  userId: user.id,
1755
1749
  providerId: provider.providerId,
package/src/saml.test.ts CHANGED
@@ -1182,6 +1182,171 @@ describe("SAML SSO", async () => {
1182
1182
  },
1183
1183
  });
1184
1184
  });
1185
+
1186
+ it("should deny account linking when provider is not trusted and domain is not verified", async () => {
1187
+ const {
1188
+ auth: authUntrusted,
1189
+ signInWithTestUser,
1190
+ client,
1191
+ } = await getTestInstance({
1192
+ account: {
1193
+ accountLinking: {
1194
+ enabled: true,
1195
+ trustedProviders: [],
1196
+ },
1197
+ },
1198
+ plugins: [sso()],
1199
+ });
1200
+
1201
+ const { headers } = await signInWithTestUser();
1202
+
1203
+ await authUntrusted.api.registerSSOProvider({
1204
+ body: {
1205
+ providerId: "untrusted-saml-provider",
1206
+ issuer: "http://localhost:8081",
1207
+ domain: "http://localhost:8081",
1208
+ samlConfig: {
1209
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1210
+ cert: certificate,
1211
+ callbackUrl: "http://localhost:3000/dashboard",
1212
+ wantAssertionsSigned: false,
1213
+ signatureAlgorithm: "sha256",
1214
+ digestAlgorithm: "sha256",
1215
+ idpMetadata: {
1216
+ metadata: idpMetadata,
1217
+ },
1218
+ spMetadata: {
1219
+ metadata: spMetadata,
1220
+ },
1221
+ identifierFormat:
1222
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1223
+ },
1224
+ },
1225
+ headers,
1226
+ });
1227
+
1228
+ const ctx = await authUntrusted.$context;
1229
+ await ctx.adapter.create({
1230
+ model: "user",
1231
+ data: {
1232
+ id: "existing-user-id",
1233
+ email: "test@email.com",
1234
+ name: "Existing User",
1235
+ emailVerified: true,
1236
+ createdAt: new Date(),
1237
+ updatedAt: new Date(),
1238
+ },
1239
+ });
1240
+
1241
+ let samlResponse: any;
1242
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1243
+ onSuccess: async (context) => {
1244
+ samlResponse = await context.data;
1245
+ },
1246
+ });
1247
+
1248
+ const response = await authUntrusted.handler(
1249
+ new Request(
1250
+ "http://localhost:3000/api/auth/sso/saml2/callback/untrusted-saml-provider",
1251
+ {
1252
+ method: "POST",
1253
+ headers: {
1254
+ "Content-Type": "application/x-www-form-urlencoded",
1255
+ },
1256
+ body: new URLSearchParams({
1257
+ SAMLResponse: samlResponse.samlResponse,
1258
+ RelayState: "http://localhost:3000/dashboard",
1259
+ }),
1260
+ },
1261
+ ),
1262
+ );
1263
+
1264
+ expect(response.status).toBe(302);
1265
+ const redirectLocation = response.headers.get("location") || "";
1266
+ expect(redirectLocation).toContain("error=account_not_linked");
1267
+ });
1268
+
1269
+ it("should allow account linking when provider is in trustedProviders", async () => {
1270
+ const { auth: authWithTrusted, signInWithTestUser } = await getTestInstance(
1271
+ {
1272
+ account: {
1273
+ accountLinking: {
1274
+ enabled: true,
1275
+ trustedProviders: ["trusted-saml-provider"],
1276
+ },
1277
+ },
1278
+ plugins: [sso()],
1279
+ },
1280
+ );
1281
+
1282
+ const { headers } = await signInWithTestUser();
1283
+
1284
+ await authWithTrusted.api.registerSSOProvider({
1285
+ body: {
1286
+ providerId: "trusted-saml-provider",
1287
+ issuer: "http://localhost:8081",
1288
+ domain: "http://localhost:8081",
1289
+ samlConfig: {
1290
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1291
+ cert: certificate,
1292
+ callbackUrl: "http://localhost:3000/dashboard",
1293
+ wantAssertionsSigned: false,
1294
+ signatureAlgorithm: "sha256",
1295
+ digestAlgorithm: "sha256",
1296
+ idpMetadata: {
1297
+ metadata: idpMetadata,
1298
+ },
1299
+ spMetadata: {
1300
+ metadata: spMetadata,
1301
+ },
1302
+ identifierFormat:
1303
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1304
+ },
1305
+ },
1306
+ headers,
1307
+ });
1308
+
1309
+ const ctx = await authWithTrusted.$context;
1310
+ await ctx.adapter.create({
1311
+ model: "user",
1312
+ data: {
1313
+ id: "existing-user-id-2",
1314
+ email: "test@email.com",
1315
+ name: "Existing User",
1316
+ emailVerified: true,
1317
+ createdAt: new Date(),
1318
+ updatedAt: new Date(),
1319
+ },
1320
+ });
1321
+
1322
+ let samlResponse: any;
1323
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1324
+ onSuccess: async (context) => {
1325
+ samlResponse = await context.data;
1326
+ },
1327
+ });
1328
+
1329
+ const response = await authWithTrusted.handler(
1330
+ new Request(
1331
+ "http://localhost:3000/api/auth/sso/saml2/callback/trusted-saml-provider",
1332
+ {
1333
+ method: "POST",
1334
+ headers: {
1335
+ "Content-Type": "application/x-www-form-urlencoded",
1336
+ },
1337
+ body: new URLSearchParams({
1338
+ SAMLResponse: samlResponse.samlResponse,
1339
+ RelayState: "http://localhost:3000/dashboard",
1340
+ }),
1341
+ },
1342
+ ),
1343
+ );
1344
+
1345
+ expect(response.status).toBe(302);
1346
+ const redirectLocation = response.headers.get("location") || "";
1347
+ expect(redirectLocation).not.toContain("error");
1348
+ expect(redirectLocation).toContain("dashboard");
1349
+ });
1185
1350
  });
1186
1351
 
1187
1352
  describe("SAML SSO with custom fields", () => {
@@ -1325,3 +1490,184 @@ describe("SAML SSO with custom fields", () => {
1325
1490
  });
1326
1491
  });
1327
1492
  });
1493
+
1494
+ import { safeJsonParse } from "./utils";
1495
+
1496
+ describe("safeJsonParse", () => {
1497
+ it("returns object as-is when value is already an object", () => {
1498
+ const obj = { a: 1, nested: { b: 2 } };
1499
+ const result = safeJsonParse<typeof obj>(obj);
1500
+ expect(result).toBe(obj); // same reference
1501
+ expect(result).toEqual({ a: 1, nested: { b: 2 } });
1502
+ });
1503
+
1504
+ it("parses stringified JSON when value is a string", () => {
1505
+ const json = '{"a":1,"nested":{"b":2}}';
1506
+ const result = safeJsonParse<{ a: number; nested: { b: number } }>(json);
1507
+ expect(result).toEqual({ a: 1, nested: { b: 2 } });
1508
+ });
1509
+
1510
+ it("returns null for null input", () => {
1511
+ const result = safeJsonParse<{ a: number }>(null);
1512
+ expect(result).toBeNull();
1513
+ });
1514
+
1515
+ it("returns null for undefined input", () => {
1516
+ const result = safeJsonParse<{ a: number }>(undefined);
1517
+ expect(result).toBeNull();
1518
+ });
1519
+
1520
+ it("throws error for invalid JSON string", () => {
1521
+ expect(() => safeJsonParse<{ a: number }>("not valid json")).toThrow(
1522
+ "Failed to parse JSON",
1523
+ );
1524
+ });
1525
+
1526
+ it("handles empty object", () => {
1527
+ const obj = {};
1528
+ const result = safeJsonParse<typeof obj>(obj);
1529
+ expect(result).toBe(obj);
1530
+ });
1531
+
1532
+ it("handles empty string JSON", () => {
1533
+ const result = safeJsonParse<Record<string, never>>("{}");
1534
+ expect(result).toEqual({});
1535
+ });
1536
+ });
1537
+
1538
+ describe("SSO Provider Config Parsing", () => {
1539
+ it("returns parsed SAML config and avoids [object Object] in response", async () => {
1540
+ const data = {
1541
+ user: [] as any[],
1542
+ session: [] as any[],
1543
+ verification: [] as any[],
1544
+ account: [] as any[],
1545
+ ssoProvider: [] as any[],
1546
+ };
1547
+
1548
+ const memory = memoryAdapter(data);
1549
+
1550
+ const auth = betterAuth({
1551
+ database: memory,
1552
+ baseURL: "http://localhost:3000",
1553
+ emailAndPassword: { enabled: true },
1554
+ plugins: [sso()],
1555
+ });
1556
+
1557
+ const authClient = createAuthClient({
1558
+ baseURL: "http://localhost:3000",
1559
+ plugins: [bearer(), ssoClient()],
1560
+ fetchOptions: {
1561
+ customFetchImpl: async (url, init) =>
1562
+ auth.handler(new Request(url, init)),
1563
+ },
1564
+ });
1565
+
1566
+ const headers = new Headers();
1567
+ await authClient.signUp.email({
1568
+ email: "test@example.com",
1569
+ password: "password123",
1570
+ name: "Test User",
1571
+ });
1572
+ await authClient.signIn.email(
1573
+ { email: "test@example.com", password: "password123" },
1574
+ { onSuccess: setCookieToHeader(headers) },
1575
+ );
1576
+
1577
+ const provider = await auth.api.registerSSOProvider({
1578
+ body: {
1579
+ providerId: "saml-config-provider",
1580
+ issuer: "http://localhost:8081",
1581
+ domain: "example.com",
1582
+ samlConfig: {
1583
+ entryPoint: "http://localhost:8081/sso",
1584
+ cert: "test-cert",
1585
+ callbackUrl: "http://localhost:3000/callback",
1586
+ spMetadata: {
1587
+ entityID: "test-entity",
1588
+ },
1589
+ },
1590
+ },
1591
+ headers,
1592
+ });
1593
+
1594
+ expect(provider.samlConfig).toBeDefined();
1595
+ expect(typeof provider.samlConfig).toBe("object");
1596
+ expect(provider.samlConfig?.entryPoint).toBe("http://localhost:8081/sso");
1597
+ expect(provider.samlConfig?.cert).toBe("test-cert");
1598
+
1599
+ const serialized = JSON.stringify(provider.samlConfig);
1600
+ expect(serialized).not.toContain("[object Object]");
1601
+
1602
+ expect(provider.samlConfig?.spMetadata?.entityID).toBe("test-entity");
1603
+ });
1604
+
1605
+ it("returns parsed OIDC config and avoids [object Object] in response", async () => {
1606
+ const data = {
1607
+ user: [] as any[],
1608
+ session: [] as any[],
1609
+ verification: [] as any[],
1610
+ account: [] as any[],
1611
+ ssoProvider: [] as any[],
1612
+ };
1613
+
1614
+ const memory = memoryAdapter(data);
1615
+
1616
+ const auth = betterAuth({
1617
+ database: memory,
1618
+ baseURL: "http://localhost:3000",
1619
+ emailAndPassword: { enabled: true },
1620
+ plugins: [sso()],
1621
+ });
1622
+
1623
+ const authClient = createAuthClient({
1624
+ baseURL: "http://localhost:3000",
1625
+ plugins: [bearer(), ssoClient()],
1626
+ fetchOptions: {
1627
+ customFetchImpl: async (url, init) =>
1628
+ auth.handler(new Request(url, init)),
1629
+ },
1630
+ });
1631
+
1632
+ const headers = new Headers();
1633
+ await authClient.signUp.email({
1634
+ email: "test@example.com",
1635
+ password: "password123",
1636
+ name: "Test User",
1637
+ });
1638
+ await authClient.signIn.email(
1639
+ { email: "test@example.com", password: "password123" },
1640
+ { onSuccess: setCookieToHeader(headers) },
1641
+ );
1642
+
1643
+ const provider = await auth.api.registerSSOProvider({
1644
+ body: {
1645
+ providerId: "oidc-config-provider",
1646
+ issuer: "http://localhost:8080",
1647
+ domain: "example.com",
1648
+ oidcConfig: {
1649
+ clientId: "test-client",
1650
+ clientSecret: "test-secret",
1651
+ discoveryEndpoint:
1652
+ "http://localhost:8080/.well-known/openid-configuration",
1653
+ mapping: {
1654
+ id: "sub",
1655
+ email: "email",
1656
+ name: "name",
1657
+ },
1658
+ },
1659
+ },
1660
+ headers,
1661
+ });
1662
+
1663
+ expect(provider.oidcConfig).toBeDefined();
1664
+ expect(typeof provider.oidcConfig).toBe("object");
1665
+ expect(provider.oidcConfig?.clientId).toBe("test-client");
1666
+ expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
1667
+
1668
+ const serialized = JSON.stringify(provider.oidcConfig);
1669
+ expect(serialized).not.toContain("[object Object]");
1670
+
1671
+ expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1672
+ });
1673
+ });
package/src/types.ts CHANGED
@@ -232,7 +232,13 @@ export interface SSOOptions {
232
232
  *
233
233
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
234
234
  * providers in the `trustedProviders` list.
235
+ *
235
236
  * @default false
237
+ *
238
+ * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
239
+ * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
240
+ * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
241
+ * This option may be removed in a future major version.
236
242
  */
237
243
  trustEmailVerified?: boolean | undefined;
238
244
  /**
package/src/utils.ts CHANGED
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Safely parses a value that might be a JSON string or already a parsed object.
3
+ * This handles cases where ORMs like Drizzle might return already parsed objects
4
+ * instead of JSON strings from TEXT/JSON columns.
5
+ *
6
+ * @param value - The value to parse (string, object, null, or undefined)
7
+ * @returns The parsed object or null
8
+ * @throws Error if string parsing fails
9
+ */
10
+ export function safeJsonParse<T>(
11
+ value: string | T | null | undefined,
12
+ ): T | null {
13
+ if (!value) return null;
14
+
15
+ if (typeof value === "object") {
16
+ return value as T;
17
+ }
18
+
19
+ if (typeof value === "string") {
20
+ try {
21
+ return JSON.parse(value) as T;
22
+ } catch (error) {
23
+ throw new Error(
24
+ `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
25
+ );
26
+ }
27
+ }
28
+
29
+ return null;
30
+ }
31
+
1
32
  export const validateEmailDomain = (email: string, domain: string) => {
2
33
  const emailDomain = email.split("@")[1]?.toLowerCase();
3
34
  const providerDomain = domain.toLowerCase();