@spfn/auth 0.2.1 → 0.3.0-beta.1

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.
Files changed (55) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1032 -2397
  3. package/dist/{authenticate-eucncHxN.d.ts → authenticate-DlTGaBT8.d.ts} +545 -82
  4. package/dist/client-proof.d.ts +606 -0
  5. package/dist/client-proof.js +1842 -0
  6. package/dist/client-proof.js.map +1 -0
  7. package/dist/config.d.ts +319 -3
  8. package/dist/config.js +155 -5
  9. package/dist/config.js.map +1 -1
  10. package/dist/errors.d.ts +180 -3
  11. package/dist/errors.js +116 -1
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +122 -18
  14. package/dist/index.js +129 -8
  15. package/dist/index.js.map +1 -1
  16. package/dist/nextjs/api.js +404 -96
  17. package/dist/nextjs/api.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +5 -4
  19. package/dist/nextjs/server.js +165 -26
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/server.d.ts +2130 -1016
  22. package/dist/server.js +4916 -739
  23. package/dist/server.js.map +1 -1
  24. package/dist/session-DTHahDQ9.d.ts +53 -0
  25. package/dist/types-DYyhze28.d.ts +98 -0
  26. package/dist/wire-version-CtzMKvBB.d.ts +134 -0
  27. package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
  28. package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
  29. package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
  30. package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
  31. package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
  32. package/migrations/20260629103209_lethal_lifeguard/migration.sql +32 -0
  33. package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
  34. package/migrations/20260709073531_easy_hardball/migration.sql +24 -0
  35. package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
  36. package/migrations/20260714081434_glossy_major_mapleleaf/migration.sql +1 -0
  37. package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
  38. package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
  39. package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
  40. package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
  41. package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
  42. package/migrations/20260805143152_vengeful_ravenous/migration.sql +4 -0
  43. package/migrations/20260805143152_vengeful_ravenous/snapshot.json +3190 -0
  44. package/package.json +54 -40
  45. package/migrations/meta/0000_snapshot.json +0 -1632
  46. package/migrations/meta/0001_snapshot.json +0 -1660
  47. package/migrations/meta/0002_snapshot.json +0 -1660
  48. package/migrations/meta/0003_snapshot.json +0 -1689
  49. package/migrations/meta/0004_snapshot.json +0 -1721
  50. package/migrations/meta/_journal.json +0 -41
  51. /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
  52. /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
  53. /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
  54. /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
  55. /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
@@ -1,8 +1,257 @@
1
1
  // src/nextjs/api.ts
2
2
  import { registerInterceptors } from "@spfn/core/nextjs/server";
3
3
 
