@spfn/auth 0.2.0-beta.8 → 0.2.0-beta.81

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 (42) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +561 -1827
  3. package/dist/authenticate-ofdEmk6x.d.ts +1109 -0
  4. package/dist/config.d.ts +431 -39
  5. package/dist/config.js +217 -29
  6. package/dist/config.js.map +1 -1
  7. package/dist/errors.d.ts +117 -3
  8. package/dist/errors.js +83 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +351 -109
  11. package/dist/index.js +124 -7
  12. package/dist/index.js.map +1 -1
  13. package/dist/nextjs/api.js +591 -61
  14. package/dist/nextjs/api.js.map +1 -1
  15. package/dist/nextjs/client.d.ts +28 -0
  16. package/dist/nextjs/client.js +80 -0
  17. package/dist/nextjs/client.js.map +1 -0
  18. package/dist/nextjs/server.d.ts +92 -3
  19. package/dist/nextjs/server.js +288 -24
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/server.d.ts +2019 -511
  22. package/dist/server.js +4435 -1157
  23. package/dist/server.js.map +1 -1
  24. package/dist/session-CGxgH3C9.d.ts +53 -0
  25. package/dist/types-1BMx0OX1.d.ts +84 -0
  26. package/migrations/0001_smooth_the_fury.sql +3 -0
  27. package/migrations/0002_deep_iceman.sql +11 -0
  28. package/migrations/0003_perfect_deathbird.sql +3 -0
  29. package/migrations/0004_concerned_rawhide_kid.sql +5 -0
  30. package/migrations/0005_lethal_lifeguard.sql +32 -0
  31. package/migrations/0006_easy_hardball.sql +24 -0
  32. package/migrations/0007_glossy_major_mapleleaf.sql +1 -0
  33. package/migrations/meta/0001_snapshot.json +1660 -0
  34. package/migrations/meta/0002_snapshot.json +1660 -0
  35. package/migrations/meta/0003_snapshot.json +1689 -0
  36. package/migrations/meta/0004_snapshot.json +1721 -0
  37. package/migrations/meta/0005_snapshot.json +1721 -0
  38. package/migrations/meta/0006_snapshot.json +1921 -0
  39. package/migrations/meta/0007_snapshot.json +1916 -0
  40. package/migrations/meta/_journal.json +49 -0
  41. package/package.json +43 -39
  42. package/dist/dto-lZmWuObc.d.ts +0 -645
@@ -1,11 +1,271 @@
1
1
  // src/nextjs/api.ts
2
2
  import { registerInterceptors } from "@spfn/core/nextjs/server";
3
3
 
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
+ }
255
+
256
+ // src/nextjs/interceptors/cookie-options.ts
257
+ function resolveSecure() {
258
+ const override = process.env.SPFN_AUTH_COOKIE_SECURE;
259
+ if (override !== void 0) {
260
+ return override === "true";
261
+ }
262
+ return process.env.NODE_ENV === "production";
263
+ }
264
+ var cookieSecure = resolveSecure();
265
+
4
266
  // src/nextjs/interceptors/login-register.ts
