@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/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,81 @@ 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
+ baseURL: "http://localhost:3000",
1945
+ emailAndPassword: { enabled: true },
1946
+ plugins: [sso()],
1947
+ });
1935
1948
 
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
- });
1949
+ const authClient = createAuthClient({
1950
+ baseURL: "http://localhost:3000",
1951
+ plugins: [bearer(), ssoClient()],
1952
+ fetchOptions: {
1953
+ customFetchImpl: async (url, init) =>
1954
+ auth.handler(new Request(url, init)),
1955
+ },
1956
+ });
1944
1957
 
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
- );
1958
+ const headers = new Headers();
1959
+ await authClient.signUp.email({
1960
+ email: "test@example.com",
1961
+ password: "password123",
1962
+ name: "Test User",
1963
+ });
1964
+ await authClient.signIn.email(
1965
+ { email: "test@example.com", password: "password123" },
1966
+ { onSuccess: setCookieToHeader(headers) },
1967
+ );
1955
1968
 
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",
1969
+ const provider = await auth.api.registerSSOProvider({
1970
+ body: {
1971
+ providerId: "oidc-config-provider",
1972
+ issuer: oidcServer.issuer.url!,
1973
+ domain: "example.com",
1974
+ oidcConfig: {
1975
+ clientId: "test-client",
1976
+ clientSecret: "test-secret",
1977
+ tokenEndpointAuthentication: "client_secret_basic",
1978
+ mapping: {
1979
+ id: "sub",
1980
+ email: "email",
1981
+ name: "name",
1982
+ },
1970
1983
  },
1971
1984
  },
1972
- },
1973
- headers,
1974
- });
1985
+ headers,
1986
+ });
1975
1987
 
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");
1988
+ expect(provider.oidcConfig).toBeDefined();
1989
+ expect(typeof provider.oidcConfig).toBe("object");
1990
+ expect(provider.oidcConfig?.clientId).toBe("test-client");
1991
+ expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
1980
1992
 
1981
- const serialized = JSON.stringify(provider.oidcConfig);
1982
- expect(serialized).not.toContain("[object Object]");
1993
+ const serialized = JSON.stringify(provider.oidcConfig);
1994
+ expect(serialized).not.toContain("[object Object]");
1983
1995
 
1984
- expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1996
+ expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1997
+ } finally {
1998
+ await oidcServer.stop().catch(() => {});
1999
+ }
1985
2000
  });
1986
2001
  });
1987
2002
 
@@ -2138,3 +2153,232 @@ describe("SAML SSO - Signature Validation Security", () => {
2138
2153
  });
2139
2154
  });
2140
2155
  });
