@better-auth/sso 1.4.7-beta.3 → 1.4.7-beta.4

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/src/oidc.test.ts CHANGED
@@ -571,167 +571,3 @@ 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
@@ -24,11 +24,98 @@ import type { FlowResult } from "samlify/types/src/flow";
24
24
  import * as z from "zod/v4";
25
25
  import type { AuthnRequestRecord } from "../authn-request-store";
26
26
  import { DEFAULT_AUTHN_REQUEST_TTL_MS } from "../authn-request-store";
27
+ import type { HydratedOIDCConfig } from "../oidc";
28
+ import {
29
+ DiscoveryError,
30
+ discoverOIDCConfig,
31
+ mapDiscoveryErrorToAPIError,
32
+ } from "../oidc";
27
33
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
28
34
 
29
35
  import { safeJsonParse, validateEmailDomain } from "../utils";
30
36
 
31
37
  const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
38
+
39
+ /** Default clock skew tolerance: 5 minutes */
40
+ export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
41
+
42
+ export interface TimestampValidationOptions {
43
+ clockSkew?: number;
44
+ requireTimestamps?: boolean;
45
+ logger?: {
46
+ warn: (message: string, data?: Record<string, unknown>) => void;
47
+ };
48
+ }
49
+
50
+ /** Conditions extracted from SAML assertion */
51
+ export interface SAMLConditions {
52
+ notBefore?: string;
53
+ notOnOrAfter?: string;
54
+ }
55
+
56
+ /**
57
+ * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
58
+ * Prevents acceptance of expired or future-dated assertions.
59
+ * @throws {APIError} If timestamps are invalid, expired, or not yet valid
60
+ */
61
+ export function validateSAMLTimestamp(
62
+ conditions: SAMLConditions | undefined,
63
+ options: TimestampValidationOptions = {},
64
+ ): void {
65
+ const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
66
+ const hasTimestamps = conditions?.notBefore || conditions?.notOnOrAfter;
67
+
68
+ if (!hasTimestamps) {
69
+ if (options.requireTimestamps) {
70
+ throw new APIError("BAD_REQUEST", {
71
+ message: "SAML assertion missing required timestamp conditions",
72
+ details:
73
+ "Assertions must include NotBefore and/or NotOnOrAfter conditions",
74
+ });
75
+ }
76
+ // Log warning for missing timestamps when not required
77
+ options.logger?.warn(
78
+ "SAML assertion accepted without timestamp conditions",
79
+ { hasConditions: !!conditions },
80
+ );
81
+ return;
82
+ }
83
+
84
+ const now = Date.now();
85
+
86
+ if (conditions?.notBefore) {
87
+ const notBeforeTime = new Date(conditions.notBefore).getTime();
88
+ if (Number.isNaN(notBeforeTime)) {
89
+ throw new APIError("BAD_REQUEST", {
90
+ message: "SAML assertion has invalid NotBefore timestamp",
91
+ details: `Unable to parse NotBefore value: ${conditions.notBefore}`,
92
+ });
93
+ }
94
+ if (now < notBeforeTime - clockSkew) {
95
+ throw new APIError("BAD_REQUEST", {
96
+ message: "SAML assertion is not yet valid",
97
+ details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`,
98
+ });
99
+ }
100
+ }
101
+
102
+ if (conditions?.notOnOrAfter) {
103
+ const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
104
+ if (Number.isNaN(notOnOrAfterTime)) {
105
+ throw new APIError("BAD_REQUEST", {
106
+ message: "SAML assertion has invalid NotOnOrAfter timestamp",
107
+ details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`,
108
+ });
109
+ }
110
+ if (now > notOnOrAfterTime + clockSkew) {
111
+ throw new APIError("BAD_REQUEST", {
112
+ message: "SAML assertion has expired",
113
+ details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`,
114
+ });
115
+ }
116
+ }
117
+ }
118
+
32
119
  const spMetadataQuerySchema = z.object({
33
120
  providerId: z.string(),
34
121
  format: z.enum(["xml", "json"]).default("xml"),
@@ -154,6 +241,13 @@ const ssoProviderBodySchema = z.object({
154
241
  })
155
242
  .optional(),
156
243
  discoveryEndpoint: z.string().optional(),
244
+ skipDiscovery: z
245
+ .boolean()
246
+ .meta({
247
+ description:
248
+ "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually.",
249
+ })
250
+ .optional(),
157
251
  scopes: z
158
252
  .array(z.string(), {})
159
253
  .meta({
@@ -573,6 +667,80 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
573
667
  });
574
668
  }
575
669
 
670
+ let hydratedOIDCConfig: HydratedOIDCConfig | null = null;
671
+ if (body.oidcConfig && !body.oidcConfig.skipDiscovery) {
672
+ try {
673
+ hydratedOIDCConfig = await discoverOIDCConfig({
674
+ issuer: body.issuer,
675
+ existingConfig: {
676
+ discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
677
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
678
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
679
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
680
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
681
+ tokenEndpointAuthentication:
682
+ body.oidcConfig.tokenEndpointAuthentication,
683
+ },
684
+ });
685
+ } catch (error) {
686
+ if (error instanceof DiscoveryError) {
687
+ throw mapDiscoveryErrorToAPIError(error);
688
+ }
689
+ throw error;
690
+ }
691
+ }
692
+
693
+ const buildOIDCConfig = () => {
694
+ if (!body.oidcConfig) return null;
695
+
696
+ if (body.oidcConfig.skipDiscovery) {
697
+ return JSON.stringify({
698
+ issuer: body.issuer,
699
+ clientId: body.oidcConfig.clientId,
700
+ clientSecret: body.oidcConfig.clientSecret,
701
+ authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
702
+ tokenEndpoint: body.oidcConfig.tokenEndpoint,
703
+ tokenEndpointAuthentication:
704
+ body.oidcConfig.tokenEndpointAuthentication ||
705
+ "client_secret_basic",
706
+ jwksEndpoint: body.oidcConfig.jwksEndpoint,
707
+ pkce: body.oidcConfig.pkce,
708
+ discoveryEndpoint:
709
+ body.oidcConfig.discoveryEndpoint ||
710
+ `${body.issuer}/.well-known/openid-configuration`,
711
+ mapping: body.oidcConfig.mapping,
712
+ scopes: body.oidcConfig.scopes,
713
+ userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
714
+ overrideUserInfo:
715
+ ctx.body.overrideUserInfo ||
716
+ options?.defaultOverrideUserInfo ||
717
+ false,
718
+ });
719
+ }
720
+
721
+ if (!hydratedOIDCConfig) return null;
722
+
723
+ return JSON.stringify({
724
+ issuer: hydratedOIDCConfig.issuer,
725
+ clientId: body.oidcConfig.clientId,
726
+ clientSecret: body.oidcConfig.clientSecret,
727
+ authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
728
+ tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
729
+ tokenEndpointAuthentication:
730
+ hydratedOIDCConfig.tokenEndpointAuthentication,
731
+ jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
732
+ pkce: body.oidcConfig.pkce,
733
+ discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
734
+ mapping: body.oidcConfig.mapping,
735
+ scopes: body.oidcConfig.scopes,
736
+ userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
737
+ overrideUserInfo:
738
+ ctx.body.overrideUserInfo ||
739
+ options?.defaultOverrideUserInfo ||
740
+ false,
741
+ });
742
+ };
743
+
576
744
  const provider = await ctx.context.adapter.create<
577
745
  Record<string, any>,
578
746
  SSOProvider<O>
@@ -582,29 +750,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
582
750
  issuer: body.issuer,
583
751
  domain: body.domain,
584
752
  domainVerified: false,
585
- oidcConfig: body.oidcConfig
586
- ? JSON.stringify({
587
- issuer: body.issuer,
588
- clientId: body.oidcConfig.clientId,
589
- clientSecret: body.oidcConfig.clientSecret,
590
- authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
591
- tokenEndpoint: body.oidcConfig.tokenEndpoint,
592
- tokenEndpointAuthentication:
593
- body.oidcConfig.tokenEndpointAuthentication,
594
- jwksEndpoint: body.oidcConfig.jwksEndpoint,
595
- pkce: body.oidcConfig.pkce,
596
- discoveryEndpoint:
597
- body.oidcConfig.discoveryEndpoint ||
598
- `${body.issuer}/.well-known/openid-configuration`,
599
- mapping: body.oidcConfig.mapping,
600
- scopes: body.oidcConfig.scopes,
601
- userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
602
- overrideUserInfo:
603
- ctx.body.overrideUserInfo ||
604
- options?.defaultOverrideUserInfo ||
605
- false,
606
- })
607
- : null,
753
+ oidcConfig: buildOIDCConfig(),
608
754
  samlConfig: body.samlConfig
609
755
  ? JSON.stringify({
610
756
  issuer: body.issuer,
@@ -1661,6 +1807,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1661
1807
 
1662
1808
  const { extract } = parsedResponse!;
1663
1809
 
1810
+ validateSAMLTimestamp((extract as any).conditions, {
1811
+ clockSkew: options?.saml?.clockSkew,
1812
+ requireTimestamps: options?.saml?.requireTimestamps,
1813
+ logger: ctx.context.logger,
1814
+ });
1815
+
1664
1816
  const inResponseTo = (extract as any).inResponseTo as string | undefined;
1665
1817
  const shouldValidateInResponseTo =
1666
1818
  options?.saml?.authnRequestStore ||
@@ -1685,6 +1837,13 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1685
1837
  storedRequest = JSON.parse(
1686
1838
  verification.value,
1687
1839
  ) as AuthnRequestRecord;
1840
+ // Validate expiration for database-stored records
1841
+ // Note: Cleanup of expired records is handled automatically by
1842
+ // findVerificationValue, but we still need to check expiration
1843
+ // since the record is returned before cleanup runs
1844
+ if (storedRequest && storedRequest.expiresAt < Date.now()) {
1845
+ storedRequest = null;
1846
+ }
1688
1847
  } catch {
1689
1848
  storedRequest = null;
1690
1849
  }
@@ -2086,6 +2245,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
2086
2245
 
2087
2246
  const { extract } = parsedResponse!;
2088
2247
 
2248
+ validateSAMLTimestamp((extract as any).conditions, {
2249
+ clockSkew: options?.saml?.clockSkew,
2250
+ requireTimestamps: options?.saml?.requireTimestamps,
2251
+ logger: ctx.context.logger,
2252
+ });
2253
+
2089
2254
  const inResponseToAcs = (extract as any).inResponseTo as
2090
2255
  | string
2091
2256
  | undefined;
@@ -2112,6 +2277,9 @@ export const acsEndpoint = (options?: SSOOptions) => {
2112
2277
  storedRequest = JSON.parse(
2113
2278
  verification.value,
2114
2279
  ) as AuthnRequestRecord;
2280
+ if (storedRequest && storedRequest.expiresAt < Date.now()) {
2281
+ storedRequest = null;
2282
+ }
2115
2283
  } catch {
2116
2284
  storedRequest = null;
2117
2285
  }