5
- import { generateKeyPair, sealSession, getSessionTtl, COOKIE_NAMES, authLogger } from "@spfn/auth/server";
6
- import { env } from "@spfn/core/config";
7
267
  var loginRegisterInterceptor = {
8
- pathPattern: /^\/_auth\/(login|register)$/,
268
+ pathPattern: /^\/_auth\/(login|register|invitations\/accept)$/,
9
269
  method: "POST",
10
270
  request: async (ctx, next) => {
11
271
  const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
@@ -54,8 +314,8 @@ var loginRegisterInterceptor = {
54
314
  value: sealed,
55
315
  options: {
56
316
  httpOnly: true,
57
- secure: env.NODE_ENV === "production",
58
- sameSite: "strict",
317
+ secure: cookieSecure,
318
+ sameSite: "lax",
59
319
  maxAge: ttl,
60
320
  path: "/"
61
321
  }
@@ -65,8 +325,8 @@ var loginRegisterInterceptor = {
65
325
  value: ctx.metadata.keyId,
66
326
  options: {
67
327
  httpOnly: true,
68
- secure: env.NODE_ENV === "production",
69
- sameSite: "strict",
328
+ secure: cookieSecure,
329
+ sameSite: "lax",
70
330
  maxAge: ttl,
71
331
  path: "/"
72
332
  }
@@ -80,8 +340,6 @@ var loginRegisterInterceptor = {
80
340
  };
81
341
 
82
342
  // src/nextjs/interceptors/general-auth.ts
83
- import { unsealSession, sealSession as sealSession2, shouldRefreshSession, generateClientToken, getSessionTtl as getSessionTtl2, COOKIE_NAMES as COOKIE_NAMES2, authLogger as authLogger2 } from "@spfn/auth/server";
84
- import { env as env2 } from "@spfn/core/config";
85
343
  function requiresAuth(path) {
86
344
  const publicPaths = [
87
345
  /^\/_auth\/login$/,
@@ -101,37 +359,39 @@ var generalAuthInterceptor = {
101
359
  method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
102
360
  request: async (ctx, next) => {
103
361
  if (!requiresAuth(ctx.path)) {
104
- authLogger2.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
362
+ authLogger.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
105
363
  await next();
106
364
  return;
107
365
  }
108
366
  const cookieNames = Array.from(ctx.cookies.keys());
109
- authLogger2.interceptor.general.debug("Available cookies:", {
367
+ authLogger.interceptor.general.debug("Available cookies:", {
110
368
  cookieNames,
111
369
  totalCount: cookieNames.length,
112
- lookingFor: COOKIE_NAMES2.SESSION
370
+ lookingFor: COOKIE_NAMES.SESSION
113
371
  });
114
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES2.SESSION);
115
- authLogger2.interceptor.general.debug("Request", {
372
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
373
+ authLogger.interceptor.general.debug("Request", {
116
374
  method: ctx.method,
117
375
  path: ctx.path,
118
376
  hasSession: !!sessionCookie,
119
- sessionCookieValue: sessionCookie ? "***EXISTS***" : "NOT_FOUND"
377
+ sessionLength: sessionCookie?.length ?? 0,
378
+ sessionPrefix: sessionCookie?.slice(0, 20) ?? "",
379
+ sessionSuffix: sessionCookie?.slice(-10) ?? ""
120
380
  });
121
381
  if (!sessionCookie) {
122
- authLogger2.interceptor.general.debug("No session cookie, proceeding without auth");
382
+ authLogger.interceptor.general.debug("No session cookie, proceeding without auth");
123
383
  await next();
124
384
  return;
125
385
  }
126
386
  try {
127
387
  const session = await unsealSession(sessionCookie);
128
- authLogger2.interceptor.general.debug("Session valid", {
388
+ authLogger.interceptor.general.debug("Session valid", {
129
389
  userId: session.userId,
130
390
  keyId: session.keyId
131
391
  });
132
392
  const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
133
393
  if (needsRefresh) {
134
- authLogger2.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
394
+ authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
135
395
  ctx.metadata.refreshSession = true;
136
396
  ctx.metadata.sessionData = session;
137
397
  }
@@ -145,28 +405,49 @@ var generalAuthInterceptor = {
145
405
  session.algorithm,
146
406
  { expiresIn: "15m" }
147
407
  );
148
- authLogger2.interceptor.general.debug("Generated JWT token (expires in 15m)");
408
+ authLogger.interceptor.general.debug("Generated JWT token (expires in 15m)");
149
409
  ctx.headers["Authorization"] = `Bearer ${token}`;
150
410
  ctx.headers["X-Key-Id"] = session.keyId;
151
411
  ctx.metadata.userId = session.userId;
152
412
  ctx.metadata.sessionValid = true;
153
413
  } catch (error) {
154
414
  const err = error;
155
- if (err.message.includes("expired") || err.message.includes("invalid")) {
156
- authLogger2.interceptor.general.warn("Session expired or invalid", { message: err.message });
157
- authLogger2.interceptor.general.debug("Marking session for cleanup");
415
+ const msg = err.message.toLowerCase();
416
+ if (msg.includes("expired") || msg.includes("invalid")) {
417
+ authLogger.interceptor.general.warn("Session expired or invalid", {
418
+ message: err.message,
419
+ cookieLength: sessionCookie.length,
420
+ cookiePrefix: sessionCookie.slice(0, 20),
421
+ cookieSuffix: sessionCookie.slice(-10)
422
+ });
423
+ authLogger.interceptor.general.debug("Marking session for cleanup");
158
424
  ctx.metadata.clearSession = true;
159
425
  ctx.metadata.sessionValid = false;
160
426
  } else {
161
- authLogger2.interceptor.general.error("Failed to process session", err);
427
+ authLogger.interceptor.general.error("Failed to process session", err);
162
428
  }
163
429
  }
164
430
  await next();
165
431
  },
166
432
  response: async (ctx, next) => {
433
+ if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
434
+ authLogger.interceptor.general.warn("Backend returned 401, clearing session");
435
+ ctx.setCookies.push({
436
+ name: COOKIE_NAMES.SESSION,
437
+ value: "",
438
+ options: { maxAge: 0, path: "/" }
439
+ });
440
+ ctx.setCookies.push({
441
+ name: COOKIE_NAMES.SESSION_KEY_ID,
442
+ value: "",
443
+ options: { maxAge: 0, path: "/" }
444
+ });
445
+ await next();
446
+ return;
447
+ }
167
448
  if (ctx.metadata.clearSession) {
168
449
  ctx.setCookies.push({
169
- name: COOKIE_NAMES2.SESSION,
450
+ name: COOKIE_NAMES.SESSION,
170
451
  value: "",
171
452
  options: {
172
453
  maxAge: 0,
@@ -174,7 +455,7 @@ var generalAuthInterceptor = {
174
455
  }
175
456
  });
176
457
  ctx.setCookies.push({
177
- name: COOKIE_NAMES2.SESSION_KEY_ID,
458
+ name: COOKIE_NAMES.SESSION_KEY_ID,
178
459
  value: "",
179
460
  options: {
180
461
  maxAge: 0,
@@ -184,51 +465,60 @@ var generalAuthInterceptor = {
184
465
  } else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
185
466
  try {
186
467
  const sessionData = ctx.metadata.sessionData;
187
- const ttl = getSessionTtl2();
188
- const sealed = await sealSession2(sessionData, ttl);
468
+ const ttl = getSessionTtl();
469
+ const sealed = await sealSession(sessionData, ttl);
189
470
  ctx.setCookies.push({
190
- name: COOKIE_NAMES2.SESSION,
471
+ name: COOKIE_NAMES.SESSION,
191
472
  value: sealed,
192
473
  options: {
193
474
  httpOnly: true,
194
- secure: env2.NODE_ENV === "production",
195
- sameSite: "strict",
475
+ secure: cookieSecure,
476
+ sameSite: "lax",
196
477
  maxAge: ttl,
197
478
  path: "/"
198
479
  }
199
480
  });
200
481
  ctx.setCookies.push({
201
- name: COOKIE_NAMES2.SESSION_KEY_ID,
482
+ name: COOKIE_NAMES.SESSION_KEY_ID,
202
483
  value: sessionData.keyId,
203
484
  options: {
204
485
  httpOnly: true,
205
- secure: process.env.NODE_ENV === "production",
206
- sameSite: "strict",
486
+ secure: cookieSecure,
487
+ sameSite: "lax",
207
488
  maxAge: ttl,
208
489
  path: "/"
209
490
  }
210
491
  });
211
- authLogger2.interceptor.general.info("Session refreshed", { userId: sessionData.userId });
492
+ authLogger.interceptor.general.info("Session refreshed", {
493
+ userId: sessionData.userId,
494
+ sealedLength: sealed.length,
495
+ sealedPrefix: sealed.slice(0, 20)
496
+ });
212
497
  } catch (error) {
213
498
  const err = error;
214
- authLogger2.interceptor.general.error("Failed to refresh session", err);
499
+ authLogger.interceptor.general.error("Failed to refresh session", err);
215
500
  }
216
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
+ };
217
508
  ctx.setCookies.push({
218
- name: COOKIE_NAMES2.SESSION,
509
+ name: COOKIE_NAMES.SESSION,
219
510
  value: "",
220
- options: {
221
- maxAge: 0,
222
- path: "/"
223
- }
511
+ options: { ...base, sameSite: "lax" }
224
512
  });
225
513
  ctx.setCookies.push({
226
- name: COOKIE_NAMES2.SESSION_KEY_ID,
514
+ name: COOKIE_NAMES.SESSION_KEY_ID,
227
515
  value: "",
228
- options: {
229
- maxAge: 0,
230
- path: "/"
231
- }
516
+ options: { ...base, sameSite: "lax" }
517
+ });
518
+ ctx.setCookies.push({
519
+ name: COOKIE_NAMES.OAUTH_PENDING,
520
+ value: "",
521
+ options: { ...base, sameSite: "lax" }
232
522
  });
233
523
  }
234
524
  await next();
@@ -236,19 +526,18 @@ var generalAuthInterceptor = {
236
526
  };
237
527
 
238
528
  // src/nextjs/interceptors/key-rotation.ts
239
- 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";
240
529
  var keyRotationInterceptor = {
241
530
  pathPattern: "/_auth/keys/rotate",
242
531
  method: "POST",
243
532
  request: async (ctx, next) => {
244
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES3.SESSION);
533
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
245
534
  if (!sessionCookie) {
246
535
  await next();
247
536
  return;
248
537
  }
249
538
  try {
250
- const currentSession = await unsealSession2(sessionCookie);
251
- const newKeyPair = generateKeyPair2("ES256");
539
+ const currentSession = await unsealSession(sessionCookie);
540
+ const newKeyPair = generateKeyPair("ES256");
252
541
  if (!ctx.body) {
253
542
  ctx.body = {};
254
543
  }
@@ -261,7 +550,7 @@ var keyRotationInterceptor = {
261
550
  console.log("publicKey:", newKeyPair.publicKey);
262
551
  console.log("keyId:", newKeyPair.keyId);
263
552
  console.log("fingerprint:", newKeyPair.fingerprint);
264
- const token = generateClientToken2(
553
+ const token = generateClientToken(
265
554
  {
266
555
  userId: currentSession.userId,
267
556
  keyId: currentSession.keyId,
@@ -280,7 +569,7 @@ var keyRotationInterceptor = {
280
569
  ctx.metadata.userId = currentSession.userId;
281
570
  } catch (error) {
282
571
  const err = error;
283
- authLogger3.interceptor.keyRotation.error("Failed to prepare key rotation", err);
572
+ authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
284
573
  }
285
574
  await next();
286
575
  },
@@ -290,44 +579,283 @@ var keyRotationInterceptor = {
290
579
  return;
291
580
  }
292
581
  if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId) {
293
- authLogger3.interceptor.keyRotation.error("Missing key rotation metadata");
582
+ authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
294
583
  await next();
295
584
  return;
296
585
  }
297
586
  try {
298
- const ttl = getSessionTtl3();
587
+ const ttl = getSessionTtl();
299
588
  const newSessionData = {
300
589
  userId: ctx.metadata.userId,
301
590
  privateKey: ctx.metadata.newPrivateKey,
302
591
  keyId: ctx.metadata.newKeyId,
303
592
  algorithm: ctx.metadata.newAlgorithm
304
593
  };
305
- const sealed = await sealSession3(newSessionData, ttl);
594
+ const sealed = await sealSession(newSessionData, ttl);
306
595
  ctx.setCookies.push({
307
- name: COOKIE_NAMES3.SESSION,
596
+ name: COOKIE_NAMES.SESSION,
308
597
  value: sealed,
309
598
  options: {
310
599
  httpOnly: true,
311
- secure: process.env.NODE_ENV === "production",
312
- sameSite: "strict",
600
+ secure: cookieSecure,
601
+ sameSite: "lax",
313
602
  maxAge: ttl,
314
603
  path: "/"
315
604
  }
316
605
  });
317
606
  ctx.setCookies.push({
318
- name: COOKIE_NAMES3.SESSION_KEY_ID,
607
+ name: COOKIE_NAMES.SESSION_KEY_ID,
319
608
  value: ctx.metadata.newKeyId,
320
609
  options: {
321
610
  httpOnly: true,
322
- secure: process.env.NODE_ENV === "production",
323
- sameSite: "strict",
611
+ secure: cookieSecure,
612
+ sameSite: "lax",
324
613
  maxAge: ttl,
325
614
  path: "/"
326
615
  }
327
616
  });
328
617
  } catch (error) {
329
618
  const err = error;
330
- authLogger3.interceptor.keyRotation.error("Failed to update session after rotation", err);
619
+ authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
620
+ }
621
+ await next();
622
+ }
623
+ };
624
+
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
+ }
658
+
659
+ // src/nextjs/session-helpers.ts
660
+ import * as jose3 from "jose";
661
+ import { cookies } from "next/headers.js";
662
+ import { env as env4 } from "@spfn/auth/config";
663
+ import { logger } from "@spfn/core/logger";
664
+ async function getPendingSessionKey() {
665
+ const secret = env4.SPFN_AUTH_SESSION_SECRET;
666
+ const encoder = new TextEncoder();
667
+ const data = encoder.encode(`oauth-pending:${secret}`);
668
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
669
+ return new Uint8Array(hashBuffer);
670
+ }
671
+ async function sealPendingSession(data, ttl = 600) {
672
+ const key = await getPendingSessionKey();
673
+ return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
674
+ }
675
+ async function unsealPendingSession(jwt2) {
676
+ const key = await getPendingSessionKey();
677
+ const { payload } = await jose3.jwtDecrypt(jwt2, key, {
678
+ issuer: "spfn-auth",
679
+ audience: "spfn-oauth"
680
+ });
681
+ return payload.data;
682
+ }
683
+
684
+ // src/nextjs/interceptors/oauth.ts
685
+ var oauthUrlInterceptor = {
686
+ pathPattern: /^\/_auth\/oauth\/\w+\/url$/,
687
+ method: "POST",
688
+ request: async (ctx, next) => {
689
+ const provider = ctx.path.split("/")[3];
690
+ const returnUrl = ctx.body?.returnUrl || "/";
691
+ const metadata = ctx.body?.metadata;
692
+ const keyPair = generateKeyPair("ES256");
693
+ const csrfNonce = generateOAuthNonce();
694
+ const state = await createOAuthState({
695
+ provider,
696
+ returnUrl,
697
+ publicKey: keyPair.publicKey,
698
+ keyId: keyPair.keyId,
699
+ fingerprint: keyPair.fingerprint,
700
+ algorithm: keyPair.algorithm,
701
+ nonce: csrfNonce,
702
+ metadata
703
+ });
704
+ if (!ctx.body) {
705
+ ctx.body = {};
706
+ }
707
+ ctx.body.state = state;
708
+ ctx.metadata.pendingSession = {
709
+ privateKey: keyPair.privateKey,
710
+ keyId: keyPair.keyId,
711
+ algorithm: keyPair.algorithm
712
+ };
713
+ ctx.metadata.oauthCsrf = csrfNonce;
714
+ authLogger.interceptor.oauth?.debug?.("OAuth state created", {
715
+ provider,
716
+ keyId: keyPair.keyId
717
+ });
718
+ await next();
719
+ },
720
+ response: async (ctx, next) => {
721
+ if (ctx.response.ok && ctx.metadata.pendingSession) {
722
+ try {
723
+ const sealed = await sealPendingSession(ctx.metadata.pendingSession);
724
+ ctx.setCookies.push({
725
+ name: COOKIE_NAMES.OAUTH_PENDING,
726
+ value: sealed,
727
+ options: {
728
+ httpOnly: true,
729
+ secure: cookieSecure,
730
+ sameSite: "lax",
731
+ // OAuth 리다이렉트 허용
732
+ maxAge: 600,
733
+ // 10분
734
+ path: "/"
735
+ }
736
+ });
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", {
751
+ keyId: ctx.metadata.pendingSession.keyId
752
+ });
753
+ } catch (error) {
754
+ const err = error;
755
+ authLogger.interceptor.oauth?.error?.("Failed to set pending session", err);
756
+ }
757
+ }
758
+ await next();
759
+ }
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
+ }
778
+ var oauthFinalizeInterceptor = {
779
+ pathPattern: /^\/_auth\/oauth\/finalize$/,
780
+ method: "POST",
781
+ response: async (ctx, next) => {
782
+ if (!ctx.response.ok) {
783
+ await next();
784
+ return;
785
+ }
786
+ const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
787
+ if (!pendingCookie) {
788
+ authLogger.interceptor.oauth?.warn?.("No pending session cookie found");
789
+ setFinalizeError(ctx, "OAuth session expired. Please try again.");
790
+ await next();
791
+ return;
792
+ }
793
+ try {
794
+ const pendingSession = await unsealPendingSession(pendingCookie);
795
+ const { userId, keyId } = ctx.response.body || {};
796
+ if (!userId || !keyId) {
797
+ authLogger.interceptor.oauth?.error?.("Missing userId or keyId in response");
798
+ setFinalizeError(ctx, "OAuth finalize failed: missing credentials");
799
+ await next();
800
+ return;
801
+ }
802
+ if (pendingSession.keyId !== keyId) {
803
+ authLogger.interceptor.oauth?.error?.("KeyId mismatch", {
804
+ expected: pendingSession.keyId,
805
+ received: keyId
806
+ });
807
+ setFinalizeError(ctx, "OAuth session mismatch. Please try again.");
808
+ await next();
809
+ return;
810
+ }
811
+ const ttl = getSessionTtl();
812
+ const sessionToken = await sealSession({
813
+ userId,
814
+ privateKey: pendingSession.privateKey,
815
+ keyId: pendingSession.keyId,
816
+ algorithm: pendingSession.algorithm
817
+ }, ttl);
818
+ ctx.setCookies.push({
819
+ name: COOKIE_NAMES.SESSION,
820
+ value: sessionToken,
821
+ options: {
822
+ httpOnly: true,
823
+ secure: cookieSecure,
824
+ sameSite: "lax",
825
+ maxAge: ttl,
826
+ path: "/"
827
+ }
828
+ });
829
+ ctx.setCookies.push({
830
+ name: COOKIE_NAMES.SESSION_KEY_ID,
831
+ value: keyId,
832
+ options: {
833
+ httpOnly: true,
834
+ secure: cookieSecure,
835
+ sameSite: "lax",
836
+ maxAge: ttl,
837
+ path: "/"
838
+ }
839
+ });
840
+ ctx.setCookies.push({
841
+ name: COOKIE_NAMES.OAUTH_PENDING,
842
+ value: "",
843
+ options: {
844
+ httpOnly: true,
845
+ secure: cookieSecure,
846
+ sameSite: "lax",
847
+ maxAge: 0,
848
+ path: "/"
849
+ }
850
+ });
851
+ authLogger.interceptor.oauth?.debug?.("OAuth session finalized", {
852
+ userId,
853
+ keyId
854
+ });
855
+ } catch (error) {
856
+ const err = error;
857
+ authLogger.interceptor.oauth?.error?.("Failed to finalize OAuth session", err);
858
+ setFinalizeError(ctx, err.message);
331
859
  }
332
860
  await next();
333
861
  }
@@ -337,6 +865,8 @@ var keyRotationInterceptor = {
337
865
  var authInterceptors = [
338
866
  loginRegisterInterceptor,
339
867
  keyRotationInterceptor,
868
+ oauthUrlInterceptor,
869
+ oauthFinalizeInterceptor,
340
870
  generalAuthInterceptor
341
871
  ];
342
872