@spfn/auth 0.2.0-beta.59 → 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.
- package/dist/{authenticate-CZW-7GE2.d.ts → authenticate-B_HkYBzq.d.ts} +3 -46
- package/dist/index.d.ts +4 -3
- package/dist/nextjs/api.js +336 -79
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +4 -4
- package/dist/nextjs/server.js +157 -22
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +59 -106
- package/dist/session-Dbvz9Sdp.d.ts +53 -0
- package/dist/types-B1CzVZkU.d.ts +45 -0
- package/package.json +4 -4
package/dist/nextjs/api.js
CHANGED
|
@@ -1,8 +1,253 @@
|
|
|
1
1
|
// src/nextjs/api.ts
|
|
2
2
|
import { registerInterceptors } from "@spfn/core/nextjs/server";
|
|
3
3
|
|
|
4
|
-
// src/
|
|
5
|
-
import
|
|
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
|
+
}
|
|
6
251
|
|
|
7
252
|
// src/nextjs/interceptors/cookie-options.ts
|
|
8
253
|
function resolveSecure() {
|
|
@@ -91,7 +336,6 @@ var loginRegisterInterceptor = {
|
|
|
91
336
|
};
|
|
92
337
|
|
|
93
338
|
// 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
339
|
function requiresAuth(path) {
|
|
96
340
|
const publicPaths = [
|
|
97
341
|
/^\/_auth\/login$/,
|
|
@@ -111,18 +355,18 @@ var generalAuthInterceptor = {
|
|
|
111
355
|
method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
112
356
|
request: async (ctx, next) => {
|
|
113
357
|
if (!requiresAuth(ctx.path)) {
|
|
114
|
-
|
|
358
|
+
authLogger.interceptor.general.debug(`Public path, skipping auth: ${ctx.path}`);
|
|
115
359
|
await next();
|
|
116
360
|
return;
|
|
117
361
|
}
|
|
118
362
|
const cookieNames = Array.from(ctx.cookies.keys());
|
|
119
|
-
|
|
363
|
+
authLogger.interceptor.general.debug("Available cookies:", {
|
|
120
364
|
cookieNames,
|
|
121
365
|
totalCount: cookieNames.length,
|
|
122
|
-
lookingFor:
|
|
366
|
+
lookingFor: COOKIE_NAMES.SESSION
|
|
123
367
|
});
|
|
124
|
-
const sessionCookie = ctx.cookies.get(
|
|
125
|
-
|
|
368
|
+
const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
|
|
369
|
+
authLogger.interceptor.general.debug("Request", {
|
|
126
370
|
method: ctx.method,
|
|
127
371
|
path: ctx.path,
|
|
128
372
|
hasSession: !!sessionCookie,
|
|
@@ -131,19 +375,19 @@ var generalAuthInterceptor = {
|
|
|
131
375
|
sessionSuffix: sessionCookie?.slice(-10) ?? ""
|
|
132
376
|
});
|
|
133
377
|
if (!sessionCookie) {
|
|
134
|
-
|
|
378
|
+
authLogger.interceptor.general.debug("No session cookie, proceeding without auth");
|
|
135
379
|
await next();
|
|
136
380
|
return;
|
|
137
381
|
}
|
|
138
382
|
try {
|
|
139
383
|
const session = await unsealSession(sessionCookie);
|
|
140
|
-
|
|
384
|
+
authLogger.interceptor.general.debug("Session valid", {
|
|
141
385
|
userId: session.userId,
|
|
142
386
|
keyId: session.keyId
|
|
143
387
|
});
|
|
144
388
|
const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
|
|
145
389
|
if (needsRefresh) {
|
|
146
|
-
|
|
390
|
+
authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
|
|
147
391
|
ctx.metadata.refreshSession = true;
|
|
148
392
|
ctx.metadata.sessionData = session;
|
|
149
393
|
}
|
|
@@ -157,7 +401,7 @@ var generalAuthInterceptor = {
|
|
|
157
401
|
session.algorithm,
|
|
158
402
|
{ expiresIn: "15m" }
|
|
159
403
|
);
|
|
160
|
-
|
|
404
|
+
authLogger.interceptor.general.debug("Generated JWT token (expires in 15m)");
|
|
161
405
|
ctx.headers["Authorization"] = `Bearer ${token}`;
|
|
162
406
|
ctx.headers["X-Key-Id"] = session.keyId;
|
|
163
407
|
ctx.metadata.userId = session.userId;
|
|
@@ -166,31 +410,31 @@ var generalAuthInterceptor = {
|
|
|
166
410
|
const err = error;
|
|
167
411
|
const msg = err.message.toLowerCase();
|
|
168
412
|
if (msg.includes("expired") || msg.includes("invalid")) {
|
|
169
|
-
|
|
413
|
+
authLogger.interceptor.general.warn("Session expired or invalid", {
|
|
170
414
|
message: err.message,
|
|
171
415
|
cookieLength: sessionCookie.length,
|
|
172
416
|
cookiePrefix: sessionCookie.slice(0, 20),
|
|
173
417
|
cookieSuffix: sessionCookie.slice(-10)
|
|
174
418
|
});
|
|
175
|
-
|
|
419
|
+
authLogger.interceptor.general.debug("Marking session for cleanup");
|
|
176
420
|
ctx.metadata.clearSession = true;
|
|
177
421
|
ctx.metadata.sessionValid = false;
|
|
178
422
|
} else {
|
|
179
|
-
|
|
423
|
+
authLogger.interceptor.general.error("Failed to process session", err);
|
|
180
424
|
}
|
|
181
425
|
}
|
|
182
426
|
await next();
|
|
183
427
|
},
|
|
184
428
|
response: async (ctx, next) => {
|
|
185
429
|
if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
|
|
186
|
-
|
|
430
|
+
authLogger.interceptor.general.warn("Backend returned 401, clearing session");
|
|
187
431
|
ctx.setCookies.push({
|
|
188
|
-
name:
|
|
432
|
+
name: COOKIE_NAMES.SESSION,
|
|
189
433
|
value: "",
|
|
190
434
|
options: { maxAge: 0, path: "/" }
|
|
191
435
|
});
|
|
192
436
|
ctx.setCookies.push({
|
|
193
|
-
name:
|
|
437
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
194
438
|
value: "",
|
|
195
439
|
options: { maxAge: 0, path: "/" }
|
|
196
440
|
});
|
|
@@ -199,7 +443,7 @@ var generalAuthInterceptor = {
|
|
|
199
443
|
}
|
|
200
444
|
if (ctx.metadata.clearSession) {
|
|
201
445
|
ctx.setCookies.push({
|
|
202
|
-
name:
|
|
446
|
+
name: COOKIE_NAMES.SESSION,
|
|
203
447
|
value: "",
|
|
204
448
|
options: {
|
|
205
449
|
maxAge: 0,
|
|
@@ -207,7 +451,7 @@ var generalAuthInterceptor = {
|
|
|
207
451
|
}
|
|
208
452
|
});
|
|
209
453
|
ctx.setCookies.push({
|
|
210
|
-
name:
|
|
454
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
211
455
|
value: "",
|
|
212
456
|
options: {
|
|
213
457
|
maxAge: 0,
|
|
@@ -217,10 +461,10 @@ var generalAuthInterceptor = {
|
|
|
217
461
|
} else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
|
|
218
462
|
try {
|
|
219
463
|
const sessionData = ctx.metadata.sessionData;
|
|
220
|
-
const ttl =
|
|
221
|
-
const sealed = await
|
|
464
|
+
const ttl = getSessionTtl();
|
|
465
|
+
const sealed = await sealSession(sessionData, ttl);
|
|
222
466
|
ctx.setCookies.push({
|
|
223
|
-
name:
|
|
467
|
+
name: COOKIE_NAMES.SESSION,
|
|
224
468
|
value: sealed,
|
|
225
469
|
options: {
|
|
226
470
|
httpOnly: true,
|
|
@@ -231,7 +475,7 @@ var generalAuthInterceptor = {
|
|
|
231
475
|
}
|
|
232
476
|
});
|
|
233
477
|
ctx.setCookies.push({
|
|
234
|
-
name:
|
|
478
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
235
479
|
value: sessionData.keyId,
|
|
236
480
|
options: {
|
|
237
481
|
httpOnly: true,
|
|
@@ -241,18 +485,18 @@ var generalAuthInterceptor = {
|
|
|
241
485
|
path: "/"
|
|
242
486
|
}
|
|
243
487
|
});
|
|
244
|
-
|
|
488
|
+
authLogger.interceptor.general.info("Session refreshed", {
|
|
245
489
|
userId: sessionData.userId,
|
|
246
490
|
sealedLength: sealed.length,
|
|
247
491
|
sealedPrefix: sealed.slice(0, 20)
|
|
248
492
|
});
|
|
249
493
|
} catch (error) {
|
|
250
494
|
const err = error;
|
|
251
|
-
|
|
495
|
+
authLogger.interceptor.general.error("Failed to refresh session", err);
|
|
252
496
|
}
|
|
253
497
|
} else if (ctx.path === "/_auth/logout" && ctx.response.ok) {
|
|
254
498
|
ctx.setCookies.push({
|
|
255
|
-
name:
|
|
499
|
+
name: COOKIE_NAMES.SESSION,
|
|
256
500
|
value: "",
|
|
257
501
|
options: {
|
|
258
502
|
maxAge: 0,
|
|
@@ -260,7 +504,7 @@ var generalAuthInterceptor = {
|
|
|
260
504
|
}
|
|
261
505
|
});
|
|
262
506
|
ctx.setCookies.push({
|
|
263
|
-
name:
|
|
507
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
264
508
|
value: "",
|
|
265
509
|
options: {
|
|
266
510
|
maxAge: 0,
|
|
@@ -273,19 +517,18 @@ var generalAuthInterceptor = {
|
|
|
273
517
|
};
|
|
274
518
|
|
|
275
519
|
// 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
520
|
var keyRotationInterceptor = {
|
|
278
521
|
pathPattern: "/_auth/keys/rotate",
|
|
279
522
|
method: "POST",
|
|
280
523
|
request: async (ctx, next) => {
|
|
281
|
-
const sessionCookie = ctx.cookies.get(
|
|
524
|
+
const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
|
|
282
525
|
if (!sessionCookie) {
|
|
283
526
|
await next();
|
|
284
527
|
return;
|
|
285
528
|
}
|
|
286
529
|
try {
|
|
287
|
-
const currentSession = await
|
|
288
|
-
const newKeyPair =
|
|
530
|
+
const currentSession = await unsealSession(sessionCookie);
|
|
531
|
+
const newKeyPair = generateKeyPair("ES256");
|
|
289
532
|
if (!ctx.body) {
|
|
290
533
|
ctx.body = {};
|
|
291
534
|
}
|
|
@@ -298,7 +541,7 @@ var keyRotationInterceptor = {
|
|
|
298
541
|
console.log("publicKey:", newKeyPair.publicKey);
|
|
299
542
|
console.log("keyId:", newKeyPair.keyId);
|
|
300
543
|
console.log("fingerprint:", newKeyPair.fingerprint);
|
|
301
|
-
const token =
|
|
544
|
+
const token = generateClientToken(
|
|
302
545
|
{
|
|
303
546
|
userId: currentSession.userId,
|
|
304
547
|
keyId: currentSession.keyId,
|
|
@@ -317,7 +560,7 @@ var keyRotationInterceptor = {
|
|
|
317
560
|
ctx.metadata.userId = currentSession.userId;
|
|
318
561
|
} catch (error) {
|
|
319
562
|
const err = error;
|
|
320
|
-
|
|
563
|
+
authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
|
|
321
564
|
}
|
|
322
565
|
await next();
|
|
323
566
|
},
|
|
@@ -327,21 +570,21 @@ var keyRotationInterceptor = {
|
|
|
327
570
|
return;
|
|
328
571
|
}
|
|
329
572
|
if (!ctx.metadata.newPrivateKey || !ctx.metadata.userId) {
|
|
330
|
-
|
|
573
|
+
authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
|
|
331
574
|
await next();
|
|
332
575
|
return;
|
|
333
576
|
}
|
|
334
577
|
try {
|
|
335
|
-
const ttl =
|
|
578
|
+
const ttl = getSessionTtl();
|
|
336
579
|
const newSessionData = {
|
|
337
580
|
userId: ctx.metadata.userId,
|
|
338
581
|
privateKey: ctx.metadata.newPrivateKey,
|
|
339
582
|
keyId: ctx.metadata.newKeyId,
|
|
340
583
|
algorithm: ctx.metadata.newAlgorithm
|
|
341
584
|
};
|
|
342
|
-
const sealed = await
|
|
585
|
+
const sealed = await sealSession(newSessionData, ttl);
|
|
343
586
|
ctx.setCookies.push({
|
|
344
|
-
name:
|
|
587
|
+
name: COOKIE_NAMES.SESSION,
|
|
345
588
|
value: sealed,
|
|
346
589
|
options: {
|
|
347
590
|
httpOnly: true,
|
|
@@ -352,7 +595,7 @@ var keyRotationInterceptor = {
|
|
|
352
595
|
}
|
|
353
596
|
});
|
|
354
597
|
ctx.setCookies.push({
|
|
355
|
-
name:
|
|
598
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
356
599
|
value: ctx.metadata.newKeyId,
|
|
357
600
|
options: {
|
|
358
601
|
httpOnly: true,
|
|
@@ -364,36 +607,50 @@ var keyRotationInterceptor = {
|
|
|
364
607
|
});
|
|
365
608
|
} catch (error) {
|
|
366
609
|
const err = error;
|
|
367
|
-
|
|
610
|
+
authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
|
|
368
611
|
}
|
|
369
612
|
await next();
|
|
370
613
|
}
|
|
371
614
|
};
|
|
372
615
|
|
|
373
|
-
// src/
|
|
374
|
-
import
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
}
|
|
382
646
|
|
|
383
647
|
// src/nextjs/session-helpers.ts
|
|
384
|
-
import * as
|
|
648
|
+
import * as jose3 from "jose";
|
|
385
649
|
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";
|
|
650
|
+
import { env as env4 } from "@spfn/auth/config";
|
|
394
651
|
import { logger } from "@spfn/core/logger";
|
|
395
652
|
async function getPendingSessionKey() {
|
|
396
|
-
const secret =
|
|
653
|
+
const secret = env4.SPFN_AUTH_SESSION_SECRET;
|
|
397
654
|
const encoder = new TextEncoder();
|
|
398
655
|
const data = encoder.encode(`oauth-pending:${secret}`);
|
|
399
656
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -401,11 +658,11 @@ async function getPendingSessionKey() {
|
|
|
401
658
|
}
|
|
402
659
|
async function sealPendingSession(data, ttl = 600) {
|
|
403
660
|
const key = await getPendingSessionKey();
|
|
404
|
-
return await new
|
|
661
|
+
return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
|
|
405
662
|
}
|
|
406
|
-
async function unsealPendingSession(
|
|
663
|
+
async function unsealPendingSession(jwt2) {
|
|
407
664
|
const key = await getPendingSessionKey();
|
|
408
|
-
const { payload } = await
|
|
665
|
+
const { payload } = await jose3.jwtDecrypt(jwt2, key, {
|
|
409
666
|
issuer: "spfn-auth",
|
|
410
667
|
audience: "spfn-oauth"
|
|
411
668
|
});
|
|
@@ -419,7 +676,7 @@ var oauthUrlInterceptor = {
|
|
|
419
676
|
request: async (ctx, next) => {
|
|
420
677
|
const provider = ctx.path.split("/")[3];
|
|
421
678
|
const returnUrl = ctx.body?.returnUrl || "/";
|
|
422
|
-
const keyPair =
|
|
679
|
+
const keyPair = generateKeyPair("ES256");
|
|
423
680
|
const state = await createOAuthState({
|
|
424
681
|
provider,
|
|
425
682
|
returnUrl,
|
|
@@ -437,7 +694,7 @@ var oauthUrlInterceptor = {
|
|
|
437
694
|
keyId: keyPair.keyId,
|
|
438
695
|
algorithm: keyPair.algorithm
|
|
439
696
|
};
|
|
440
|
-
|
|
697
|
+
authLogger.interceptor.oauth?.debug?.("OAuth state created", {
|
|
441
698
|
provider,
|
|
442
699
|
keyId: keyPair.keyId
|
|
443
700
|
});
|
|
@@ -448,7 +705,7 @@ var oauthUrlInterceptor = {
|
|
|
448
705
|
try {
|
|
449
706
|
const sealed = await sealPendingSession(ctx.metadata.pendingSession);
|
|
450
707
|
ctx.setCookies.push({
|
|
451
|
-
name:
|
|
708
|
+
name: COOKIE_NAMES.OAUTH_PENDING,
|
|
452
709
|
value: sealed,
|
|
453
710
|
options: {
|
|
454
711
|
httpOnly: true,
|
|
@@ -460,12 +717,12 @@ var oauthUrlInterceptor = {
|
|
|
460
717
|
path: "/"
|
|
461
718
|
}
|
|
462
719
|
});
|
|
463
|
-
|
|
720
|
+
authLogger.interceptor.oauth?.debug?.("Pending session cookie set", {
|
|
464
721
|
keyId: ctx.metadata.pendingSession.keyId
|
|
465
722
|
});
|
|
466
723
|
} catch (error) {
|
|
467
724
|
const err = error;
|
|
468
|
-
|
|
725
|
+
authLogger.interceptor.oauth?.error?.("Failed to set pending session", err);
|
|
469
726
|
}
|
|
470
727
|
}
|
|
471
728
|
await next();
|
|
@@ -477,7 +734,7 @@ function setFinalizeError(ctx, message) {
|
|
|
477
734
|
ctx.response.statusText = "Unauthorized";
|
|
478
735
|
ctx.response.body = { success: false, message };
|
|
479
736
|
ctx.setCookies.push({
|
|
480
|
-
name:
|
|
737
|
+
name: COOKIE_NAMES.OAUTH_PENDING,
|
|
481
738
|
value: "",
|
|
482
739
|
options: {
|
|
483
740
|
httpOnly: true,
|
|
@@ -496,9 +753,9 @@ var oauthFinalizeInterceptor = {
|
|
|
496
753
|
await next();
|
|
497
754
|
return;
|
|
498
755
|
}
|
|
499
|
-
const pendingCookie = ctx.cookies.get(
|
|
756
|
+
const pendingCookie = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
|
|
500
757
|
if (!pendingCookie) {
|
|
501
|
-
|
|
758
|
+
authLogger.interceptor.oauth?.warn?.("No pending session cookie found");
|
|
502
759
|
setFinalizeError(ctx, "OAuth session expired. Please try again.");
|
|
503
760
|
await next();
|
|
504
761
|
return;
|
|
@@ -507,13 +764,13 @@ var oauthFinalizeInterceptor = {
|
|
|
507
764
|
const pendingSession = await unsealPendingSession(pendingCookie);
|
|
508
765
|
const { userId, keyId } = ctx.response.body || {};
|
|
509
766
|
if (!userId || !keyId) {
|
|
510
|
-
|
|
767
|
+
authLogger.interceptor.oauth?.error?.("Missing userId or keyId in response");
|
|
511
768
|
setFinalizeError(ctx, "OAuth finalize failed: missing credentials");
|
|
512
769
|
await next();
|
|
513
770
|
return;
|
|
514
771
|
}
|
|
515
772
|
if (pendingSession.keyId !== keyId) {
|
|
516
|
-
|
|
773
|
+
authLogger.interceptor.oauth?.error?.("KeyId mismatch", {
|
|
517
774
|
expected: pendingSession.keyId,
|
|
518
775
|
received: keyId
|
|
519
776
|
});
|
|
@@ -521,15 +778,15 @@ var oauthFinalizeInterceptor = {
|
|
|
521
778
|
await next();
|
|
522
779
|
return;
|
|
523
780
|
}
|
|
524
|
-
const ttl =
|
|
525
|
-
const sessionToken = await
|
|
781
|
+
const ttl = getSessionTtl();
|
|
782
|
+
const sessionToken = await sealSession({
|
|
526
783
|
userId,
|
|
527
784
|
privateKey: pendingSession.privateKey,
|
|
528
785
|
keyId: pendingSession.keyId,
|
|
529
786
|
algorithm: pendingSession.algorithm
|
|
530
787
|
}, ttl);
|
|
531
788
|
ctx.setCookies.push({
|
|
532
|
-
name:
|
|
789
|
+
name: COOKIE_NAMES.SESSION,
|
|
533
790
|
value: sessionToken,
|
|
534
791
|
options: {
|
|
535
792
|
httpOnly: true,
|
|
@@ -540,7 +797,7 @@ var oauthFinalizeInterceptor = {
|
|
|
540
797
|
}
|
|
541
798
|
});
|
|
542
799
|
ctx.setCookies.push({
|
|
543
|
-
name:
|
|
800
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
544
801
|
value: keyId,
|
|
545
802
|
options: {
|
|
546
803
|
httpOnly: true,
|
|
@@ -551,7 +808,7 @@ var oauthFinalizeInterceptor = {
|
|
|
551
808
|
}
|
|
552
809
|
});
|
|
553
810
|
ctx.setCookies.push({
|
|
554
|
-
name:
|
|
811
|
+
name: COOKIE_NAMES.OAUTH_PENDING,
|
|
555
812
|
value: "",
|
|
556
813
|
options: {
|
|
557
814
|
httpOnly: true,
|
|
@@ -561,13 +818,13 @@ var oauthFinalizeInterceptor = {
|
|
|
561
818
|
path: "/"
|
|
562
819
|
}
|
|
563
820
|
});
|
|
564
|
-
|
|
821
|
+
authLogger.interceptor.oauth?.debug?.("OAuth session finalized", {
|
|
565
822
|
userId,
|
|
566
823
|
keyId
|
|
567
824
|
});
|
|
568
825
|
} catch (error) {
|
|
569
826
|
const err = error;
|
|
570
|
-
|
|
827
|
+
authLogger.interceptor.oauth?.error?.("Failed to finalize OAuth session", err);
|
|
571
828
|
setFinalizeError(ctx, err.message);
|
|
572
829
|
}
|
|
573
830
|
await next();
|