@authyon/server 0.2.0-beta.5 → 0.2.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bff.js ADDED
@@ -0,0 +1,791 @@
1
+ import {
2
+ AuthyonError,
3
+ DEFAULT_BASE_URL,
4
+ JsonHttpClient,
5
+ createSharedTransport
6
+ } from "./chunk-56H66KLG.js";
7
+ import "./chunk-77J4NURK.js";
8
+
9
+ // src/bff/contracts.ts
10
+ var BffSessionError = class extends Error {
11
+ constructor(code, status, clearCookie = false) {
12
+ super(code);
13
+ this.code = code;
14
+ this.status = status;
15
+ this.clearCookie = clearCookie;
16
+ this.name = "BffSessionError";
17
+ }
18
+ };
19
+
20
+ // src/bff/http.ts
21
+ var MAX_BODY_BYTES = 16384;
22
+ var SESSION_ID = /^[a-f0-9]{64}$/;
23
+ function createBffHttp(options) {
24
+ const url = new URL(options.origin);
25
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
26
+ const insecure = url.protocol === "http:" && loopback && options.allowInsecureLocalhost === true;
27
+ if (!insecure && url.protocol !== "https:" || url.origin !== options.origin) {
28
+ throw new Error("Authyon BFF: origin must be an HTTPS origin, without path or credentials");
29
+ }
30
+ const cookieName = insecure ? "authyon.session" : "__Host-authyon.session";
31
+ const attributes = `Path=/; HttpOnly; SameSite=Lax${insecure ? "" : "; Secure"}`;
32
+ function assertRequest(request) {
33
+ if (new URL(request.url).origin !== options.origin)
34
+ throw new BffSessionError("request.origin_denied", 403);
35
+ const origin = request.headers.get("origin");
36
+ if (origin && origin !== options.origin || request.headers.get("sec-fetch-site") === "cross-site") {
37
+ throw new BffSessionError("request.origin_denied", 403);
38
+ }
39
+ if (!["GET", "HEAD"].includes(request.method)) {
40
+ if (origin !== options.origin || request.headers.get("x-authyon-csrf") !== "1") {
41
+ throw new BffSessionError("request.csrf_denied", 403);
42
+ }
43
+ }
44
+ }
45
+ function sessionId(request) {
46
+ const matches = (request.headers.get("cookie") ?? "").split(";").map((part) => part.trim()).filter((part) => part.startsWith(`${cookieName}=`));
47
+ if (matches.length > 1) throw new BffSessionError("session.ambiguous_cookie", 401, true);
48
+ const value = matches[0]?.slice(cookieName.length + 1);
49
+ return value && SESSION_ID.test(value) ? value : null;
50
+ }
51
+ function response(body, init = {}) {
52
+ const headers = new Headers({
53
+ "cache-control": "no-store",
54
+ pragma: "no-cache",
55
+ vary: "Cookie",
56
+ "x-content-type-options": "nosniff"
57
+ });
58
+ if (init.cookie) headers.set("set-cookie", init.cookie);
59
+ if (body !== null) headers.set("content-type", "application/json");
60
+ return new Response(body === null ? null : JSON.stringify(body), {
61
+ status: init.status ?? 200,
62
+ headers
63
+ });
64
+ }
65
+ function expireCookie() {
66
+ return `${cookieName}=; ${attributes}; Max-Age=0`;
67
+ }
68
+ function errorResponse(error) {
69
+ if (error instanceof BffSessionError) {
70
+ return response(
71
+ { error: { code: error.code } },
72
+ { status: error.status, cookie: error.clearCookie ? expireCookie() : void 0 }
73
+ );
74
+ }
75
+ if (error instanceof AuthyonError) {
76
+ const status = [400, 401, 403, 429].includes(error.status) ? error.status : 503;
77
+ const result = response(
78
+ { error: { code: status === 503 ? "provider.unavailable" : "provider.rejected" } },
79
+ { status }
80
+ );
81
+ if (error.retryAfter !== void 0)
82
+ result.headers.set("retry-after", String(error.retryAfter));
83
+ return result;
84
+ }
85
+ return response({ error: { code: "session.unavailable" } }, { status: 503 });
86
+ }
87
+ return {
88
+ assertRequest,
89
+ sessionId,
90
+ response,
91
+ expireCookie,
92
+ errorResponse,
93
+ cookie(id, expiresAt) {
94
+ return `${cookieName}=${id}; ${attributes}; Max-Age=${Math.max(0, Math.floor((expiresAt - Date.now()) / 1e3))}`;
95
+ }
96
+ };
97
+ }
98
+ async function readBffBody(request) {
99
+ if (request.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") {
100
+ throw new BffSessionError("request.json_required", 415);
101
+ }
102
+ if (!request.body) throw new BffSessionError("request.invalid_body", 400);
103
+ const reader = request.body.getReader();
104
+ const chunks = [];
105
+ let size = 0;
106
+ try {
107
+ while (true) {
108
+ const { value, done } = await reader.read();
109
+ if (done) break;
110
+ size += value.byteLength;
111
+ if (size > MAX_BODY_BYTES) {
112
+ await reader.cancel();
113
+ throw new BffSessionError("request.body_too_large", 413);
114
+ }
115
+ chunks.push(value);
116
+ }
117
+ const bytes = new Uint8Array(size);
118
+ let offset = 0;
119
+ for (const chunk of chunks) {
120
+ bytes.set(chunk, offset);
121
+ offset += chunk.byteLength;
122
+ }
123
+ const body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
124
+ if (!body || typeof body !== "object" || Array.isArray(body))
125
+ throw new Error("invalid JSON object");
126
+ return body;
127
+ } catch (error) {
128
+ if (error instanceof BffSessionError) throw error;
129
+ throw new BffSessionError("request.invalid_body", 400);
130
+ } finally {
131
+ reader.releaseLock();
132
+ }
133
+ }
134
+ function inputString(body, name) {
135
+ const value = body[name];
136
+ if (typeof value !== "string" || value.length === 0)
137
+ throw new BffSessionError("request.invalid_body", 400);
138
+ return value;
139
+ }
140
+ function sessionPersistence(body) {
141
+ const value = body.sessionPersistence;
142
+ if (value === void 0 || value === "standard") return "standard";
143
+ if (value === "remembered") return "remembered";
144
+ throw new BffSessionError("request.invalid_body", 400);
145
+ }
146
+ function loginInput(body) {
147
+ return {
148
+ password: inputString(body, "password"),
149
+ ...body.email !== void 0 ? { email: inputString(body, "email") } : { username: inputString(body, "username") },
150
+ ...body.organizationSlug !== void 0 ? { organizationSlug: inputString(body, "organizationSlug") } : {},
151
+ sessionPersistence: sessionPersistence(body)
152
+ };
153
+ }
154
+ function twoFactorInput(body) {
155
+ const method = inputString(body, "method");
156
+ const challengeToken = inputString(body, "challengeToken");
157
+ const persistence = sessionPersistence(body);
158
+ if (method !== "webauthn") {
159
+ return {
160
+ challengeToken,
161
+ method,
162
+ code: inputString(body, "code"),
163
+ sessionPersistence: persistence
164
+ };
165
+ }
166
+ const assertion = body.webAuthnAssertion;
167
+ if (!assertion || typeof assertion !== "object" || Array.isArray(assertion))
168
+ throw new BffSessionError("request.invalid_body", 400);
169
+ const fields = assertion;
170
+ return {
171
+ challengeToken,
172
+ method,
173
+ sessionPersistence: persistence,
174
+ webAuthnAssertion: {
175
+ ceremonyToken: inputString(fields, "ceremonyToken"),
176
+ assertionJson: inputString(fields, "assertionJson")
177
+ }
178
+ };
179
+ }
180
+
181
+ // src/bff/sessionRepository.ts
182
+ import { createHash, randomBytes } from "crypto";
183
+ var OPERATION_TIMEOUT_MS = 12e4;
184
+ var CONTENTION_TIMEOUT_MS = 2e3;
185
+ var CONTENTION_POLL_MS = 20;
186
+ var newSessionSecret = () => randomBytes(32).toString("hex");
187
+ var sessionKey = (id, origin) => createHash("sha256").update(`${origin}\0${id}`).digest("hex");
188
+ var BffSessionRepository = class {
189
+ constructor(store, origin) {
190
+ this.store = store;
191
+ this.origin = origin;
192
+ }
193
+ async create(record) {
194
+ const id = newSessionSecret();
195
+ if (!await this.store.compareAndSwap(sessionKey(id, this.origin), null, record)) {
196
+ throw new BffSessionError("session.creation_failed", 503);
197
+ }
198
+ return id;
199
+ }
200
+ async acquire(id) {
201
+ const key = sessionKey(id, this.origin);
202
+ const deadline = Date.now() + CONTENTION_TIMEOUT_MS;
203
+ while (Date.now() < deadline) {
204
+ const record = await this.readAvailable(key, deadline);
205
+ const reserved = {
206
+ ...record,
207
+ revision: newSessionSecret(),
208
+ busyUntil: Date.now() + OPERATION_TIMEOUT_MS
209
+ };
210
+ if (await this.store.compareAndSwap(key, record.revision, reserved))
211
+ return { key, record: reserved };
212
+ }
213
+ throw new BffSessionError("session.busy", 503);
214
+ }
215
+ async inspect(id) {
216
+ const key = sessionKey(id, this.origin);
217
+ const record = await this.readAvailable(key, Date.now() + CONTENTION_TIMEOUT_MS);
218
+ return { key, record };
219
+ }
220
+ async touch(lease, idleExpiresAt) {
221
+ const next = {
222
+ ...lease.record,
223
+ revision: newSessionSecret(),
224
+ idleExpiresAt
225
+ };
226
+ return await this.store.compareAndSwap(lease.key, lease.record.revision, next) ? next : null;
227
+ }
228
+ async removeIfCurrent(lease) {
229
+ return this.store.compareAndSwap(lease.key, lease.record.revision, null);
230
+ }
231
+ async release(lease) {
232
+ if (lease.record.busyUntil === null || lease.record.busyUntil <= Date.now()) {
233
+ await this.remove(lease);
234
+ throw new BffSessionError("session.interrupted", 503, true);
235
+ }
236
+ const next = { ...lease.record, revision: newSessionSecret(), busyUntil: null };
237
+ if (!await this.store.compareAndSwap(lease.key, lease.record.revision, next)) {
238
+ throw new BffSessionError("session.changed", 401, true);
239
+ }
240
+ }
241
+ async remove(lease) {
242
+ if (!await this.removeIfCurrent(lease)) {
243
+ throw new BffSessionError("session.changed", 401, true);
244
+ }
245
+ }
246
+ async readAvailable(key, deadline) {
247
+ while (Date.now() < deadline) {
248
+ const record = await this.store.read(key);
249
+ if (!record) throw new BffSessionError("session.missing", 401, true);
250
+ if (Math.min(record.expiresAt, record.idleExpiresAt) <= Date.now()) {
251
+ if (await this.store.compareAndSwap(key, record.revision, null)) {
252
+ throw new BffSessionError("session.expired", 401, true);
253
+ }
254
+ continue;
255
+ }
256
+ if (record.busyUntil === null) return record;
257
+ if (record.busyUntil <= Date.now()) {
258
+ if (await this.store.compareAndSwap(key, record.revision, null)) {
259
+ throw new BffSessionError("session.interrupted", 503, true);
260
+ }
261
+ continue;
262
+ }
263
+ await new Promise((resolve) => setTimeout(resolve, CONTENTION_POLL_MS));
264
+ }
265
+ throw new BffSessionError("session.busy", 503);
266
+ }
267
+ };
268
+
269
+ // src/bff/sessionManager.ts
270
+ var DEFAULT_ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60 * 1e3;
271
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
272
+ var DEFAULT_REMEMBERED_ABSOLUTE_TIMEOUT_MS = 90 * 24 * 60 * 60 * 1e3;
273
+ var DEFAULT_REMEMBERED_IDLE_TIMEOUT_MS = 30 * 24 * 60 * 60 * 1e3;
274
+ var DEFAULT_REFRESH_AHEAD_MS = 3e4;
275
+ var MAX_SESSION_CHANGE_RETRIES = 2;
276
+ function positiveDuration(value) {
277
+ if (!Number.isSafeInteger(value) || value <= 0)
278
+ throw new Error("Authyon BFF: durations must be positive integer milliseconds");
279
+ return value;
280
+ }
281
+ function rejectedCredential(error) {
282
+ return error instanceof AuthyonError && [401, 403].includes(error.status);
283
+ }
284
+ var BffSessionManager = class {
285
+ constructor(options) {
286
+ this.options = options;
287
+ this.repository = new BffSessionRepository(options.store, options.origin);
288
+ this.standardSession = this.createSessionLifetime({
289
+ absoluteTimeoutMs: options.absoluteTimeoutMs ?? DEFAULT_ABSOLUTE_TIMEOUT_MS,
290
+ idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
291
+ });
292
+ this.rememberedSession = this.createSessionLifetime({
293
+ absoluteTimeoutMs: options.rememberedSession?.absoluteTimeoutMs ?? DEFAULT_REMEMBERED_ABSOLUTE_TIMEOUT_MS,
294
+ idleTimeoutMs: options.rememberedSession?.idleTimeoutMs ?? DEFAULT_REMEMBERED_IDLE_TIMEOUT_MS
295
+ });
296
+ this.refreshAhead = positiveDuration(options.refreshAheadMs ?? DEFAULT_REFRESH_AHEAD_MS);
297
+ }
298
+ async create(tokens, organizationSlug, sessionPersistence2 = "standard") {
299
+ try {
300
+ const user = await this.verifiedProfile(tokens);
301
+ if (organizationSlug !== void 0 && user.organization?.slug !== organizationSlug) {
302
+ throw new BffSessionError("session.organization_mismatch", 502);
303
+ }
304
+ const lifetime = this.sessionLifetime(sessionPersistence2);
305
+ const record = {
306
+ tokens,
307
+ user,
308
+ revision: newSessionSecret(),
309
+ busyUntil: null,
310
+ sessionPersistence: sessionPersistence2,
311
+ expiresAt: Date.now() + lifetime.absoluteTimeoutMs,
312
+ idleExpiresAt: Date.now() + lifetime.idleTimeoutMs
313
+ };
314
+ return { id: await this.repository.create(record), record };
315
+ } catch (error) {
316
+ await this.revokeUnstoredTokens(tokens);
317
+ throw error;
318
+ }
319
+ }
320
+ async read(id) {
321
+ return this.readCurrent(id, 0);
322
+ }
323
+ async switchOrganization(id, slug) {
324
+ return this.update(id, async (lease) => {
325
+ const refreshed = await this.refreshIfNeeded(lease);
326
+ if (!refreshed && !await this.options.provider.validate(lease.record.tokens.accessToken)) {
327
+ throw new BffSessionError("session.revoked", 401, true);
328
+ }
329
+ await this.rotate(lease, {
330
+ issue: () => this.issueOrganizationTokens(lease.record.tokens.accessToken, slug),
331
+ accepts: (user) => user.id === lease.record.user.id && user.organization?.slug === slug
332
+ });
333
+ });
334
+ }
335
+ async logout(id) {
336
+ const lease = await this.repository.acquire(id);
337
+ await this.repository.remove(lease);
338
+ try {
339
+ await this.options.provider.logout(lease.record.tokens.refreshToken);
340
+ } catch {
341
+ throw new BffSessionError("session.upstream_revocation_failed", 503, true);
342
+ }
343
+ }
344
+ async update(id, operation) {
345
+ const lease = await this.repository.acquire(id);
346
+ try {
347
+ await operation(lease);
348
+ } catch (error) {
349
+ if (rejectedCredential(error) || error instanceof BffSessionError && error.clearCookie) {
350
+ await this.repository.remove(lease);
351
+ if (error instanceof BffSessionError) throw error;
352
+ throw new BffSessionError("session.revoked", 401, true);
353
+ }
354
+ await this.repository.release(lease);
355
+ throw error;
356
+ }
357
+ lease.record.idleExpiresAt = Math.min(
358
+ lease.record.expiresAt,
359
+ Date.now() + this.sessionLifetime(lease.record.sessionPersistence).idleTimeoutMs
360
+ );
361
+ await this.repository.release(lease);
362
+ return lease.record;
363
+ }
364
+ async readCurrent(id, changeRetries) {
365
+ const lease = await this.repository.inspect(id);
366
+ if (this.needsRefresh(lease.record)) {
367
+ return this.update(id, async (reserved) => {
368
+ const refreshed = await this.refreshIfNeeded(reserved);
369
+ if (!refreshed && !await this.options.provider.validate(reserved.record.tokens.accessToken)) {
370
+ throw new BffSessionError("session.revoked", 401, true);
371
+ }
372
+ });
373
+ }
374
+ let valid;
375
+ try {
376
+ valid = await this.options.provider.validate(lease.record.tokens.accessToken);
377
+ } catch (error) {
378
+ if (!rejectedCredential(error)) throw error;
379
+ valid = false;
380
+ }
381
+ if (!valid) {
382
+ if (await this.repository.removeIfCurrent(lease)) {
383
+ throw new BffSessionError("session.revoked", 401, true);
384
+ }
385
+ if (changeRetries >= MAX_SESSION_CHANGE_RETRIES) {
386
+ throw new BffSessionError("session.changed", 503);
387
+ }
388
+ return this.readCurrent(id, changeRetries + 1);
389
+ }
390
+ const idleExpiresAt = Math.min(
391
+ lease.record.expiresAt,
392
+ Date.now() + this.sessionLifetime(lease.record.sessionPersistence).idleTimeoutMs
393
+ );
394
+ return await this.repository.touch(lease, idleExpiresAt) ?? lease.record;
395
+ }
396
+ createSessionLifetime(lifetime) {
397
+ const absoluteTimeoutMs = positiveDuration(lifetime.absoluteTimeoutMs);
398
+ const idleTimeoutMs = positiveDuration(lifetime.idleTimeoutMs);
399
+ if (idleTimeoutMs > absoluteTimeoutMs)
400
+ throw new Error("Authyon BFF: idle timeout must not exceed absolute timeout");
401
+ return { absoluteTimeoutMs, idleTimeoutMs };
402
+ }
403
+ sessionLifetime(sessionPersistence2) {
404
+ if (sessionPersistence2 === "remembered") return this.rememberedSession;
405
+ return this.standardSession;
406
+ }
407
+ needsRefresh(record) {
408
+ return record.tokens.expiresAt - this.refreshAhead <= Date.now();
409
+ }
410
+ async refreshIfNeeded(lease) {
411
+ if (!this.needsRefresh(lease.record)) return false;
412
+ await this.rotate(lease, {
413
+ issue: () => this.options.provider.refresh(lease.record.tokens.refreshToken),
414
+ accepts: (user) => user.id === lease.record.user.id && user.organization?.id === lease.record.user.organization?.id
415
+ });
416
+ return true;
417
+ }
418
+ async rotate(lease, operation) {
419
+ let tokens;
420
+ try {
421
+ tokens = await operation.issue();
422
+ } catch (error) {
423
+ if (error instanceof AuthyonError && error.status === 429) throw error;
424
+ if (error instanceof BffSessionError && error.code === "organization.forbidden") throw error;
425
+ if (rejectedCredential(error)) throw error;
426
+ if (error instanceof BffSessionError && error.clearCookie) throw error;
427
+ throw new BffSessionError("session.rotation_interrupted", 503, true);
428
+ }
429
+ try {
430
+ const user = await this.verifiedProfile(tokens);
431
+ if (!operation.accepts(user))
432
+ throw new BffSessionError("session.identity_changed", 502, true);
433
+ lease.record.tokens = tokens;
434
+ lease.record.user = user;
435
+ } catch {
436
+ await this.revokeUnstoredTokens(tokens);
437
+ throw new BffSessionError("session.rotation_interrupted", 503, true);
438
+ }
439
+ }
440
+ async issueOrganizationTokens(accessToken, slug) {
441
+ try {
442
+ return await this.options.provider.switchOrganization(accessToken, slug);
443
+ } catch (error) {
444
+ if (error instanceof AuthyonError && error.status === 403)
445
+ throw new BffSessionError("organization.forbidden", 403);
446
+ throw error;
447
+ }
448
+ }
449
+ async verifiedProfile(tokens) {
450
+ if (!tokens.accessToken || !tokens.refreshToken || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= Date.now()) {
451
+ throw new BffSessionError("provider.invalid_tokens", 502);
452
+ }
453
+ if (!await this.options.provider.validate(tokens.accessToken))
454
+ throw new BffSessionError("session.revoked", 401, true);
455
+ const user = await this.options.provider.profile(tokens.accessToken);
456
+ return {
457
+ id: user.id,
458
+ email: user.email,
459
+ organization: user.organization ? { id: user.organization.id, slug: user.organization.slug } : null
460
+ };
461
+ }
462
+ async revokeUnstoredTokens(tokens) {
463
+ try {
464
+ await this.options.provider.logout(tokens.refreshToken);
465
+ } catch {
466
+ throw new BffSessionError("session.upstream_revocation_failed", 503, true);
467
+ }
468
+ }
469
+ };
470
+
471
+ // src/bff/handlers.ts
472
+ function publicSession(record) {
473
+ return { authenticated: true, user: record.user, expiresAt: record.expiresAt };
474
+ }
475
+ function createBffSession(options) {
476
+ const http = createBffHttp(options);
477
+ const manager = new BffSessionManager(options);
478
+ function requiredSessionId(request) {
479
+ const id = http.sessionId(request);
480
+ if (!id) throw new BffSessionError("session.missing", 401, true);
481
+ return id;
482
+ }
483
+ function handler(method, action) {
484
+ return async (request) => {
485
+ try {
486
+ if (request.method !== method) {
487
+ const response = http.response(
488
+ { error: { code: "request.method_not_allowed" } },
489
+ { status: 405 }
490
+ );
491
+ response.headers.set("allow", method);
492
+ return response;
493
+ }
494
+ http.assertRequest(request);
495
+ return await action(request);
496
+ } catch (error) {
497
+ return http.errorResponse(error);
498
+ }
499
+ };
500
+ }
501
+ async function finishLogin(tokens, organizationSlug, sessionPersistence2) {
502
+ const { id, record } = await manager.create(tokens, organizationSlug, sessionPersistence2);
503
+ return http.response(publicSession(record), { cookie: http.cookie(id, record.expiresAt) });
504
+ }
505
+ async function assertSignedOut(request) {
506
+ const id = http.sessionId(request);
507
+ if (!id) return;
508
+ try {
509
+ await manager.read(id);
510
+ } catch (error) {
511
+ if (error instanceof BffSessionError && error.status === 401) return;
512
+ throw error;
513
+ }
514
+ throw new BffSessionError("session.already_authenticated", 409);
515
+ }
516
+ return {
517
+ login: handler("POST", async (request) => {
518
+ await assertSignedOut(request);
519
+ const input = loginInput(await readBffBody(request));
520
+ const result = await options.provider.login(input);
521
+ if ("twoFactorRequired" in result) {
522
+ return http.response({
523
+ twoFactorRequired: true,
524
+ challengeToken: result.challengeToken,
525
+ methods: result.methods
526
+ });
527
+ }
528
+ return finishLogin(result, input.organizationSlug, input.sessionPersistence);
529
+ }),
530
+ verifyTwoFactor: handler("POST", async (request) => {
531
+ await assertSignedOut(request);
532
+ const input = twoFactorInput(await readBffBody(request));
533
+ const tokens = await options.provider.verifyTwoFactor(input);
534
+ return finishLogin(tokens, void 0, input.sessionPersistence);
535
+ }),
536
+ session: handler(
537
+ "GET",
538
+ async (request) => http.response(publicSession(await manager.read(requiredSessionId(request))))
539
+ ),
540
+ switchOrganization: handler("POST", async (request) => {
541
+ const slug = inputString(await readBffBody(request), "organizationSlug");
542
+ return http.response(
543
+ publicSession(await manager.switchOrganization(requiredSessionId(request), slug))
544
+ );
545
+ }),
546
+ logout: handler("POST", async (request) => {
547
+ const id = http.sessionId(request);
548
+ if (id) {
549
+ try {
550
+ await manager.logout(id);
551
+ } catch (error) {
552
+ if (!(error instanceof BffSessionError) || error.status !== 401) throw error;
553
+ }
554
+ }
555
+ return http.response(null, { status: 204, cookie: http.expireCookie() });
556
+ }),
557
+ /** Server-only credential for downstream calls. Never serialize this result to the browser. */
558
+ async requireSession(request) {
559
+ http.assertRequest(request);
560
+ const record = await manager.read(requiredSessionId(request));
561
+ return {
562
+ accessToken: record.tokens.accessToken,
563
+ user: record.user,
564
+ expiresAt: record.expiresAt
565
+ };
566
+ },
567
+ errorResponse: http.errorResponse
568
+ };
569
+ }
570
+
571
+ // src/bff/authyonProvider.ts
572
+ function object(value) {
573
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
574
+ throw new BffSessionError("provider.malformed_response", 502);
575
+ }
576
+ return value;
577
+ }
578
+ function requiredString(value) {
579
+ if (typeof value !== "string" || value.length === 0) {
580
+ throw new BffSessionError("provider.malformed_response", 502);
581
+ }
582
+ return value;
583
+ }
584
+ function readTokens(value) {
585
+ const response = object(value);
586
+ const tokens = object(response.tokens ?? response);
587
+ if (typeof tokens.expiresIn !== "number" || !Number.isFinite(tokens.expiresIn) || tokens.expiresIn <= 0) {
588
+ throw new BffSessionError("provider.missing_token_lifetime", 502);
589
+ }
590
+ return {
591
+ accessToken: requiredString(tokens.accessToken),
592
+ refreshToken: requiredString(tokens.refreshToken),
593
+ expiresAt: Date.now() + tokens.expiresIn * 1e3
594
+ };
595
+ }
596
+ function readChallenge(value) {
597
+ const challenge = object(value);
598
+ if (!Array.isArray(challenge.methods) || !challenge.methods.every((method) => typeof method === "string")) {
599
+ throw new BffSessionError("provider.malformed_challenge", 502);
600
+ }
601
+ return {
602
+ twoFactorRequired: true,
603
+ challengeToken: requiredString(challenge.challengeToken),
604
+ methods: challenge.methods
605
+ };
606
+ }
607
+ function readProfile(value) {
608
+ const user = object(value);
609
+ const activeOrganization = user.activeOrganization ?? user.tenant;
610
+ const organization = activeOrganization == null ? null : object(activeOrganization);
611
+ return {
612
+ id: requiredString(user.id),
613
+ email: requiredString(user.email),
614
+ organization: organization ? { id: requiredString(organization.id), slug: requiredString(organization.slug) } : null
615
+ };
616
+ }
617
+ function createAuthyonBffProvider(options) {
618
+ if (!options.envKey) throw new Error("Authyon BFF: envKey is required");
619
+ const http = new JsonHttpClient(
620
+ createSharedTransport({
621
+ ...options,
622
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL
623
+ })
624
+ );
625
+ const headers = { "X-Authyon-Environment": options.envKey };
626
+ const bearer = (token) => ({ ...headers, Authorization: `Bearer ${token}` });
627
+ return {
628
+ async login(input) {
629
+ const credentials = { ...input };
630
+ delete credentials.organizationSlug;
631
+ delete credentials.sessionPersistence;
632
+ const response = object(
633
+ await http.request("/auth/login", {
634
+ method: "POST",
635
+ headers,
636
+ body: {
637
+ ...credentials,
638
+ ...input.organizationSlug ? { tenantSlug: input.organizationSlug } : {}
639
+ }
640
+ })
641
+ );
642
+ return response.twoFactor ? readChallenge(response.twoFactor) : readTokens(response);
643
+ },
644
+ async verifyTwoFactor(input) {
645
+ const verification = { ...input };
646
+ delete verification.sessionPersistence;
647
+ return readTokens(
648
+ await http.request("/auth/2fa/verify", { method: "POST", headers, body: verification })
649
+ );
650
+ },
651
+ async refresh(refreshToken) {
652
+ return readTokens(
653
+ await http.request("/auth/refresh", { method: "POST", headers, body: { refreshToken } })
654
+ );
655
+ },
656
+ async logout(refreshToken) {
657
+ await http.request("/auth/logout", { method: "POST", headers, body: { refreshToken } });
658
+ },
659
+ async switchOrganization(accessToken, slug) {
660
+ return readTokens(
661
+ await http.request("/auth/switch-tenant", {
662
+ method: "POST",
663
+ headers: bearer(accessToken),
664
+ body: { tenantSlug: slug }
665
+ })
666
+ );
667
+ },
668
+ async validate(accessToken) {
669
+ try {
670
+ const result = await options.validator.validate(accessToken);
671
+ return result.valid === true && result.user != null;
672
+ } catch (error) {
673
+ if (error instanceof AuthyonError)
674
+ throw new BffSessionError("provider.validation_unavailable", 503);
675
+ throw error;
676
+ }
677
+ },
678
+ async profile(accessToken) {
679
+ return readProfile(await http.request("/auth/me", { headers: bearer(accessToken) }));
680
+ }
681
+ };
682
+ }
683
+
684
+ // src/bff/memoryStore.ts
685
+ function createMemoryBffSessionStore() {
686
+ const sessions = /* @__PURE__ */ new Map();
687
+ function read(key) {
688
+ const session = sessions.get(key);
689
+ if (!session) return null;
690
+ if (Math.min(session.expiresAt, session.idleExpiresAt) <= Date.now()) {
691
+ sessions.delete(key);
692
+ return null;
693
+ }
694
+ return structuredClone(session);
695
+ }
696
+ return {
697
+ async read(key) {
698
+ return read(key);
699
+ },
700
+ async compareAndSwap(key, expectedRevision, next) {
701
+ if ((read(key)?.revision ?? null) !== expectedRevision) return false;
702
+ if (next) {
703
+ if (expectedRevision === null) {
704
+ for (const sessionKey2 of sessions.keys()) read(sessionKey2);
705
+ }
706
+ sessions.set(key, structuredClone(next));
707
+ } else {
708
+ sessions.delete(key);
709
+ }
710
+ return true;
711
+ }
712
+ };
713
+ }
714
+
715
+ // src/bff/redisStore.ts
716
+ import { EncryptJWT, jwtDecrypt } from "jose";
717
+ var COMPARE_AND_SWAP = `
718
+ local current = redis.call('GET', KEYS[1])
719
+ local revision = ''
720
+ if current then revision = cjson.decode(current).revision end
721
+ if revision ~= ARGV[1] then return 0 end
722
+ if ARGV[2] == '' then
723
+ redis.call('DEL', KEYS[1])
724
+ else
725
+ redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
726
+ end
727
+ return 1
728
+ `;
729
+ function isObject(value) {
730
+ return typeof value === "object" && value !== null;
731
+ }
732
+ function isFiniteNumber(value) {
733
+ return typeof value === "number" && Number.isFinite(value);
734
+ }
735
+ function isOrganization(value) {
736
+ return value === null || isObject(value) && typeof value.id === "string" && typeof value.slug === "string";
737
+ }
738
+ function isSessionRecord(value) {
739
+ if (!isObject(value) || !isObject(value.tokens) || !isObject(value.user)) return false;
740
+ 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));
741
+ }
742
+ function createRedisBffSessionStore(options) {
743
+ if (options.encryptionKey.byteLength !== 32) {
744
+ throw new Error("Authyon BFF: encryptionKey must contain 32 bytes");
745
+ }
746
+ const encryptionKey = new Uint8Array(options.encryptionKey);
747
+ const prefix = options.prefix ?? "authyon:bff:";
748
+ return {
749
+ async read(key) {
750
+ const serialized = await options.client.get(prefix + key);
751
+ if (!serialized) return null;
752
+ try {
753
+ const envelope = JSON.parse(serialized);
754
+ const { payload } = await jwtDecrypt(envelope.ciphertext, encryptionKey, {
755
+ keyManagementAlgorithms: ["dir"],
756
+ contentEncryptionAlgorithms: ["A256GCM"],
757
+ audience: prefix + key
758
+ });
759
+ const record = payload.session;
760
+ if (!isSessionRecord(record) || record.revision !== envelope.revision) {
761
+ throw new Error("invalid session record");
762
+ }
763
+ return record;
764
+ } catch {
765
+ throw new BffSessionError("session.storage_invalid", 503);
766
+ }
767
+ },
768
+ async compareAndSwap(key, expectedRevision, next) {
769
+ const expiresAt = next ? Math.min(next.expiresAt, next.idleExpiresAt) : Date.now();
770
+ const ttl = Math.ceil(expiresAt - Date.now());
771
+ if (next && ttl <= 0) return false;
772
+ let serialized = "";
773
+ if (next) {
774
+ const ciphertext = await new EncryptJWT({ session: next }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setAudience(prefix + key).encrypt(encryptionKey);
775
+ serialized = JSON.stringify({ revision: next.revision, ciphertext });
776
+ }
777
+ const result = await options.client.eval(COMPARE_AND_SWAP, {
778
+ keys: [prefix + key],
779
+ arguments: [expectedRevision ?? "", serialized, String(ttl)]
780
+ });
781
+ return result === 1;
782
+ }
783
+ };
784
+ }
785
+ export {
786
+ BffSessionError,
787
+ createAuthyonBffProvider,
788
+ createBffSession,
789
+ createMemoryBffSessionStore,
790
+ createRedisBffSessionStore
791
+ };