@spfn/auth 0.2.0-beta.6 → 0.2.0-beta.61

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.
@@ -1,9 +1,265 @@
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
+ };
211
+ function parseDuration(duration) {
212
+ if (typeof duration === "number") {
213
+ return duration;
214
+ }
215
+ const match = duration.match(/^(\d+)([dhms]?)$/);
216
+ if (!match) {
217
+ throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);
218
+ }
219
+ const value = parseInt(match[1], 10);
220
+ const unit = match[2] || "s";
221
+ switch (unit) {
222
+ case "d":
223
+ return value * 24 * 60 * 60;
224
+ case "h":
225
+ return value * 60 * 60;
226
+ case "m":
227
+ return value * 60;
228
+ case "s":
229
+ return value;
230
+ default:
231
+ throw new Error(`Unknown duration unit: ${unit}`);
232
+ }
233
+ }
234
+ var globalConfig = {
235
+ sessionTtl: "7d"
236
+ // Default: 7 days
237
+ };
238
+ function getSessionTtl(override) {
239
+ if (override !== void 0) {
240
+ return parseDuration(override);
241
+ }
242
+ if (globalConfig.sessionTtl !== void 0) {
243
+ return parseDuration(globalConfig.sessionTtl);
244
+ }
245
+ const envTtl = env2.SPFN_AUTH_SESSION_TTL;
246
+ if (envTtl) {
247
+ return parseDuration(envTtl);
248
+ }
249
+ return 7 * 24 * 60 * 60;
250
+ }
251
+
252
+ // src/nextjs/interceptors/cookie-options.ts
253
+ function resolveSecure() {
254
+ const override = process.env.SPFN_AUTH_COOKIE_SECURE;
255
+ if (override !== void 0) {
256
+ return override === "true";
257
+ }
258
+ return process.env.NODE_ENV === "production";
259
+ }
260
+ var cookieSecure = resolveSecure();
261
+
4
262
  // 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
