@opengeni/api-router 2.3.2-canary.2 → 2.5.0
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/app.js +1 -1
- package/dist/auth/managed-auth-attempt-context.d.ts +4 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +4 -0
- package/dist/{chunk-IBV7Z6F4.js → chunk-QESX7HDK.js} +3645 -470
- package/dist/chunk-QESX7HDK.js.map +1 -0
- package/dist/fatal-process-boundary.d.ts +25 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +168 -6
- package/dist/index.js.map +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/receipts.d.ts +9 -0
- package/dist/mcp/server.d.ts +33 -0
- package/dist/organization-recovery-notifications.d.ts +41 -0
- package/dist/routes/managed-auth-session-sets.d.ts +19 -0
- package/dist/routes/organization-recovery.d.ts +19 -0
- package/dist/routes/sessions.d.ts +17 -5
- package/dist/routes/workspaces.d.ts +1 -0
- package/dist/work-discovery-observability.d.ts +33 -0
- package/package.json +18 -18
- package/src/app.ts +133 -1
- package/src/auth/managed-auth-attempt-context.ts +24 -0
- package/src/auth/managed-auth-session-adapter.ts +205 -0
- package/src/auth/managed-auth.ts +52 -2
- package/src/fatal-process-boundary.ts +231 -0
- package/src/http/sse.ts +7 -0
- package/src/index.ts +25 -5
- package/src/integrations/slack-interactions.ts +30 -17
- package/src/mcp/receipts.ts +34 -0
- package/src/mcp/server.ts +349 -68
- package/src/organization-recovery-notifications.ts +103 -0
- package/src/routes/canonical-human-identities.ts +29 -14
- package/src/routes/codex.ts +5 -1
- package/src/routes/environments.ts +23 -0
- package/src/routes/interaction-resources.ts +3 -0
- package/src/routes/managed-auth-session-sets.ts +994 -0
- package/src/routes/managed-onboarding.ts +2 -0
- package/src/routes/organization-memberships.ts +2 -0
- package/src/routes/organization-recovery.ts +325 -0
- package/src/routes/sessions.ts +401 -120
- package/src/routes/supergrok.ts +5 -1
- package/src/routes/workspaces.ts +23 -1
- package/src/work-discovery-observability.ts +121 -0
- package/dist/chunk-IBV7Z6F4.js.map +0 -1
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
BeginManagedAuthLoginTransactionRequest,
|
|
4
|
+
BootstrapManagedAuthSessionSetRequest,
|
|
5
|
+
CancelManagedAuthLoginTransactionRequest,
|
|
6
|
+
CompleteManagedAuthEmailPasswordTransactionRequest,
|
|
7
|
+
CompleteManagedAuthLoginTransactionResponse,
|
|
8
|
+
LogoutManagedAuthLoginSlotRequest,
|
|
9
|
+
LogoutManagedAuthSessionSetRequest,
|
|
10
|
+
MANAGED_AUTH_SESSION_SET_API_CONTRACT_HEADER,
|
|
11
|
+
MANAGED_AUTH_SESSION_SET_API_CONTRACT_REVISION,
|
|
12
|
+
ManagedAuthDeepLinkResolution,
|
|
13
|
+
ManagedAuthLoginTransaction,
|
|
14
|
+
ManagedAuthSessionSetProjection,
|
|
15
|
+
ManagedAuthSessionSetErrorCode,
|
|
16
|
+
ResolveManagedAuthDeepLinkRequest,
|
|
17
|
+
SelectManagedAuthLoginSlotRequest,
|
|
18
|
+
type ManagedAuthSessionSetProjection as ManagedAuthSessionSetProjectionType,
|
|
19
|
+
type ManagedAuthSessionSetErrorCode as ManagedAuthSessionSetErrorCodeType,
|
|
20
|
+
} from "@opengeni/contracts/managed-auth-session-sets";
|
|
21
|
+
import { hasPermission, requireSessionAuthorization, type ApiRouteDeps } from "@opengeni/core";
|
|
22
|
+
import {
|
|
23
|
+
authenticateAndAdoptManagedAuthSession,
|
|
24
|
+
isolatedManagedAuthHeaders,
|
|
25
|
+
MANAGED_AUTH_ACTOR_EPOCH_HEADER,
|
|
26
|
+
MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE,
|
|
27
|
+
MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE_PATH,
|
|
28
|
+
MANAGED_AUTH_SESSION_SET_COOKIE,
|
|
29
|
+
ManagedAuthActorChangeError,
|
|
30
|
+
ManagedAuthCompletionOutcomeUnknownError,
|
|
31
|
+
ManagedAuthRequestAdmissionError,
|
|
32
|
+
managedAuthCsrfHash,
|
|
33
|
+
managedAuthDerivedUuid,
|
|
34
|
+
managedAuthRandomAuthority,
|
|
35
|
+
managedAuthSecretRequestDigest,
|
|
36
|
+
managedAuthSha256,
|
|
37
|
+
managedAuthTransactionSecret,
|
|
38
|
+
requireManagedAuthMutationAdmission,
|
|
39
|
+
resolveManagedAuthSelectedSession,
|
|
40
|
+
withManagedAuthCsrfToken,
|
|
41
|
+
} from "@opengeni/core/managed-auth-session-sets";
|
|
42
|
+
import {
|
|
43
|
+
beginManagedAuthLoginTransaction,
|
|
44
|
+
bootstrapManagedAuthSessionSet,
|
|
45
|
+
getManagedAuthAdoptedSessionSnapshot,
|
|
46
|
+
getManagedAuthSessionSetOperationReceipt,
|
|
47
|
+
getManagedAuthSessionSetSnapshot,
|
|
48
|
+
getManagedAuthSessionSetAuthorityState,
|
|
49
|
+
ManagedAuthLoginSlotLimitError,
|
|
50
|
+
ManagedAuthLoginSlotAlreadyExistsError,
|
|
51
|
+
ManagedAuthLoginSlotUnavailableError,
|
|
52
|
+
ManagedAuthActorMutationInFlightError,
|
|
53
|
+
ManagedAuthLoginTransactionRateLimitError,
|
|
54
|
+
ManagedAuthSessionSetAuthorityError,
|
|
55
|
+
ManagedAuthSessionSetGenerationConflictError,
|
|
56
|
+
ManagedAuthSessionSetOperationReuseError,
|
|
57
|
+
mutateManagedAuthSessionSet,
|
|
58
|
+
} from "@opengeni/db/managed-auth-session-sets";
|
|
59
|
+
import { ensureManagedAccessForUser, getSession } from "@opengeni/db";
|
|
60
|
+
import type { Context, Hono } from "hono";
|
|
61
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
62
|
+
import { HTTPException } from "hono/http-exception";
|
|
63
|
+
import { ApiHttpError } from "../http/api-error";
|
|
64
|
+
import type { z } from "zod";
|
|
65
|
+
|
|
66
|
+
export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
67
|
+
const noStore = async (context: Context, next: () => Promise<void>) => {
|
|
68
|
+
context.header("cache-control", "no-store");
|
|
69
|
+
context.header("pragma", "no-cache");
|
|
70
|
+
if (deps.settings.managedAuthSessionSetMode === "broker") {
|
|
71
|
+
appendCookies(
|
|
72
|
+
context,
|
|
73
|
+
await requireAvailable(deps).adapter.createLegacySelectedSessionCookies(
|
|
74
|
+
null,
|
|
75
|
+
context.req.header("cookie") ?? null,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
await next();
|
|
80
|
+
};
|
|
81
|
+
app.use("/v1/auth/session-set", noStore);
|
|
82
|
+
app.use("/v1/auth/session-set/*", noStore);
|
|
83
|
+
|
|
84
|
+
app.get("/v1/auth/get-session", async (context) => {
|
|
85
|
+
if (deps.settings.managedAuthSessionSetMode === "legacy") {
|
|
86
|
+
if (!deps.managedAuth) throw new HTTPException(404);
|
|
87
|
+
return await deps.managedAuth.handler(context.req.raw);
|
|
88
|
+
}
|
|
89
|
+
context.header("cache-control", "no-store");
|
|
90
|
+
const available = requireAvailable(deps);
|
|
91
|
+
if (deps.settings.managedAuthSessionSetMode === "broker") {
|
|
92
|
+
appendCookies(
|
|
93
|
+
context,
|
|
94
|
+
await available.adapter.createLegacySelectedSessionCookies(
|
|
95
|
+
null,
|
|
96
|
+
context.req.header("cookie") ?? null,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const authority = getAuthority(context);
|
|
101
|
+
try {
|
|
102
|
+
if (authority) {
|
|
103
|
+
const expectedActorEpoch = context.req.header(MANAGED_AUTH_ACTOR_EPOCH_HEADER) ?? null;
|
|
104
|
+
const ambient =
|
|
105
|
+
deps.settings.managedAuthSessionSetMode === "dual" && expectedActorEpoch === null
|
|
106
|
+
? await available.adapter.resolveAmbientSession(context.req.raw.headers)
|
|
107
|
+
: null;
|
|
108
|
+
const selected = await resolveManagedAuthSelectedSession({
|
|
109
|
+
db: deps.db,
|
|
110
|
+
adapter: available.adapter,
|
|
111
|
+
authority,
|
|
112
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
113
|
+
expectedActorEpoch,
|
|
114
|
+
legacyAmbientSessionId: ambient?.session.id ?? null,
|
|
115
|
+
});
|
|
116
|
+
if (!selected?.session) return context.json(null);
|
|
117
|
+
return jsonWithActorEpoch(
|
|
118
|
+
context,
|
|
119
|
+
selected.projection.actorEpoch,
|
|
120
|
+
safeBetterAuthSession(selected.session),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (deps.settings.managedAuthSessionSetMode === "broker") return context.json(null);
|
|
124
|
+
const ambient = await available.adapter.resolveAmbientSession(context.req.raw.headers);
|
|
125
|
+
if (!ambient?.session.id) return context.json(null);
|
|
126
|
+
const adopted = await getManagedAuthAdoptedSessionSnapshot(deps.db, ambient.session.id);
|
|
127
|
+
if (adopted) {
|
|
128
|
+
if (
|
|
129
|
+
adopted.actorEpoch !== "1" ||
|
|
130
|
+
!adopted.selected ||
|
|
131
|
+
adopted.selected.authSessionId !== ambient.session.id ||
|
|
132
|
+
adopted.selected.authUserId !== ambient.user.id
|
|
133
|
+
) {
|
|
134
|
+
throw new ManagedAuthActorChangeError();
|
|
135
|
+
}
|
|
136
|
+
return jsonWithActorEpoch(context, adopted.actorEpoch, safeBetterAuthSession(ambient));
|
|
137
|
+
}
|
|
138
|
+
return context.json(safeBetterAuthSession(ambient));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof ManagedAuthActorChangeError) {
|
|
141
|
+
context.header("x-opengeni-actor-state", "changed");
|
|
142
|
+
throw managedAuthApiError(409, "actor_change_required", { cause: error });
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
app.get("/v1/auth/session-set", async (context) => {
|
|
149
|
+
requireAvailable(deps);
|
|
150
|
+
const existingAuthority = getAuthority(context);
|
|
151
|
+
const snapshot = existingAuthority ? await snapshotFor(deps, existingAuthority) : null;
|
|
152
|
+
if (snapshot && existingAuthority) {
|
|
153
|
+
return jsonWithActorEpoch(
|
|
154
|
+
context,
|
|
155
|
+
snapshot.projection.actorEpoch,
|
|
156
|
+
project(deps, existingAuthority, snapshot.projection),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
existingAuthority &&
|
|
161
|
+
(await getManagedAuthSessionSetAuthorityState(
|
|
162
|
+
deps.db,
|
|
163
|
+
managedAuthSha256(existingAuthority),
|
|
164
|
+
)) === "absent"
|
|
165
|
+
) {
|
|
166
|
+
const projection = emptyProjection(deps, existingAuthority);
|
|
167
|
+
return jsonWithActorEpoch(context, projection.actorEpoch, projection);
|
|
168
|
+
}
|
|
169
|
+
const authority = managedAuthRandomAuthority();
|
|
170
|
+
setAuthorityCookie(context, deps, authority);
|
|
171
|
+
const projection = emptyProjection(deps, authority);
|
|
172
|
+
return jsonWithActorEpoch(context, projection.actorEpoch, projection);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
app.post("/v1/auth/session-set/bootstrap", async (context) => {
|
|
176
|
+
const body = await bodyAs(context, BootstrapManagedAuthSessionSetRequest);
|
|
177
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
178
|
+
const ambient = await requireAvailable(deps).managedAuth!.api.getSession({
|
|
179
|
+
headers: context.req.raw.headers,
|
|
180
|
+
returnHeaders: true,
|
|
181
|
+
});
|
|
182
|
+
const authSessionId = ambient.response?.session?.id;
|
|
183
|
+
if (typeof authSessionId !== "string") {
|
|
184
|
+
throw managedAuthApiError(401, "managed_authentication_required");
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const projection = await bootstrapManagedAuthSessionSet(deps.db, {
|
|
188
|
+
authorityHash: managedAuthSha256(authority),
|
|
189
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
190
|
+
authSessionId,
|
|
191
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
192
|
+
operationId: body.operationId,
|
|
193
|
+
requestDigest: digest(deps, body),
|
|
194
|
+
expectedGeneration: body.expectedGeneration,
|
|
195
|
+
expectedActorEpoch: actorEpoch,
|
|
196
|
+
});
|
|
197
|
+
if (deps.settings.managedAuthSessionSetMode === "dual") {
|
|
198
|
+
for (const cookie of setCookieHeaders(ambient.headers)) {
|
|
199
|
+
context.header("set-cookie", cookie, { append: true });
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
appendCookies(
|
|
203
|
+
context,
|
|
204
|
+
await requireAvailable(deps).adapter.createLegacySelectedSessionCookies(
|
|
205
|
+
null,
|
|
206
|
+
context.req.header("cookie") ?? null,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return jsonWithActorEpoch(
|
|
211
|
+
context,
|
|
212
|
+
projection.actorEpoch,
|
|
213
|
+
project(deps, authority, projection),
|
|
214
|
+
);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throwHttp(error);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
app.post("/v1/auth/session-set/transactions", async (context) => {
|
|
221
|
+
const body = await bodyAs(context, BeginManagedAuthLoginTransactionRequest);
|
|
222
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
223
|
+
const transactionId = randomUUID();
|
|
224
|
+
const transactionSecret = managedAuthTransactionSecret(
|
|
225
|
+
requireSigningSecret(deps),
|
|
226
|
+
authority,
|
|
227
|
+
body.operationId,
|
|
228
|
+
);
|
|
229
|
+
try {
|
|
230
|
+
const transaction = ManagedAuthLoginTransaction.parse(
|
|
231
|
+
await beginManagedAuthLoginTransaction(deps.db, {
|
|
232
|
+
authorityHash: managedAuthSha256(authority),
|
|
233
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
234
|
+
rateScopeHash: loginTransactionClientScope(context, deps),
|
|
235
|
+
operationId: body.operationId,
|
|
236
|
+
requestDigest: digest(deps, body),
|
|
237
|
+
expectedGeneration: body.expectedGeneration,
|
|
238
|
+
expectedActorEpoch: actorEpoch,
|
|
239
|
+
transactionId,
|
|
240
|
+
transactionSecretHash: managedAuthSha256(transactionSecret),
|
|
241
|
+
kind: body.kind,
|
|
242
|
+
targetSlotId: body.slotId ?? null,
|
|
243
|
+
returnIntentId: body.returnIntent
|
|
244
|
+
? managedAuthDerivedUuid("opengeni:managed-auth:return-intent", body.operationId)
|
|
245
|
+
: null,
|
|
246
|
+
returnPath: body.returnIntent ?? null,
|
|
247
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
setTransactionCookie(context, deps, transaction.id, transactionSecret);
|
|
251
|
+
return jsonWithActorEpoch(context, actorEpoch, transaction);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throwHttp(error);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
app.post("/v1/auth/session-set/transactions/email-password", async (context) => {
|
|
258
|
+
const body = await bodyAs(context, CompleteManagedAuthEmailPasswordTransactionRequest);
|
|
259
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
260
|
+
const available = requireAvailable(deps);
|
|
261
|
+
const requestDigest = digest(deps, body);
|
|
262
|
+
try {
|
|
263
|
+
let completed: Awaited<ReturnType<typeof getManagedAuthSessionSetOperationReceipt>>;
|
|
264
|
+
try {
|
|
265
|
+
completed = await getManagedAuthSessionSetOperationReceipt(deps.db, {
|
|
266
|
+
authorityHash: managedAuthSha256(authority),
|
|
267
|
+
operationId: body.operationId,
|
|
268
|
+
requestDigest,
|
|
269
|
+
});
|
|
270
|
+
} catch (error) {
|
|
271
|
+
throw new ManagedAuthCompletionOutcomeUnknownError({ cause: error });
|
|
272
|
+
}
|
|
273
|
+
if (!completed) {
|
|
274
|
+
const transactionSecret = requireTransactionSecret(context, body.transactionId);
|
|
275
|
+
completed = await authenticateAndAdoptManagedAuthSession({
|
|
276
|
+
db: deps.db,
|
|
277
|
+
adapter: available.adapter,
|
|
278
|
+
isolatedHeaders: isolatedManagedAuthHeaders(context.req.raw),
|
|
279
|
+
authority,
|
|
280
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
281
|
+
operationId: body.operationId,
|
|
282
|
+
requestDigest,
|
|
283
|
+
expectedGeneration: body.expectedGeneration,
|
|
284
|
+
expectedActorEpoch: actorEpoch,
|
|
285
|
+
transactionId: body.transactionId,
|
|
286
|
+
transactionSecret,
|
|
287
|
+
email: body.email,
|
|
288
|
+
password: body.password,
|
|
289
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
deleteCookie(context, MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE, transactionCookieOptions(deps));
|
|
293
|
+
await mirrorCurrentSelection(context, deps, authority);
|
|
294
|
+
return jsonWithActorEpoch(
|
|
295
|
+
context,
|
|
296
|
+
completed.projection.actorEpoch,
|
|
297
|
+
CompleteManagedAuthLoginTransactionResponse.parse({
|
|
298
|
+
projection: project(deps, authority, completed.projection),
|
|
299
|
+
returnIntent: completed.returnIntent,
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throwHttp(error);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
app.delete("/v1/auth/session-set/transactions/:transactionId", async (context) => {
|
|
308
|
+
const body = await bodyAs(context, CancelManagedAuthLoginTransactionRequest);
|
|
309
|
+
if (body.transactionId !== context.req.param("transactionId")) invalid();
|
|
310
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
311
|
+
const transactionSecret = requireTransactionSecret(context, body.transactionId);
|
|
312
|
+
try {
|
|
313
|
+
const projection = await mutateManagedAuthSessionSet(deps.db, {
|
|
314
|
+
authorityHash: managedAuthSha256(authority),
|
|
315
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
316
|
+
operationId: body.operationId,
|
|
317
|
+
requestDigest: digest(deps, body),
|
|
318
|
+
expectedGeneration: body.expectedGeneration,
|
|
319
|
+
expectedActorEpoch: actorEpoch,
|
|
320
|
+
operationType: "cancel_transaction",
|
|
321
|
+
transactionId: body.transactionId,
|
|
322
|
+
transactionSecretHash: managedAuthSha256(transactionSecret),
|
|
323
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
324
|
+
});
|
|
325
|
+
deleteCookie(context, MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE, transactionCookieOptions(deps));
|
|
326
|
+
return jsonWithActorEpoch(
|
|
327
|
+
context,
|
|
328
|
+
(projection as ManagedAuthSessionSetProjectionType).actorEpoch,
|
|
329
|
+
project(deps, authority, projection as never),
|
|
330
|
+
);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throwHttp(error);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
app.post("/v1/auth/session-set/select", async (context) => {
|
|
337
|
+
const body = await bodyAs(context, SelectManagedAuthLoginSlotRequest);
|
|
338
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
339
|
+
try {
|
|
340
|
+
const projection = await mutateManagedAuthSessionSet(deps.db, {
|
|
341
|
+
authorityHash: managedAuthSha256(authority),
|
|
342
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
343
|
+
operationId: body.operationId,
|
|
344
|
+
requestDigest: digest(deps, body),
|
|
345
|
+
expectedGeneration: body.expectedGeneration,
|
|
346
|
+
expectedActorEpoch: actorEpoch,
|
|
347
|
+
operationType: "select",
|
|
348
|
+
targetSlotId: body.slotId,
|
|
349
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
350
|
+
});
|
|
351
|
+
await mirrorCurrentSelection(context, deps, authority);
|
|
352
|
+
return jsonWithActorEpoch(
|
|
353
|
+
context,
|
|
354
|
+
(projection as ManagedAuthSessionSetProjectionType).actorEpoch,
|
|
355
|
+
project(deps, authority, projection as never),
|
|
356
|
+
);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throwHttp(error);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
app.post("/v1/auth/session-set/logout-one", async (context) => {
|
|
363
|
+
const body = await bodyAs(context, LogoutManagedAuthLoginSlotRequest);
|
|
364
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
365
|
+
try {
|
|
366
|
+
const projection = await mutateManagedAuthSessionSet(deps.db, {
|
|
367
|
+
authorityHash: managedAuthSha256(authority),
|
|
368
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
369
|
+
operationId: body.operationId,
|
|
370
|
+
requestDigest: digest(deps, body),
|
|
371
|
+
expectedGeneration: body.expectedGeneration,
|
|
372
|
+
expectedActorEpoch: actorEpoch,
|
|
373
|
+
operationType: "logout_one",
|
|
374
|
+
targetSlotId: body.slotId,
|
|
375
|
+
replacementSlotId: body.replacementSlotId,
|
|
376
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
377
|
+
});
|
|
378
|
+
await mirrorCurrentSelection(context, deps, authority);
|
|
379
|
+
return jsonWithActorEpoch(
|
|
380
|
+
context,
|
|
381
|
+
(projection as ManagedAuthSessionSetProjectionType).actorEpoch,
|
|
382
|
+
project(deps, authority, projection as never),
|
|
383
|
+
);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
throwHttp(error);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
app.post("/v1/auth/session-set/logout-all", async (context) => {
|
|
390
|
+
const body = await bodyAs(context, LogoutManagedAuthSessionSetRequest);
|
|
391
|
+
const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
|
|
392
|
+
try {
|
|
393
|
+
const receipt = await mutateManagedAuthSessionSet(deps.db, {
|
|
394
|
+
authorityHash: managedAuthSha256(authority),
|
|
395
|
+
csrfHash: managedAuthCsrfHash(authority),
|
|
396
|
+
operationId: body.operationId,
|
|
397
|
+
requestDigest: digest(deps, body),
|
|
398
|
+
expectedGeneration: body.expectedGeneration,
|
|
399
|
+
expectedActorEpoch: actorEpoch,
|
|
400
|
+
operationType: "logout_all",
|
|
401
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
402
|
+
});
|
|
403
|
+
// Keep the now-retired authority cookie until the next authoritative GET
|
|
404
|
+
// rotates it. If response headers commit but the body is lost, the SDK
|
|
405
|
+
// can still replay this exact operation and recover its durable receipt;
|
|
406
|
+
// every new command remains denied by the revoked server-side set.
|
|
407
|
+
appendCookies(
|
|
408
|
+
context,
|
|
409
|
+
await requireAvailable(deps).adapter.createLegacySelectedSessionCookies(
|
|
410
|
+
null,
|
|
411
|
+
context.req.header("cookie") ?? null,
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
return jsonWithActorEpoch(context, receipt.actorEpoch, receipt);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throwHttp(error);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
app.post("/v1/auth/session-set/deep-link/resolve", async (context) => {
|
|
421
|
+
const body = await bodyAs(context, ResolveManagedAuthDeepLinkRequest);
|
|
422
|
+
const authority = getAuthority(context);
|
|
423
|
+
if (!authority)
|
|
424
|
+
return context.json(ManagedAuthDeepLinkResolution.parse({ kind: "unavailable" }));
|
|
425
|
+
const snapshot = await snapshotFor(deps, authority);
|
|
426
|
+
if (!snapshot)
|
|
427
|
+
return context.json(ManagedAuthDeepLinkResolution.parse({ kind: "unavailable" }));
|
|
428
|
+
requireApiContract(context);
|
|
429
|
+
try {
|
|
430
|
+
requireManagedAuthMutationAdmission({
|
|
431
|
+
request: context.req.raw,
|
|
432
|
+
allowedOrigins: allowedOrigins(deps),
|
|
433
|
+
authority,
|
|
434
|
+
signingSecret: requireSigningSecret(deps),
|
|
435
|
+
expectedGeneration: snapshot.projection.generation,
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (error instanceof ManagedAuthRequestAdmissionError) {
|
|
439
|
+
throw managedAuthApiError(403, "origin_rejected", { cause: error });
|
|
440
|
+
}
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
requireActorEpoch(context, snapshot.projection);
|
|
444
|
+
return jsonWithActorEpoch(
|
|
445
|
+
context,
|
|
446
|
+
snapshot.projection.actorEpoch,
|
|
447
|
+
await resolveCanonicalDeepLink(deps, authority, body.path, snapshot.projection.actorEpoch),
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Better Auth 1.6.26 provider lifecycle routes retained while session-set mode
|
|
454
|
+
* owns all selected-session reads, enumeration, revocation, and user mutation.
|
|
455
|
+
* The list is intentionally exact so a dependency upgrade cannot silently add
|
|
456
|
+
* a selected-session capability under the wildcard.
|
|
457
|
+
*/
|
|
458
|
+
export function requireManagedAuthProviderRouteAllowed(method: string, pathname: string): void {
|
|
459
|
+
const normalizedMethod = method.toUpperCase();
|
|
460
|
+
const allowed =
|
|
461
|
+
(normalizedMethod === "POST" &&
|
|
462
|
+
new Set([
|
|
463
|
+
"/v1/auth/sign-up/email",
|
|
464
|
+
"/v1/auth/sign-in/email",
|
|
465
|
+
"/v1/auth/send-verification-email",
|
|
466
|
+
"/v1/auth/request-password-reset",
|
|
467
|
+
"/v1/auth/reset-password",
|
|
468
|
+
]).has(pathname)) ||
|
|
469
|
+
(normalizedMethod === "GET" &&
|
|
470
|
+
(pathname === "/v1/auth/verify-email" ||
|
|
471
|
+
pathname === "/v1/auth/error" ||
|
|
472
|
+
pathname === "/v1/auth/ok" ||
|
|
473
|
+
/^\/v1\/auth\/reset-password\/[^/]+$/.test(pathname)));
|
|
474
|
+
if (!allowed) throw managedAuthApiError(409, "provider_route_blocked");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Remove provider bearer/session material and enforce browser-auth cache/cookie policy. */
|
|
478
|
+
export async function scrubManagedAuthProviderResponse(
|
|
479
|
+
response: Response,
|
|
480
|
+
options: { replacementCookies?: readonly string[] | undefined } = {},
|
|
481
|
+
): Promise<Response> {
|
|
482
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
483
|
+
const headers = new Headers(response.headers);
|
|
484
|
+
headers.set("cache-control", "no-store");
|
|
485
|
+
headers.set("pragma", "no-cache");
|
|
486
|
+
if (options.replacementCookies !== undefined) {
|
|
487
|
+
headers.delete("set-cookie");
|
|
488
|
+
for (const cookie of options.replacementCookies) headers.append("set-cookie", cookie);
|
|
489
|
+
}
|
|
490
|
+
let body: BodyInit | null = response.body;
|
|
491
|
+
if (contentType.includes("application/json")) {
|
|
492
|
+
const value = await response
|
|
493
|
+
.clone()
|
|
494
|
+
.json()
|
|
495
|
+
.catch(() => undefined);
|
|
496
|
+
if (value !== undefined) {
|
|
497
|
+
headers.delete("content-length");
|
|
498
|
+
body = JSON.stringify(removeProviderSecrets(value));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return new Response(body, {
|
|
502
|
+
status: response.status,
|
|
503
|
+
statusText: response.statusText,
|
|
504
|
+
headers,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function removeProviderSecrets(value: unknown): unknown {
|
|
509
|
+
if (Array.isArray(value)) return value.map(removeProviderSecrets);
|
|
510
|
+
if (!value || typeof value !== "object") return value;
|
|
511
|
+
const secretKeys = new Set([
|
|
512
|
+
"token",
|
|
513
|
+
"session",
|
|
514
|
+
"accessToken",
|
|
515
|
+
"refreshToken",
|
|
516
|
+
"idToken",
|
|
517
|
+
"access_token",
|
|
518
|
+
"refresh_token",
|
|
519
|
+
"id_token",
|
|
520
|
+
]);
|
|
521
|
+
return Object.fromEntries(
|
|
522
|
+
Object.entries(value as Record<string, unknown>)
|
|
523
|
+
.filter(([key]) => !secretKeys.has(key))
|
|
524
|
+
.map(([key, child]) => [key, removeProviderSecrets(child)]),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function jsonWithActorEpoch(context: Context, actorEpoch: string, value: unknown): Response {
|
|
529
|
+
context.header(MANAGED_AUTH_ACTOR_EPOCH_HEADER, actorEpoch);
|
|
530
|
+
return context.json(value);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function safeBetterAuthSession(resolved: {
|
|
534
|
+
session: { id: string; userId: string; [key: string]: unknown };
|
|
535
|
+
user: {
|
|
536
|
+
id: string;
|
|
537
|
+
email: string;
|
|
538
|
+
name: string;
|
|
539
|
+
emailVerified: boolean;
|
|
540
|
+
[key: string]: unknown;
|
|
541
|
+
};
|
|
542
|
+
}) {
|
|
543
|
+
const safeDate = (value: unknown): string | Date | undefined =>
|
|
544
|
+
value instanceof Date || typeof value === "string" ? value : undefined;
|
|
545
|
+
const session = resolved.session;
|
|
546
|
+
const user = resolved.user;
|
|
547
|
+
return {
|
|
548
|
+
session: {
|
|
549
|
+
id: session.id,
|
|
550
|
+
userId: session.userId,
|
|
551
|
+
...(safeDate(session.createdAt) ? { createdAt: safeDate(session.createdAt) } : {}),
|
|
552
|
+
...(safeDate(session.updatedAt) ? { updatedAt: safeDate(session.updatedAt) } : {}),
|
|
553
|
+
...(safeDate(session.expiresAt) ? { expiresAt: safeDate(session.expiresAt) } : {}),
|
|
554
|
+
...(typeof session.ipAddress === "string" ? { ipAddress: session.ipAddress } : {}),
|
|
555
|
+
...(typeof session.userAgent === "string" ? { userAgent: session.userAgent } : {}),
|
|
556
|
+
},
|
|
557
|
+
user: {
|
|
558
|
+
id: user.id,
|
|
559
|
+
email: user.email,
|
|
560
|
+
name: user.name,
|
|
561
|
+
emailVerified: user.emailVerified,
|
|
562
|
+
...(typeof user.image === "string" ? { image: user.image } : {}),
|
|
563
|
+
...(safeDate(user.createdAt) ? { createdAt: safeDate(user.createdAt) } : {}),
|
|
564
|
+
...(safeDate(user.updatedAt) ? { updatedAt: safeDate(user.updatedAt) } : {}),
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function resolveCanonicalDeepLink(
|
|
570
|
+
deps: ApiRouteDeps,
|
|
571
|
+
authority: string,
|
|
572
|
+
path: string,
|
|
573
|
+
expectedActorEpoch: string,
|
|
574
|
+
) {
|
|
575
|
+
const target = parseSupportedDeepLink(path);
|
|
576
|
+
if (!target) return ManagedAuthDeepLinkResolution.parse({ kind: "unavailable" });
|
|
577
|
+
const snapshot = await getManagedAuthSessionSetSnapshot(deps.db, {
|
|
578
|
+
authorityHash: managedAuthSha256(authority),
|
|
579
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
580
|
+
includeInternal: true,
|
|
581
|
+
readOnly: true,
|
|
582
|
+
});
|
|
583
|
+
if (!snapshot) return ManagedAuthDeepLinkResolution.parse({ kind: "unavailable" });
|
|
584
|
+
if (snapshot.projection.actorEpoch !== expectedActorEpoch) {
|
|
585
|
+
throw managedAuthApiError(409, "actor_change_required");
|
|
586
|
+
}
|
|
587
|
+
const adapter = requireAvailable(deps).adapter;
|
|
588
|
+
const selectedSlotId = snapshot.projection.selectedSlotId;
|
|
589
|
+
const orderedSlots = [
|
|
590
|
+
...snapshot.internalSlots.filter((slot) => slot.slotId === selectedSlotId),
|
|
591
|
+
...snapshot.internalSlots.filter((slot) => slot.slotId !== selectedSlotId),
|
|
592
|
+
];
|
|
593
|
+
const eligibleSlotIds: string[] = [];
|
|
594
|
+
for (const slot of orderedSlots) {
|
|
595
|
+
const resolved = await adapter.resolveSelectedSession(slot);
|
|
596
|
+
if (
|
|
597
|
+
!resolved ||
|
|
598
|
+
resolved.session.id !== slot.authSessionId ||
|
|
599
|
+
resolved.user.id !== slot.authUserId
|
|
600
|
+
) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const access = await ensureManagedAccessForUser(deps.db, {
|
|
604
|
+
userId: resolved.user.id,
|
|
605
|
+
email: resolved.user.email,
|
|
606
|
+
name: resolved.user.name,
|
|
607
|
+
emailVerified: resolved.user.emailVerified,
|
|
608
|
+
provisionFallbackOrganization: false,
|
|
609
|
+
bindPendingInvitations: false,
|
|
610
|
+
});
|
|
611
|
+
const grants = target.workspaceId
|
|
612
|
+
? access.workspaceGrants.filter((grant) => grant.workspaceId === target.workspaceId)
|
|
613
|
+
: access.workspaceGrants;
|
|
614
|
+
let authorized = false;
|
|
615
|
+
for (const grant of grants) {
|
|
616
|
+
if (
|
|
617
|
+
!hasPermission(grant.permissions, target.sessionId ? "sessions:read" : target.permission)
|
|
618
|
+
) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (target.sessionId) {
|
|
622
|
+
const session = await getSession(deps.db, grant.workspaceId, target.sessionId);
|
|
623
|
+
if (!session) continue;
|
|
624
|
+
try {
|
|
625
|
+
await requireSessionAuthorization(deps, grant, {
|
|
626
|
+
sessionId: target.sessionId,
|
|
627
|
+
operation: "session.read",
|
|
628
|
+
surface: "http",
|
|
629
|
+
});
|
|
630
|
+
} catch {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
authorized = true;
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
if (authorized) {
|
|
638
|
+
if (slot.slotId === selectedSlotId) {
|
|
639
|
+
return ManagedAuthDeepLinkResolution.parse({ kind: "current" });
|
|
640
|
+
}
|
|
641
|
+
eligibleSlotIds.push(slot.slotId);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (eligibleSlotIds.length !== 1) {
|
|
645
|
+
return ManagedAuthDeepLinkResolution.parse({ kind: "unavailable" });
|
|
646
|
+
}
|
|
647
|
+
const safeSlot = snapshot.projection.slots.find((slot) => slot.id === eligibleSlotIds[0]);
|
|
648
|
+
return safeSlot
|
|
649
|
+
? ManagedAuthDeepLinkResolution.parse({ kind: "switch_required", slot: safeSlot })
|
|
650
|
+
: ManagedAuthDeepLinkResolution.parse({ kind: "unavailable" });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function parseSupportedDeepLink(path: string): {
|
|
654
|
+
workspaceId: string | null;
|
|
655
|
+
sessionId: string | null;
|
|
656
|
+
permission: "workspace:read" | "sessions:read";
|
|
657
|
+
} | null {
|
|
658
|
+
if (
|
|
659
|
+
/%(?:00|2f|5c)/i.test(path) ||
|
|
660
|
+
path.includes("?") ||
|
|
661
|
+
path.includes("#") ||
|
|
662
|
+
path.includes("\\") ||
|
|
663
|
+
/[\u0000-\u001f\u007f]/.test(path)
|
|
664
|
+
)
|
|
665
|
+
return null;
|
|
666
|
+
let url: URL;
|
|
667
|
+
try {
|
|
668
|
+
url = new URL(path, "https://opengeni.invalid");
|
|
669
|
+
} catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
if (url.origin !== "https://opengeni.invalid") return null;
|
|
673
|
+
if (url.search || url.hash) return null;
|
|
674
|
+
const uuid = "([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})";
|
|
675
|
+
const workspace = url.pathname.match(new RegExp(`^/workspaces/${uuid}$`, "i"));
|
|
676
|
+
if (workspace?.[1]) {
|
|
677
|
+
return { workspaceId: workspace[1], sessionId: null, permission: "workspace:read" };
|
|
678
|
+
}
|
|
679
|
+
const sessionIndex = url.pathname.match(new RegExp(`^/workspaces/${uuid}/sessions$`, "i"));
|
|
680
|
+
if (sessionIndex?.[1]) {
|
|
681
|
+
return { workspaceId: sessionIndex[1], sessionId: null, permission: "sessions:read" };
|
|
682
|
+
}
|
|
683
|
+
const workspaceSession = url.pathname.match(
|
|
684
|
+
new RegExp(`^/workspaces/${uuid}/sessions/${uuid}$`, "i"),
|
|
685
|
+
);
|
|
686
|
+
if (workspaceSession?.[1] && workspaceSession[2]) {
|
|
687
|
+
return {
|
|
688
|
+
workspaceId: workspaceSession[1],
|
|
689
|
+
sessionId: workspaceSession[2],
|
|
690
|
+
permission: "sessions:read",
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const compatibilitySession = url.pathname.match(new RegExp(`^/sessions/${uuid}$`, "i"));
|
|
694
|
+
return compatibilitySession?.[1]
|
|
695
|
+
? { workspaceId: null, sessionId: compatibilitySession[1], permission: "sessions:read" }
|
|
696
|
+
: null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function requireAvailable(deps: ApiRouteDeps) {
|
|
700
|
+
if (
|
|
701
|
+
deps.settings.productAccessMode !== "managed" ||
|
|
702
|
+
deps.settings.managedAuthSessionSetMode === "legacy" ||
|
|
703
|
+
!deps.managedAuth ||
|
|
704
|
+
!deps.managedAuthSessionAdapter
|
|
705
|
+
) {
|
|
706
|
+
throw managedAuthApiError(404, "browser_session_set_unavailable");
|
|
707
|
+
}
|
|
708
|
+
requireSigningSecret(deps);
|
|
709
|
+
return { managedAuth: deps.managedAuth, adapter: deps.managedAuthSessionAdapter };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function requireSigningSecret(deps: ApiRouteDeps): string {
|
|
713
|
+
const secret = deps.settings.betterAuthSecret;
|
|
714
|
+
if (!secret)
|
|
715
|
+
throw managedAuthApiError(503, "managed_authentication_unavailable", { retryable: true });
|
|
716
|
+
return secret;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function bodyAs<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
|
|
720
|
+
const parsed = schema.safeParse(await context.req.json().catch(() => null));
|
|
721
|
+
if (!parsed.success) invalid();
|
|
722
|
+
return parsed.data;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function invalid(): never {
|
|
726
|
+
throw managedAuthApiError(422, "invalid_browser_session_set_request");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function getAuthority(context: Context): string | null {
|
|
730
|
+
const value = getCookie(context, MANAGED_AUTH_SESSION_SET_COOKIE);
|
|
731
|
+
return value && /^[A-Za-z0-9_-]{43}$/.test(value) ? value : null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function requireMutation(
|
|
735
|
+
context: Context,
|
|
736
|
+
deps: ApiRouteDeps,
|
|
737
|
+
expectedGeneration: string,
|
|
738
|
+
): Promise<{ authority: string; actorEpoch: string }> {
|
|
739
|
+
requireAvailable(deps);
|
|
740
|
+
requireApiContract(context);
|
|
741
|
+
const authority = getAuthority(context);
|
|
742
|
+
if (!authority) throw managedAuthApiError(401, "browser_session_set_required");
|
|
743
|
+
try {
|
|
744
|
+
requireManagedAuthMutationAdmission({
|
|
745
|
+
request: context.req.raw,
|
|
746
|
+
allowedOrigins: allowedOrigins(deps),
|
|
747
|
+
authority,
|
|
748
|
+
signingSecret: requireSigningSecret(deps),
|
|
749
|
+
expectedGeneration,
|
|
750
|
+
});
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error instanceof ManagedAuthRequestAdmissionError) {
|
|
753
|
+
throw managedAuthApiError(403, "origin_rejected", { cause: error });
|
|
754
|
+
}
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
const actorEpoch = context.req.header(MANAGED_AUTH_ACTOR_EPOCH_HEADER);
|
|
758
|
+
if (!actorEpoch || !/^[1-9][0-9]*$/.test(actorEpoch)) {
|
|
759
|
+
context.header("x-opengeni-actor-state", "changed");
|
|
760
|
+
throw managedAuthApiError(409, "actor_change_required");
|
|
761
|
+
}
|
|
762
|
+
return { authority, actorEpoch };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function requireApiContract(context: Context): void {
|
|
766
|
+
if (
|
|
767
|
+
context.req.header(MANAGED_AUTH_SESSION_SET_API_CONTRACT_HEADER) !==
|
|
768
|
+
MANAGED_AUTH_SESSION_SET_API_CONTRACT_REVISION
|
|
769
|
+
) {
|
|
770
|
+
throw managedAuthApiError(409, "api_contract_changed");
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function requireActorEpoch(context: Context, projection: { actorEpoch: string }): void {
|
|
775
|
+
if (context.req.header(MANAGED_AUTH_ACTOR_EPOCH_HEADER) !== projection.actorEpoch) {
|
|
776
|
+
context.header("x-opengeni-actor-state", "changed");
|
|
777
|
+
throw managedAuthApiError(409, "actor_change_required");
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function snapshotFor(deps: ApiRouteDeps, authority: string) {
|
|
782
|
+
return await getManagedAuthSessionSetSnapshot(deps.db, {
|
|
783
|
+
authorityHash: managedAuthSha256(authority),
|
|
784
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
785
|
+
readOnly: true,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function project(
|
|
790
|
+
deps: ApiRouteDeps,
|
|
791
|
+
authority: string,
|
|
792
|
+
projection: Omit<ManagedAuthSessionSetProjectionType, "csrfToken">,
|
|
793
|
+
): ManagedAuthSessionSetProjectionType {
|
|
794
|
+
return ManagedAuthSessionSetProjection.parse(
|
|
795
|
+
withManagedAuthCsrfToken(projection, requireSigningSecret(deps), authority),
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function emptyProjection(deps: ApiRouteDeps, authority: string) {
|
|
800
|
+
return project(deps, authority, {
|
|
801
|
+
mode: deps.settings.managedAuthSessionSetMode,
|
|
802
|
+
generation: "1",
|
|
803
|
+
actorEpoch: "1",
|
|
804
|
+
selectedSlotId: null,
|
|
805
|
+
state: "ready",
|
|
806
|
+
slots: [],
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function digest(deps: ApiRouteDeps, value: unknown): string {
|
|
811
|
+
return managedAuthSecretRequestDigest(requireSigningSecret(deps), value);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function loginTransactionClientScope(context: Context, deps: ApiRouteDeps): string {
|
|
815
|
+
const forwarded = context.req.header("x-forwarded-for")?.split(",")[0]?.trim();
|
|
816
|
+
const address = forwarded || context.req.header("x-real-ip")?.trim() || "unknown";
|
|
817
|
+
return digest(deps, {
|
|
818
|
+
purpose: "managed-auth-login-transaction-rate-limit",
|
|
819
|
+
client: address.slice(0, 128),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function allowedOrigins(deps: ApiRouteDeps): string[] {
|
|
824
|
+
const origins = new Set<string>();
|
|
825
|
+
for (const candidate of [
|
|
826
|
+
deps.settings.publicBaseUrl,
|
|
827
|
+
deps.settings.webBaseUrl,
|
|
828
|
+
...deps.settings.betterAuthTrustedOrigins.split(","),
|
|
829
|
+
]) {
|
|
830
|
+
if (!candidate?.trim()) continue;
|
|
831
|
+
try {
|
|
832
|
+
origins.add(new URL(candidate.trim()).origin);
|
|
833
|
+
} catch {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return [...origins];
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function setAuthorityCookie(context: Context, deps: ApiRouteDeps, authority: string): void {
|
|
841
|
+
setCookie(context, MANAGED_AUTH_SESSION_SET_COOKIE, authority, authorityCookieOptions(deps));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function authorityCookieOptions(deps: ApiRouteDeps) {
|
|
845
|
+
return {
|
|
846
|
+
httpOnly: true,
|
|
847
|
+
secure: deps.settings.publicBaseUrl?.startsWith("https://") ?? false,
|
|
848
|
+
sameSite: "Lax" as const,
|
|
849
|
+
path: "/",
|
|
850
|
+
maxAge: 180 * 24 * 60 * 60,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function transactionCookieOptions(deps: ApiRouteDeps) {
|
|
855
|
+
return {
|
|
856
|
+
httpOnly: true,
|
|
857
|
+
secure: deps.settings.publicBaseUrl?.startsWith("https://") ?? false,
|
|
858
|
+
sameSite: "Strict" as const,
|
|
859
|
+
path: MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE_PATH,
|
|
860
|
+
maxAge: 600,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function setTransactionCookie(
|
|
865
|
+
context: Context,
|
|
866
|
+
deps: ApiRouteDeps,
|
|
867
|
+
transactionId: string,
|
|
868
|
+
transactionSecret: string,
|
|
869
|
+
): void {
|
|
870
|
+
setCookie(
|
|
871
|
+
context,
|
|
872
|
+
MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE,
|
|
873
|
+
`${transactionId}.${transactionSecret}`,
|
|
874
|
+
transactionCookieOptions(deps),
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function requireTransactionSecret(context: Context, transactionId: string): string {
|
|
879
|
+
const value = getCookie(context, MANAGED_AUTH_LOGIN_TRANSACTION_COOKIE);
|
|
880
|
+
const prefix = `${transactionId}.`;
|
|
881
|
+
const secret = value?.startsWith(prefix) ? value.slice(prefix.length) : null;
|
|
882
|
+
if (!secret || !/^[A-Za-z0-9_-]{43}$/.test(secret)) {
|
|
883
|
+
throw managedAuthApiError(401, "invalid_transaction");
|
|
884
|
+
}
|
|
885
|
+
return secret;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function mirrorCurrentSelection(
|
|
889
|
+
context: Context,
|
|
890
|
+
deps: ApiRouteDeps,
|
|
891
|
+
authority: string,
|
|
892
|
+
): Promise<void> {
|
|
893
|
+
if (deps.settings.managedAuthSessionSetMode !== "dual") return;
|
|
894
|
+
const snapshot = await getManagedAuthSessionSetSnapshot(deps.db, {
|
|
895
|
+
authorityHash: managedAuthSha256(authority),
|
|
896
|
+
mode: "dual",
|
|
897
|
+
includeInternal: true,
|
|
898
|
+
readOnly: true,
|
|
899
|
+
});
|
|
900
|
+
appendCookies(
|
|
901
|
+
context,
|
|
902
|
+
await requireAvailable(deps).adapter.createLegacySelectedSessionCookies(
|
|
903
|
+
snapshot?.selected ?? null,
|
|
904
|
+
context.req.header("cookie") ?? null,
|
|
905
|
+
),
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function appendCookies(context: Context, cookies: readonly string[]): void {
|
|
910
|
+
for (const cookie of cookies) context.header("set-cookie", cookie, { append: true });
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function throwHttp(error: unknown): never {
|
|
914
|
+
if (error instanceof HTTPException) throw error;
|
|
915
|
+
if (error instanceof ManagedAuthSessionSetGenerationConflictError) {
|
|
916
|
+
throw managedAuthApiError(409, "generation_conflict", { cause: error, retryable: true });
|
|
917
|
+
}
|
|
918
|
+
if (error instanceof ManagedAuthSessionSetOperationReuseError) {
|
|
919
|
+
throw managedAuthApiError(409, "operation_reused", { cause: error });
|
|
920
|
+
}
|
|
921
|
+
if (error instanceof ManagedAuthLoginSlotUnavailableError) {
|
|
922
|
+
throw managedAuthApiError(409, "slot_unavailable", { cause: error });
|
|
923
|
+
}
|
|
924
|
+
if (error instanceof ManagedAuthLoginSlotLimitError) {
|
|
925
|
+
throw managedAuthApiError(409, "slot_limit_reached", { cause: error });
|
|
926
|
+
}
|
|
927
|
+
if (error instanceof ManagedAuthLoginSlotAlreadyExistsError) {
|
|
928
|
+
throw managedAuthApiError(409, "slot_already_exists", { cause: error });
|
|
929
|
+
}
|
|
930
|
+
if (error instanceof ManagedAuthActorMutationInFlightError) {
|
|
931
|
+
throw managedAuthApiError(409, "actor_mutation_in_flight", { cause: error, retryable: true });
|
|
932
|
+
}
|
|
933
|
+
if (error instanceof ManagedAuthLoginTransactionRateLimitError) {
|
|
934
|
+
throw managedAuthApiError(429, "login_transaction_rate_limited", {
|
|
935
|
+
cause: error,
|
|
936
|
+
retryable: true,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
if (error instanceof ManagedAuthSessionSetAuthorityError) {
|
|
940
|
+
throw managedAuthApiError(401, "browser_session_set_required", { cause: error });
|
|
941
|
+
}
|
|
942
|
+
if (error instanceof ManagedAuthCompletionOutcomeUnknownError) {
|
|
943
|
+
throw managedAuthApiError(503, "operation_outcome_unknown", {
|
|
944
|
+
cause: error,
|
|
945
|
+
retryable: true,
|
|
946
|
+
outcomeUnknown: true,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function managedAuthApiError(
|
|
953
|
+
status: 401 | 403 | 404 | 409 | 422 | 429 | 503,
|
|
954
|
+
code: ManagedAuthSessionSetErrorCodeType,
|
|
955
|
+
options: {
|
|
956
|
+
cause?: unknown;
|
|
957
|
+
retryable?: boolean;
|
|
958
|
+
outcomeUnknown?: boolean;
|
|
959
|
+
} = {},
|
|
960
|
+
): ApiHttpError {
|
|
961
|
+
ManagedAuthSessionSetErrorCode.parse(code);
|
|
962
|
+
const outerCode =
|
|
963
|
+
status === 401
|
|
964
|
+
? "unauthenticated"
|
|
965
|
+
: status === 403
|
|
966
|
+
? "forbidden"
|
|
967
|
+
: status === 404
|
|
968
|
+
? "not_found"
|
|
969
|
+
: status === 422
|
|
970
|
+
? "validation_failed"
|
|
971
|
+
: status === 429
|
|
972
|
+
? "limit_exceeded"
|
|
973
|
+
: status === 503
|
|
974
|
+
? "upstream_unavailable"
|
|
975
|
+
: code === "operation_reused"
|
|
976
|
+
? "idempotency_conflict"
|
|
977
|
+
: "conflict";
|
|
978
|
+
const error = new ApiHttpError(status, {
|
|
979
|
+
code: outerCode,
|
|
980
|
+
message: code,
|
|
981
|
+
retryable: options.retryable ?? false,
|
|
982
|
+
...(options.outcomeUnknown === undefined ? {} : { outcomeUnknown: options.outcomeUnknown }),
|
|
983
|
+
details: { managedAuthCode: code },
|
|
984
|
+
});
|
|
985
|
+
if (options.cause !== undefined) (error as Error & { cause?: unknown }).cause = options.cause;
|
|
986
|
+
return error;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function setCookieHeaders(headers: Headers): string[] {
|
|
990
|
+
const getter = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
|
|
991
|
+
if (getter) return getter.call(headers);
|
|
992
|
+
const cookie = headers.get("set-cookie");
|
|
993
|
+
return cookie ? [cookie] : [];
|
|
994
|
+
}
|