4
- // src/nextjs/interceptors/login-register.ts
5
- import { generateKeyPair, sealSession, getSessionTtl, COOKIE_NAMES, authLogger } from "@spfn/auth/server";
4
+ // src/server/lib/crypto.ts
5
+ import crypto2 from "crypto";
6
+ import jwt from "jsonwebtoken";
7
+ function generateKeyPairES256() {
8
+ const keyId = crypto2.randomUUID();
9
+ const { privateKey, publicKey } = crypto2.generateKeyPairSync("ec", {
10
+ namedCurve: "P-256",
11
+ // ES256
12
+ publicKeyEncoding: {
13
+ type: "spki",
14
+ format: "der"
15
+ },
16
+ privateKeyEncoding: {
17
+ type: "pkcs8",
18
+ format: "der"
19
+ }
20
+ });
21
+ const privateKeyB64 = privateKey.toString("base64");
22
+ const publicKeyB64 = publicKey.toString("base64");
23
+ const fingerprint = crypto2.createHash("sha256").update(publicKey).digest("hex");
24
+ return {
25
+ privateKey: privateKeyB64,
26
+ publicKey: publicKeyB64,
27
+ keyId,
28
+ fingerprint,
29
+ algorithm: "ES256"
30
+ };
31
+ }
32
+ function generateKeyPairRS256() {
33
+ const keyId = crypto2.randomUUID();
34
+ const { privateKey, publicKey } = crypto2.generateKeyPairSync("rsa", {
35
+ modulusLength: 2048,
36
+ publicKeyEncoding: {
37
+ type: "spki",
38
+ format: "der"
39
+ },
40
+ privateKeyEncoding: {
41
+ type: "pkcs8",
42
+ format: "der"
43
+ }
44
+ });
45
+ const privateKeyB64 = privateKey.toString("base64");
46
+ const publicKeyB64 = publicKey.toString("base64");
47
+ const fingerprint = crypto2.createHash("sha256").update(publicKey).digest("hex");
48
+ return {
49
+ privateKey: privateKeyB64,
50
+ publicKey: publicKeyB64,
51
+ keyId,
52
+ fingerprint,
53
+ algorithm: "RS256"
54
+ };
55
+ }
56
+ function generateKeyPair(algorithm = "ES256") {
57
+ return algorithm === "ES256" ? generateKeyPairES256() : generateKeyPairRS256();
58
+ }
59
+ function generateClientToken(payload, privateKeyB64, algorithm, options) {
60
+ try {
61
+ const privateKeyDER = Buffer.from(privateKeyB64, "base64");
62
+ const privateKeyObject = crypto2.createPrivateKey({
63
+ key: privateKeyDER,
64
+ format: "der",
65
+ type: "pkcs8"
66
+ });
67
+ const privateKeyPEM = privateKeyObject.export({
68
+ type: "pkcs8",
69
+ format: "pem"
70
+ });
71
+ const signOptions = {
72
+ algorithm,
73
+ issuer: options?.issuer || "spfn-client",
74
+ expiresIn: options?.expiresIn ?? "15m"
75
+ // Default to 15 minutes
76
+ };
77
+ return jwt.sign(payload, privateKeyPEM, signOptions);
78
+ } catch (error) {
79
+ throw new Error(
80
+ `Failed to generate client token: ${error instanceof Error ? error.message : "Unknown error"}`
81
+ );
82
+ }
83
+ }
84
+
85
+ // src/server/lib/session.ts
86
+ import * as jose from "jose";
87
+ import { env } from "@spfn/auth/config";
88
+ import { env as coreEnv } from "@spfn/core/config";
89
+
90
+ // src/server/logger.ts
91
+ import { logger as rootLogger } from "@spfn/core/logger";
92
+ var authLogger = {
93
+ plugin: rootLogger.child("@spfn/auth:plugin"),
94
+ middleware: rootLogger.child("@spfn/auth:middleware"),
95
+ interceptor: {
96
+ general: rootLogger.child("@spfn/auth:interceptor:general"),
97
+ login: rootLogger.child("@spfn/auth:interceptor:login"),
98
+ keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
99
+ oauth: rootLogger.child("@spfn/auth:interceptor:oauth")
100
+ },
101
+ session: rootLogger.child("@spfn/auth:session"),
102
+ service: rootLogger.child("@spfn/auth:service"),
103
+ setup: rootLogger.child("@spfn/auth:setup"),
104
+ email: rootLogger.child("@spfn/auth:email"),
105
+ sms: rootLogger.child("@spfn/auth:sms")
106
+ };
107
+
108
+ // src/server/lib/session.ts
109
+ async function getSessionSecretKey() {
110
+ const secret = env.SPFN_AUTH_SESSION_SECRET;
111
+ const encoder = new TextEncoder();
112
+ const data = encoder.encode(secret);
113
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
114
+ return new Uint8Array(hashBuffer);
115
+ }
116
+ async function getSecretFingerprint() {
117
+ const key = await getSessionSecretKey();
118
+ const hash = await crypto.subtle.digest("SHA-256", key.buffer);
119
+ const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
120
+ return hex.slice(0, 8);
121
+ }
122
+ async function sealSession(data, ttl = 60 * 60 * 24 * 7) {
123
+ const secret = await getSessionSecretKey();
124
+ const result = await new jose.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-client").encrypt(secret);
125
+ if (coreEnv.NODE_ENV !== "production") {
126
+ const fingerprint = await getSecretFingerprint();
127
+ authLogger.session.debug(`Sealed session`, {
128
+ secretFingerprint: fingerprint,
129
+ resultLength: result.length,
130
+ resultPrefix: result.slice(0, 20)
131
+ });
132
+ }
133
+ return result;
134
+ }
135
+ async function unsealSession(jwt2) {
136
+ try {
137
+ const secret = await getSessionSecretKey();
138
+ const { payload } = await jose.jwtDecrypt(jwt2, secret, {
139
+ issuer: "spfn-auth",
140
+ audience: "spfn-client"
141
+ });
142
+ return payload.data;
143
+ } catch (err) {
144
+ if (err instanceof jose.errors.JWTExpired) {
145
+ throw new Error("Session expired");
146
+ }
147
+ if (err instanceof jose.errors.JWEDecryptionFailed) {
148
+ if (coreEnv.NODE_ENV !== "production") {
149
+ const fingerprint = await getSecretFingerprint();
150
+ authLogger.session.warn(`JWE decryption failed`, {
151
+ secretFingerprint: fingerprint,
152
+ jwtLength: jwt2.length,
153
+ jwtPrefix: jwt2.slice(0, 20),
154
+ jwtSuffix: jwt2.slice(-10)
155
+ });
156
+ }
157
+ throw new Error("Invalid session");
158
+ }
159
+ if (err instanceof jose.errors.JWTClaimValidationFailed) {
160
+ throw new Error("Session validation failed");
161
+ }
162
+ throw new Error("Failed to unseal session");
163
+ }
164
+ }
165
+ async function getSessionInfo(jwt2) {
166
+ const secret = await getSessionSecretKey();
167
+ try {
168
+ const { payload } = await jose.jwtDecrypt(jwt2, secret);
169
+ return {
170
+ issuedAt: new Date(payload.iat * 1e3),
171
+ expiresAt: new Date(payload.exp * 1e3),
172
+ issuer: payload.iss || "",
173
+ audience: Array.isArray(payload.aud) ? payload.aud[0] : payload.aud || ""
174
+ };
175
+ } catch (err) {
176
+ if (coreEnv.NODE_ENV !== "production") {
177
+ authLogger.session.warn("Failed to get session info:", err instanceof Error ? err.message : "Unknown error");
178
+ }
179
+ return null;
180
+ }
181
+ }
182
+ async function shouldRefreshSession(jwt2, thresholdHours = 24) {
183
+ const info = await getSessionInfo(jwt2);
184
+ if (!info) {
185
+ return true;
186
+ }
187
+ const hoursRemaining = (info.expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60);
188
+ return hoursRemaining < thresholdHours;
189
+ }
190
+
191
+ // src/server/lib/config.ts
192
+ import { env as env2 } from "@spfn/auth/config";
193
+ function getCookieSuffix() {
194
+ const port = process.env.PORT;
195
+ return port ? `_${port}` : "";
196
+ }
197
+ var COOKIE_NAMES = {
198
+ /** Encrypted session data (userId, privateKey, keyId, algorithm) */
199
+ get SESSION() {
200
+ return `spfn_session${getCookieSuffix()}`;
201
+ },
202
+ /** Current key ID (for key rotation) */
203
+ get SESSION_KEY_ID() {
204
+ return `spfn_session_key_id${getCookieSuffix()}`;
205
+ },
206
+ /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */
207
+ get OAUTH_PENDING() {
208
+ return `spfn_oauth_pending${getCookieSuffix()}`;
209
+ },
210
+ /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
211
+ get OAUTH_CSRF() {
212
+ return `spfn_oauth_csrf${getCookieSuffix()}`;
213
+ }
214
+ };
215
+ function parseDuration(duration) {
216
+ if (typeof duration === "number") {
217
+ return duration;
218
+ }
219
+ const match = duration.match(/^(\d+)([dhms]?)$/);
220
+ if (!match) {
221
+ throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);
222
+ }
223
+ const value = parseInt(match[1], 10);
224
+ const unit = match[2] || "s";
225
+ switch (unit) {
226
+ case "d":
227
+ return value * 24 * 60 * 60;
228
+ case "h":
229
+ return value * 60 * 60;
230
+ case "m":
231
+ return value * 60;
232
+ case "s":
233
+ return value;
234
+ default:
235
+ throw new Error(`Unknown duration unit: ${unit}`);
236
+ }
237
+ }
238
+ var globalConfig = {
239
+ sessionTtl: "7d"
240
+ // Default: 7 days
241
+ };
242
+ function getSessionTtl(override) {
243
+ if (override !== void 0) {
244
+ return parseDuration(override);
245
+ }
246
+ if (globalConfig.sessionTtl !== void 0) {
247
+ return parseDuration(globalConfig.sessionTtl);
248
+ }
249
+ const envTtl = env2.SPFN_AUTH_SESSION_TTL;
250
+ if (envTtl) {
251
+ return parseDuration(envTtl);
252
+ }
253
+ return 7 * 24 * 60 * 60;
254
+ }
6
255
 