2156
+
2157
+ describe("SAML SSO - Timestamp Validation", () => {
2158
+ describe("Valid assertions within time window", () => {
2159
+ it("should accept assertion with current NotBefore and future NotOnOrAfter", () => {
2160
+ const now = new Date();
2161
+ const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);
2162
+ expect(() =>
2163
+ validateSAMLTimestamp({
2164
+ notBefore: now.toISOString(),
2165
+ notOnOrAfter: fiveMinutesFromNow.toISOString(),
2166
+ }),
2167
+ ).not.toThrow();
2168
+ });
2169
+
2170
+ it("should accept assertion within clock skew tolerance (expired 2 min ago with 5 min skew)", () => {
2171
+ const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
2172
+ expect(() =>
2173
+ validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
2174
+ ).not.toThrow();
2175
+ });
2176
+
2177
+ it("should accept assertion with NotBefore slightly in future (within clock skew)", () => {
2178
+ const twoMinutesFromNow = new Date(
2179
+ Date.now() + 2 * 60 * 1000,
2180
+ ).toISOString();
2181
+ expect(() =>
2182
+ validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
2183
+ ).not.toThrow();
2184
+ });
2185
+ });
2186
+
2187
+ describe("NotBefore validation (future-dated assertions)", () => {
2188
+ it("should reject assertion with NotBefore too far in future (beyond clock skew)", () => {
2189
+ const tenMinutesFromNow = new Date(
2190
+ Date.now() + 10 * 60 * 1000,
2191
+ ).toISOString();
2192
+ expect(() =>
2193
+ validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
2194
+ ).toThrow("SAML assertion is not yet valid");
2195
+ });
2196
+
2197
+ it("should reject with custom strict clock skew (1 second)", () => {
2198
+ const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
2199
+ expect(() =>
2200
+ validateSAMLTimestamp(
2201
+ { notBefore: threeSecondsFromNow },
2202
+ { clockSkew: 1000 },
2203
+ ),
2204
+ ).toThrow("SAML assertion is not yet valid");
2205
+ });
2206
+ });
2207
+
2208
+ describe("NotOnOrAfter validation (expired assertions)", () => {
2209
+ it("should reject expired assertion (NotOnOrAfter in past beyond clock skew)", () => {
2210
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
2211
+ expect(() =>
2212
+ validateSAMLTimestamp({ notOnOrAfter: tenMinutesAgo }),
2213
+ ).toThrow("SAML assertion has expired");
2214
+ });
2215
+
2216
+ it("should reject with custom strict clock skew (1 second)", () => {
2217
+ const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
2218
+ expect(() =>
2219
+ validateSAMLTimestamp(
2220
+ { notOnOrAfter: threeSecondsAgo },
2221
+ { clockSkew: 1000 },
2222
+ ),
2223
+ ).toThrow("SAML assertion has expired");
2224
+ });
2225
+ });
2226
+
2227
+ describe("Boundary conditions (exactly at window edges)", () => {
2228
+ const FIXED_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
2229
+
2230
+ beforeEach(() => {
2231
+ vi.useFakeTimers();
2232
+ vi.setSystemTime(FIXED_TIME);
2233
+ });
2234
+
2235
+ afterEach(() => {
2236
+ vi.useRealTimers();
2237
+ });
2238
+
2239
+ it("should accept assertion expiring exactly at clock skew boundary", () => {
2240
+ const exactlyAtBoundary = new Date(
2241
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS,
2242
+ ).toISOString();
2243
+ expect(() =>
2244
+ validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
2245
+ ).not.toThrow();
2246
+ });
2247
+
2248
+ it("should reject assertion expiring 1ms beyond clock skew boundary", () => {
2249
+ const justPastBoundary = new Date(
2250
+ FIXED_TIME - DEFAULT_CLOCK_SKEW_MS - 1,
2251
+ ).toISOString();
2252
+ expect(() =>
2253
+ validateSAMLTimestamp({ notOnOrAfter: justPastBoundary }),
2254
+ ).toThrow("SAML assertion has expired");
2255
+ });
2256
+
2257
+ it("should accept assertion with NotBefore exactly at clock skew boundary", () => {
2258
+ const exactlyAtBoundary = new Date(
2259
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS,
2260
+ ).toISOString();
2261
+ expect(() =>
2262
+ validateSAMLTimestamp({ notBefore: exactlyAtBoundary }),
2263
+ ).not.toThrow();
2264
+ });
2265
+
2266
+ it("should reject assertion with NotBefore 1ms beyond clock skew boundary", () => {
2267
+ const justPastBoundary = new Date(
2268
+ FIXED_TIME + DEFAULT_CLOCK_SKEW_MS + 1,
2269
+ ).toISOString();
2270
+ expect(() =>
2271
+ validateSAMLTimestamp({ notBefore: justPastBoundary }),
2272
+ ).toThrow("SAML assertion is not yet valid");
2273
+ });
2274
+ });
2275
+
2276
+ describe("Missing timestamps behavior", () => {
2277
+ it("should accept missing timestamps when requireTimestamps is false (default)", () => {
2278
+ expect(() =>
2279
+ validateSAMLTimestamp(undefined, { requireTimestamps: false }),
2280
+ ).not.toThrow();
2281
+ });
2282
+
2283
+ it("should accept empty conditions when requireTimestamps is false", () => {
2284
+ expect(() =>
2285
+ validateSAMLTimestamp({}, { requireTimestamps: false }),
2286
+ ).not.toThrow();
2287
+ });
2288
+
2289
+ it("should reject missing timestamps when requireTimestamps is true", () => {
2290
+ expect(() =>
2291
+ validateSAMLTimestamp(undefined, { requireTimestamps: true }),
2292
+ ).toThrow("SAML assertion missing required timestamp conditions");
2293
+ });
2294
+
2295
+ it("should reject empty conditions when requireTimestamps is true", () => {
2296
+ expect(() =>
2297
+ validateSAMLTimestamp({}, { requireTimestamps: true }),
2298
+ ).toThrow("SAML assertion missing required timestamp conditions");
2299
+ });
2300
+
2301
+ it("should accept assertions with only NotBefore (valid)", () => {
2302
+ const now = new Date().toISOString();
2303
+ expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
2304
+ });
2305
+
2306
+ it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
2307
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
2308
+ expect(() =>
2309
+ validateSAMLTimestamp({ notOnOrAfter: future }),
2310
+ ).not.toThrow();
2311
+ });
2312
+ });
2313
+
2314
+ describe("Custom clock skew configuration", () => {
2315
+ it("should use custom clockSkew when provided", () => {
2316
+ const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
2317
+
2318
+ expect(() =>
2319
+ validateSAMLTimestamp(
2320
+ { notOnOrAfter: twoSecondsAgo },
2321
+ { clockSkew: 1000 },
2322
+ ),
2323
+ ).toThrow("SAML assertion has expired");
2324
+
2325
+ expect(() =>
2326
+ validateSAMLTimestamp(
2327
+ { notOnOrAfter: twoSecondsAgo },
2328
+ { clockSkew: 5 * 60 * 1000 },
2329
+ ),
2330
+ ).not.toThrow();
2331
+ });
2332
+
2333
+ it("should use default 5 minute clock skew when not specified", () => {
2334
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
2335
+ expect(() =>
2336
+ validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
2337
+ ).not.toThrow();
2338
+
2339
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
2340
+ expect(() =>
2341
+ validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
2342
+ ).toThrow("SAML assertion has expired");
2343
+ });
2344
+ });
2345
+
2346
+ describe("Malformed timestamp handling", () => {
2347
+ it("should reject malformed NotBefore timestamp", () => {
2348
+ expect(() =>
2349
+ validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
2350
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
2351
+ });
2352
+
2353
+ it("should reject malformed NotOnOrAfter timestamp", () => {
2354
+ expect(() =>
2355
+ validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
2356
+ ).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
2357
+ });
2358
+
2359
+ it("should treat empty string timestamps as missing (falsy values)", () => {
2360
+ expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
2361
+ expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
2362
+ });
2363
+
2364
+ it("should reject garbage data in timestamps", () => {
2365
+ expect(() =>
2366
+ validateSAMLTimestamp({
2367
+ notBefore: "abc123xyz",
2368
+ notOnOrAfter: "!@#$%^&*()",
2369
+ }),
2370
+ ).toThrow("SAML assertion has invalid NotBefore timestamp");
2371
+ });
2372
+
2373
+ it("should accept valid ISO 8601 timestamps", () => {
2374
+ const now = new Date();
2375
+ const future = new Date(Date.now() + 10 * 60 * 1000);
2376
+ expect(() =>
2377
+ validateSAMLTimestamp({
2378
+ notBefore: now.toISOString(),
2379
+ notOnOrAfter: future.toISOString(),
2380
+ }),
2381
+ ).not.toThrow();
2382
+ });
2383
+ });
2384
+ });
package/src/types.ts CHANGED
@@ -307,5 +307,37 @@ export interface SSOOptions {
307
307
  * verification table fallback) is used automatically.
