@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.
- package/.turbo/turbo-build.log +6 -6
- package/bump.config.ts +5 -0
- package/dist/client.d.mts +1 -1
- package/dist/{index-D-JmJR9N.d.mts → index-CYgzSZS4.d.mts} +6 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +59 -39
- package/package.json +7 -5
- package/src/oidc.test.ts +164 -0
- package/src/routes/sso.ts +40 -46
- package/src/saml.test.ts +346 -0
- package/src/types.ts +6 -0
- package/src/utils.ts +31 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.6-beta.
|
|
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
|
[34mℹ[39m tsdown [2mv0.17.0[22m powered by rolldown [2mv1.0.0-beta.53[22m
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
[34mℹ[39m entry: [34msrc/index.ts, src/client.ts[39m
|
|
8
8
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
9
9
|
[34mℹ[39m Build start
|
|
10
|
-
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [
|
|
10
|
+
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m59.70 kB[22m [2m│ gzip: 10.49 kB[22m
|
|
11
11
|
[34mℹ[39m [2mdist/[22m[1mclient.mjs[22m [2m 0.15 kB[22m [2m│ gzip: 0.14 kB[22m
|
|
12
|
-
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.
|
|
12
|
+
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.29 kB[22m
|
|
13
13
|
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 0.21 kB[22m [2m│ gzip: 0.15 kB[22m
|
|
14
|
-
[34mℹ[39m [2mdist/[22m[32mindex-
|
|
15
|
-
[34mℹ[39m 5 files, total:
|
|
16
|
-
[32m✔[39m Build complete in [
|
|
14
|
+
[34mℹ[39m [2mdist/[22m[32mindex-CYgzSZS4.d.mts[39m [2m25.84 kB[22m [2m│ gzip: 4.13 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 86.39 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m11274ms[39m
|
package/bump.config.ts
ADDED
package/dist/client.d.mts
CHANGED
|
@@ -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-
|
|
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:
|
|
591
|
-
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)
|
|
1121
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
69
|
+
"better-auth": "1.4.6-beta.6"
|
|
69
70
|
},
|
|
70
71
|
"peerDependencies": {
|
|
71
|
-
"better-auth": "1.4.6-beta.
|
|
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:
|
|
658
|
+
oidcConfig: safeJsonParse<OIDCConfig>(
|
|
687
659
|
provider.oidcConfig as unknown as string,
|
|
688
|
-
)
|
|
689
|
-
samlConfig:
|
|
660
|
+
),
|
|
661
|
+
samlConfig: safeJsonParse<SAMLConfig>(
|
|
690
662
|
provider.samlConfig as unknown as string,
|
|
691
|
-
)
|
|
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();
|