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

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,38 +461,42 @@ 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) {
217
498
  ctx.setCookies.push({
218
- name: COOKIE_NAMES2.SESSION,
499
+ name: COOKIE_NAMES.SESSION,
219
500
  value: "",
220
501
  options: {
221
502
  maxAge: 0,
@@ -223,7 +504,7 @@ var generalAuthInterceptor = {
223
504
  }
224
505
  });
225
506
  ctx.setCookies.push({
226
- name: COOKIE_NAMES2.SESSION_KEY_ID,
507
+ name: COOKIE_NAMES.SESSION_KEY_ID,
227
508
  value: "",
228
509
  options: {
229
510
  maxAge: 0,
@@ -236,19 +517,18 @@ var generalAuthInterceptor = {
236
517
  };
237
518
 
238
519
  // 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
520
  var keyRotationInterceptor = {
241
521
  pathPattern: "/_auth/keys/rotate",
242
522
  method: "POST",
243
523
  request: async (ctx, next) => {
244
- const sessionCookie = ctx.cookies.get(COOKIE_NAMES3.SESSION);
524
+ const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
245
525
  if (!sessionCookie) {
246
526
  await next();
247
527
  return;
248
528
  }
249
529
  try {
250
- const currentSession = await unsealSession2(sessionCookie);
251
- const newKeyPair = generateKeyPair2("ES256");
530
+ const currentSession = await unsealSession(sessionCookie);
531
+ const newKeyPair = generateKeyPair("ES256");
252
532
  if (!ctx.body) {
253
533
  ctx.body = {};
254
534
  }
@@ -261,7 +541,7 @@ var keyRotationInterceptor = {
261
541
  console.log("publicKey:", newKeyPair.publicKey);
262
542
  console.log("keyId:", newKeyPair.keyId);
263
543
  console.log("fingerprint:", newKeyPair.fingerprint);
264
- const token = generateClientToken2(
544
+ const token = generateClientToken(
265
545
  {
266
546
  userId: currentSession.userId,
267
547
  keyId: currentSession.keyId,
@@ -280,7 +560,7 @@ var keyRotationInterceptor = {
280
560
  ctx.metadata.userId = currentSession.userId;
281
561
  } catch (error) {
282
562
  const err = error;
283
- authLogger3.interceptor.keyRotation.error("Failed to prepare key rotation", err);
563
+ authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
284
564
  }
285
565
  await next();
286
566
  },
@@ -290,44 +570,262 @@ var keyRotationInterceptor = {
290
570
  return;
291
571
  }
292
572
  if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId) {
293
- authLogger3.interceptor.keyRotation.error("Missing key rotation metadata");
573
+ authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
294
574
  await next();
295
575
  return;
296
576
  }
297
577
  try {
298
- const ttl = getSessionTtl3();
578
+ const ttl = getSessionTtl();
299
579
  const newSessionData = {
300
580
  userId: ctx.metadata.userId,
301
581
  privateKey: ctx.metadata.newPrivateKey,
302
582
  keyId: ctx.metadata.newKeyId,
303
583
  algorithm: ctx.metadata.newAlgorithm
304
584
  };
305
- const sealed = await sealSession3(newSessionData, ttl);
585
+ const sealed = await sealSession(newSessionData, ttl);
306
586
  ctx.setCookies.push({
307
- name: COOKIE_NAMES3.SESSION,
587
+ name: COOKIE_NAMES.SESSION,
308
588
  value: sealed,
309
589
  options: {
310
590
  httpOnly: true,
311
- secure: process.env.NODE_ENV === "production",
591
+ secure: cookieSecure,
312
592
  sameSite: "strict",
313
593
  maxAge: ttl,
314
594
  path: "/"
315
595
  }
316
596
  });
317
597
  ctx.setCookies.push({
318
- name: COOKIE_NAMES3.SESSION_KEY_ID,
598
+ name: COOKIE_NAMES.SESSION_KEY_ID,
319
599
  value: ctx.metadata.newKeyId,
320
600
  options: {
321
601
  httpOnly: true,
322
- secure: process.env.NODE_ENV === "production",
602
+ secure: cookieSecure,
603
+ sameSite: "strict",
604
+ maxAge: ttl,
605
+ path: "/"
606
+ }
607
+ });
608
+ } catch (error) {
609
+ const err = error;
610
+ authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
611
+ }
612
+ await next();
613
+ }
614
+ };
615
+
616
+ // src/server/lib/oauth/state.ts
617
+ import * as jose2 from "jose";
618
+ import { env as env3 } from "@spfn/auth/config";
619
+ async function getStateKey() {
620
+ const secret = env3.SPFN_AUTH_SESSION_SECRET;
621
+ const encoder = new TextEncoder();
622
+ const data = encoder.encode(`oauth-state:${secret}`);
623
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
624
+ return new Uint8Array(hashBuffer);
625
+ }
626
+ function generateNonce() {
627
+ const array = new Uint8Array(16);
628
+ crypto.getRandomValues(array);
629
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
630
+ }
631
+ async function createOAuthState(params) {
632
+ const key = await getStateKey();
633
+ const state = {
634
+ returnUrl: params.returnUrl,
635
+ nonce: generateNonce(),
636
+ provider: params.provider,
637
+ publicKey: params.publicKey,
638
+ keyId: params.keyId,
639
+ fingerprint: params.fingerprint,
640
+ algorithm: params.algorithm,
641
+ metadata: params.metadata
642
+ };
643
+ const jwe = await new jose2.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
644
+ return encodeURIComponent(jwe);
645
+ }
646
+
647
+ // src/nextjs/session-helpers.ts
648
+ import * as jose3 from "jose";
649
+ import { cookies } from "next/headers.js";
650
+ import { env as env4 } from "@spfn/auth/config";
651
+ import { logger } from "@spfn/core/logger";
652
+ async function getPendingSessionKey() {
653
+ const secret = env4.SPFN_AUTH_SESSION_SECRET;
654
+ const encoder = new TextEncoder();
655
+ const data = encoder.encode(`oauth-pending:${secret}`);
656
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
657
+ return new Uint8Array(hashBuffer);
658
+ }
659
+ async function sealPendingSession(data, ttl = 600) {
660
+ const key = await getPendingSessionKey();
661
+ return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
662
+ }
663
+ async function unsealPendingSession(jwt2) {
664
+ const key = await getPendingSessionKey();
665
+ const { payload } = await jose3.jwtDecrypt(jwt2, key, {
666
+ issuer: "spfn-auth",
667
+ audience: "spfn-oauth"
668
+ });
669
+ return payload.data;
670
+ }
671
+
672
+ // src/nextjs/interceptors/oauth.ts
673
+ var oauthUrlInterceptor = {
674
+ pathPattern: /^\/_auth\/oauth\/\w+\/url$/,
675
+ method: "POST",
676
+ request: async (ctx, next) => {
677
+ const provider = ctx.path.split("/")[3];
678
+ const returnUrl = ctx.body?.returnUrl || "/";
679
+ const keyPair = generateKeyPair("ES256");
680
+ const state = await createOAuthState({
681
+ provider,
682
+ returnUrl,
683
+ publicKey: keyPair.publicKey,
684
+ keyId: keyPair.keyId,
685
+ fingerprint: keyPair.fingerprint,
686
+ algorithm: keyPair.algorithm
687
+ });
688
+ if (!ctx.body) {
689
+ ctx.body = {};
690
+ }
691
+ ctx.body.state = state;
692
+ ctx.metadata.pendingSession = {
693
+ privateKey: keyPair.privateKey,
694
+ keyId: keyPair.keyId,
695
+ algorithm: keyPair.algorithm
696
+ };
697
+ authLogger.interceptor.oauth?.debug?.("OAuth state created", {
698
+ provider,
699
+ keyId: keyPair.keyId
700
+ });
701
+ await next();
702
+ },
703
+ response: async (ctx, next) => {
704
+ if (ctx.response.ok && ctx.metadata.pendingSession) {
705
+ try {
706
+ const sealed = await sealPendingSession(ctx.metadata.pendingSession);
707
+ ctx.setCookies.push({
708
+ name: COOKIE_NAMES.OAUTH_PENDING,
709
+ value: sealed,
710
+ options: {
711
+ httpOnly: true,
712
+ secure: cookieSecure,
713
+ sameSite: "lax",
714
+ // OAuth 리다이렉트 허용
715
+ maxAge: 600,
716
+ // 10분
717
+ path: "/"
718
+ }
719
+ });
720
+ authLogger.interceptor.oauth?.debug?.("Pending session cookie set", {
721
+ keyId: ctx.metadata.pendingSession.keyId
722
+ });
723
+ } catch (error) {
724
+ const err = error;
725
+ authLogger.interceptor.oauth?.error?.("Failed to set pending session", err);
726
+ }
727
+ }
728
+ await next();
729
+ }
730
+ };
731
+ function setFinalizeError(ctx, message) {
732
+ ctx.response.ok = false;
733
+ ctx.response.status = 401;
734
+ ctx.response.statusText = "Unauthorized";
735
+ ctx.response.body = { success: false, message };
736
+ ctx.setCookies.push({
737
+ name: COOKIE_NAMES.OAUTH_PENDING,
738
+ value: "",
739
+ options: {
740
+ httpOnly: true,
741
+ secure: cookieSecure,
742
+ sameSite: "lax",
743
+ maxAge: 0,
744
+ path: "/"
745
+ }
746
+ });
747
+ }
748
+ var oauthFinalizeInterceptor = {
749
+ pathPattern: /^\/_auth\/oauth\/finalize$/,
750
+ method: "POST",
751
+ response: async (ctx, next) => {
752
+ if (!ctx.response.ok) {
753
+ await next();
754
+ return;
755
+ }
756
+ const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
757
+ if (!pendingCookie) {
758
+ authLogger.interceptor.oauth?.warn?.("No pending session cookie found");
759
+ setFinalizeError(ctx, "OAuth session expired. Please try again.");
760
+ await next();
761
+ return;
762
+ }
763
+ try {
764
+ const pendingSession = await unsealPendingSession(pendingCookie);
765
+ const { userId, keyId } = ctx.response.body || {};
766
+ if (!userId || !keyId) {
767
+ authLogger.interceptor.oauth?.error?.("Missing userId or keyId in response");
768
+ setFinalizeError(ctx, "OAuth finalize failed: missing credentials");
769
+ await next();
770
+ return;
771
+ }
772
+ if (pendingSession.keyId !== keyId) {
773
+ authLogger.interceptor.oauth?.error?.("KeyId mismatch", {
774
+ expected: pendingSession.keyId,
775
+ received: keyId
776
+ });
777
+ setFinalizeError(ctx, "OAuth session mismatch. Please try again.");
778
+ await next();
779
+ return;
780
+ }
781
+ const ttl = getSessionTtl();
782
+ const sessionToken = await sealSession({
783
+ userId,
784
+ privateKey: pendingSession.privateKey,
785
+ keyId: pendingSession.keyId,
786
+ algorithm: pendingSession.algorithm
787
+ }, ttl);
788
+ ctx.setCookies.push({
789
+ name: COOKIE_NAMES.SESSION,
790
+ value: sessionToken,
791
+ options: {
792
+ httpOnly: true,
793
+ secure: cookieSecure,
794
+ sameSite: "strict",
795
+ maxAge: ttl,
796
+ path: "/"
797
+ }
798
+ });
799
+ ctx.setCookies.push({
800
+ name: COOKIE_NAMES.SESSION_KEY_ID,
801
+ value: keyId,
802
+ options: {
803
+ httpOnly: true,
804
+ secure: cookieSecure,
323
805
  sameSite: "strict",
324
806
  maxAge: ttl,
325
807
  path: "/"
326
808
  }
327
809
  });
810
+ ctx.setCookies.push({
811
+ name: COOKIE_NAMES.OAUTH_PENDING,
812
+ value: "",
813
+ options: {
814
+ httpOnly: true,
815
+ secure: cookieSecure,
816
+ sameSite: "lax",
817
+ maxAge: 0,
818
+ path: "/"
819
+ }
820
+ });
821
+ authLogger.interceptor.oauth?.debug?.("OAuth session finalized", {
822
+ userId,
823
+ keyId
824
+ });
328
825
  } catch (error) {
329
826
  const err = error;
330
- authLogger3.interceptor.keyRotation.error("Failed to update session after rotation", err);
827
+ authLogger.interceptor.oauth?.error?.("Failed to finalize OAuth session", err);
828
+ setFinalizeError(ctx, err.message);
331
829
  }
332
830
  await next();
333
831
  }
@@ -337,6 +835,8 @@ var keyRotationInterceptor = {
337
835
  var authInterceptors = [
338
836
  loginRegisterInterceptor,
339
837
  keyRotationInterceptor,
838
+ oauthUrlInterceptor,
839
+ oauthFinalizeInterceptor,
340
840
  generalAuthInterceptor
341
841
  ];
342
842