308
308
  */
309
309
  authnRequestStore?: AuthnRequestStore;
310
+ /**
311
+ * Clock skew tolerance for SAML assertion timestamp validation in milliseconds.
312
+ * Allows for minor time differences between IdP and SP servers.
313
+ *
314
+ * Defaults to 300000 (5 minutes) to accommodate:
315
+ * - Network latency and processing time
316
+ * - Clock synchronization differences (NTP drift)
317
+ * - Distributed systems across timezones
318
+ *
319
+ * For stricter security, reduce to 1-2 minutes (60000-120000).
320
+ * For highly distributed systems, increase up to 10 minutes (600000).
321
+ *
322
+ * @default 300000 (5 minutes)
323
+ */
324
+ clockSkew?: number;
325
+ /**
326
+ * Require timestamp conditions (NotBefore/NotOnOrAfter) in SAML assertions.
327
+ * When enabled, assertions without timestamp conditions will be rejected.
328
+ *
329
+ * When disabled (default), assertions without timestamps are accepted
330
+ * but a warning is logged.
331
+ *
332
+ * **SAML Spec Notes:**
333
+ * - SAML 2.0 Core: Timestamps are OPTIONAL
334
+ * - SAML2Int (enterprise profile): Timestamps are REQUIRED
335
+ *
336
+ * **Recommendation:** Enable for enterprise/production deployments
337
+ * where your IdP follows SAML2Int (Okta, Azure AD, OneLogin, etc.)
338
+ *
339
+ * @default false
340
+ */
341
+ requireTimestamps?: boolean;
310
342
  };
311
343
  }