7
256
  // src/nextjs/interceptors/cookie-options.ts
8
257
  function resolveSecure() {
@@ -16,7 +265,7 @@ var cookieSecure = resolveSecure();
16
265
 
17
266
  // src/nextjs/interceptors/login-register.ts
18
267
  var loginRegisterInterceptor = {
19
- pathPattern: /^\/_auth\/(login|register)$/,
268
+ pathPattern: /^\/_auth\/(login|register|invitations\/accept)$/,
20
269
  method: "POST",
21
270
  request: async (ctx, next) => {
22
271
  const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
@@ -66,7 +315,7 @@ var loginRegisterInterceptor = {
66
315
  options: {
67
316
  httpOnly: true,
68
317
  secure: cookieSecure,
69
- sameSite: "strict",
318
+ sameSite: "lax",
70
319
  maxAge: ttl,
71
320
  path: "/"
72
321
  }
@@ -77,7 +326,7 @@ var loginRegisterInterceptor = {
77
326
  options: {
78
327
  httpOnly: true,
79
328
  secure: cookieSecure,
80
- sameSite: "strict",
329
+ sameSite: "lax",
81
330
  maxAge: ttl,
82
331
  path: "/"
83
332
  }
@@ -91,7 +340,6 @@ var loginRegisterInterceptor = {
91
340
  };
92
341
 
93
342
  // src/nextjs/interceptors/general-auth.ts
94
- import { unsealSession, sealSession as sealSession2, shouldRefreshSession, generateClientToken, getSessionTtl as getSessionTtl2, COOKIE_NAMES as COOKIE_NAMES2, authLogger as authLogger2 } from "@spfn/auth/server";
95
343
  function requiresAuth(path) {
96
344
  const publicPaths = [
97
345
  /^\/_auth\/login$/,
@@ -111,18 +359,18 @@ var generalAuthInterceptor = {
111
359
  method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
112
360
  request: async (ctx, next) => {
113
361
  if (!requiresAuth(ctx.path)) {
114
- authLogger2.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
362
+ authLogger.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
115
363
  await next();
116
364
  return;
117
365
  }
118
366
  const cookieNames = Array.from(ctx.cookies.keys());
119
- authLogger2.interceptor.general.debug("Available cookies:", {
367
+ authLogger.interceptor.general.debug("Available cookies:", {
120
368
  cookieNames,
121
369
  totalCount: cookieNames.length,
122
- lookingFor: COOKIE_NAMES2.SESSION
370
+ lookingFor: COOKIE_NAMES.SESSION
123
371
  });
124
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES2.SESSION);
125
- authLogger2.interceptor.general.debug("Request", {
372
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
373
+ authLogger.interceptor.general.debug("Request", {
126
374
  method: ctx.method,
127
375
  path: ctx.path,
128
376
  hasSession: !!sessionCookie,
@@ -131,19 +379,19 @@ var generalAuthInterceptor = {
131
379
  sessionSuffix: sessionCookie?.slice(-10) ?? ""
132
380
  });
133
381
  if (!sessionCookie) {
134
- authLogger2.interceptor.general.debug("No session cookie, proceeding without auth");
382
+ authLogger.interceptor.general.debug("No session cookie, proceeding without auth");
135
383
  await next();
136
384
  return;
137
385
  }
138
386
  try {
139
387
  const session = await unsealSession(sessionCookie);
140
- authLogger2.interceptor.general.debug("Session valid", {
388
+ authLogger.interceptor.general.debug("Session valid", {
141
389
  userId: session.userId,
142
390
  keyId: session.keyId
143
391
  });
144
392
  const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
145
393
  if (needsRefresh) {
146
- authLogger2.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
394
+ authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
147
395
  ctx.metadata.refreshSession = true;
148
396
  ctx.metadata.sessionData = session;
149
397
  }
@@ -157,7 +405,7 @@ var generalAuthInterceptor = {
157
405
  session.algorithm,
158
406
  { expiresIn: "15m" }
159
407
  );
160
- authLogger2.interceptor.general.debug("Generated JWT token (expires in 15m)");
408
+ authLogger.interceptor.general.debug("Generated JWT token (expires in 15m)");
161
409
  ctx.headers["Authorization"] = `Bearer ${token}`;
162
410
  ctx.headers["X-Key-Id"] = session.keyId;
163
411
  ctx.metadata.userId = session.userId;
@@ -166,31 +414,31 @@ var generalAuthInterceptor = {
166
414
  const err = error;
167
415
  const msg = err.message.toLowerCase();
168
416
  if (msg.includes("expired") || msg.includes("invalid")) {
169
- authLogger2.interceptor.general.warn("Session expired or invalid", {
417
+ authLogger.interceptor.general.warn("Session expired or invalid", {
170
418
  message: err.message,
171
419
  cookieLength: sessionCookie.length,
172
420
  cookiePrefix: sessionCookie.slice(0, 20),
173
421
  cookieSuffix: sessionCookie.slice(-10)
174
422
  });
175
- authLogger2.interceptor.general.debug("Marking session for cleanup");
423
+ authLogger.interceptor.general.debug("Marking session for cleanup");
176
424
  ctx.metadata.clearSession = true;
177
425
  ctx.metadata.sessionValid = false;
178
426
  } else {
179
- authLogger2.interceptor.general.error("Failed to process session", err);
427
+ authLogger.interceptor.general.error("Failed to process session", err);
180
428
  }
181
429
  }
182
430
  await next();
183
431
  },
184
432
  response: async (ctx, next) => {
185
433
  if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
186
- authLogger2.interceptor.general.warn("Backend returned 401, clearing session");
434
+ authLogger.interceptor.general.warn("Backend returned 401, clearing session");
187
435
  ctx.setCookies.push({
188
- name: COOKIE_NAMES2.SESSION,
436
+ name: COOKIE_NAMES.SESSION,
189
437
  value: "",
190
438
  options: { maxAge: 0, path: "/" }
191
439
  });
192
440
  ctx.setCookies.push({
193
- name: COOKIE_NAMES2.SESSION_KEY_ID,
441
+ name: COOKIE_NAMES.SESSION_KEY_ID,
194
442
  value: "",
195
443
  options: { maxAge: 0, path: "/" }
196
444
  });
@@ -199,7 +447,7 @@ var generalAuthInterceptor = {
199
447
  }
200
448
  if (ctx.metadata.clearSession) {
201
449
  ctx.setCookies.push({
202
- name: COOKIE_NAMES2.SESSION,
450
+ name: COOKIE_NAMES.SESSION,
203
451
  value: "",
204
452
  options: {
205
453
  maxAge: 0,
@@ -207,7 +455,7 @@ var generalAuthInterceptor = {
207
455
  }
208
456
  });
209
457
  ctx.setCookies.push({
210
- name: COOKIE_NAMES2.SESSION_KEY_ID,
458
+ name: COOKIE_NAMES.SESSION_KEY_ID,
211
459
  value: "",
212
460
  options: {
213
461
  maxAge: 0,
@@ -217,55 +465,60 @@ var generalAuthInterceptor = {
217
465
  } else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
218
466
  try {
219
467
  const sessionData = ctx.metadata.sessionData;
220
- const ttl = getSessionTtl2();
221
- const sealed = await sealSession2(sessionData, ttl);
468
+ const ttl = getSessionTtl();
469
+ const sealed = await sealSession(sessionData, ttl);
222
470
  ctx.setCookies.push({
223
- name: COOKIE_NAMES2.SESSION,
471
+ name: COOKIE_NAMES.SESSION,
224
472
  value: sealed,
225
473
  options: {
226
474
  httpOnly: true,
227
475
  secure: cookieSecure,
228
- sameSite: "strict",
476
+ sameSite: "lax",
229
477
  maxAge: ttl,
230
478
  path: "/"
231
479
  }
232
480
  });
233
481
  ctx.setCookies.push({
234
- name: COOKIE_NAMES2.SESSION_KEY_ID,
482
+ name: COOKIE_NAMES.SESSION_KEY_ID,
235
483
  value: sessionData.keyId,
236
484
  options: {
237
485
  httpOnly: true,
238
486
  secure: cookieSecure,
239
- sameSite: "strict",
487
+ sameSite: "lax",
240
488
  maxAge: ttl,
241
489
  path: "/"
242
490
  }
243
491
  });
244
- authLogger2.interceptor.general.info("Session refreshed", {
492
+ authLogger.interceptor.general.info("Session refreshed", {
245
493
  userId: sessionData.userId,
246
494
  sealedLength: sealed.length,
247
495
  sealedPrefix: sealed.slice(0, 20)
248
496
  });
249
497
  } catch (error) {
250
498
  const err = error;
251
- authLogger2.interceptor.general.error("Failed to refresh session", err);
499
+ authLogger.interceptor.general.error("Failed to refresh session", err);
252
500
  }
253
501
  } else if (ctx.path === "/_auth/logout" && ctx.response.ok) {
502
+ const base = {
503
+ httpOnly: true,
504
+ secure: cookieSecure,
505
+ maxAge: 0,
506
+ path: "/"
507
+ };
254
508
  ctx.setCookies.push({
255
- name: COOKIE_NAMES2.SESSION,
509
+ name: COOKIE_NAMES.SESSION,
256
510
  value: "",
257
- options: {
258
- maxAge: 0,
259
- path: "/"
260
- }
511
+ options: { ...base, sameSite: "lax" }
261
512
  });
262
513
  ctx.setCookies.push({
263
- name: COOKIE_NAMES2.SESSION_KEY_ID,
514
+ name: COOKIE_NAMES.SESSION_KEY_ID,
264
515
  value: "",
265
- options: {
266
- maxAge: 0,
267
- path: "/"
268
- }
516
+ options: { ...base, sameSite: "lax" }
517
+ });
518
+ ctx.setCookies.push({
519
+ name: COOKIE_NAMES.OAUTH_PENDING,
520
+ value: "",
521
+ options: { ...base, sameSite: "lax" }
269
522
  });
270
523
  }
271
524
  await next();
@@ -273,19 +526,18 @@ var generalAuthInterceptor = {
273
526
  };
274
527
 
275
528
  // src/nextjs/interceptors/key-rotation.ts
276
- import { generateKeyPair as generateKeyPair2, unsealSession as unsealSession2, sealSession as sealSession3, generateClientToken as generateClientToken2, getSessionTtl as getSessionTtl3, COOKIE_NAMES as COOKIE_NAMES3, authLogger as authLogger3 } from "@spfn/auth/server";
277
529
  var keyRotationInterceptor = {
278
530
  pathPattern: "/_auth/keys/rotate",
279
531
  method: "POST",
280
532
  request: async (ctx, next) => {
281
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES3.SESSION);
533
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
282
534
  if (!sessionCookie) {
283
535
  await next();
284
536
  return;
285
537
  }
286
538
  try {
287
- const currentSession = await unsealSession2(sessionCookie);
288
- const newKeyPair = generateKeyPair2("ES256");
539
+ const currentSession = await unsealSession(sessionCookie);
540
+ const newKeyPair = generateKeyPair("ES256");
289
541
  if (!ctx.body) {
290
542
  ctx.body = {};
291
543
  }
@@ -298,7 +550,7 @@ var keyRotationInterceptor = {
298
550
  console.log("publicKey:", newKeyPair.publicKey);
299
551
  console.log("keyId:", newKeyPair.keyId);
300
552
  console.log("fingerprint:", newKeyPair.fingerprint);
301
- const token = generateClientToken2(
553
+ const token = generateClientToken(
302
554
  {
303
555
  userId: currentSession.userId,
304
556
  keyId: currentSession.keyId,
@@ -317,7 +569,7 @@ var keyRotationInterceptor = {
317
569
  ctx.metadata.userId = currentSession.userId;
318
570
  } catch (error) {
319
571
  const err = error;
320
- authLogger3.interceptor.keyRotation.error("Failed to prepare key rotation", err);
572
+ authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
321
573
  }
322
574
  await next();
323
575
  },
@@ -327,73 +579,90 @@ var keyRotationInterceptor = {
327
579
  return;
328
580
  }
329
581
  if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId) {
330
- authLogger3.interceptor.keyRotation.error("Missing key rotation metadata");
582
+ authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
331
583
  await next();
332
584
  return;
333
585
  }
334
586
  try {
335
- const ttl = getSessionTtl3();
587
+ const ttl = getSessionTtl();
336
588
  const newSessionData = {
337
589
  userId: ctx.metadata.userId,
338
590
  privateKey: ctx.metadata.newPrivateKey,
339
591
  keyId: ctx.metadata.newKeyId,
340
592
  algorithm: ctx.metadata.newAlgorithm
341
593
  };
342
- const sealed = await sealSession3(newSessionData, ttl);
594
+ const sealed = await sealSession(newSessionData, ttl);
343
595
  ctx.setCookies.push({
344
- name: COOKIE_NAMES3.SESSION,
596
+ name: COOKIE_NAMES.SESSION,
345
597
  value: sealed,
346
598
  options: {
347
599
  httpOnly: true,
348
600
  secure: cookieSecure,
349
- sameSite: "strict",
601
+ sameSite: "lax",
350
602
  maxAge: ttl,
351
603
  path: "/"
352
604
  }
353
605
  });
354
606
  ctx.setCookies.push({
355
- name: COOKIE_NAMES3.SESSION_KEY_ID,
607
+ name: COOKIE_NAMES.SESSION_KEY_ID,
356
608
  value: ctx.metadata.newKeyId,
357
609
  options: {
358
610
  httpOnly: true,
359
611
  secure: cookieSecure,
360
- sameSite: "strict",
612
+ sameSite: "lax",
361
613
  maxAge: ttl,
362
614
  path: "/"
363
615
  }
364
616
  });
365
617
  } catch (error) {
366
618
  const err = error;
367
- authLogger3.interceptor.keyRotation.error("Failed to update session after rotation", err);
619
+ authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
368
620
  }
369
621
  await next();
370
622
  }
371
623
  };
372
624
 
373
- // src/nextjs/interceptors/oauth.ts
374
- import {
375
- generateKeyPair as generateKeyPair3,
376
- createOAuthState,
377
- sealSession as sealSession5,
378
- COOKIE_NAMES as COOKIE_NAMES5,
379
- getSessionTtl as getSessionTtl5,
380
- authLogger as authLogger4
381
- } from "@spfn/auth/server";
625
+ // src/server/lib/oauth/state.ts
626
+ import * as jose2 from "jose";
627
+ import { env as env3 } from "@spfn/auth/config";
628
+ async function getStateKey() {
629
+ const secret = env3.SPFN_AUTH_SESSION_SECRET;
630
+ const encoder = new TextEncoder();
631
+ const data = encoder.encode(`oauth-state:${secret}`);
632
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
633
+ return new Uint8Array(hashBuffer);
634
+ }
635
+ function generateNonce() {
636
+ const array = new Uint8Array(16);
637
+ crypto.getRandomValues(array);
638
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
639
+ }
640
+ function generateOAuthNonce() {
641
+ return generateNonce();
642
+ }
643
+ async function createOAuthState(params) {
644
+ const key = await getStateKey();
645
+ const state = {
646
+ returnUrl: params.returnUrl,
647
+ nonce: params.nonce ?? generateNonce(),
648
+ provider: params.provider,
649
+ publicKey: params.publicKey,
650
+ keyId: params.keyId,
651
+ fingerprint: params.fingerprint,
652
+ algorithm: params.algorithm,
653
+ metadata: params.metadata
654
+ };
655
+ const jwe = await new jose2.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
656
+ return encodeURIComponent(jwe);
657
+ }
382
658
 
383
659
  // src/nextjs/session-helpers.ts
384
- import * as jose from "jose";
660
+ import * as jose3 from "jose";
385
661
  import { cookies } from "next/headers.js";
386
- import {
387
- sealSession as sealSession4,
388
- unsealSession as unsealSession3,
389
- COOKIE_NAMES as COOKIE_NAMES4,
390
- getSessionTtl as getSessionTtl4,
391
- parseDuration
392
- } from "@spfn/auth/server";
393
- import { env } from "@spfn/auth/config";
662
+ import { env as env4 } from "@spfn/auth/config";
394
663
  import { logger } from "@spfn/core/logger";
395
664
  async function getPendingSessionKey() {
396
- const secret = env.SPFN_AUTH_SESSION_SECRET;
665
+ const secret = env4.SPFN_AUTH_SESSION_SECRET;
397
666
  const encoder = new TextEncoder();
398
667
  const data = encoder.encode(`oauth-pending:${secret}`);
399
668
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -401,11 +670,11 @@ async function getPendingSessionKey() {
401
670
  }
402
671
  async function sealPendingSession(data, ttl = 600) {
403
672
  const key = await getPendingSessionKey();
404
- return await new jose.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
673
+ return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
405
674
  }
406
- async function unsealPendingSession(jwt) {
675
+ async function unsealPendingSession(jwt2) {
407
676
  const key = await getPendingSessionKey();
408
- const { payload } = await jose.jwtDecrypt(jwt, key, {
677
+ const { payload } = await jose3.jwtDecrypt(jwt2, key, {
409
678
  issuer: "spfn-auth",
410
679
  audience: "spfn-oauth"
411
680
  });
@@ -419,14 +688,18 @@ var oauthUrlInterceptor = {
419
688
  request: async (ctx, next) => {
420
689
  const provider = ctx.path.split("/")[3];
421
690
  const returnUrl = ctx.body?.returnUrl || "/";
422
- const keyPair = generateKeyPair3("ES256");
691
+ const metadata = ctx.body?.metadata;
692
+ const keyPair = generateKeyPair("ES256");
693
+ const csrfNonce = generateOAuthNonce();
423
694
  const state = await createOAuthState({
424
695
  provider,
425
696
  returnUrl,
426
697
  publicKey: keyPair.publicKey,
427
698
  keyId: keyPair.keyId,
428
699
  fingerprint: keyPair.fingerprint,
429
- algorithm: keyPair.algorithm
700
+ algorithm: keyPair.algorithm,
701
+ nonce: csrfNonce,
702
+ metadata
430
703
  });
431
704
  if (!ctx.body) {
432
705
  ctx.body = {};
@@ -437,7 +710,8 @@ var oauthUrlInterceptor = {
437
710
  keyId: keyPair.keyId,
438
711
  algorithm: keyPair.algorithm
439
712
  };
440
- authLogger4.interceptor.oauth?.debug?.("OAuth state created", {
713
+ ctx.metadata.oauthCsrf = csrfNonce;
714
+ authLogger.interceptor.oauth?.debug?.("OAuth state created", {
441
715
  provider,
442
716
  keyId: keyPair.keyId
443
717
  });
@@ -448,7 +722,7 @@ var oauthUrlInterceptor = {
448
722
  try {
449
723
  const sealed = await sealPendingSession(ctx.metadata.pendingSession);
450
724
  ctx.setCookies.push({
451
- name: COOKIE_NAMES5.OAUTH_PENDING,
725
+ name: COOKIE_NAMES.OAUTH_PENDING,
452
726
  value: sealed,
453
727
  options: {
454
728
  httpOnly: true,
@@ -460,17 +734,47 @@ var oauthUrlInterceptor = {
460
734
  path: "/"
461
735
  }
462
736
  });
463
- authLogger4.interceptor.oauth?.debug?.("Pending session cookie set", {
737
+ if (ctx.metadata.oauthCsrf) {
738
+ ctx.setCookies.push({
739
+ name: COOKIE_NAMES.OAUTH_CSRF,
740
+ value: ctx.metadata.oauthCsrf,
741
+ options: {
742
+ httpOnly: true,
743
+ secure: cookieSecure,
744
+ sameSite: "lax",
745
+ maxAge: 600,
746
+ path: "/"
747
+ }
748
+ });
749
+ }
750
+ authLogger.interceptor.oauth?.debug?.("Pending session cookie set", {
464
751
  keyId: ctx.metadata.pendingSession.keyId
465
752
  });
466
753
  } catch (error) {
467
754
  const err = error;
468
- authLogger4.interceptor.oauth?.error?.("Failed to set pending session", err);
755
+ authLogger.interceptor.oauth?.error?.("Failed to set pending session", err);
469
756
  }
470
757
  }
471
758
  await next();
472
759
  }
473
760
  };
761
+ function setFinalizeError(ctx, message) {
762
+ ctx.response.ok = false;
763
+ ctx.response.status = 401;
764
+ ctx.response.statusText = "Unauthorized";
765
+ ctx.response.body = { success: false, message };
766
+ ctx.setCookies.push({
767
+ name: COOKIE_NAMES.OAUTH_PENDING,
768
+ value: "",
769
+ options: {
770
+ httpOnly: true,
771
+ secure: cookieSecure,
772
+ sameSite: "lax",
773
+ maxAge: 0,
774
+ path: "/"
775
+ }
776
+ });
777
+ }
474
778
  var oauthFinalizeInterceptor = {
475
779
  pathPattern: /^\/_auth\/oauth\/finalize$/,
476
780
  method: "POST",
@@ -479,9 +783,10 @@ var oauthFinalizeInterceptor = {
479
783
  await next();
480
784
  return;
481
785
  }
482
- const pendingCookie = ctx.cookies.get(COOKIE_NAMES5.OAUTH_PENDING);
786
+ const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
483
787
  if (!pendingCookie) {
484
- authLogger4.interceptor.oauth?.warn?.("No pending session cookie found");
788
+ authLogger.interceptor.oauth?.warn?.("No pending session cookie found");
789
+ setFinalizeError(ctx, "OAuth session expired. Please try again.");
485
790
  await next();
486
791
  return;
487
792
  }
@@ -489,49 +794,51 @@ var oauthFinalizeInterceptor = {
489
794
  const pendingSession = await unsealPendingSession(pendingCookie);
490
795
  const { userId, keyId } = ctx.response.body || {};
491
796
  if (!userId || !keyId) {
492
- authLogger4.interceptor.oauth?.error?.("Missing userId or keyId in response");
797
+ authLogger.interceptor.oauth?.error?.("Missing userId or keyId in response");
798
+ setFinalizeError(ctx, "OAuth finalize failed: missing credentials");
493
799
  await next();
494
800
  return;
495
801
  }
496
802
  if (pendingSession.keyId !== keyId) {
497
- authLogger4.interceptor.oauth?.error?.("KeyId mismatch", {
803
+ authLogger.interceptor.oauth?.error?.("KeyId mismatch", {
498
804
  expected: pendingSession.keyId,
499
805
  received: keyId
500
806
  });
807
+ setFinalizeError(ctx, "OAuth session mismatch. Please try again.");
501
808
  await next();
502
809
  return;
503
810
  }
504
- const ttl = getSessionTtl5();
505
- const sessionToken = await sealSession5({
811
+ const ttl = getSessionTtl();
812
+ const sessionToken = await sealSession({
506
813
  userId,
507
814
  privateKey: pendingSession.privateKey,
508
815
  keyId: pendingSession.keyId,
509
816
  algorithm: pendingSession.algorithm
510
817
  }, ttl);
511
818
  ctx.setCookies.push({
512
- name: COOKIE_NAMES5.SESSION,
819
+ name: COOKIE_NAMES.SESSION,
513
820
  value: sessionToken,
514
821
  options: {
515
822
  httpOnly: true,
516
823
  secure: cookieSecure,
517
- sameSite: "strict",
824
+ sameSite: "lax",
518
825
  maxAge: ttl,
519
826
  path: "/"
520
827
  }
521
828
  });
522
829
  ctx.setCookies.push({
523
- name: COOKIE_NAMES5.SESSION_KEY_ID,
830
+ name: COOKIE_NAMES.SESSION_KEY_ID,
524
831
  value: keyId,
525
832
  options: {
526
833
  httpOnly: true,
527
834
  secure: cookieSecure,
528
- sameSite: "strict",
835
+ sameSite: "lax",
529
836
  maxAge: ttl,
530
837
  path: "/"
531
838
  }
532
839
  });
533
840
  ctx.setCookies.push({
534
- name: COOKIE_NAMES5.OAUTH_PENDING,
841
+ name: COOKIE_NAMES.OAUTH_PENDING,
535
842
  value: "",
536
843
  options: {
537
844
  httpOnly: true,
@@ -541,13 +848,14 @@ var oauthFinalizeInterceptor = {
541
848
  path: "/"
542
849
  }
543
850
  });
544
- authLogger4.interceptor.oauth?.debug?.("OAuth session finalized", {
851
+ authLogger.interceptor.oauth?.debug?.("OAuth session finalized", {
545
852
  userId,
546
853
  keyId
547
854
  });
548
855
  } catch (error) {
549
856
  const err = error;
550
- authLogger4.interceptor.oauth?.error?.("Failed to finalize OAuth session", err);
857
+ authLogger.interceptor.oauth?.error?.("Failed to finalize OAuth session", err);
858
+ setFinalizeError(ctx, err.message);
551
859
  }
552
860
  await next();
553
861
  }