@authyon/server 0.2.0-beta.6 → 0.2.0-beta.9
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 +11 -0
- package/dist/authorization-CJ1TMuhD.d.ts +129 -0
- package/dist/authorization-SvylMv24.d.cts +129 -0
- package/dist/{authorization-BGURVMFT.d.cts → authyonServerClient-DPz5ZTrZ.d.cts} +15 -116
- package/dist/{authorization-BGURVMFT.d.ts → authyonServerClient-DPz5ZTrZ.d.ts} +15 -116
- package/dist/bff.cjs +1126 -0
- package/dist/bff.d.cts +142 -0
- package/dist/bff.d.ts +142 -0
- package/dist/bff.js +791 -0
- package/dist/chunk-56H66KLG.js +323 -0
- package/dist/chunk-77J4NURK.js +13 -0
- package/dist/{chunk-24IFDJ73.js → chunk-NKWQMYB3.js} +6 -11
- package/dist/index.cjs +19 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +30 -318
- package/dist/next.d.cts +2 -1
- package/dist/next.d.ts +2 -1
- package/dist/next.js +2 -1
- package/package.json +10 -2
package/dist/bff.cjs
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/bff.ts
|
|
21
|
+
var bff_exports = {};
|
|
22
|
+
__export(bff_exports, {
|
|
23
|
+
BffSessionError: () => BffSessionError,
|
|
24
|
+
createAuthyonBffProvider: () => createAuthyonBffProvider,
|
|
25
|
+
createBffSession: () => createBffSession,
|
|
26
|
+
createMemoryBffSessionStore: () => createMemoryBffSessionStore,
|
|
27
|
+
createRedisBffSessionStore: () => createRedisBffSessionStore
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(bff_exports);
|
|
30
|
+
|
|
31
|
+
// src/bff/contracts.ts
|
|
32
|
+
var BffSessionError = class extends Error {
|
|
33
|
+
constructor(code, status, clearCookie = false) {
|
|
34
|
+
super(code);
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.clearCookie = clearCookie;
|
|
38
|
+
this.name = "BffSessionError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ../../internal/core/errors/authyonError.ts
|
|
43
|
+
var ErrorCodes = {
|
|
44
|
+
Unknown: "unknown",
|
|
45
|
+
NetworkError: "request.network_error",
|
|
46
|
+
Timeout: "request.timeout",
|
|
47
|
+
SessionMalformed: "session.malformed",
|
|
48
|
+
NotAuthenticated: "auth.not_authenticated",
|
|
49
|
+
InvalidToken: "auth.invalid_token",
|
|
50
|
+
MissingToken: "auth.missing_token",
|
|
51
|
+
EmailTaken: "user.email_taken",
|
|
52
|
+
PasswordWeak: "user.password_weak",
|
|
53
|
+
PasswordPwned: "user.password_pwned",
|
|
54
|
+
RateLimited: "rate_limited"
|
|
55
|
+
};
|
|
56
|
+
var AuthyonError = class extends Error {
|
|
57
|
+
constructor(status, body, options = {}) {
|
|
58
|
+
super(body.detail ?? body.title ?? `Authyon request failed with status ${status}`);
|
|
59
|
+
this.name = "AuthyonError";
|
|
60
|
+
this.status = status;
|
|
61
|
+
this.code = body.code ?? "unknown";
|
|
62
|
+
this.title = body.title ?? "Error";
|
|
63
|
+
this.detail = body.detail;
|
|
64
|
+
this.requestId = options.requestId;
|
|
65
|
+
this.retryAfter = options.retryAfter;
|
|
66
|
+
this.cause = options.cause;
|
|
67
|
+
}
|
|
68
|
+
is(code) {
|
|
69
|
+
return this.code === code;
|
|
70
|
+
}
|
|
71
|
+
isAny(...codes) {
|
|
72
|
+
return codes.includes(this.code);
|
|
73
|
+
}
|
|
74
|
+
hasPrefix(prefix) {
|
|
75
|
+
return this.code.startsWith(prefix);
|
|
76
|
+
}
|
|
77
|
+
isStatus(...statuses) {
|
|
78
|
+
return statuses.includes(this.status);
|
|
79
|
+
}
|
|
80
|
+
/** Stable interpretation for UI decisions, retries, telemetry and support flows. */
|
|
81
|
+
interpret() {
|
|
82
|
+
const category = classifyError(this.status, this.code);
|
|
83
|
+
return {
|
|
84
|
+
category,
|
|
85
|
+
action: actionFor(category),
|
|
86
|
+
retryable: isRetryable(category),
|
|
87
|
+
...this.retryAfter !== void 0 ? { retryAfter: this.retryAfter } : {},
|
|
88
|
+
...this.requestId !== void 0 ? { requestId: this.requestId } : {}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
get category() {
|
|
92
|
+
return this.interpret().category;
|
|
93
|
+
}
|
|
94
|
+
get retryable() {
|
|
95
|
+
return this.interpret().retryable;
|
|
96
|
+
}
|
|
97
|
+
toJSON() {
|
|
98
|
+
return {
|
|
99
|
+
name: this.name,
|
|
100
|
+
message: this.message,
|
|
101
|
+
status: this.status,
|
|
102
|
+
code: this.code,
|
|
103
|
+
title: this.title,
|
|
104
|
+
detail: this.detail,
|
|
105
|
+
requestId: this.requestId,
|
|
106
|
+
retryAfter: this.retryAfter,
|
|
107
|
+
...this.interpret()
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
function classifyError(status, code) {
|
|
112
|
+
if (code === ErrorCodes.NetworkError) return "network";
|
|
113
|
+
if (code === ErrorCodes.Timeout || status === 408) return "timeout";
|
|
114
|
+
if (code === ErrorCodes.RateLimited || status === 429) return "rate_limit";
|
|
115
|
+
if (code === ErrorCodes.EmailTaken || status === 409) return "conflict";
|
|
116
|
+
if (code === ErrorCodes.PasswordWeak || code === ErrorCodes.PasswordPwned) return "validation";
|
|
117
|
+
if (code === ErrorCodes.SessionMalformed) return "server";
|
|
118
|
+
if (code.startsWith("auth.") || status === 401) return "authentication";
|
|
119
|
+
if (status === 403) return "authorization";
|
|
120
|
+
if (status === 404) return "not_found";
|
|
121
|
+
if (status === 400 || status === 422) return "validation";
|
|
122
|
+
if (status >= 500) return "server";
|
|
123
|
+
return "unknown";
|
|
124
|
+
}
|
|
125
|
+
function actionFor(category) {
|
|
126
|
+
switch (category) {
|
|
127
|
+
case "network":
|
|
128
|
+
case "timeout":
|
|
129
|
+
case "rate_limit":
|
|
130
|
+
case "server":
|
|
131
|
+
return "retry";
|
|
132
|
+
case "authentication":
|
|
133
|
+
return "reauthenticate";
|
|
134
|
+
case "authorization":
|
|
135
|
+
return "request_access";
|
|
136
|
+
case "validation":
|
|
137
|
+
return "fix_input";
|
|
138
|
+
case "not_found":
|
|
139
|
+
return "not_found";
|
|
140
|
+
case "conflict":
|
|
141
|
+
return "resolve_conflict";
|
|
142
|
+
case "unknown":
|
|
143
|
+
return "contact_support";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function isRetryable(category) {
|
|
147
|
+
return category === "network" || category === "timeout" || category === "rate_limit" || category === "server";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/bff/http.ts
|
|
151
|
+
var MAX_BODY_BYTES = 16384;
|
|
152
|
+
var SESSION_ID = /^[a-f0-9]{64}$/;
|
|
153
|
+
function createBffHttp(options) {
|
|
154
|
+
const url = new URL(options.origin);
|
|
155
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
156
|
+
const insecure = url.protocol === "http:" && loopback && options.allowInsecureLocalhost === true;
|
|
157
|
+
if (!insecure && url.protocol !== "https:" || url.origin !== options.origin) {
|
|
158
|
+
throw new Error("Authyon BFF: origin must be an HTTPS origin, without path or credentials");
|
|
159
|
+
}
|
|
160
|
+
const cookieName = insecure ? "authyon.session" : "__Host-authyon.session";
|
|
161
|
+
const attributes = `Path=/; HttpOnly; SameSite=Lax${insecure ? "" : "; Secure"}`;
|
|
162
|
+
function assertRequest(request) {
|
|
163
|
+
if (new URL(request.url).origin !== options.origin)
|
|
164
|
+
throw new BffSessionError("request.origin_denied", 403);
|
|
165
|
+
const origin = request.headers.get("origin");
|
|
166
|
+
if (origin && origin !== options.origin || request.headers.get("sec-fetch-site") === "cross-site") {
|
|
167
|
+
throw new BffSessionError("request.origin_denied", 403);
|
|
168
|
+
}
|
|
169
|
+
if (!["GET", "HEAD"].includes(request.method)) {
|
|
170
|
+
if (origin !== options.origin || request.headers.get("x-authyon-csrf") !== "1") {
|
|
171
|
+
throw new BffSessionError("request.csrf_denied", 403);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function sessionId(request) {
|
|
176
|
+
const matches = (request.headers.get("cookie") ?? "").split(";").map((part) => part.trim()).filter((part) => part.startsWith(`${cookieName}=`));
|
|
177
|
+
if (matches.length > 1) throw new BffSessionError("session.ambiguous_cookie", 401, true);
|
|
178
|
+
const value = matches[0]?.slice(cookieName.length + 1);
|
|
179
|
+
return value && SESSION_ID.test(value) ? value : null;
|
|
180
|
+
}
|
|
181
|
+
function response(body, init = {}) {
|
|
182
|
+
const headers = new Headers({
|
|
183
|
+
"cache-control": "no-store",
|
|
184
|
+
pragma: "no-cache",
|
|
185
|
+
vary: "Cookie",
|
|
186
|
+
"x-content-type-options": "nosniff"
|
|
187
|
+
});
|
|
188
|
+
if (init.cookie) headers.set("set-cookie", init.cookie);
|
|
189
|
+
if (body !== null) headers.set("content-type", "application/json");
|
|
190
|
+
return new Response(body === null ? null : JSON.stringify(body), {
|
|
191
|
+
status: init.status ?? 200,
|
|
192
|
+
headers
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function expireCookie() {
|
|
196
|
+
return `${cookieName}=; ${attributes}; Max-Age=0`;
|
|
197
|
+
}
|
|
198
|
+
function errorResponse(error) {
|
|
199
|
+
if (error instanceof BffSessionError) {
|
|
200
|
+
return response(
|
|
201
|
+
{ error: { code: error.code } },
|
|
202
|
+
{ status: error.status, cookie: error.clearCookie ? expireCookie() : void 0 }
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (error instanceof AuthyonError) {
|
|
206
|
+
const status = [400, 401, 403, 429].includes(error.status) ? error.status : 503;
|
|
207
|
+
const result = response(
|
|
208
|
+
{ error: { code: status === 503 ? "provider.unavailable" : "provider.rejected" } },
|
|
209
|
+
{ status }
|
|
210
|
+
);
|
|
211
|
+
if (error.retryAfter !== void 0)
|
|
212
|
+
result.headers.set("retry-after", String(error.retryAfter));
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
return response({ error: { code: "session.unavailable" } }, { status: 503 });
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
assertRequest,
|
|
219
|
+
sessionId,
|
|
220
|
+
response,
|
|
221
|
+
expireCookie,
|
|
222
|
+
errorResponse,
|
|
223
|
+
cookie(id, expiresAt) {
|
|
224
|
+
return `${cookieName}=${id}; ${attributes}; Max-Age=${Math.max(0, Math.floor((expiresAt - Date.now()) / 1e3))}`;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function readBffBody(request) {
|
|
229
|
+
if (request.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") {
|
|
230
|
+
throw new BffSessionError("request.json_required", 415);
|
|
231
|
+
}
|
|
232
|
+
if (!request.body) throw new BffSessionError("request.invalid_body", 400);
|
|
233
|
+
const reader = request.body.getReader();
|
|
234
|
+
const chunks = [];
|
|
235
|
+
let size = 0;
|
|
236
|
+
try {
|
|
237
|
+
while (true) {
|
|
238
|
+
const { value, done } = await reader.read();
|
|
239
|
+
if (done) break;
|
|
240
|
+
size += value.byteLength;
|
|
241
|
+
if (size > MAX_BODY_BYTES) {
|
|
242
|
+
await reader.cancel();
|
|
243
|
+
throw new BffSessionError("request.body_too_large", 413);
|
|
244
|
+
}
|
|
245
|
+
chunks.push(value);
|
|
246
|
+
}
|
|
247
|
+
const bytes = new Uint8Array(size);
|
|
248
|
+
let offset = 0;
|
|
249
|
+
for (const chunk of chunks) {
|
|
250
|
+
bytes.set(chunk, offset);
|
|
251
|
+
offset += chunk.byteLength;
|
|
252
|
+
}
|
|
253
|
+
const body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
254
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
255
|
+
throw new Error("invalid JSON object");
|
|
256
|
+
return body;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error instanceof BffSessionError) throw error;
|
|
259
|
+
throw new BffSessionError("request.invalid_body", 400);
|
|
260
|
+
} finally {
|
|
261
|
+
reader.releaseLock();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function inputString(body, name) {
|
|
265
|
+
const value = body[name];
|
|
266
|
+
if (typeof value !== "string" || value.length === 0)
|
|
267
|
+
throw new BffSessionError("request.invalid_body", 400);
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
function sessionPersistence(body) {
|
|
271
|
+
const value = body.sessionPersistence;
|
|
272
|
+
if (value === void 0 || value === "standard") return "standard";
|
|
273
|
+
if (value === "remembered") return "remembered";
|
|
274
|
+
throw new BffSessionError("request.invalid_body", 400);
|
|
275
|
+
}
|
|
276
|
+
function loginInput(body) {
|
|
277
|
+
return {
|
|
278
|
+
password: inputString(body, "password"),
|
|
279
|
+
...body.email !== void 0 ? { email: inputString(body, "email") } : { username: inputString(body, "username") },
|
|
280
|
+
...body.organizationSlug !== void 0 ? { organizationSlug: inputString(body, "organizationSlug") } : {},
|
|
281
|
+
sessionPersistence: sessionPersistence(body)
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function twoFactorInput(body) {
|
|
285
|
+
const method = inputString(body, "method");
|
|
286
|
+
const challengeToken = inputString(body, "challengeToken");
|
|
287
|
+
const persistence = sessionPersistence(body);
|
|
288
|
+
if (method !== "webauthn") {
|
|
289
|
+
return {
|
|
290
|
+
challengeToken,
|
|
291
|
+
method,
|
|
292
|
+
code: inputString(body, "code"),
|
|
293
|
+
sessionPersistence: persistence
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const assertion = body.webAuthnAssertion;
|
|
297
|
+
if (!assertion || typeof assertion !== "object" || Array.isArray(assertion))
|
|
298
|
+
throw new BffSessionError("request.invalid_body", 400);
|
|
299
|
+
const fields = assertion;
|
|
300
|
+
return {
|
|
301
|
+
challengeToken,
|
|
302
|
+
method,
|
|
303
|
+
sessionPersistence: persistence,
|
|
304
|
+
webAuthnAssertion: {
|
|
305
|
+
ceremonyToken: inputString(fields, "ceremonyToken"),
|
|
306
|
+
assertionJson: inputString(fields, "assertionJson")
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/bff/sessionRepository.ts
|
|
312
|
+
var import_node_crypto = require("crypto");
|
|
313
|
+
var OPERATION_TIMEOUT_MS = 12e4;
|
|
314
|
+
var CONTENTION_TIMEOUT_MS = 2e3;
|
|
315
|
+
var CONTENTION_POLL_MS = 20;
|
|
316
|
+
var newSessionSecret = () => (0, import_node_crypto.randomBytes)(32).toString("hex");
|
|
317
|
+
var sessionKey = (id, origin) => (0, import_node_crypto.createHash)("sha256").update(`${origin}\0${id}`).digest("hex");
|
|
318
|
+
var BffSessionRepository = class {
|
|
319
|
+
constructor(store, origin) {
|
|
320
|
+
this.store = store;
|
|
321
|
+
this.origin = origin;
|
|
322
|
+
}
|
|
323
|
+
async create(record) {
|
|
324
|
+
const id = newSessionSecret();
|
|
325
|
+
if (!await this.store.compareAndSwap(sessionKey(id, this.origin), null, record)) {
|
|
326
|
+
throw new BffSessionError("session.creation_failed", 503);
|
|
327
|
+
}
|
|
328
|
+
return id;
|
|
329
|
+
}
|
|
330
|
+
async acquire(id) {
|
|
331
|
+
const key = sessionKey(id, this.origin);
|
|
332
|
+
const deadline = Date.now() + CONTENTION_TIMEOUT_MS;
|
|
333
|
+
while (Date.now() < deadline) {
|
|
334
|
+
const record = await this.readAvailable(key, deadline);
|
|
335
|
+
const reserved = {
|
|
336
|
+
...record,
|
|
337
|
+
revision: newSessionSecret(),
|
|
338
|
+
busyUntil: Date.now() + OPERATION_TIMEOUT_MS
|
|
339
|
+
};
|
|
340
|
+
if (await this.store.compareAndSwap(key, record.revision, reserved))
|
|
341
|
+
return { key, record: reserved };
|
|
342
|
+
}
|
|
343
|
+
throw new BffSessionError("session.busy", 503);
|
|
344
|
+
}
|
|
345
|
+
async inspect(id) {
|
|
346
|
+
const key = sessionKey(id, this.origin);
|
|
347
|
+
const record = await this.readAvailable(key, Date.now() + CONTENTION_TIMEOUT_MS);
|
|
348
|
+
return { key, record };
|
|
349
|
+
}
|
|
350
|
+
async touch(lease, idleExpiresAt) {
|
|
351
|
+
const next = {
|
|
352
|
+
...lease.record,
|
|
353
|
+
revision: newSessionSecret(),
|
|
354
|
+
idleExpiresAt
|
|
355
|
+
};
|
|
356
|
+
return await this.store.compareAndSwap(lease.key, lease.record.revision, next) ? next : null;
|
|
357
|
+
}
|
|
358
|
+
async removeIfCurrent(lease) {
|
|
359
|
+
return this.store.compareAndSwap(lease.key, lease.record.revision, null);
|
|
360
|
+
}
|
|
361
|
+
async release(lease) {
|
|
362
|
+
if (lease.record.busyUntil === null || lease.record.busyUntil <= Date.now()) {
|
|
363
|
+
await this.remove(lease);
|
|
364
|
+
throw new BffSessionError("session.interrupted", 503, true);
|
|
365
|
+
}
|
|
366
|
+
const next = { ...lease.record, revision: newSessionSecret(), busyUntil: null };
|
|
367
|
+
if (!await this.store.compareAndSwap(lease.key, lease.record.revision, next)) {
|
|
368
|
+
throw new BffSessionError("session.changed", 401, true);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async remove(lease) {
|
|
372
|
+
if (!await this.removeIfCurrent(lease)) {
|
|
373
|
+
throw new BffSessionError("session.changed", 401, true);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async readAvailable(key, deadline) {
|
|
377
|
+
while (Date.now() < deadline) {
|
|
378
|
+
const record = await this.store.read(key);
|
|
379
|
+
if (!record) throw new BffSessionError("session.missing", 401, true);
|
|
380
|
+
if (Math.min(record.expiresAt, record.idleExpiresAt) <= Date.now()) {
|
|
381
|
+
if (await this.store.compareAndSwap(key, record.revision, null)) {
|
|
382
|
+
throw new BffSessionError("session.expired", 401, true);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (record.busyUntil === null) return record;
|
|
387
|
+
if (record.busyUntil <= Date.now()) {
|
|
388
|
+
if (await this.store.compareAndSwap(key, record.revision, null)) {
|
|
389
|
+
throw new BffSessionError("session.interrupted", 503, true);
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
await new Promise((resolve) => setTimeout(resolve, CONTENTION_POLL_MS));
|
|
394
|
+
}
|
|
395
|
+
throw new BffSessionError("session.busy", 503);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// src/bff/sessionManager.ts
|
|
400
|
+
var DEFAULT_ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60 * 1e3;
|
|
401
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
402
|
+
var DEFAULT_REMEMBERED_ABSOLUTE_TIMEOUT_MS = 90 * 24 * 60 * 60 * 1e3;
|
|
403
|
+
var DEFAULT_REMEMBERED_IDLE_TIMEOUT_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
404
|
+
var DEFAULT_REFRESH_AHEAD_MS = 3e4;
|
|
405
|
+
var MAX_SESSION_CHANGE_RETRIES = 2;
|
|
406
|
+
function positiveDuration(value) {
|
|
407
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
408
|
+
throw new Error("Authyon BFF: durations must be positive integer milliseconds");
|
|
409
|
+
return value;
|
|
410
|
+
}
|
|
411
|
+
function rejectedCredential(error) {
|
|
412
|
+
return error instanceof AuthyonError && [401, 403].includes(error.status);
|
|
413
|
+
}
|
|
414
|
+
var BffSessionManager = class {
|
|
415
|
+
constructor(options) {
|
|
416
|
+
this.options = options;
|
|
417
|
+
this.repository = new BffSessionRepository(options.store, options.origin);
|
|
418
|
+
this.standardSession = this.createSessionLifetime({
|
|
419
|
+
absoluteTimeoutMs: options.absoluteTimeoutMs ?? DEFAULT_ABSOLUTE_TIMEOUT_MS,
|
|
420
|
+
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
421
|
+
});
|
|
422
|
+
this.rememberedSession = this.createSessionLifetime({
|
|
423
|
+
absoluteTimeoutMs: options.rememberedSession?.absoluteTimeoutMs ?? DEFAULT_REMEMBERED_ABSOLUTE_TIMEOUT_MS,
|
|
424
|
+
idleTimeoutMs: options.rememberedSession?.idleTimeoutMs ?? DEFAULT_REMEMBERED_IDLE_TIMEOUT_MS
|
|
425
|
+
});
|
|
426
|
+
this.refreshAhead = positiveDuration(options.refreshAheadMs ?? DEFAULT_REFRESH_AHEAD_MS);
|
|
427
|
+
}
|
|
428
|
+
async create(tokens, organizationSlug, sessionPersistence2 = "standard") {
|
|
429
|
+
try {
|
|
430
|
+
const user = await this.verifiedProfile(tokens);
|
|
431
|
+
if (organizationSlug !== void 0 && user.organization?.slug !== organizationSlug) {
|
|
432
|
+
throw new BffSessionError("session.organization_mismatch", 502);
|
|
433
|
+
}
|
|
434
|
+
const lifetime = this.sessionLifetime(sessionPersistence2);
|
|
435
|
+
const record = {
|
|
436
|
+
tokens,
|
|
437
|
+
user,
|
|
438
|
+
revision: newSessionSecret(),
|
|
439
|
+
busyUntil: null,
|
|
440
|
+
sessionPersistence: sessionPersistence2,
|
|
441
|
+
expiresAt: Date.now() + lifetime.absoluteTimeoutMs,
|
|
442
|
+
idleExpiresAt: Date.now() + lifetime.idleTimeoutMs
|
|
443
|
+
};
|
|
444
|
+
return { id: await this.repository.create(record), record };
|
|
445
|
+
} catch (error) {
|
|
446
|
+
await this.revokeUnstoredTokens(tokens);
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async read(id) {
|
|
451
|
+
return this.readCurrent(id, 0);
|
|
452
|
+
}
|
|
453
|
+
async switchOrganization(id, slug) {
|
|
454
|
+
return this.update(id, async (lease) => {
|
|
455
|
+
const refreshed = await this.refreshIfNeeded(lease);
|
|
456
|
+
if (!refreshed && !await this.options.provider.validate(lease.record.tokens.accessToken)) {
|
|
457
|
+
throw new BffSessionError("session.revoked", 401, true);
|
|
458
|
+
}
|
|
459
|
+
await this.rotate(lease, {
|
|
460
|
+
issue: () => this.issueOrganizationTokens(lease.record.tokens.accessToken, slug),
|
|
461
|
+
accepts: (user) => user.id === lease.record.user.id && user.organization?.slug === slug
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
async logout(id) {
|
|
466
|
+
const lease = await this.repository.acquire(id);
|
|
467
|
+
await this.repository.remove(lease);
|
|
468
|
+
try {
|
|
469
|
+
await this.options.provider.logout(lease.record.tokens.refreshToken);
|
|
470
|
+
} catch {
|
|
471
|
+
throw new BffSessionError("session.upstream_revocation_failed", 503, true);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async update(id, operation) {
|
|
475
|
+
const lease = await this.repository.acquire(id);
|
|
476
|
+
try {
|
|
477
|
+
await operation(lease);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (rejectedCredential(error) || error instanceof BffSessionError && error.clearCookie) {
|
|
480
|
+
await this.repository.remove(lease);
|
|
481
|
+
if (error instanceof BffSessionError) throw error;
|
|
482
|
+
throw new BffSessionError("session.revoked", 401, true);
|
|
483
|
+
}
|
|
484
|
+
await this.repository.release(lease);
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
lease.record.idleExpiresAt = Math.min(
|
|
488
|
+
lease.record.expiresAt,
|
|
489
|
+
Date.now() + this.sessionLifetime(lease.record.sessionPersistence).idleTimeoutMs
|
|
490
|
+
);
|
|
491
|
+
await this.repository.release(lease);
|
|
492
|
+
return lease.record;
|
|
493
|
+
}
|
|
494
|
+
async readCurrent(id, changeRetries) {
|
|
495
|
+
const lease = await this.repository.inspect(id);
|
|
496
|
+
if (this.needsRefresh(lease.record)) {
|
|
497
|
+
return this.update(id, async (reserved) => {
|
|
498
|
+
const refreshed = await this.refreshIfNeeded(reserved);
|
|
499
|
+
if (!refreshed && !await this.options.provider.validate(reserved.record.tokens.accessToken)) {
|
|
500
|
+
throw new BffSessionError("session.revoked", 401, true);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
let valid;
|
|
505
|
+
try {
|
|
506
|
+
valid = await this.options.provider.validate(lease.record.tokens.accessToken);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (!rejectedCredential(error)) throw error;
|
|
509
|
+
valid = false;
|
|
510
|
+
}
|
|
511
|
+
if (!valid) {
|
|
512
|
+
if (await this.repository.removeIfCurrent(lease)) {
|
|
513
|
+
throw new BffSessionError("session.revoked", 401, true);
|
|
514
|
+
}
|
|
515
|
+
if (changeRetries >= MAX_SESSION_CHANGE_RETRIES) {
|
|
516
|
+
throw new BffSessionError("session.changed", 503);
|
|
517
|
+
}
|
|
518
|
+
return this.readCurrent(id, changeRetries + 1);
|
|
519
|
+
}
|
|
520
|
+
const idleExpiresAt = Math.min(
|
|
521
|
+
lease.record.expiresAt,
|
|
522
|
+
Date.now() + this.sessionLifetime(lease.record.sessionPersistence).idleTimeoutMs
|
|
523
|
+
);
|
|
524
|
+
return await this.repository.touch(lease, idleExpiresAt) ?? lease.record;
|
|
525
|
+
}
|
|
526
|
+
createSessionLifetime(lifetime) {
|
|
527
|
+
const absoluteTimeoutMs = positiveDuration(lifetime.absoluteTimeoutMs);
|
|
528
|
+
const idleTimeoutMs = positiveDuration(lifetime.idleTimeoutMs);
|
|
529
|
+
if (idleTimeoutMs > absoluteTimeoutMs)
|
|
530
|
+
throw new Error("Authyon BFF: idle timeout must not exceed absolute timeout");
|
|
531
|
+
return { absoluteTimeoutMs, idleTimeoutMs };
|
|
532
|
+
}
|
|
533
|
+
sessionLifetime(sessionPersistence2) {
|
|
534
|
+
if (sessionPersistence2 === "remembered") return this.rememberedSession;
|
|
535
|
+
return this.standardSession;
|
|
536
|
+
}
|
|
537
|
+
needsRefresh(record) {
|
|
538
|
+
return record.tokens.expiresAt - this.refreshAhead <= Date.now();
|
|
539
|
+
}
|
|
540
|
+
async refreshIfNeeded(lease) {
|
|
541
|
+
if (!this.needsRefresh(lease.record)) return false;
|
|
542
|
+
await this.rotate(lease, {
|
|
543
|
+
issue: () => this.options.provider.refresh(lease.record.tokens.refreshToken),
|
|
544
|
+
accepts: (user) => user.id === lease.record.user.id && user.organization?.id === lease.record.user.organization?.id
|
|
545
|
+
});
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
async rotate(lease, operation) {
|
|
549
|
+
let tokens;
|
|
550
|
+
try {
|
|
551
|
+
tokens = await operation.issue();
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error instanceof AuthyonError && error.status === 429) throw error;
|
|
554
|
+
if (error instanceof BffSessionError && error.code === "organization.forbidden") throw error;
|
|
555
|
+
if (rejectedCredential(error)) throw error;
|
|
556
|
+
if (error instanceof BffSessionError && error.clearCookie) throw error;
|
|
557
|
+
throw new BffSessionError("session.rotation_interrupted", 503, true);
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
const user = await this.verifiedProfile(tokens);
|
|
561
|
+
if (!operation.accepts(user))
|
|
562
|
+
throw new BffSessionError("session.identity_changed", 502, true);
|
|
563
|
+
lease.record.tokens = tokens;
|
|
564
|
+
lease.record.user = user;
|
|
565
|
+
} catch {
|
|
566
|
+
await this.revokeUnstoredTokens(tokens);
|
|
567
|
+
throw new BffSessionError("session.rotation_interrupted", 503, true);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async issueOrganizationTokens(accessToken, slug) {
|
|
571
|
+
try {
|
|
572
|
+
return await this.options.provider.switchOrganization(accessToken, slug);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error instanceof AuthyonError && error.status === 403)
|
|
575
|
+
throw new BffSessionError("organization.forbidden", 403);
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async verifiedProfile(tokens) {
|
|
580
|
+
if (!tokens.accessToken || !tokens.refreshToken || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= Date.now()) {
|
|
581
|
+
throw new BffSessionError("provider.invalid_tokens", 502);
|
|
582
|
+
}
|
|
583
|
+
if (!await this.options.provider.validate(tokens.accessToken))
|
|
584
|
+
throw new BffSessionError("session.revoked", 401, true);
|
|
585
|
+
const user = await this.options.provider.profile(tokens.accessToken);
|
|
586
|
+
return {
|
|
587
|
+
id: user.id,
|
|
588
|
+
email: user.email,
|
|
589
|
+
organization: user.organization ? { id: user.organization.id, slug: user.organization.slug } : null
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
async revokeUnstoredTokens(tokens) {
|
|
593
|
+
try {
|
|
594
|
+
await this.options.provider.logout(tokens.refreshToken);
|
|
595
|
+
} catch {
|
|
596
|
+
throw new BffSessionError("session.upstream_revocation_failed", 503, true);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// src/bff/handlers.ts
|
|
602
|
+
function publicSession(record) {
|
|
603
|
+
return { authenticated: true, user: record.user, expiresAt: record.expiresAt };
|
|
604
|
+
}
|
|
605
|
+
function createBffSession(options) {
|
|
606
|
+
const http = createBffHttp(options);
|
|
607
|
+
const manager = new BffSessionManager(options);
|
|
608
|
+
function requiredSessionId(request) {
|
|
609
|
+
const id = http.sessionId(request);
|
|
610
|
+
if (!id) throw new BffSessionError("session.missing", 401, true);
|
|
611
|
+
return id;
|
|
612
|
+
}
|
|
613
|
+
function handler(method, action) {
|
|
614
|
+
return async (request) => {
|
|
615
|
+
try {
|
|
616
|
+
if (request.method !== method) {
|
|
617
|
+
const response = http.response(
|
|
618
|
+
{ error: { code: "request.method_not_allowed" } },
|
|
619
|
+
{ status: 405 }
|
|
620
|
+
);
|
|
621
|
+
response.headers.set("allow", method);
|
|
622
|
+
return response;
|
|
623
|
+
}
|
|
624
|
+
http.assertRequest(request);
|
|
625
|
+
return await action(request);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
return http.errorResponse(error);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
async function finishLogin(tokens, organizationSlug, sessionPersistence2) {
|
|
632
|
+
const { id, record } = await manager.create(tokens, organizationSlug, sessionPersistence2);
|
|
633
|
+
return http.response(publicSession(record), { cookie: http.cookie(id, record.expiresAt) });
|
|
634
|
+
}
|
|
635
|
+
async function assertSignedOut(request) {
|
|
636
|
+
const id = http.sessionId(request);
|
|
637
|
+
if (!id) return;
|
|
638
|
+
try {
|
|
639
|
+
await manager.read(id);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (error instanceof BffSessionError && error.status === 401) return;
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
throw new BffSessionError("session.already_authenticated", 409);
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
login: handler("POST", async (request) => {
|
|
648
|
+
await assertSignedOut(request);
|
|
649
|
+
const input = loginInput(await readBffBody(request));
|
|
650
|
+
const result = await options.provider.login(input);
|
|
651
|
+
if ("twoFactorRequired" in result) {
|
|
652
|
+
return http.response({
|
|
653
|
+
twoFactorRequired: true,
|
|
654
|
+
challengeToken: result.challengeToken,
|
|
655
|
+
methods: result.methods
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return finishLogin(result, input.organizationSlug, input.sessionPersistence);
|
|
659
|
+
}),
|
|
660
|
+
verifyTwoFactor: handler("POST", async (request) => {
|
|
661
|
+
await assertSignedOut(request);
|
|
662
|
+
const input = twoFactorInput(await readBffBody(request));
|
|
663
|
+
const tokens = await options.provider.verifyTwoFactor(input);
|
|
664
|
+
return finishLogin(tokens, void 0, input.sessionPersistence);
|
|
665
|
+
}),
|
|
666
|
+
session: handler(
|
|
667
|
+
"GET",
|
|
668
|
+
async (request) => http.response(publicSession(await manager.read(requiredSessionId(request))))
|
|
669
|
+
),
|
|
670
|
+
switchOrganization: handler("POST", async (request) => {
|
|
671
|
+
const slug = inputString(await readBffBody(request), "organizationSlug");
|
|
672
|
+
return http.response(
|
|
673
|
+
publicSession(await manager.switchOrganization(requiredSessionId(request), slug))
|
|
674
|
+
);
|
|
675
|
+
}),
|
|
676
|
+
logout: handler("POST", async (request) => {
|
|
677
|
+
const id = http.sessionId(request);
|
|
678
|
+
if (id) {
|
|
679
|
+
try {
|
|
680
|
+
await manager.logout(id);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (!(error instanceof BffSessionError) || error.status !== 401) throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return http.response(null, { status: 204, cookie: http.expireCookie() });
|
|
686
|
+
}),
|
|
687
|
+
/** Server-only credential for downstream calls. Never serialize this result to the browser. */
|
|
688
|
+
async requireSession(request) {
|
|
689
|
+
http.assertRequest(request);
|
|
690
|
+
const record = await manager.read(requiredSessionId(request));
|
|
691
|
+
return {
|
|
692
|
+
accessToken: record.tokens.accessToken,
|
|
693
|
+
user: record.user,
|
|
694
|
+
expiresAt: record.expiresAt
|
|
695
|
+
};
|
|
696
|
+
},
|
|
697
|
+
errorResponse: http.errorResponse
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ../../internal/core/config/defaults.ts
|
|
702
|
+
var DEFAULT_BASE_URL = "https://api.authyon.com";
|
|
703
|
+
|
|
704
|
+
// ../../internal/core/http/query.ts
|
|
705
|
+
function appendQuery(path, params) {
|
|
706
|
+
if (!params) return path;
|
|
707
|
+
const query = new URLSearchParams();
|
|
708
|
+
for (const [key, value2] of Object.entries(params)) {
|
|
709
|
+
if (value2 !== void 0) query.set(key, String(value2));
|
|
710
|
+
}
|
|
711
|
+
const value = query.toString();
|
|
712
|
+
return value ? `${path}${path.includes("?") ? "&" : "?"}${value}` : path;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ../../internal/core/http/httpAdapter.ts
|
|
716
|
+
var FetchHttpAdapter = class {
|
|
717
|
+
constructor(fetchImpl = fetch.bind(globalThis)) {
|
|
718
|
+
this.fetchImpl = fetchImpl;
|
|
719
|
+
}
|
|
720
|
+
request(request) {
|
|
721
|
+
return this.fetchImpl(request.url, {
|
|
722
|
+
method: request.method,
|
|
723
|
+
headers: request.headers,
|
|
724
|
+
body: request.body,
|
|
725
|
+
signal: request.signal,
|
|
726
|
+
redirect: "error"
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
var LoggingHttpAdapter = class {
|
|
731
|
+
constructor(adapter, options) {
|
|
732
|
+
this.adapter = adapter;
|
|
733
|
+
this.options = options;
|
|
734
|
+
}
|
|
735
|
+
async request(request) {
|
|
736
|
+
const startedAt = Date.now();
|
|
737
|
+
const eventBase = {
|
|
738
|
+
method: request.method,
|
|
739
|
+
url: sanitizeUrl(request.url)
|
|
740
|
+
};
|
|
741
|
+
this.log({ type: "request", ...eventBase, timestamp: startedAt });
|
|
742
|
+
try {
|
|
743
|
+
const response = await this.adapter.request(request);
|
|
744
|
+
this.log({
|
|
745
|
+
type: "response",
|
|
746
|
+
...eventBase,
|
|
747
|
+
status: response.status,
|
|
748
|
+
durationMs: Date.now() - startedAt,
|
|
749
|
+
requestId: response.headers.get("x-request-id") ?? response.headers.get("trace-id") ?? void 0,
|
|
750
|
+
timestamp: Date.now()
|
|
751
|
+
});
|
|
752
|
+
return response;
|
|
753
|
+
} catch (error) {
|
|
754
|
+
this.log({
|
|
755
|
+
type: "error",
|
|
756
|
+
...eventBase,
|
|
757
|
+
errorName: error instanceof Error ? error.name : "UnknownError",
|
|
758
|
+
durationMs: Date.now() - startedAt,
|
|
759
|
+
timestamp: Date.now()
|
|
760
|
+
});
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
log(event) {
|
|
765
|
+
if (!this.options.enabled) return;
|
|
766
|
+
const logger = this.options.logger ?? defaultHttpLogger;
|
|
767
|
+
try {
|
|
768
|
+
logger(event);
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
function defaultHttpLogger(event) {
|
|
774
|
+
console.debug("[Authyon HTTP]", event);
|
|
775
|
+
}
|
|
776
|
+
function sanitizeUrl(value) {
|
|
777
|
+
const url = new URL(value);
|
|
778
|
+
url.username = "";
|
|
779
|
+
url.password = "";
|
|
780
|
+
url.hash = "";
|
|
781
|
+
url.pathname = url.pathname.replace(/(\/platform\/workspace-invites\/)[^/]+/u, "$1REDACTED");
|
|
782
|
+
const queryKeys = /* @__PURE__ */ new Set();
|
|
783
|
+
url.searchParams.forEach((_value, key) => queryKeys.add(key));
|
|
784
|
+
url.search = queryKeys.size > 0 ? [...queryKeys].map((key) => `${encodeURIComponent(key)}=REDACTED`).join("&") : "";
|
|
785
|
+
return url.toString();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ../../internal/core/http/transport.ts
|
|
789
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
790
|
+
var SharedTransportError = class extends Error {
|
|
791
|
+
constructor(code, cause) {
|
|
792
|
+
super(code === "request.timeout" ? "Request timed out" : "Network request failed");
|
|
793
|
+
this.name = "SharedTransportError";
|
|
794
|
+
this.code = code;
|
|
795
|
+
this.cause = cause;
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
function createSharedTransport(options) {
|
|
799
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl, options.allowInsecureHttp ?? false);
|
|
800
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
801
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
802
|
+
throw new Error("Authyon: `timeoutMs` must be a non-negative finite number");
|
|
803
|
+
}
|
|
804
|
+
if (options.httpAdapter && options.fetch) {
|
|
805
|
+
throw new Error("Authyon: use either `httpAdapter` or `fetch`, not both");
|
|
806
|
+
}
|
|
807
|
+
const baseAdapter = options.httpAdapter ?? new FetchHttpAdapter(options.fetch);
|
|
808
|
+
const httpAdapter = options.httpLogger ? new LoggingHttpAdapter(baseAdapter, options.httpLogger) : baseAdapter;
|
|
809
|
+
return {
|
|
810
|
+
baseUrl,
|
|
811
|
+
async request(path, init) {
|
|
812
|
+
const controller = new AbortController();
|
|
813
|
+
const timeout = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
|
|
814
|
+
try {
|
|
815
|
+
return await httpAdapter.request({
|
|
816
|
+
url: `${baseUrl}${path}`,
|
|
817
|
+
method: init.method ?? "GET",
|
|
818
|
+
headers: normalizeHeaders(init.headers),
|
|
819
|
+
body: init.body,
|
|
820
|
+
signal: controller.signal
|
|
821
|
+
});
|
|
822
|
+
} catch (cause) {
|
|
823
|
+
throw new SharedTransportError(
|
|
824
|
+
controller.signal.aborted ? ErrorCodes.Timeout : ErrorCodes.NetworkError,
|
|
825
|
+
cause
|
|
826
|
+
);
|
|
827
|
+
} finally {
|
|
828
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function normalizeHeaders(headers) {
|
|
834
|
+
const normalized = {};
|
|
835
|
+
new Headers(headers).forEach((value, key) => {
|
|
836
|
+
normalized[key] = value;
|
|
837
|
+
});
|
|
838
|
+
return normalized;
|
|
839
|
+
}
|
|
840
|
+
function responseMetadata(response) {
|
|
841
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
842
|
+
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : void 0;
|
|
843
|
+
return {
|
|
844
|
+
requestId: response.headers.get("x-request-id") ?? response.headers.get("trace-id") ?? void 0,
|
|
845
|
+
retryAfter: Number.isFinite(retryAfter) ? retryAfter : void 0
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function normalizeBaseUrl(value, allowInsecureHttp) {
|
|
849
|
+
let url;
|
|
850
|
+
try {
|
|
851
|
+
url = new URL(value);
|
|
852
|
+
} catch {
|
|
853
|
+
throw new Error("Authyon: `baseUrl` must be an absolute URL");
|
|
854
|
+
}
|
|
855
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
856
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && (loopback || allowInsecureHttp))) {
|
|
857
|
+
throw new Error(
|
|
858
|
+
"Authyon: `baseUrl` must use HTTPS (HTTP is allowed only for loopback or with `allowInsecureHttp`)"
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
862
|
+
throw new Error(
|
|
863
|
+
"Authyon: `baseUrl` cannot contain credentials, query parameters, or fragments"
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return url.toString().replace(/\/+$/, "");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ../../internal/core/http/jsonHttpClient.ts
|
|
870
|
+
var JsonHttpClient = class {
|
|
871
|
+
constructor(transport) {
|
|
872
|
+
this.transport = transport;
|
|
873
|
+
}
|
|
874
|
+
async send(path, options = {}) {
|
|
875
|
+
const headers = { ...options.headers };
|
|
876
|
+
if (options.body !== void 0) headers["Content-Type"] = "application/json";
|
|
877
|
+
try {
|
|
878
|
+
return await this.transport.request(appendQuery(path, options.query), {
|
|
879
|
+
method: options.method ?? "GET",
|
|
880
|
+
headers,
|
|
881
|
+
body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
|
|
882
|
+
});
|
|
883
|
+
} catch (cause) {
|
|
884
|
+
if (!(cause instanceof SharedTransportError)) throw cause;
|
|
885
|
+
throw new AuthyonError(0, { code: cause.code, title: cause.message }, { cause: cause.cause });
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
async parse(response) {
|
|
889
|
+
if (!response.ok) {
|
|
890
|
+
let body = {};
|
|
891
|
+
try {
|
|
892
|
+
body = await response.json();
|
|
893
|
+
} catch {
|
|
894
|
+
}
|
|
895
|
+
throw new AuthyonError(response.status, body, responseMetadata(response));
|
|
896
|
+
}
|
|
897
|
+
if (response.status === 204) return void 0;
|
|
898
|
+
return await response.json();
|
|
899
|
+
}
|
|
900
|
+
async request(path, options = {}) {
|
|
901
|
+
return this.parse(await this.send(path, options));
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// src/bff/authyonProvider.ts
|
|
906
|
+
function object(value) {
|
|
907
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
908
|
+
throw new BffSessionError("provider.malformed_response", 502);
|
|
909
|
+
}
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
function requiredString(value) {
|
|
913
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
914
|
+
throw new BffSessionError("provider.malformed_response", 502);
|
|
915
|
+
}
|
|
916
|
+
return value;
|
|
917
|
+
}
|
|
918
|
+
function readTokens(value) {
|
|
919
|
+
const response = object(value);
|
|
920
|
+
const tokens = object(response.tokens ?? response);
|
|
921
|
+
if (typeof tokens.expiresIn !== "number" || !Number.isFinite(tokens.expiresIn) || tokens.expiresIn <= 0) {
|
|
922
|
+
throw new BffSessionError("provider.missing_token_lifetime", 502);
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
accessToken: requiredString(tokens.accessToken),
|
|
926
|
+
refreshToken: requiredString(tokens.refreshToken),
|
|
927
|
+
expiresAt: Date.now() + tokens.expiresIn * 1e3
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
function readChallenge(value) {
|
|
931
|
+
const challenge = object(value);
|
|
932
|
+
if (!Array.isArray(challenge.methods) || !challenge.methods.every((method) => typeof method === "string")) {
|
|
933
|
+
throw new BffSessionError("provider.malformed_challenge", 502);
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
twoFactorRequired: true,
|
|
937
|
+
challengeToken: requiredString(challenge.challengeToken),
|
|
938
|
+
methods: challenge.methods
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function readProfile(value) {
|
|
942
|
+
const user = object(value);
|
|
943
|
+
const activeOrganization = user.activeOrganization ?? user.tenant;
|
|
944
|
+
const organization = activeOrganization == null ? null : object(activeOrganization);
|
|
945
|
+
return {
|
|
946
|
+
id: requiredString(user.id),
|
|
947
|
+
email: requiredString(user.email),
|
|
948
|
+
organization: organization ? { id: requiredString(organization.id), slug: requiredString(organization.slug) } : null
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function createAuthyonBffProvider(options) {
|
|
952
|
+
if (!options.envKey) throw new Error("Authyon BFF: envKey is required");
|
|
953
|
+
const http = new JsonHttpClient(
|
|
954
|
+
createSharedTransport({
|
|
955
|
+
...options,
|
|
956
|
+
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL
|
|
957
|
+
})
|
|
958
|
+
);
|
|
959
|
+
const headers = { "X-Authyon-Environment": options.envKey };
|
|
960
|
+
const bearer = (token) => ({ ...headers, Authorization: `Bearer ${token}` });
|
|
961
|
+
return {
|
|
962
|
+
async login(input) {
|
|
963
|
+
const credentials = { ...input };
|
|
964
|
+
delete credentials.organizationSlug;
|
|
965
|
+
delete credentials.sessionPersistence;
|
|
966
|
+
const response = object(
|
|
967
|
+
await http.request("/auth/login", {
|
|
968
|
+
method: "POST",
|
|
969
|
+
headers,
|
|
970
|
+
body: {
|
|
971
|
+
...credentials,
|
|
972
|
+
...input.organizationSlug ? { tenantSlug: input.organizationSlug } : {}
|
|
973
|
+
}
|
|
974
|
+
})
|
|
975
|
+
);
|
|
976
|
+
return response.twoFactor ? readChallenge(response.twoFactor) : readTokens(response);
|
|
977
|
+
},
|
|
978
|
+
async verifyTwoFactor(input) {
|
|
979
|
+
const verification = { ...input };
|
|
980
|
+
delete verification.sessionPersistence;
|
|
981
|
+
return readTokens(
|
|
982
|
+
await http.request("/auth/2fa/verify", { method: "POST", headers, body: verification })
|
|
983
|
+
);
|
|
984
|
+
},
|
|
985
|
+
async refresh(refreshToken) {
|
|
986
|
+
return readTokens(
|
|
987
|
+
await http.request("/auth/refresh", { method: "POST", headers, body: { refreshToken } })
|
|
988
|
+
);
|
|
989
|
+
},
|
|
990
|
+
async logout(refreshToken) {
|
|
991
|
+
await http.request("/auth/logout", { method: "POST", headers, body: { refreshToken } });
|
|
992
|
+
},
|
|
993
|
+
async switchOrganization(accessToken, slug) {
|
|
994
|
+
return readTokens(
|
|
995
|
+
await http.request("/auth/switch-tenant", {
|
|
996
|
+
method: "POST",
|
|
997
|
+
headers: bearer(accessToken),
|
|
998
|
+
body: { tenantSlug: slug }
|
|
999
|
+
})
|
|
1000
|
+
);
|
|
1001
|
+
},
|
|
1002
|
+
async validate(accessToken) {
|
|
1003
|
+
try {
|
|
1004
|
+
const result = await options.validator.validate(accessToken);
|
|
1005
|
+
return result.valid === true && result.user != null;
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
if (error instanceof AuthyonError)
|
|
1008
|
+
throw new BffSessionError("provider.validation_unavailable", 503);
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
async profile(accessToken) {
|
|
1013
|
+
return readProfile(await http.request("/auth/me", { headers: bearer(accessToken) }));
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/bff/memoryStore.ts
|
|
1019
|
+
function createMemoryBffSessionStore() {
|
|
1020
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1021
|
+
function read(key) {
|
|
1022
|
+
const session = sessions.get(key);
|
|
1023
|
+
if (!session) return null;
|
|
1024
|
+
if (Math.min(session.expiresAt, session.idleExpiresAt) <= Date.now()) {
|
|
1025
|
+
sessions.delete(key);
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
return structuredClone(session);
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
async read(key) {
|
|
1032
|
+
return read(key);
|
|
1033
|
+
},
|
|
1034
|
+
async compareAndSwap(key, expectedRevision, next) {
|
|
1035
|
+
if ((read(key)?.revision ?? null) !== expectedRevision) return false;
|
|
1036
|
+
if (next) {
|
|
1037
|
+
if (expectedRevision === null) {
|
|
1038
|
+
for (const sessionKey2 of sessions.keys()) read(sessionKey2);
|
|
1039
|
+
}
|
|
1040
|
+
sessions.set(key, structuredClone(next));
|
|
1041
|
+
} else {
|
|
1042
|
+
sessions.delete(key);
|
|
1043
|
+
}
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/bff/redisStore.ts
|
|
1050
|
+
var import_jose = require("jose");
|
|
1051
|
+
var COMPARE_AND_SWAP = `
|
|
1052
|
+
local current = redis.call('GET', KEYS[1])
|
|
1053
|
+
local revision = ''
|
|
1054
|
+
if current then revision = cjson.decode(current).revision end
|
|
1055
|
+
if revision ~= ARGV[1] then return 0 end
|
|
1056
|
+
if ARGV[2] == '' then
|
|
1057
|
+
redis.call('DEL', KEYS[1])
|
|
1058
|
+
else
|
|
1059
|
+
redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
|
|
1060
|
+
end
|
|
1061
|
+
return 1
|
|
1062
|
+
`;
|
|
1063
|
+
function isObject(value) {
|
|
1064
|
+
return typeof value === "object" && value !== null;
|
|
1065
|
+
}
|
|
1066
|
+
function isFiniteNumber(value) {
|
|
1067
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1068
|
+
}
|
|
1069
|
+
function isOrganization(value) {
|
|
1070
|
+
return value === null || isObject(value) && typeof value.id === "string" && typeof value.slug === "string";
|
|
1071
|
+
}
|
|
1072
|
+
function isSessionRecord(value) {
|
|
1073
|
+
if (!isObject(value) || !isObject(value.tokens) || !isObject(value.user)) return false;
|
|
1074
|
+
return typeof value.revision === "string" && typeof value.tokens.accessToken === "string" && typeof value.tokens.refreshToken === "string" && isFiniteNumber(value.tokens.expiresAt) && typeof value.user.id === "string" && typeof value.user.email === "string" && isOrganization(value.user.organization) && (value.sessionPersistence === void 0 || value.sessionPersistence === "standard" || value.sessionPersistence === "remembered") && isFiniteNumber(value.expiresAt) && isFiniteNumber(value.idleExpiresAt) && (value.busyUntil === null || isFiniteNumber(value.busyUntil));
|
|
1075
|
+
}
|
|
1076
|
+
function createRedisBffSessionStore(options) {
|
|
1077
|
+
if (options.encryptionKey.byteLength !== 32) {
|
|
1078
|
+
throw new Error("Authyon BFF: encryptionKey must contain 32 bytes");
|
|
1079
|
+
}
|
|
1080
|
+
const encryptionKey = new Uint8Array(options.encryptionKey);
|
|
1081
|
+
const prefix = options.prefix ?? "authyon:bff:";
|
|
1082
|
+
return {
|
|
1083
|
+
async read(key) {
|
|
1084
|
+
const serialized = await options.client.get(prefix + key);
|
|
1085
|
+
if (!serialized) return null;
|
|
1086
|
+
try {
|
|
1087
|
+
const envelope = JSON.parse(serialized);
|
|
1088
|
+
const { payload } = await (0, import_jose.jwtDecrypt)(envelope.ciphertext, encryptionKey, {
|
|
1089
|
+
keyManagementAlgorithms: ["dir"],
|
|
1090
|
+
contentEncryptionAlgorithms: ["A256GCM"],
|
|
1091
|
+
audience: prefix + key
|
|
1092
|
+
});
|
|
1093
|
+
const record = payload.session;
|
|
1094
|
+
if (!isSessionRecord(record) || record.revision !== envelope.revision) {
|
|
1095
|
+
throw new Error("invalid session record");
|
|
1096
|
+
}
|
|
1097
|
+
return record;
|
|
1098
|
+
} catch {
|
|
1099
|
+
throw new BffSessionError("session.storage_invalid", 503);
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
async compareAndSwap(key, expectedRevision, next) {
|
|
1103
|
+
const expiresAt = next ? Math.min(next.expiresAt, next.idleExpiresAt) : Date.now();
|
|
1104
|
+
const ttl = Math.ceil(expiresAt - Date.now());
|
|
1105
|
+
if (next && ttl <= 0) return false;
|
|
1106
|
+
let serialized = "";
|
|
1107
|
+
if (next) {
|
|
1108
|
+
const ciphertext = await new import_jose.EncryptJWT({ session: next }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setAudience(prefix + key).encrypt(encryptionKey);
|
|
1109
|
+
serialized = JSON.stringify({ revision: next.revision, ciphertext });
|
|
1110
|
+
}
|
|
1111
|
+
const result = await options.client.eval(COMPARE_AND_SWAP, {
|
|
1112
|
+
keys: [prefix + key],
|
|
1113
|
+
arguments: [expectedRevision ?? "", serialized, String(ttl)]
|
|
1114
|
+
});
|
|
1115
|
+
return result === 1;
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1120
|
+
0 && (module.exports = {
|
|
1121
|
+
BffSessionError,
|
|
1122
|
+
createAuthyonBffProvider,
|
|
1123
|
+
createBffSession,
|
|
1124
|
+
createMemoryBffSessionStore,
|
|
1125
|
+
createRedisBffSessionStore
|
|
1126
|
+
});
|