@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.
- package/README.md +831 -198
- package/dist/{dto-Bb2qFUO6.d.ts → authenticate-B_HkYBzq.d.ts} +449 -199
- package/dist/config.d.ts +176 -44
- package/dist/config.js +99 -35
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +30 -2
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +289 -113
- package/dist/index.js +59 -1
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +560 -55
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.d.ts +28 -0
- package/dist/nextjs/client.js +80 -0
- package/dist/nextjs/client.js.map +1 -0
- package/dist/nextjs/server.d.ts +92 -3
- package/dist/nextjs/server.js +282 -22
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +860 -468
- package/dist/server.js +1454 -607
- package/dist/server.js.map +1 -1
- package/dist/session-Dbvz9Sdp.d.ts +53 -0
- package/dist/types-B1CzVZkU.d.ts +45 -0
- package/migrations/0001_smooth_the_fury.sql +3 -0
- package/migrations/0002_deep_iceman.sql +11 -0
- package/migrations/0003_perfect_deathbird.sql +3 -0
- package/migrations/0004_concerned_rawhide_kid.sql +5 -0
- package/migrations/meta/0001_snapshot.json +1660 -0
- package/migrations/meta/0002_snapshot.json +1660 -0
- package/migrations/meta/0003_snapshot.json +1689 -0
- package/migrations/meta/0004_snapshot.json +1721 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +15 -11
package/dist/nextjs/api.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
363
|
+
authLogger.interceptor.general.debug("Available cookies:", {
|
|
110
364
|
cookieNames,
|
|
111
365
|
totalCount: cookieNames.length,
|
|
112
|
-
lookingFor:
|
|
366
|
+
lookingFor: COOKIE_NAMES.SESSION
|
|
113
367
|
});
|
|
114
|
-
const sessionCookie = ctx.cookies.get(
|
|
115
|
-
|
|
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
|
-
|
|
373
|
+
sessionLength: sessionCookie?.length ?? 0,
|
|
374
|
+
sessionPrefix: sessionCookie?.slice(0, 20) ?? "",
|
|
375
|
+
sessionSuffix: sessionCookie?.slice(-10) ?? ""
|
|
120
376
|
});
|
|
121
377
|
if (!sessionCookie) {
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
188
|
-
const sealed = await
|
|
464
|
+
const ttl = getSessionTtl();
|
|
465
|
+
const sealed = await sealSession(sessionData, ttl);
|
|
189
466
|
ctx.setCookies.push({
|
|
190
|
-
name:
|
|
467
|
+
name: COOKIE_NAMES.SESSION,
|
|
191
468
|
value: sealed,
|
|
192
469
|
options: {
|
|
193
470
|
httpOnly: true,
|
|
194
|
-
secure:
|
|
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:
|
|
478
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
202
479
|
value: sessionData.keyId,
|
|
203
480
|
options: {
|
|
204
481
|
httpOnly: true,
|
|
205
|
-
secure:
|
|
482
|
+
secure: cookieSecure,
|
|
206
483
|
sameSite: "strict",
|
|
207
484
|
maxAge: ttl,
|
|
208
485
|
path: "/"
|
|
209
486
|
}
|
|
210
487
|
});
|
|
211
|
-
|
|
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
|
-
|
|
495
|
+
authLogger.interceptor.general.error("Failed to refresh session", err);
|
|
215
496
|
}
|
|
216
|
-
} else if (ctx.path === "/_auth/logout" && ctx.response.
|
|
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:
|
|
505
|
+
name: COOKIE_NAMES.SESSION,
|
|
219
506
|
value: "",
|
|
220
|
-
options: {
|
|
221
|
-
|
|
222
|
-
|
|
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:
|
|
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(
|
|
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
|
|
251
|
-
const newKeyPair =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
578
|
+
authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
|
|
294
579
|
await next();
|
|
295
580
|
return;
|
|
296
581
|
}
|
|
297
582
|
try {
|
|
298
|
-
const ttl =
|
|
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
|
|
590
|
+
const sealed = await sealSession(newSessionData, ttl);
|
|
306
591
|
ctx.setCookies.push({
|
|
307
|
-
name:
|
|
592
|
+
name: COOKIE_NAMES.SESSION,
|
|
308
593
|
value: sealed,
|
|
309
594
|
options: {
|
|
310
595
|
httpOnly: true,
|
|
311
|
-
secure:
|
|
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:
|
|
603
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
319
604
|
value: ctx.metadata.newKeyId,
|
|
320
605
|
options: {
|
|
321
606
|
httpOnly: true,
|
|
322
|
-
secure:
|
|
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
|
-
|
|
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
|
|