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