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

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/saml.test.ts CHANGED
@@ -17,6 +17,7 @@ import express from "express";
17
17
  import * as saml from "samlify";
18
18
  import {
19
19
  afterAll,
20
+ afterEach,
20
21
  beforeAll,
21
22
  beforeEach,
22
23
  describe,
@@ -24,7 +25,12 @@ import {
24
25
  it,
25
26
  vi,
26
27
  } from "vitest";
27
- import { createInMemoryAuthnRequestStore, sso } from ".";
28
+ import {
29
+ createInMemoryAuthnRequestStore,
30
+ DEFAULT_CLOCK_SKEW_MS,
31
+ sso,
32
+ validateSAMLTimestamp,
33
+ } from ".";
28
34
  import { ssoClient } from "./client";
29
35
 
30
36
  const spMetadata = `
@@ -1916,72 +1922,82 @@ describe("SSO Provider Config Parsing", () => {
1916
1922
  });
1917
1923
 
1918
1924
  it("returns parsed OIDC config and avoids [object Object] in response", async () => {
1919
- const data = {
1920
- user: [] as any[],
1921
- session: [] as any[],
1922
- verification: [] as any[],
1923
- account: [] as any[],
1924
- ssoProvider: [] as any[],
1925
- };
1925
+ const { OAuth2Server } = await import("oauth2-mock-server");
1926
+ const oidcServer = new OAuth2Server();
1926
1927
 
1927
- const memory = memoryAdapter(data);
1928
+ await oidcServer.issuer.keys.generate("RS256");
1929
+ await oidcServer.start(8082, "localhost");
1928
1930
 
1929
- const auth = betterAuth({
1930
- database: memory,
1931
- baseURL: "http://localhost:3000",
1932
- emailAndPassword: { enabled: true },
1933
- plugins: [sso()],
1934
- });
1931
+ try {
1932
+ const data = {
1933
+ user: [] as any[],
1934
+ session: [] as any[],
1935
+ verification: [] as any[],
1936
+ account: [] as any[],
1937
+ ssoProvider: [] as any[],
1938
+ };
1939
+
1940
+ const memory = memoryAdapter(data);
1941
+
1942
+ const auth = betterAuth({
1943
+ database: memory,
1944
+ trustedOrigins: ["http://localhost:8082"],
1945
+ baseURL: "http://localhost:3000",
1946
+ emailAndPassword: { enabled: true },
1947
+ plugins: [sso()],
1948
+ });
1935
1949
 
1936
- const authClient = createAuthClient({
1937
- baseURL: "http://localhost:3000",
1938
- plugins: [bearer(), ssoClient()],
1939
- fetchOptions: {
1940
- customFetchImpl: async (url, init) =>
1941
- auth.handler(new Request(url, init)),
1942
- },
1943
- });
1950
+ const authClient = createAuthClient({
1951
+ baseURL: "http://localhost:3000",
1952
+ plugins: [bearer(), ssoClient()],
1953
+ fetchOptions: {
1954
+ customFetchImpl: async (url, init) =>
1955
+ auth.handler(new Request(url, init)),
1956
+ },
1957
+ });
1944
1958
 
1945
- const headers = new Headers();
1946
- await authClient.signUp.email({
1947
- email: "test@example.com",
1948
- password: "password123",
1949
- name: "Test User",
1950
- });
1951
- await authClient.signIn.email(
1952
- { email: "test@example.com", password: "password123" },
1953
- { onSuccess: setCookieToHeader(headers) },
1954
- );
1959
+ const headers = new Headers();
1960
+ await authClient.signUp.email({
1961
+ email: "test@example.com",
1962
+ password: "password123",
1963
+ name: "Test User",
1964
+ });
1965
+ await authClient.signIn.email(
1966
+ { email: "test@example.com", password: "password123" },
1967
+ { onSuccess: setCookieToHeader(headers) },
1968
+ );
1955
1969
 
1956
- const provider = await auth.api.registerSSOProvider({
1957
- body: {
1958
- providerId: "oidc-config-provider",
1959
- issuer: "http://localhost:8080",
1960
- domain: "example.com",
1961
- oidcConfig: {
1962
- clientId: "test-client",
1963
- clientSecret: "test-secret",
1964
- discoveryEndpoint:
1965
- "http://localhost:8080/.well-known/openid-configuration",
1966
- mapping: {
1967
- id: "sub",
1968
- email: "email",
1969
- name: "name",
1970
+ const provider = await auth.api.registerSSOProvider({
1971
+ body: {
1972
+ providerId: "oidc-config-provider",
1973
+ issuer: oidcServer.issuer.url!,
1974
+ domain: "example.com",
1975
+ oidcConfig: {
1976
+ clientId: "test-client",
1977
+ clientSecret: "test-secret",
1978
+ tokenEndpointAuthentication: "client_secret_basic",
1979
+ mapping: {
1980
+ id: "sub",
1981
+ email: "email",
1982
+ name: "name",
1983
+ },
1970
1984
  },
1971
1985
  },
1972
- },
1973
- headers,
1974
- });
1986
+ headers,
1987
+ });
1975
1988
 
1976
- expect(provider.oidcConfig).toBeDefined();
1977
- expect(typeof provider.oidcConfig).toBe("object");
1978
- expect(provider.oidcConfig?.clientId).toBe("test-client");
1979
- expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
1989
+ expect(provider.oidcConfig).toBeDefined();
1990
+ expect(typeof provider.oidcConfig).toBe("object");
1991
+ expect(provider.oidcConfig?.clientId).toBe("test-client");
1992
+ expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
1980
1993
 
1981
- const serialized = JSON.stringify(provider.oidcConfig);
1982
- expect(serialized).not.toContain("[object Object]");
1994
+ const serialized = JSON.stringify(provider.oidcConfig);
1995
+ expect(serialized).not.toContain("[object Object]");
1983
1996
 
1984
- expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1997
+ expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1998
+ } finally {
1999
+ await oidcServer.stop().catch(() => {});
2000
+ }
1985
2001
  });
1986
2002
  });
1987
2003
 
@@ -2138,3 +2154,232 @@ describe("SAML SSO - Signature Validation Security", () => {
2138
2154
  });
2139
2155
  });
2140
2156
  });
2157
+
2158
+ describe("SAML SSO - Timestamp Validation", () => {
2159
+ describe("Valid assertions within time window", () => {
2160
+ it("should accept assertion with current NotBefore and future NotOnOrAfter", () => {
2161
+ const now = new Date();
2162
+ const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);
2163
+ expect(() =>
2164
+ validateSAMLTimestamp({
2165
+ notBefore: now.toISOString(),
2166
+ notOnOrAfter: fiveMinutesFromNow.toISOString(),
2167
+ }),
2168
+ ).not.toThrow();
2169
+ });
2170
+
2171
+ it("should accept assertion within clock skew tolerance (expired 2 min ago with 5 min skew)", () => {
2172
+ const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
2173
+ expect(() =>
2174
+ validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
2175
+ ).not.toThrow();
2176
+ });
2177
+
2178
+ it("should accept assertion with NotBefore slightly in future (within clock skew)", () => {
2179
+ const twoMinutesFromNow = new Date(
2180
+ Date.now() + 2 * 60 * 1000,
2181
+ ).toISOString();
2182
+ expect(() =>
2183
+ validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
2184
+ ).not.toThrow();
2185
+ });
2186
+ });
2187
+
2188
+ describe("NotBefore validation (future-dated assertions)", () => {
2189
+ it("should reject assertion with NotBefore too far in future (beyond clock skew)", () => {
2190
+ const tenMinutesFromNow = new Date(
2191
+ Date.now() + 10 * 60 * 1000,
2192
+ ).toISOString();
2193
+ expect(() =>
2194
+ validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
2195
+ ).toThrow("SAML assertion is not yet valid");
2196
+ });
2197
+
2198
+ it("should reject with custom strict clock skew (1 second)", () => {
2199
+ const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
2200
+ expect(() =>
2201
+ validateSAMLTimestamp(
2202
+ { notBefore: threeSecondsFromNow },
2203
+ { clockSkew: 1000 },
2204
+ ),
2205
+ ).toThrow("SAML assertion is not yet valid");
2206
+ });
2207
+ });
2208
+
2209
+ describe("NotOnOrAfter validation (expired assertions)", () => {
2210
+ it("should reject expired assertion (NotOnOrAfter in past beyond clock skew)", () => {
2211
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
2212
+ expect(() =>
2213
+ validateSAMLTimestamp({ notOnOrAfter: tenMinutesAgo }),
2214
+ ).toThrow("SAML assertion has expired");
2215
+ });
2216
+
2217
+ it("should reject with custom strict clock skew (1 second)", () => {
2218
+ const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
2219
+ expect(() =>
2220
+ validateSAMLTimestamp(
2221
+ { notOnOrAfter: threeSecondsAgo },
2222
+ { clockSkew: 1000 },
2223
+ ),
2224
+ ).toThrow("SAML assertion has expired");
2225
+ });
2226
+ });
2227
+
2228
+ describe("Boundary conditions (exactly at window edges)", () => {
2229
+ const FIXED_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
2230
+
2231
+ beforeEach(() => {
2232
+ vi.useFakeTimers();
2233
+ vi.setSystemTime(FIXED_TIME);
2234
+ });
2235
+
2236
+ afterEach(() => {
2237
+ vi.useRealTimers();
2238
+ });
2239
+
2240
+ it("should accept assertion expiring exactly at clock skew boundary", () => {
2241
+ const exactlyAtBoundary = new Date(
2242
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS,
2243
+ ).toISOString();
2244
+ expect(() =>
2245
+ validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
2246
+ ).not.toThrow();
2247
+ });
2248
+
2249
+ it("should reject assertion expiring 1ms beyond clock skew boundary", () => {
2250
+ const justPastBoundary = new Date(
2251
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS - 1,
2252
+ ).toISOString();
2253
+ expect(() =>
2254
+ validateSAMLTimestamp({ notOnOrAfter: justPastBoundary }),
2255
+ ).toThrow("SAML assertion has expired");
2256
+ });
2257
+
2258
+ it("should accept assertion with NotBefore exactly at clock skew boundary", () => {
2259
+ const exactlyAtBoundary = new Date(
2260
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS,
2261
+ ).toISOString();
2262
+ expect(() =>
2263
+ validateSAMLTimestamp({ notBefore: exactlyAtBoundary }),
2264
+ ).not.toThrow();
2265
+ });
2266
+
2267
+ it("should reject assertion with NotBefore 1ms beyond clock skew boundary", () => {
2268
+ const justPastBoundary = new Date(
2269
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS + 1,
2270
+ ).toISOString();
2271
+ expect(() =>
2272
+ validateSAMLTimestamp({ notBefore: justPastBoundary }),
2273
+ ).toThrow("SAML assertion is not yet valid");
2274
+ });
2275
+ });
2276
+
2277
+ describe("Missing timestamps behavior", () => {
2278
+ it("should accept missing timestamps when requireTimestamps is false (default)", () => {
2279
+ expect(() =>
2280
+ validateSAMLTimestamp(undefined, { requireTimestamps: false }),
2281
+ ).not.toThrow();
2282
+ });
2283
+
2284
+ it("should accept empty conditions when requireTimestamps is false", () => {
2285
+ expect(() =>
2286
+ validateSAMLTimestamp({}, { requireTimestamps: false }),
2287
+ ).not.toThrow();
2288
+ });
2289
+
2290
+ it("should reject missing timestamps when requireTimestamps is true", () => {
2291
+ expect(() =>
2292
+ validateSAMLTimestamp(undefined, { requireTimestamps: true }),
2293
+ ).toThrow("SAML assertion missing required timestamp conditions");
2294
+ });
2295
+
2296
+ it("should reject empty conditions when requireTimestamps is true", () => {
2297
+ expect(() =>
2298
+ validateSAMLTimestamp({}, { requireTimestamps: true }),
2299
+ ).toThrow("SAML assertion missing required timestamp conditions");
2300
+ });
2301
+
2302
+ it("should accept assertions with only NotBefore (valid)", () => {
2303
+ const now = new Date().toISOString();
2304
+ expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
2305
+ });
2306
+
2307
+ it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
2308
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
2309
+ expect(() =>
2310
+ validateSAMLTimestamp({ notOnOrAfter: future }),
2311
+ ).not.toThrow();
2312
+ });
2313
+ });
2314
+
2315
+ describe("Custom clock skew configuration", () => {
2316
+ it("should use custom clockSkew when provided", () => {
2317
+ const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
2318
+
2319
+ expect(() =>
2320
+ validateSAMLTimestamp(
2321
+ { notOnOrAfter: twoSecondsAgo },
2322
+ { clockSkew: 1000 },
2323
+ ),
2324
+ ).toThrow("SAML assertion has expired");
2325
+
2326
+ expect(() =>
2327
+ validateSAMLTimestamp(
2328
+ { notOnOrAfter: twoSecondsAgo },
2329
+ { clockSkew: 5 * 60 * 1000 },
2330
+ ),
2331
+ ).not.toThrow();
2332
+ });
2333
+
2334
+ it("should use default 5 minute clock skew when not specified", () => {
2335
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
2336
+ expect(() =>
2337
+ validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
2338
+ ).not.toThrow();
2339
+
2340
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
2341
+ expect(() =>
2342
+ validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
2343
+ ).toThrow("SAML assertion has expired");
2344
+ });
2345
+ });
2346
+
2347
+ describe("Malformed timestamp handling", () => {
2348
+ it("should reject malformed NotBefore timestamp", () => {
2349
+ expect(() =>
2350
+ validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
2351
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
2352
+ });
2353
+
2354
+ it("should reject malformed NotOnOrAfter timestamp", () => {
2355
+ expect(() =>
2356
+ validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
2357
+ ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
2358
+ });
2359
+
2360
+ it("should treat empty string timestamps as missing (falsy values)", () => {
2361
+ expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
2362
+ expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
2363
+ });
2364
+
2365
+ it("should reject garbage data in timestamps", () => {
2366
+ expect(() =>
2367
+ validateSAMLTimestamp({
2368
+ notBefore: "abc123xyz",
2369
+ notOnOrAfter: "!@#$%^&*()",
2370
+ }),
2371
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
2372
+ });
2373
+
2374
+ it("should accept valid ISO 8601 timestamps", () => {
2375
+ const now = new Date();
2376
+ const future = new Date(Date.now() + 10 * 60 * 1000);
2377
+ expect(() =>
2378
+ validateSAMLTimestamp({
2379
+ notBefore: now.toISOString(),
2380
+ notOnOrAfter: future.toISOString(),
2381
+ }),
2382
+ ).not.toThrow();
2383
+ });
2384
+ });
2385
+ });
package/src/types.ts CHANGED
@@ -233,13 +233,7 @@ export interface SSOOptions {
233
233
  *
234
234
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
235
235
  * providers in the `trustedProviders` list.
236
- *
237
236
  * @default false
238
- *
239
- * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
240
- * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
241
- * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
242
- * This option may be removed in a future major version.
243
237
  */
244
238
  trustEmailVerified?: boolean | undefined;
245
239
  /**
@@ -307,5 +301,37 @@ export interface SSOOptions {
307
301
  * verification table fallback) is used automatically.
308
302
  */
309
303
  authnRequestStore?: AuthnRequestStore;
304
+ /**
305
+ * Clock skew tolerance for SAML assertion timestamp validation in milliseconds.
306
+ * Allows for minor time differences between IdP and SP servers.
307
+ *
308
+ * Defaults to 300000 (5 minutes) to accommodate:
309
+ * - Network latency and processing time
310
+ * - Clock synchronization differences (NTP drift)
311
+ * - Distributed systems across timezones
312
+ *
313
+ * For stricter security, reduce to 1-2 minutes (60000-120000).
314
+ * For highly distributed systems, increase up to 10 minutes (600000).
315
+ *
316
+ * @default 300000 (5 minutes)
317
+ */
318
+ clockSkew?: number;
319
+ /**
320
+ * Require timestamp conditions (NotBefore/NotOnOrAfter) in SAML assertions.
321
+ * When enabled, assertions without timestamp conditions will be rejected.
322
+ *
323
+ * When disabled (default), assertions without timestamps are accepted
324
+ * but a warning is logged.
325
+ *
326
+ * **SAML Spec Notes:**
327
+ * - SAML 2.0 Core: Timestamps are OPTIONAL
328
+ * - SAML2Int (enterprise profile): Timestamps are REQUIRED
329
+ *
330
+ * **Recommendation:** Enable for enterprise/production deployments
331
+ * where your IdP follows SAML2Int (Okta, Azure AD, OneLogin, etc.)
332
+ *
333
+ * @default false
334
+ */
335
+ requireTimestamps?: boolean;
310
336
  };
311
337
  }