263
  var loginRegisterInterceptor = {
8
264
  pathPattern: /^\/_auth\/(login|register)$/,
9
265
  method: "POST",
@@ -54,7 +310,7 @@ var loginRegisterInterceptor = {
54
310
  value: sealed,
55
311
  options: {
56
312
  httpOnly: true,
57
- secure: env.NODE_ENV === "production",
313
+ secure: cookieSecure,
58
314
  sameSite: "strict",
59
315
  maxAge: ttl,
60
316
  path: "/"
@@ -65,7 +321,7 @@ var loginRegisterInterceptor = {
65
321
  value: ctx.metadata.keyId,
66
322
  options: {
67
323
  httpOnly: true,
68
- secure: env.NODE_ENV === "production",
324
+ secure: cookieSecure,
69
325
  sameSite: "strict",
70
326
  maxAge: ttl,
71
327
  path: "/"
@@ -80,8 +336,6 @@ var loginRegisterInterceptor = {
80
336
  };
81
337
 
82
338
  // 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
339
  function requiresAuth(path) {
86
340
  const publicPaths = [
87
341
  /^\/_auth\/login$/,
@@ -101,37 +355,39 @@ var generalAuthInterceptor = {
101
355
  method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
102
356
  request: async (ctx, next) => {
103
357
  if (!requiresAuth(ctx.path)) {
104
- authLogger2.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
358
+ authLogger.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
105
359
  await next();
106
360
  return;
107
361
  }
108
362
  const cookieNames = Array.from(ctx.cookies.keys());
109
- authLogger2.interceptor.general.debug("Available cookies:", {
363
+ authLogger.interceptor.general.debug("Available cookies:", {
110
364
  cookieNames,
111
365
  totalCount: cookieNames.length,
112
- lookingFor: COOKIE_NAMES2.SESSION
366
+ lookingFor: COOKIE_NAMES.SESSION
113
367
  });
114
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES2.SESSION);
115
- authLogger2.interceptor.general.debug("Request", {
368
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
369
+ authLogger.interceptor.general.debug("Request", {
116
370
  method: ctx.method,
117
371
  path: ctx.path,
118
372
  hasSession: !!sessionCookie,
119
- sessionCookieValue: sessionCookie ? "***EXISTS***" : "NOT_FOUND"
373
+ sessionLength: sessionCookie?.length ?? 0,
374
+ sessionPrefix: sessionCookie?.slice(0, 20) ?? "",
375
+ sessionSuffix: sessionCookie?.slice(-10) ?? ""
120
376
  });
121
377
  if (!sessionCookie) {
122
- authLogger2.interceptor.general.debug("No session cookie, proceeding without auth");
378
+ authLogger.interceptor.general.debug("No session cookie, proceeding without auth");
123
379
  await next();
124
380
  return;
125
381
  }
126
382
  try {
127
383
  const session = await unsealSession(sessionCookie);
128
- authLogger2.interceptor.general.debug("Session valid", {
384
+ authLogger.interceptor.general.debug("Session valid", {
129
385
  userId: session.userId,
130
386
  keyId: session.keyId
131
387
  });
132
388
  const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
133
389
  if (needsRefresh) {
134
- authLogger2.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
390
+ authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
135
391
  ctx.metadata.refreshSession = true;
136
392
  ctx.metadata.sessionData = session;
137
393
  }
@@ -145,28 +401,49 @@ var generalAuthInterceptor = {
145
401
  session.algorithm,
146
402
  { expiresIn: "15m" }
147
403
  );
148
- authLogger2.interceptor.general.debug("Generated JWT token (expires in 15m)");
404
+ authLogger.interceptor.general.debug("Generated JWT token (expires in 15m)");
149
405
  ctx.headers["Authorization"] = `Bearer ${token}`;
150
406
  ctx.headers["X-Key-Id"] = session.keyId;
151
407
  ctx.metadata.userId = session.userId;
152
408
  ctx.metadata.sessionValid = true;
153
409
  } catch (error) {
154
410
  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");
411
+ const msg = err.message.toLowerCase();
412
+ if (msg.includes("expired") || msg.includes("invalid")) {
413
+ authLogger.interceptor.general.warn("Session expired or invalid", {
414
+ message: err.message,
415
+ cookieLength: sessionCookie.length,
416
+ cookiePrefix: sessionCookie.slice(0, 20),
417
+ cookieSuffix: sessionCookie.slice(-10)
418
+ });
419
+ authLogger.interceptor.general.debug("Marking session for cleanup");
158
420
  ctx.metadata.clearSession = true;
159
421
  ctx.metadata.sessionValid = false;
160
422
  } else {
161
- authLogger2.interceptor.general.error("Failed to process session", err);
423
+ authLogger.interceptor.general.error("Failed to process session", err);
162
424
  }
163
425
  }
164
426
  await next();
165
427
  },
166
428
  response: async (ctx, next) => {
429
+ if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
430
+ authLogger.interceptor.general.warn("Backend returned 401, clearing session");
431
+ ctx.setCookies.push({
432
+ name: COOKIE_NAMES.SESSION,
433
+ value: "",
434
+ options: { maxAge: 0, path: "/" }
435
+ });
436
+ ctx.setCookies.push({
437
+ name: COOKIE_NAMES.SESSION_KEY_ID,
438
+ value: "",
439
+ options: { maxAge: 0, path: "/" }
440
+ });
441
+ await next();
442
+ return;
443
+ }
167
444
  if (ctx.metadata.clearSession) {
168
445
  ctx.setCookies.push({
169
- name: COOKIE_NAMES2.SESSION,
446
+ name: COOKIE_NAMES.SESSION,
170
447
  value: "",
171
448
  options: {
172
449
  maxAge: 0,
@@ -174,7 +451,7 @@ var generalAuthInterceptor = {
174
451
  }
175
452
  });
176
453
  ctx.setCookies.push({
177
- name: COOKIE_NAMES2.SESSION_KEY_ID,
454
+ name: COOKIE_NAMES.SESSION_KEY_ID,
178
455
  value: "",
179
456
  options: {
180
457
  maxAge: 0,
@@ -184,51 +461,60 @@ var generalAuthInterceptor = {
184
461
  } else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
185
462
  try {
186
463
  const sessionData = ctx.metadata.sessionData;
187
- const ttl = getSessionTtl2();
188
- const sealed = await sealSession2(sessionData, ttl);
464
+ const ttl = getSessionTtl();
465
+ const sealed = await sealSession(sessionData, ttl);
189
466
  ctx.setCookies.push({
190
- name: COOKIE_NAMES2.SESSION,
467
+ name: COOKIE_NAMES.SESSION,
191
468
  value: sealed,
192
469
  options: {
193
470
  httpOnly: true,
194
- secure: env2.NODE_ENV === "production",
471
+ secure: cookieSecure,
195
472
  sameSite: "strict",
196
473
  maxAge: ttl,
197
474
  path: "/"
198
475
  }
199
476
  });
200
477
  ctx.setCookies.push({
201
- name: COOKIE_NAMES2.SESSION_KEY_ID,
478
+ name: COOKIE_NAMES.SESSION_KEY_ID,
202
479
  value: sessionData.keyId,
203
480
  options: {
204
481
  httpOnly: true,
205
- secure: process.env.NODE_ENV === "production",
482
+ secure: cookieSecure,
206
483
  sameSite: "strict",
207
484
  maxAge: ttl,
208
485
  path: "/"
209
486
  }
210
487
  });
211
- authLogger2.interceptor.general.info("Session refreshed", { userId: sessionData.userId });
488
+ authLogger.interceptor.general.info("Session refreshed", {
489
+ userId: sessionData.userId,
490
+ sealedLength: sealed.length,
491
+ sealedPrefix: sealed.slice(0, 20)
492
+ });
212
493
  } catch (error) {
213
494
  const err = error;
214
- authLogger2.interceptor.general.error("Failed to refresh session", err);
495
+ authLogger.interceptor.general.error("Failed to refresh session", err);
215
496
  }
216
- } else if (ctx.path === "/_auth/logout" && ctx.response.status === 200) {
497
+ } else if (ctx.path === "/_auth/logout" && ctx.response.ok) {
498
+ const base = {
499
+ httpOnly: true,
500
+ secure: cookieSecure,
501
+ maxAge: 0,
502
+ path: "/"
503
+ };
217
504
  ctx.setCookies.push({
218
- name: COOKIE_NAMES2.SESSION,
505
+ name: COOKIE_NAMES.SESSION,
219
506
  value: "",
220
- options: {
221
- maxAge: 0,
222
- path: "/"
223
- }
507
+ options: { ...base, sameSite: "strict" }
508
+ });
509
+ ctx.setCookies.push({
510
+ name: COOKIE_NAMES.SESSION_KEY_ID,
511
+ value: "",
512
+ options: { ...base, sameSite: "strict" }
224
513
  });
225
514
  ctx.setCookies.push({
226
- name: COOKIE_NAMES2.SESSION_KEY_ID,
515
+ name: COOKIE_NAMES.OAUTH_PENDING,
227
516
  value: "",
228
- options: {
229
- maxAge: 0,
230
- path: "/"
231
- }
517
+ options: { ...base, sameSite: "lax" }
232
518
  });
233
519
  }
234
520
  await next();
@@ -236,19 +522,18 @@ var generalAuthInterceptor = {
236
522
  };
237
523
 
238
524
  // 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
525
  var keyRotationInterceptor = {
241
526
  pathPattern: "/_auth/keys/rotate",
242
527
  method: "POST",
243
528
  request: async (ctx, next) => {
244
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES3.SESSION);
529
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
245
530
  if (!sessionCookie) {
246
531
  await next();
247
532
  return;
248
533
  }
249
534
  try {
250
- const currentSession = await unsealSession2(sessionCookie);
251
- const newKeyPair = generateKeyPair2("ES256");
535
+ const currentSession = await unsealSession(sessionCookie);
536
+ const newKeyPair = generateKeyPair("ES256");
252
537
  if (!ctx.body) {
253
538
  ctx.body = {};
254
539
  }
@@ -261,7 +546,7 @@ var keyRotationInterceptor = {
261
546
  console.log("publicKey:", newKeyPair.publicKey);
262
547
  console.log("keyId:", newKeyPair.keyId);
263
548
  console.log("fingerprint:", newKeyPair.fingerprint);
264
- const token = generateClientToken2(
549
+ const token = generateClientToken(
265
550
  {
266
551
  userId: currentSession.userId,
267
552
  keyId: currentSession.keyId,
@@ -280,7 +565,7 @@ var keyRotationInterceptor = {
280
565
  ctx.metadata.userId = currentSession.userId;
281
566
  } catch (error) {
282
567
  const err = error;
283
- authLogger3.interceptor.keyRotation.error("Failed to prepare key rotation", err);
568
+ authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
284
569
  }
285
570
  await next();
286
571
  },
@@ -290,36 +575,36 @@ var keyRotationInterceptor = {
290
575
  return;
291
576
  }
292
577
  if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId) {
293
- authLogger3.interceptor.keyRotation.error("Missing key rotation metadata");
578
+ authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
294
579
  await next();
295
580
  return;
296
581
  }
297
582
  try {
298
- const ttl = getSessionTtl3();
583
+ const ttl = getSessionTtl();
299
584
  const newSessionData = {
300
585
  userId: ctx.metadata.userId,
301
586
  privateKey: ctx.metadata.newPrivateKey,
302
587
  keyId: ctx.metadata.newKeyId,
303
588
  algorithm: ctx.metadata.newAlgorithm
304
589
  };
305
- const sealed = await sealSession3(newSessionData, ttl);
590
+ const sealed = await sealSession(newSessionData, ttl);
306
591
  ctx.setCookies.push({
307
- name: COOKIE_NAMES3.SESSION,
592
+ name: COOKIE_NAMES.SESSION,
308
593
  value: sealed,
309
594
  options: {
310
595
  httpOnly: true,
311
- secure: process.env.NODE_ENV === "production",
596
+ secure: cookieSecure,
312
597
  sameSite: "strict",
313
598
  maxAge: ttl,
314
599
  path: "/"
315
600
  }
316
601
  });
317
602
  ctx.setCookies.push({
318
- name: COOKIE_NAMES3.SESSION_KEY_ID,
603
+ name: COOKIE_NAMES.SESSION_KEY_ID,
319
604
  value: ctx.metadata.newKeyId,
320
605
  options: {
321
606
  httpOnly: true,
322
- secure: process.env.NODE_ENV === "production",
607
+ secure: cookieSecure,
323
608
  sameSite: "strict",
324
609
  maxAge: ttl,
325
610
  path: "/"
@@ -327,7 +612,225 @@ var keyRotationInterceptor = {
327
612
  });
328
613
  } catch (error) {
329
614
  const err = error;
330
- authLogger3.interceptor.keyRotation.error("Failed to update session after rotation", err);
615
+ authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
616
+ }
617
+ await next();
618
+ }
619
+ };
620
+
621
+ // src/server/lib/oauth/state.ts
622
+ import * as jose2 from "jose";
623
+ import { env as env3 } from "@spfn/auth/config";
624
+ async function getStateKey() {
625
+ const secret = env3.SPFN_AUTH_SESSION_SECRET;
626
+ const encoder = new TextEncoder();
627
+ const data = encoder.encode(`oauth-state:${secret}`);
628
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
629
+ return new Uint8Array(hashBuffer);
630
+ }
631
+ function generateNonce() {
632
+ const array = new Uint8Array(16);
633
+ crypto.getRandomValues(array);
634
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
635
+ }
636
+ async function createOAuthState(params) {
637
+ const key = await getStateKey();
638
+ const state = {
639
+ returnUrl: params.returnUrl,
640
+ nonce: generateNonce(),
641
+ provider: params.provider,
642
+ publicKey: params.publicKey,
643
+ keyId: params.keyId,
644
+ fingerprint: params.fingerprint,
645
+ algorithm: params.algorithm,
646
+ metadata: params.metadata
647
+ };
648
+ const jwe = await new jose2.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
649
+ return encodeURIComponent(jwe);
650
+ }
651
+
652
+ // src/nextjs/session-helpers.ts
653
+ import * as jose3 from "jose";
654
+ import { cookies } from "next/headers.js";
655
+ import { env as env4 } from "@spfn/auth/config";
656
+ import { logger } from "@spfn/core/logger";
657
+ async function getPendingSessionKey() {
658
+ const secret = env4.SPFN_AUTH_SESSION_SECRET;
659
+ const encoder = new TextEncoder();
660
+ const data = encoder.encode(`oauth-pending:${secret}`);
661
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
662
+ return new Uint8Array(hashBuffer);
663
+ }
664
+ async function sealPendingSession(data, ttl = 600) {
665
+ const key = await getPendingSessionKey();
666
+ return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
667
+ }
668
+ async function unsealPendingSession(jwt2) {
669
+ const key = await getPendingSessionKey();
670
+ const { payload } = await jose3.jwtDecrypt(jwt2, key, {
671
+ issuer: "spfn-auth",
672
+ audience: "spfn-oauth"
673
+ });
674
+ return payload.data;
675
+ }
676
+
677
+ // src/nextjs/interceptors/oauth.ts
678
+ var oauthUrlInterceptor = {
679
+ pathPattern: /^\/_auth\/oauth\/\w+\/url$/,
680
+ method: "POST",
681
+ request: async (ctx, next) => {
682
+ const provider = ctx.path.split("/")[3];
683
+ const returnUrl = ctx.body?.returnUrl || "/";
684
+ const keyPair = generateKeyPair("ES256");
685
+ const state = await createOAuthState({
686
+ provider,
687
+ returnUrl,
688
+ publicKey: keyPair.publicKey,
689
+ keyId: keyPair.keyId,
690
+ fingerprint: keyPair.fingerprint,
691
+ algorithm: keyPair.algorithm
692
+ });
693
+ if (!ctx.body) {
694
+ ctx.body = {};
695
+ }
696
+ ctx.body.state = state;
697
+ ctx.metadata.pendingSession = {
698
+ privateKey: keyPair.privateKey,
699
+ keyId: keyPair.keyId,
700
+ algorithm: keyPair.algorithm
701
+ };
702
+ authLogger.interceptor.oauth?.debug?.("OAuth state created", {
703
+ provider,
704
+ keyId: keyPair.keyId
705
+ });
706
+ await next();
707
+ },
708
+ response: async (ctx, next) => {
709
+ if (ctx.response.ok && ctx.metadata.pendingSession) {
710
+ try {
711
+ const sealed = await sealPendingSession(ctx.metadata.pendingSession);
712
+ ctx.setCookies.push({
713
+ name: COOKIE_NAMES.OAUTH_PENDING,
714
+ value: sealed,
715
+ options: {
716
+ httpOnly: true,
717
+ secure: cookieSecure,
718
+ sameSite: "lax",
719
+ // OAuth 리다이렉트 허용
720
+ maxAge: 600,
721
+ // 10분
722
+ path: "/"
723
+ }
724
+ });
725
+ authLogger.interceptor.oauth?.debug?.("Pending session cookie set", {
726
+ keyId: ctx.metadata.pendingSession.keyId
727
+ });
728
+ } catch (error) {
729
+ const err = error;
730
+ authLogger.interceptor.oauth?.error?.("Failed to set pending session", err);
731
+ }
732
+ }
733
+ await next();
734
+ }
735
+ };
736
+ function setFinalizeError(ctx, message) {
737
+ ctx.response.ok = false;
738
+ ctx.response.status = 401;
739
+ ctx.response.statusText = "Unauthorized";
740
+ ctx.response.body = { success: false, message };
741
+ ctx.setCookies.push({
742
+ name: COOKIE_NAMES.OAUTH_PENDING,
743
+ value: "",
744
+ options: {
745
+ httpOnly: true,
746
+ secure: cookieSecure,
747
+ sameSite: "lax",
748
+ maxAge: 0,
749
+ path: "/"
750
+ }
751
+ });
752
+ }
753
+ var oauthFinalizeInterceptor = {
754
+ pathPattern: /^\/_auth\/oauth\/finalize$/,
755
+ method: "POST",
756
+ response: async (ctx, next) => {
757
+ if (!ctx.response.ok) {
758
+ await next();
759
+ return;
760
+ }
761
+ const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
762
+ if (!pendingCookie) {
763
+ authLogger.interceptor.oauth?.warn?.("No pending session cookie found");
764
+ setFinalizeError(ctx, "OAuth session expired. Please try again.");
765
+ await next();
766
+ return;
767
+ }
768
+ try {
769
+ const pendingSession = await unsealPendingSession(pendingCookie);
770
+ const { userId, keyId } = ctx.response.body || {};
771
+ if (!userId || !keyId) {
772
+ authLogger.interceptor.oauth?.error?.("Missing userId or keyId in response");
773
+ setFinalizeError(ctx, "OAuth finalize failed: missing credentials");
774
+ await next();
775
+ return;
776
+ }
777
+ if (pendingSession.keyId !== keyId) {
778
+ authLogger.interceptor.oauth?.error?.("KeyId mismatch", {
779
+ expected: pendingSession.keyId,
780
+ received: keyId
781
+ });
782
+ setFinalizeError(ctx, "OAuth session mismatch. Please try again.");
783
+ await next();
784
+ return;
785
+ }
786
+ const ttl = getSessionTtl();
787
+ const sessionToken = await sealSession({
788
+ userId,
789
+ privateKey: pendingSession.privateKey,
790
+ keyId: pendingSession.keyId,
791
+ algorithm: pendingSession.algorithm
792
+ }, ttl);
793
+ ctx.setCookies.push({
794
+ name: COOKIE_NAMES.SESSION,
795
+ value: sessionToken,
796
+ options: {
797
+ httpOnly: true,
798
+ secure: cookieSecure,
799
+ sameSite: "strict",
800
+ maxAge: ttl,
801
+ path: "/"
802
+ }
803
+ });
804
+ ctx.setCookies.push({
805
+ name: COOKIE_NAMES.SESSION_KEY_ID,
806
+ value: keyId,
807
+ options: {
808
+ httpOnly: true,
809
+ secure: cookieSecure,
810
+ sameSite: "strict",
811
+ maxAge: ttl,
812
+ path: "/"
813
+ }
814
+ });
815
+ ctx.setCookies.push({
816
+ name: COOKIE_NAMES.OAUTH_PENDING,
817
+ value: "",
818
+ options: {
819
+ httpOnly: true,
820
+ secure: cookieSecure,
821
+ sameSite: "lax",
822
+ maxAge: 0,
823
+ path: "/"
824
+ }
825
+ });
826
+ authLogger.interceptor.oauth?.debug?.("OAuth session finalized", {
827
+ userId,
828
+ keyId
829
+ });
830
+ } catch (error) {
831
+ const err = error;
832
+ authLogger.interceptor.oauth?.error?.("Failed to finalize OAuth session", err);
833
+ setFinalizeError(ctx, err.message);
331
834
  }
332
835
  await next();
333
836
  }
@@ -337,6 +840,8 @@ var keyRotationInterceptor = {
337
840
  var authInterceptors = [
338
841
  loginRegisterInterceptor,
339
842
  keyRotationInterceptor,
843
+ oauthUrlInterceptor,
844
+ oauthFinalizeInterceptor,
340
845
  generalAuthInterceptor
341
846
  ];
342
847