@better-auth/sso 1.4.6 → 1.4.7-beta.3
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 +8 -8
- package/dist/client.d.mts +1 -1
- package/dist/{index-D-JmJR9N.d.mts → index-m7FISidt.d.mts} +95 -21
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +160 -49
- package/package.json +6 -5
- package/src/authn-request-store.ts +76 -0
- package/src/authn-request.test.ts +99 -0
- package/src/index.ts +19 -7
- package/src/routes/sso.ts +225 -74
- package/src/saml.test.ts +500 -33
- package/src/types.ts +55 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.
|
|
2
|
+
> @better-auth/sso@1.4.7-beta.3 build /home/runner/work/better-auth/better-auth/packages/sso
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
|
-
[34mℹ[39m tsdown [2mv0.17.
|
|
5
|
+
[34mℹ[39m tsdown [2mv0.17.2[22m powered by rolldown [2mv1.0.0-beta.53[22m
|
|
6
6
|
[34mℹ[39m config file: [4m/home/runner/work/better-auth/better-auth/packages/sso/tsdown.config.ts[24m
|
|
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 [2m65.14 kB[22m [2m│ gzip: 11.40 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.
|
|
13
|
-
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 0.
|
|
14
|
-
[34mℹ[39m [2mdist/[22m[32mindex-
|
|
15
|
-
[34mℹ[39m 5 files, total: 85
|
|
16
|
-
[32m✔[39m Build complete in [
|
|
12
|
+
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.29 kB[22m
|
|
13
|
+
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 0.43 kB[22m [2m│ gzip: 0.23 kB[22m
|
|
14
|
+
[34mℹ[39m [2mdist/[22m[32mindex-m7FISidt.d.mts[39m [2m28.63 kB[22m [2m│ gzip: 5.07 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 94.85 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m11484ms[39m
|
package/dist/client.d.mts
CHANGED
|
@@ -2,6 +2,42 @@ import * as z from "zod/v4";
|
|
|
2
2
|
import { OAuth2Tokens, User } from "better-auth";
|
|
3
3
|
import * as better_call0 from "better-call";
|
|
4
4
|
|
|
5
|
+
//#region src/authn-request-store.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AuthnRequest Store
|
|
9
|
+
*
|
|
10
|
+
* Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
|
|
11
|
+
* This prevents:
|
|
12
|
+
* - Unsolicited SAML responses
|
|
13
|
+
* - Cross-provider response injection
|
|
14
|
+
* - Replay attacks
|
|
15
|
+
* - Expired login completions
|
|
16
|
+
*/
|
|
17
|
+
interface AuthnRequestRecord {
|
|
18
|
+
id: string;
|
|
19
|
+
providerId: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
}
|
|
23
|
+
interface AuthnRequestStore {
|
|
24
|
+
save(record: AuthnRequestRecord): Promise<void>;
|
|
25
|
+
get(id: string): Promise<AuthnRequestRecord | null>;
|
|
26
|
+
delete(id: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
30
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
31
|
+
*/
|
|
32
|
+
declare const DEFAULT_AUTHN_REQUEST_TTL_MS: number;
|
|
33
|
+
/**
|
|
34
|
+
* In-memory implementation of AuthnRequestStore.
|
|
35
|
+
* ⚠️ Only suitable for testing or single-instance non-serverless deployments.
|
|
36
|
+
* For production, rely on the default behavior (uses verification table)
|
|
37
|
+
* or provide a custom Redis-backed store.
|
|
38
|
+
*/
|
|
39
|
+
declare function createInMemoryAuthnRequestStore(): AuthnRequestStore;
|
|
40
|
+
//#endregion
|
|
5
41
|
//#region src/types.d.ts
|
|
6
42
|
interface OIDCMapping {
|
|
7
43
|
id?: string | undefined;
|
|
@@ -216,7 +252,13 @@ interface SSOOptions {
|
|
|
216
252
|
*
|
|
217
253
|
* If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
|
|
218
254
|
* providers in the `trustedProviders` list.
|
|
255
|
+
*
|
|
219
256
|
* @default false
|
|
257
|
+
*
|
|
258
|
+
* @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
|
|
259
|
+
* trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
|
|
260
|
+
* Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
|
|
261
|
+
* This option may be removed in a future major version.
|
|
220
262
|
*/
|
|
221
263
|
trustEmailVerified?: boolean | undefined;
|
|
222
264
|
/**
|
|
@@ -237,6 +279,54 @@ interface SSOOptions {
|
|
|
237
279
|
*/
|
|
238
280
|
tokenPrefix?: string;
|
|
239
281
|
};
|
|
282
|
+
/**
|
|
283
|
+
* SAML security options for AuthnRequest/InResponseTo validation.
|
|
284
|
+
* This prevents unsolicited responses, replay attacks, and cross-provider injection.
|
|
285
|
+
*/
|
|
286
|
+
saml?: {
|
|
287
|
+
/**
|
|
288
|
+
* Enable InResponseTo validation for SP-initiated SAML flows.
|
|
289
|
+
* When enabled, AuthnRequest IDs are tracked and validated against SAML responses.
|
|
290
|
+
*
|
|
291
|
+
* Storage behavior:
|
|
292
|
+
* - Uses `secondaryStorage` (e.g., Redis) if configured in your auth options
|
|
293
|
+
* - Falls back to the verification table in the database otherwise
|
|
294
|
+
*
|
|
295
|
+
* This works correctly in serverless environments without any additional configuration.
|
|
296
|
+
*
|
|
297
|
+
* @default false
|
|
298
|
+
*/
|
|
299
|
+
enableInResponseToValidation?: boolean;
|
|
300
|
+
/**
|
|
301
|
+
* Allow IdP-initiated SSO (unsolicited SAML responses).
|
|
302
|
+
* When true, responses without InResponseTo are accepted.
|
|
303
|
+
* When false, all responses must correlate to a stored AuthnRequest.
|
|
304
|
+
*
|
|
305
|
+
* Only applies when InResponseTo validation is enabled.
|
|
306
|
+
*
|
|
307
|
+
* @default true
|
|
308
|
+
*/
|
|
309
|
+
allowIdpInitiated?: boolean;
|
|
310
|
+
/**
|
|
311
|
+
* TTL for AuthnRequest records in milliseconds.
|
|
312
|
+
* Requests older than this will be rejected.
|
|
313
|
+
*
|
|
314
|
+
* Only applies when InResponseTo validation is enabled.
|
|
315
|
+
*
|
|
316
|
+
* @default 300000 (5 minutes)
|
|
317
|
+
*/
|
|
318
|
+
requestTTL?: number;
|
|
319
|
+
/**
|
|
320
|
+
* Custom AuthnRequest store implementation.
|
|
321
|
+
* Use this to provide a custom storage backend (e.g., Redis-backed store).
|
|
322
|
+
*
|
|
323
|
+
* Providing a custom store automatically enables InResponseTo validation.
|
|
324
|
+
*
|
|
325
|
+
* Note: When not provided, the default storage (secondaryStorage with
|
|
326
|
+
* verification table fallback) is used automatically.
|
|
327
|
+
*/
|
|
328
|
+
authnRequestStore?: AuthnRequestStore;
|
|
329
|
+
};
|
|
240
330
|
}
|
|
241
331
|
//#endregion
|
|
242
332
|
//#region src/routes/domain-verification.d.ts
|
|
@@ -285,8 +375,6 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call0.S
|
|
|
285
375
|
};
|
|
286
376
|
};
|
|
287
377
|
}>)[];
|
|
288
|
-
} & {
|
|
289
|
-
use: any[];
|
|
290
378
|
}, {
|
|
291
379
|
domainVerificationToken: string;
|
|
292
380
|
}>;
|
|
@@ -338,8 +426,6 @@ declare const verifyDomain: (options: SSOOptions) => better_call0.StrictEndpoint
|
|
|
338
426
|
};
|
|
339
427
|
};
|
|
340
428
|
}>)[];
|
|
341
|
-
} & {
|
|
342
|
-
use: any[];
|
|
343
429
|
}, void>;
|
|
344
430
|
//#endregion
|
|
345
431
|
//#region src/routes/sso.d.ts
|
|
@@ -364,8 +450,6 @@ declare const spMetadata: () => better_call0.StrictEndpoint<"/sso/saml2/sp/metad
|
|
|
364
450
|
};
|
|
365
451
|
};
|
|
366
452
|
};
|
|
367
|
-
} & {
|
|
368
|
-
use: any[];
|
|
369
453
|
}, Response>;
|
|
370
454
|
declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_call0.StrictEndpoint<"/sso/register", {
|
|
371
455
|
method: "POST";
|
|
@@ -629,8 +713,6 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
|
|
|
629
713
|
};
|
|
630
714
|
};
|
|
631
715
|
};
|
|
632
|
-
} & {
|
|
633
|
-
use: any[];
|
|
634
716
|
}, O["domainVerification"] extends {
|
|
635
717
|
enabled: true;
|
|
636
718
|
} ? {
|
|
@@ -651,8 +733,8 @@ declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"
|
|
|
651
733
|
loginHint: z.ZodOptional<z.ZodString>;
|
|
652
734
|
requestSignUp: z.ZodOptional<z.ZodBoolean>;
|
|
653
735
|
providerType: z.ZodOptional<z.ZodEnum<{
|
|
654
|
-
oidc: "oidc";
|
|
655
736
|
saml: "saml";
|
|
737
|
+
oidc: "oidc";
|
|
656
738
|
}>>;
|
|
657
739
|
}, z.core.$strip>;
|
|
658
740
|
metadata: {
|
|
@@ -727,8 +809,6 @@ declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"
|
|
|
727
809
|
};
|
|
728
810
|
};
|
|
729
811
|
};
|
|
730
|
-
} & {
|
|
731
|
-
use: any[];
|
|
732
812
|
}, {
|
|
733
813
|
url: string;
|
|
734
814
|
redirect: boolean;
|
|
@@ -743,7 +823,6 @@ declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint
|
|
|
743
823
|
}, z.core.$strip>;
|
|
744
824
|
allowedMediaTypes: string[];
|
|
745
825
|
metadata: {
|
|
746
|
-
isAction: false;
|
|
747
826
|
openapi: {
|
|
748
827
|
operationId: string;
|
|
749
828
|
summary: string;
|
|
@@ -754,9 +833,8 @@ declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint
|
|
|
754
833
|
};
|
|
755
834
|
};
|
|
756
835
|
};
|
|
836
|
+
scope: "server";
|
|
757
837
|
};
|
|
758
|
-
} & {
|
|
759
|
-
use: any[];
|
|
760
838
|
}, never>;
|
|
761
839
|
declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
|
|
762
840
|
method: "POST";
|
|
@@ -765,7 +843,6 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndp
|
|
|
765
843
|
RelayState: z.ZodOptional<z.ZodString>;
|
|
766
844
|
}, z.core.$strip>;
|
|
767
845
|
metadata: {
|
|
768
|
-
isAction: false;
|
|
769
846
|
allowedMediaTypes: string[];
|
|
770
847
|
openapi: {
|
|
771
848
|
operationId: string;
|
|
@@ -783,9 +860,8 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndp
|
|
|
783
860
|
};
|
|
784
861
|
};
|
|
785
862
|
};
|
|
863
|
+
scope: "server";
|
|
786
864
|
};
|
|
787
|
-
} & {
|
|
788
|
-
use: any[];
|
|
789
865
|
}, never>;
|
|
790
866
|
declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
|
|
791
867
|
method: "POST";
|
|
@@ -797,7 +873,6 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
|
|
|
797
873
|
RelayState: z.ZodOptional<z.ZodString>;
|
|
798
874
|
}, z.core.$strip>;
|
|
799
875
|
metadata: {
|
|
800
|
-
isAction: false;
|
|
801
876
|
allowedMediaTypes: string[];
|
|
802
877
|
openapi: {
|
|
803
878
|
operationId: string;
|
|
@@ -809,9 +884,8 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
|
|
|
809
884
|
};
|
|
810
885
|
};
|
|
811
886
|
};
|
|
887
|
+
scope: "server";
|
|
812
888
|
};
|
|
813
|
-
} & {
|
|
814
|
-
use: any[];
|
|
815
889
|
}, never>;
|
|
816
890
|
//#endregion
|
|
817
891
|
//#region src/index.d.ts
|
|
@@ -850,4 +924,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
|
|
|
850
924
|
endpoints: SSOEndpoints<O>;
|
|
851
925
|
};
|
|
852
926
|
//#endregion
|
|
853
|
-
export { SSOOptions as a, SAMLConfig as i, sso as n, SSOProvider as o, OIDCConfig as r, SSOPlugin as t };
|
|
927
|
+
export { SSOOptions as a, AuthnRequestStore as c, SAMLConfig as i, DEFAULT_AUTHN_REQUEST_TTL_MS as l, sso as n, SSOProvider as o, OIDCConfig as r, AuthnRequestRecord as s, SSOPlugin as t, createInMemoryAuthnRequestStore as u };
|
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-
|
|
2
|
-
export { OIDCConfig, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, sso };
|
|
1
|
+
import { a as SSOOptions, c as AuthnRequestStore, i as SAMLConfig, l as DEFAULT_AUTHN_REQUEST_TTL_MS, n as sso, o as SSOProvider, r as OIDCConfig, s as AuthnRequestRecord, t as SSOPlugin, u as createInMemoryAuthnRequestStore } from "./index-m7FISidt.mjs";
|
|
2
|
+
export { AuthnRequestRecord, AuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS, OIDCConfig, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, createInMemoryAuthnRequestStore, sso };
|
package/dist/index.mjs
CHANGED
|
@@ -4,11 +4,51 @@ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api
|
|
|
4
4
|
import { generateRandomString } from "better-auth/crypto";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
6
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
7
|
-
import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
7
|
+
import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
8
8
|
import { setSessionCookie } from "better-auth/cookies";
|
|
9
9
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
10
10
|
import { decodeJwt } from "jose";
|
|
11
11
|
|
|
12
|
+
//#region src/authn-request-store.ts
|
|
13
|
+
/**
|
|
14
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
15
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
|
|
18
|
+
/**
|
|
19
|
+
* In-memory implementation of AuthnRequestStore.
|
|
20
|
+
* ⚠️ Only suitable for testing or single-instance non-serverless deployments.
|
|
21
|
+
* For production, rely on the default behavior (uses verification table)
|
|
22
|
+
* or provide a custom Redis-backed store.
|
|
23
|
+
*/
|
|
24
|
+
function createInMemoryAuthnRequestStore() {
|
|
25
|
+
const store = /* @__PURE__ */ new Map();
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [id, record] of store.entries()) if (record.expiresAt < now) store.delete(id);
|
|
29
|
+
};
|
|
30
|
+
const cleanupInterval = setInterval(cleanup, 60 * 1e3);
|
|
31
|
+
if (typeof cleanupInterval.unref === "function") cleanupInterval.unref();
|
|
32
|
+
return {
|
|
33
|
+
async save(record) {
|
|
34
|
+
store.set(record.id, record);
|
|
35
|
+
},
|
|
36
|
+
async get(id) {
|
|
37
|
+
const record = store.get(id);
|
|
38
|
+
if (!record) return null;
|
|
39
|
+
if (record.expiresAt < Date.now()) {
|
|
40
|
+
store.delete(id);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return record;
|
|
44
|
+
},
|
|
45
|
+
async delete(id) {
|
|
46
|
+
store.delete(id);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
12
52
|
//#region src/routes/domain-verification.ts
|
|
13
53
|
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
14
54
|
const requestDomainVerification = (options) => {
|
|
@@ -213,6 +253,7 @@ const validateEmailDomain = (email, domain) => {
|
|
|
213
253
|
|
|
214
254
|
//#endregion
|
|
215
255
|
//#region src/routes/sso.ts
|
|
256
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
216
257
|
const spMetadataQuerySchema = z.object({
|
|
217
258
|
providerId: z.string(),
|
|
218
259
|
format: z.enum(["xml", "json"]).default("xml")
|
|
@@ -781,6 +822,21 @@ const signInSSO = (options) => {
|
|
|
781
822
|
});
|
|
782
823
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
783
824
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
825
|
+
if (loginRequest.id && (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation)) {
|
|
826
|
+
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
827
|
+
const record = {
|
|
828
|
+
id: loginRequest.id,
|
|
829
|
+
providerId: provider.providerId,
|
|
830
|
+
createdAt: Date.now(),
|
|
831
|
+
expiresAt: Date.now() + ttl
|
|
832
|
+
};
|
|
833
|
+
if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.save(record);
|
|
834
|
+
else await ctx.context.internalAdapter.createVerificationValue({
|
|
835
|
+
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
836
|
+
value: JSON.stringify(record),
|
|
837
|
+
expiresAt: new Date(record.expiresAt)
|
|
838
|
+
});
|
|
839
|
+
}
|
|
784
840
|
return ctx.json({
|
|
785
841
|
url: `${loginRequest.context}&RelayState=${encodeURIComponent(body.callbackURL)}`,
|
|
786
842
|
redirect: true
|
|
@@ -801,7 +857,7 @@ const callbackSSO = (options) => {
|
|
|
801
857
|
query: callbackSSOQuerySchema,
|
|
802
858
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
803
859
|
metadata: {
|
|
804
|
-
|
|
860
|
+
...HIDE_METADATA,
|
|
805
861
|
openapi: {
|
|
806
862
|
operationId: "handleSSOCallback",
|
|
807
863
|
summary: "Callback URL for SSO provider",
|
|
@@ -810,7 +866,7 @@ const callbackSSO = (options) => {
|
|
|
810
866
|
}
|
|
811
867
|
}
|
|
812
868
|
}, async (ctx) => {
|
|
813
|
-
const { code,
|
|
869
|
+
const { code, error, error_description } = ctx.query;
|
|
814
870
|
const stateData = await parseState(ctx);
|
|
815
871
|
if (!stateData) {
|
|
816
872
|
const errorURL$1 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
@@ -986,7 +1042,7 @@ const callbackSSOSAML = (options) => {
|
|
|
986
1042
|
method: "POST",
|
|
987
1043
|
body: callbackSSOSAMLBodySchema,
|
|
988
1044
|
metadata: {
|
|
989
|
-
|
|
1045
|
+
...HIDE_METADATA,
|
|
990
1046
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
991
1047
|
openapi: {
|
|
992
1048
|
operationId: "handleSAMLCallback",
|
|
@@ -1069,22 +1125,10 @@ const callbackSSOSAML = (options) => {
|
|
|
1069
1125
|
});
|
|
1070
1126
|
let parsedResponse;
|
|
1071
1127
|
try {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
RelayState: RelayState || void 0
|
|
1077
|
-
} });
|
|
1078
|
-
} catch (parseError) {
|
|
1079
|
-
const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
|
|
1080
|
-
if (!nameIDMatch) throw parseError;
|
|
1081
|
-
parsedResponse = { extract: {
|
|
1082
|
-
nameID: nameIDMatch[1],
|
|
1083
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1084
|
-
sessionIndex: {},
|
|
1085
|
-
conditions: {}
|
|
1086
|
-
} };
|
|
1087
|
-
}
|
|
1128
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1129
|
+
SAMLResponse,
|
|
1130
|
+
RelayState: RelayState || void 0
|
|
1131
|
+
} });
|
|
1088
1132
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1089
1133
|
} catch (error) {
|
|
1090
1134
|
ctx.context.logger.error("SAML response validation failed", {
|
|
@@ -1097,6 +1141,47 @@ const callbackSSOSAML = (options) => {
|
|
|
1097
1141
|
});
|
|
1098
1142
|
}
|
|
1099
1143
|
const { extract } = parsedResponse;
|
|
1144
|
+
const inResponseTo = extract.inResponseTo;
|
|
1145
|
+
if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
|
|
1146
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1147
|
+
if (inResponseTo) {
|
|
1148
|
+
let storedRequest = null;
|
|
1149
|
+
if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseTo);
|
|
1150
|
+
else {
|
|
1151
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1152
|
+
if (verification) try {
|
|
1153
|
+
storedRequest = JSON.parse(verification.value);
|
|
1154
|
+
} catch {
|
|
1155
|
+
storedRequest = null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (!storedRequest) {
|
|
1159
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
1160
|
+
inResponseTo,
|
|
1161
|
+
providerId: provider.providerId
|
|
1162
|
+
});
|
|
1163
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1164
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
1165
|
+
}
|
|
1166
|
+
if (storedRequest.providerId !== provider.providerId) {
|
|
1167
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
1168
|
+
inResponseTo,
|
|
1169
|
+
expectedProvider: storedRequest.providerId,
|
|
1170
|
+
actualProvider: provider.providerId
|
|
1171
|
+
});
|
|
1172
|
+
if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
|
|
1173
|
+
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1174
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1175
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1176
|
+
}
|
|
1177
|
+
if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
|
|
1178
|
+
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1179
|
+
} else if (!allowIdpInitiated) {
|
|
1180
|
+
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
1181
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1182
|
+
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1100
1185
|
const attributes = extract.attributes || {};
|
|
1101
1186
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1102
1187
|
const userInfo = {
|
|
@@ -1223,7 +1308,7 @@ const acsEndpoint = (options) => {
|
|
|
1223
1308
|
params: acsEndpointParamsSchema,
|
|
1224
1309
|
body: acsEndpointBodySchema,
|
|
1225
1310
|
metadata: {
|
|
1226
|
-
|
|
1311
|
+
...HIDE_METADATA,
|
|
1227
1312
|
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
|
|
1228
1313
|
openapi: {
|
|
1229
1314
|
operationId: "handleSAMLAssertionConsumerService",
|
|
@@ -1285,26 +1370,10 @@ const acsEndpoint = (options) => {
|
|
|
1285
1370
|
}) : saml.IdentityProvider({ metadata: idpData.metadata });
|
|
1286
1371
|
let parsedResponse;
|
|
1287
1372
|
try {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
} else if (!decodedResponse.includes("saml2:Success")) decodedResponse = decodedResponse.replace(/<saml2:StatusCode Value="[^"]+"/, "<saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"");
|
|
1293
|
-
try {
|
|
1294
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1295
|
-
SAMLResponse,
|
|
1296
|
-
RelayState: RelayState || void 0
|
|
1297
|
-
} });
|
|
1298
|
-
} catch (parseError) {
|
|
1299
|
-
const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
|
|
1300
|
-
if (!nameIDMatch) throw parseError;
|
|
1301
|
-
parsedResponse = { extract: {
|
|
1302
|
-
nameID: nameIDMatch[1],
|
|
1303
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1304
|
-
sessionIndex: {},
|
|
1305
|
-
conditions: {}
|
|
1306
|
-
} };
|
|
1307
|
-
}
|
|
1373
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1374
|
+
SAMLResponse,
|
|
1375
|
+
RelayState: RelayState || void 0
|
|
1376
|
+
} });
|
|
1308
1377
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1309
1378
|
} catch (error) {
|
|
1310
1379
|
ctx.context.logger.error("SAML response validation failed", {
|
|
@@ -1317,6 +1386,47 @@ const acsEndpoint = (options) => {
|
|
|
1317
1386
|
});
|
|
1318
1387
|
}
|
|
1319
1388
|
const { extract } = parsedResponse;
|
|
1389
|
+
const inResponseToAcs = extract.inResponseTo;
|
|
1390
|
+
if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
|
|
1391
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1392
|
+
if (inResponseToAcs) {
|
|
1393
|
+
let storedRequest = null;
|
|
1394
|
+
if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseToAcs);
|
|
1395
|
+
else {
|
|
1396
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
1397
|
+
if (verification) try {
|
|
1398
|
+
storedRequest = JSON.parse(verification.value);
|
|
1399
|
+
} catch {
|
|
1400
|
+
storedRequest = null;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
if (!storedRequest) {
|
|
1404
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
1405
|
+
inResponseTo: inResponseToAcs,
|
|
1406
|
+
providerId
|
|
1407
|
+
});
|
|
1408
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1409
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
1410
|
+
}
|
|
1411
|
+
if (storedRequest.providerId !== providerId) {
|
|
1412
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
1413
|
+
inResponseTo: inResponseToAcs,
|
|
1414
|
+
expectedProvider: storedRequest.providerId,
|
|
1415
|
+
actualProvider: providerId
|
|
1416
|
+
});
|
|
1417
|
+
if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
|
|
1418
|
+
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
1419
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1420
|
+
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1421
|
+
}
|
|
1422
|
+
if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
|
|
1423
|
+
else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
1424
|
+
} else if (!allowIdpInitiated) {
|
|
1425
|
+
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
1426
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1427
|
+
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1320
1430
|
const attributes = extract.attributes || {};
|
|
1321
1431
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1322
1432
|
const userInfo = {
|
|
@@ -1439,18 +1549,19 @@ saml.setSchemaValidator({ async validate(xml) {
|
|
|
1439
1549
|
throw "ERR_INVALID_XML";
|
|
1440
1550
|
} });
|
|
1441
1551
|
function sso(options) {
|
|
1552
|
+
const optionsWithStore = options;
|
|
1442
1553
|
let endpoints = {
|
|
1443
1554
|
spMetadata: spMetadata(),
|
|
1444
|
-
registerSSOProvider: registerSSOProvider(
|
|
1445
|
-
signInSSO: signInSSO(
|
|
1446
|
-
callbackSSO: callbackSSO(
|
|
1447
|
-
callbackSSOSAML: callbackSSOSAML(
|
|
1448
|
-
acsEndpoint: acsEndpoint(
|
|
1555
|
+
registerSSOProvider: registerSSOProvider(optionsWithStore),
|
|
1556
|
+
signInSSO: signInSSO(optionsWithStore),
|
|
1557
|
+
callbackSSO: callbackSSO(optionsWithStore),
|
|
1558
|
+
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
1559
|
+
acsEndpoint: acsEndpoint(optionsWithStore)
|
|
1449
1560
|
};
|
|
1450
1561
|
if (options?.domainVerification?.enabled) {
|
|
1451
1562
|
const domainVerificationEndpoints = {
|
|
1452
|
-
requestDomainVerification: requestDomainVerification(
|
|
1453
|
-
verifyDomain: verifyDomain(
|
|
1563
|
+
requestDomainVerification: requestDomainVerification(optionsWithStore),
|
|
1564
|
+
verifyDomain: verifyDomain(optionsWithStore)
|
|
1454
1565
|
};
|
|
1455
1566
|
endpoints = {
|
|
1456
1567
|
...endpoints,
|
|
@@ -1512,4 +1623,4 @@ function sso(options) {
|
|
|
1512
1623
|
}
|
|
1513
1624
|
|
|
1514
1625
|
//#endregion
|
|
1515
|
-
export { sso };
|
|
1626
|
+
export { DEFAULT_AUTHN_REQUEST_TTL_MS, createInMemoryAuthnRequestStore, sso };
|
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.
|
|
4
|
+
"version": "1.4.7-beta.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.mts",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@better-fetch/fetch": "1.1.
|
|
55
|
+
"@better-fetch/fetch": "1.1.21",
|
|
56
56
|
"fast-xml-parser": "^5.2.5",
|
|
57
57
|
"jose": "^6.1.0",
|
|
58
58
|
"samlify": "^2.10.1",
|
|
@@ -65,16 +65,17 @@
|
|
|
65
65
|
"body-parser": "^2.2.1",
|
|
66
66
|
"express": "^5.1.0",
|
|
67
67
|
"oauth2-mock-server": "^8.2.0",
|
|
68
|
-
"tsdown": "^0.17.
|
|
69
|
-
"better-auth": "1.4.
|
|
68
|
+
"tsdown": "^0.17.2",
|
|
69
|
+
"better-auth": "1.4.7-beta.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"better-auth": "1.4.
|
|
72
|
+
"better-auth": "1.4.7-beta.3"
|
|
73
73
|
},
|
|
74
74
|
"scripts": {
|
|
75
75
|
"test": "vitest",
|
|
76
76
|
"coverage": "vitest run --coverage",
|
|
77
77
|
"lint:package": "publint run --strict",
|
|
78
|
+
"lint:types": "attw --profile esm-only --pack .",
|
|
78
79
|
"build": "tsdown",
|
|
79
80
|
"dev": "tsdown --watch",
|
|
80
81
|
"typecheck": "tsc --project tsconfig.json"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthnRequest Store
|
|
3
|
+
*
|
|
4
|
+
* Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
|
|
5
|
+
* This prevents:
|
|
6
|
+
* - Unsolicited SAML responses
|
|
7
|
+
* - Cross-provider response injection
|
|
8
|
+
* - Replay attacks
|
|
9
|
+
* - Expired login completions
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AuthnRequestRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
providerId: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuthnRequestStore {
|
|
20
|
+
save(record: AuthnRequestRecord): Promise<void>;
|
|
21
|
+
get(id: string): Promise<AuthnRequestRecord | null>;
|
|
22
|
+
delete(id: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
27
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-memory implementation of AuthnRequestStore.
|
|
33
|
+
* ⚠️ Only suitable for testing or single-instance non-serverless deployments.
|
|
34
|
+
* For production, rely on the default behavior (uses verification table)
|
|
35
|
+
* or provide a custom Redis-backed store.
|
|
36
|
+
*/
|
|
37
|
+
export function createInMemoryAuthnRequestStore(): AuthnRequestStore {
|
|
38
|
+
const store = new Map<string, AuthnRequestRecord>();
|
|
39
|
+
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
for (const [id, record] of store.entries()) {
|
|
43
|
+
if (record.expiresAt < now) {
|
|
44
|
+
store.delete(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const cleanupInterval = setInterval(cleanup, 60 * 1000);
|
|
50
|
+
|
|
51
|
+
if (typeof cleanupInterval.unref === "function") {
|
|
52
|
+
cleanupInterval.unref();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
async save(record: AuthnRequestRecord): Promise<void> {
|
|
57
|
+
store.set(record.id, record);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async get(id: string): Promise<AuthnRequestRecord | null> {
|
|
61
|
+
const record = store.get(id);
|
|
62
|
+
if (!record) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (record.expiresAt < Date.now()) {
|
|
66
|
+
store.delete(id);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return record;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async delete(id: string): Promise<void> {
|
|
73
|
+
store.delete(id);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|