@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.
- 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 +547 -47
- 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,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 =
|
|
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) {
|
|
217
498
|
ctx.setCookies.push({
|
|
218
|
-
name:
|
|
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:
|
|
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(
|
|
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
|
|
251
|
-
const newKeyPair =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
573
|
+
authLogger.interceptor.keyRotation.error("Missing key rotation metadata");
|
|
294
574
|
await next();
|
|
295
575
|
return;
|
|
296
576
|
}
|
|
297
577
|
try {
|
|
298
|
-
const ttl =
|
|
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
|
|
585
|
+
const sealed = await sealSession(newSessionData, ttl);
|
|
306
586
|
ctx.setCookies.push({
|
|
307
|
-
name:
|
|
587
|
+
name: COOKIE_NAMES.SESSION,
|
|
308
588
|
value: sealed,
|
|
309
589
|
options: {
|
|
310
590
|
httpOnly: true,
|
|
311
|
-
secure:
|
|
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:
|
|
598
|
+
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
319
599
|
value: ctx.metadata.newKeyId,
|
|
320
600
|
options: {
|
|
321
601
|
httpOnly: true,
|
|
322
|
-
secure:
|
|
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
|
-
|
|
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
|
|