@better-auth/sso 1.4.7-beta.2 → 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 +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-BWvN4yrs.d.mts → index-m7FISidt.d.mts} +101 -33
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +159 -48
- package/package.json +4 -4
- 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 +224 -73
- package/src/saml.test.ts +490 -1
- package/src/types.ts +49 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.7-beta.
|
|
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
5
|
[34mℹ[39m tsdown [2mv0.17.2[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 [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:
|
|
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
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { OAuth2Tokens, User } from "better-auth";
|
|
3
|
-
import * as
|
|
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;
|
|
@@ -243,10 +279,58 @@ interface SSOOptions {
|
|
|
243
279
|
*/
|
|
244
280
|
tokenPrefix?: string;
|
|
245
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
|
+
};
|
|
246
330
|
}
|
|
247
331
|
//#endregion
|
|
248
332
|
//#region src/routes/domain-verification.d.ts
|
|
249
|
-
declare const requestDomainVerification: (options: SSOOptions) =>
|
|
333
|
+
declare const requestDomainVerification: (options: SSOOptions) => better_call0.StrictEndpoint<"/sso/request-domain-verification", {
|
|
250
334
|
method: "POST";
|
|
251
335
|
body: z.ZodObject<{
|
|
252
336
|
providerId: z.ZodString;
|
|
@@ -268,7 +352,7 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call7.S
|
|
|
268
352
|
};
|
|
269
353
|
};
|
|
270
354
|
};
|
|
271
|
-
use: ((inputContext:
|
|
355
|
+
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
272
356
|
session: {
|
|
273
357
|
session: Record<string, any> & {
|
|
274
358
|
id: string;
|
|
@@ -291,12 +375,10 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call7.S
|
|
|
291
375
|
};
|
|
292
376
|
};
|
|
293
377
|
}>)[];
|
|
294
|
-
} & {
|
|
295
|
-
use: any[];
|
|
296
378
|
}, {
|
|
297
379
|
domainVerificationToken: string;
|
|
298
380
|
}>;
|
|
299
|
-
declare const verifyDomain: (options: SSOOptions) =>
|
|
381
|
+
declare const verifyDomain: (options: SSOOptions) => better_call0.StrictEndpoint<"/sso/verify-domain", {
|
|
300
382
|
method: "POST";
|
|
301
383
|
body: z.ZodObject<{
|
|
302
384
|
providerId: z.ZodString;
|
|
@@ -321,7 +403,7 @@ declare const verifyDomain: (options: SSOOptions) => better_call7.StrictEndpoint
|
|
|
321
403
|
};
|
|
322
404
|
};
|
|
323
405
|
};
|
|
324
|
-
use: ((inputContext:
|
|
406
|
+
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
325
407
|
session: {
|
|
326
408
|
session: Record<string, any> & {
|
|
327
409
|
id: string;
|
|
@@ -344,12 +426,10 @@ declare const verifyDomain: (options: SSOOptions) => better_call7.StrictEndpoint
|
|
|
344
426
|
};
|
|
345
427
|
};
|
|
346
428
|
}>)[];
|
|
347
|
-
} & {
|
|
348
|
-
use: any[];
|
|
349
429
|
}, void>;
|
|
350
430
|
//#endregion
|
|
351
431
|
//#region src/routes/sso.d.ts
|
|
352
|
-
declare const spMetadata: () =>
|
|
432
|
+
declare const spMetadata: () => better_call0.StrictEndpoint<"/sso/saml2/sp/metadata", {
|
|
353
433
|
method: "GET";
|
|
354
434
|
query: z.ZodObject<{
|
|
355
435
|
providerId: z.ZodString;
|
|
@@ -370,10 +450,8 @@ declare const spMetadata: () => better_call7.StrictEndpoint<"/sso/saml2/sp/metad
|
|
|
370
450
|
};
|
|
371
451
|
};
|
|
372
452
|
};
|
|
373
|
-
} & {
|
|
374
|
-
use: any[];
|
|
375
453
|
}, Response>;
|
|
376
|
-
declare const registerSSOProvider: <O extends SSOOptions>(options: O) =>
|
|
454
|
+
declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_call0.StrictEndpoint<"/sso/register", {
|
|
377
455
|
method: "POST";
|
|
378
456
|
body: z.ZodObject<{
|
|
379
457
|
providerId: z.ZodString;
|
|
@@ -451,7 +529,7 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
|
|
|
451
529
|
organizationId: z.ZodOptional<z.ZodString>;
|
|
452
530
|
overrideUserInfo: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
453
531
|
}, z.core.$strip>;
|
|
454
|
-
use: ((inputContext:
|
|
532
|
+
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
455
533
|
session: {
|
|
456
534
|
session: Record<string, any> & {
|
|
457
535
|
id: string;
|
|
@@ -635,15 +713,13 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
|
|
|
635
713
|
};
|
|
636
714
|
};
|
|
637
715
|
};
|
|
638
|
-
} & {
|
|
639
|
-
use: any[];
|
|
640
716
|
}, O["domainVerification"] extends {
|
|
641
717
|
enabled: true;
|
|
642
718
|
} ? {
|
|
643
719
|
domainVerified: boolean;
|
|
644
720
|
domainVerificationToken: string;
|
|
645
721
|
} & SSOProvider<O> : SSOProvider<O>>;
|
|
646
|
-
declare const signInSSO: (options?: SSOOptions) =>
|
|
722
|
+
declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sign-in/sso", {
|
|
647
723
|
method: "POST";
|
|
648
724
|
body: z.ZodObject<{
|
|
649
725
|
email: z.ZodOptional<z.ZodString>;
|
|
@@ -657,8 +733,8 @@ declare const signInSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"
|
|
|
657
733
|
loginHint: z.ZodOptional<z.ZodString>;
|
|
658
734
|
requestSignUp: z.ZodOptional<z.ZodBoolean>;
|
|
659
735
|
providerType: z.ZodOptional<z.ZodEnum<{
|
|
660
|
-
oidc: "oidc";
|
|
661
736
|
saml: "saml";
|
|
737
|
+
oidc: "oidc";
|
|
662
738
|
}>>;
|
|
663
739
|
}, z.core.$strip>;
|
|
664
740
|
metadata: {
|
|
@@ -733,13 +809,11 @@ declare const signInSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"
|
|
|
733
809
|
};
|
|
734
810
|
};
|
|
735
811
|
};
|
|
736
|
-
} & {
|
|
737
|
-
use: any[];
|
|
738
812
|
}, {
|
|
739
813
|
url: string;
|
|
740
814
|
redirect: boolean;
|
|
741
815
|
}>;
|
|
742
|
-
declare const callbackSSO: (options?: SSOOptions) =>
|
|
816
|
+
declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/callback/:providerId", {
|
|
743
817
|
method: "GET";
|
|
744
818
|
query: z.ZodObject<{
|
|
745
819
|
code: z.ZodOptional<z.ZodString>;
|
|
@@ -749,7 +823,6 @@ declare const callbackSSO: (options?: SSOOptions) => better_call7.StrictEndpoint
|
|
|
749
823
|
}, z.core.$strip>;
|
|
750
824
|
allowedMediaTypes: string[];
|
|
751
825
|
metadata: {
|
|
752
|
-
isAction: false;
|
|
753
826
|
openapi: {
|
|
754
827
|
operationId: string;
|
|
755
828
|
summary: string;
|
|
@@ -760,18 +833,16 @@ declare const callbackSSO: (options?: SSOOptions) => better_call7.StrictEndpoint
|
|
|
760
833
|
};
|
|
761
834
|
};
|
|
762
835
|
};
|
|
836
|
+
scope: "server";
|
|
763
837
|
};
|
|
764
|
-
} & {
|
|
765
|
-
use: any[];
|
|
766
838
|
}, never>;
|
|
767
|
-
declare const callbackSSOSAML: (options?: SSOOptions) =>
|
|
839
|
+
declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
|
|
768
840
|
method: "POST";
|
|
769
841
|
body: z.ZodObject<{
|
|
770
842
|
SAMLResponse: z.ZodString;
|
|
771
843
|
RelayState: z.ZodOptional<z.ZodString>;
|
|
772
844
|
}, z.core.$strip>;
|
|
773
845
|
metadata: {
|
|
774
|
-
isAction: false;
|
|
775
846
|
allowedMediaTypes: string[];
|
|
776
847
|
openapi: {
|
|
777
848
|
operationId: string;
|
|
@@ -789,11 +860,10 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call7.StrictEndp
|
|
|
789
860
|
};
|
|
790
861
|
};
|
|
791
862
|
};
|
|
863
|
+
scope: "server";
|
|
792
864
|
};
|
|
793
|
-
} & {
|
|
794
|
-
use: any[];
|
|
795
865
|
}, never>;
|
|
796
|
-
declare const acsEndpoint: (options?: SSOOptions) =>
|
|
866
|
+
declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
|
|
797
867
|
method: "POST";
|
|
798
868
|
params: z.ZodObject<{
|
|
799
869
|
providerId: z.ZodOptional<z.ZodString>;
|
|
@@ -803,7 +873,6 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call7.StrictEndpoint
|
|
|
803
873
|
RelayState: z.ZodOptional<z.ZodString>;
|
|
804
874
|
}, z.core.$strip>;
|
|
805
875
|
metadata: {
|
|
806
|
-
isAction: false;
|
|
807
876
|
allowedMediaTypes: string[];
|
|
808
877
|
openapi: {
|
|
809
878
|
operationId: string;
|
|
@@ -815,9 +884,8 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call7.StrictEndpoint
|
|
|
815
884
|
};
|
|
816
885
|
};
|
|
817
886
|
};
|
|
887
|
+
scope: "server";
|
|
818
888
|
};
|
|
819
|
-
} & {
|
|
820
|
-
use: any[];
|
|
821
889
|
}, never>;
|
|
822
890
|
//#endregion
|
|
823
891
|
//#region src/index.d.ts
|
|
@@ -856,4 +924,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
|
|
|
856
924
|
endpoints: SSOEndpoints<O>;
|
|
857
925
|
};
|
|
858
926
|
//#endregion
|
|
859
|
-
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",
|
|
@@ -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.7-beta.
|
|
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",
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"express": "^5.1.0",
|
|
67
67
|
"oauth2-mock-server": "^8.2.0",
|
|
68
68
|
"tsdown": "^0.17.2",
|
|
69
|
-
"better-auth": "1.4.7-beta.
|
|
69
|
+
"better-auth": "1.4.7-beta.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"better-auth": "1.4.7-beta.
|
|
72
|
+
"better-auth": "1.4.7-beta.3"
|
|
73
73
|
},
|
|
74
74
|
"scripts": {
|
|
75
75
|
"test": "vitest",
|