@opengeni/api-router 3.0.1-canary.2 → 3.0.1-canary.4

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.
@@ -0,0 +1,433 @@
1
+ import {
2
+ ManagedSignInMethods,
3
+ ManagedSignInPasswordMutation,
4
+ ManagedSignInProviderMutation,
5
+ ManagedSignInMutationResponse,
6
+ } from "@opengeni/contracts/managed-sign-in-methods";
7
+ import type { ApiRouteDeps } from "@opengeni/core";
8
+ import {
9
+ requireCanonicalHumanRequestIdentity,
10
+ getManagedAuthRequestActorLeaseStamp,
11
+ markManagedAuthRequestActorTransitionApplied,
12
+ } from "@opengeni/core/canonical-human-identities";
13
+ import {
14
+ MANAGED_AUTH_SESSION_SET_COOKIE,
15
+ managedAuthSha256,
16
+ managedAuthSecretRequestDigest,
17
+ requireManagedAuthMutationAdmission,
18
+ } from "@opengeni/core/managed-auth-session-sets";
19
+ import { getManagedAuthSessionSetSnapshot } from "@opengeni/db/managed-auth-session-sets";
20
+ import { getCanonicalHumanIdentityProjection } from "@opengeni/db/canonical-human-identities";
21
+ import { sql } from "drizzle-orm";
22
+ import type { Context, Hono } from "hono";
23
+ import { getCookie } from "hono/cookie";
24
+ import { HTTPException } from "hono/http-exception";
25
+ import { z } from "zod";
26
+ import {
27
+ isolatedManagedAuthOAuthCallbackRequest,
28
+ hashManagedAuthPassword,
29
+ verifyManagedAuthPassword,
30
+ } from "../auth/managed-auth";
31
+ import { runManagedSignInConnect } from "../auth/managed-auth-attempt-context";
32
+ import { deliverManagedSignInNotification } from "../auth/managed-sign-in-notifications";
33
+ import { managedAuthSelectedProofHeaders } from "../auth/managed-auth-session-adapter";
34
+ import { scrubManagedAuthProviderResponse } from "./managed-auth-session-sets";
35
+
36
+ const ConnectState = z.object({
37
+ opengeniSignInMethod: z.object({
38
+ intentId: z.string().uuid(),
39
+ provider: z.enum(["google", "github"]),
40
+ }),
41
+ link: z.object({ userId: z.string().min(1), email: z.string().email() }),
42
+ callbackURL: z.string().url(),
43
+ errorURL: z.string().url(),
44
+ });
45
+ const ConnectIntent = z.object({
46
+ authUserId: z.string().min(1),
47
+ authSessionId: z.string().min(1),
48
+ expectedIdentityId: z.string().uuid(),
49
+ actorFence: z
50
+ .object({
51
+ authorityHash: z.string().regex(/^[0-9a-f]{64}$/),
52
+ actorEpoch: z.string().regex(/^[1-9][0-9]*$/),
53
+ requestId: z.string().uuid(),
54
+ })
55
+ .optional(),
56
+ });
57
+
58
+ async function rows<T>(deps: ApiRouteDeps, query: ReturnType<typeof sql>): Promise<T[]> {
59
+ const result = await deps.db.execute(query);
60
+ return (Array.isArray(result) ? result : (result as { rows: T[] }).rows) as T[];
61
+ }
62
+
63
+ async function identity(context: Context, deps: ApiRouteDeps) {
64
+ return requireCanonicalHumanRequestIdentity(context, {
65
+ db: deps.db,
66
+ managedAuth: deps.managedAuth,
67
+ managedAuthSessionAdapter: deps.managedAuthSessionAdapter,
68
+ managedAuthSessionSetMode: deps.settings.managedAuthSessionSetMode,
69
+ });
70
+ }
71
+
72
+ export async function admitManagedSignInMutation(context: Context, deps: ApiRouteDeps) {
73
+ const origin = deps.settings.publicBaseUrl ? new URL(deps.settings.publicBaseUrl).origin : null;
74
+ if (
75
+ !origin ||
76
+ context.req.header("origin") !== origin ||
77
+ context.req.header("sec-fetch-site") !== "same-origin" ||
78
+ context.req.header("content-type")?.split(";", 1)[0] !== "application/json"
79
+ ) {
80
+ throw new HTTPException(403, { message: "Same-origin browser mutation required" });
81
+ }
82
+ const authority = getCookie(context, MANAGED_AUTH_SESSION_SET_COOKIE);
83
+ if (deps.settings.managedAuthSessionSetMode !== "legacy" && authority) {
84
+ const snapshot = await getManagedAuthSessionSetSnapshot(deps.db, {
85
+ authorityHash: managedAuthSha256(authority),
86
+ mode: deps.settings.managedAuthSessionSetMode,
87
+ readOnly: true,
88
+ });
89
+ if (!snapshot) throw new HTTPException(401);
90
+ try {
91
+ requireManagedAuthMutationAdmission({
92
+ request: context.req.raw,
93
+ allowedOrigins: [origin],
94
+ authority,
95
+ signingSecret: deps.settings.betterAuthSecret!,
96
+ expectedGeneration: snapshot.projection.generation,
97
+ });
98
+ } catch {
99
+ throw new HTTPException(403, { message: "Browser session-set CSRF validation failed" });
100
+ }
101
+ }
102
+ }
103
+
104
+ function configured(deps: ApiRouteDeps, provider: "credential" | "google" | "github") {
105
+ return (
106
+ provider === "credential" ||
107
+ (provider === "google"
108
+ ? Boolean(
109
+ deps.settings.managedAuthGoogleClientId && deps.settings.managedAuthGoogleClientSecret,
110
+ )
111
+ : Boolean(
112
+ deps.settings.managedAuthGithubClientId && deps.settings.managedAuthGithubClientSecret,
113
+ ))
114
+ );
115
+ }
116
+
117
+ function mutationError(context: Context, error: unknown): Response {
118
+ let candidate: unknown = error;
119
+ for (
120
+ let depth = 0;
121
+ depth < 5 && candidate instanceof Error;
122
+ depth++, candidate = candidate.cause
123
+ ) {
124
+ const code = candidate.message.match(/SIGN_IN_METHOD_[A-Z_]+/)?.[0];
125
+ if (code)
126
+ return context.json(
127
+ { code, message: code.toLowerCase().replaceAll("_", " ") },
128
+ code.includes("CONFLICT") ||
129
+ code.includes("REUSED") ||
130
+ code.includes("CHANGED") ||
131
+ code.includes("COLLISION") ||
132
+ code.includes("ALREADY")
133
+ ? 409
134
+ : 403,
135
+ );
136
+ }
137
+ throw error;
138
+ }
139
+
140
+ export function registerManagedSignInMethodRoutes(app: Hono, deps: ApiRouteDeps): void {
141
+ const base = "/v1/auth/sign-in-methods";
142
+ app.use(`${base}*`, async (context, next) => {
143
+ context.header("cache-control", "no-store");
144
+ await next();
145
+ });
146
+ app.get(base, async (context) => {
147
+ const actor = await identity(context, deps);
148
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
149
+ const [user] = await rows<{ email: string; emailVerified: boolean; fresh: boolean }>(
150
+ deps,
151
+ sql`
152
+ select u.email, u.email_verified as "emailVerified", s.created_at > clock_timestamp()-interval '5 minutes' as fresh
153
+ from auth_users u join auth_sessions s on s.user_id=u.id where u.id=${actor.authUserId} and s.id=${actor.authSessionId}`,
154
+ );
155
+ if (!user) throw new HTTPException(401);
156
+ const accounts = await rows<{ provider: string; usable: boolean }>(
157
+ deps,
158
+ sql`
159
+ select provider_id as provider, (provider_id <> 'credential' or length(password)>0) as usable from auth_identities where user_id=${actor.authUserId}`,
160
+ );
161
+ const connected = (provider: string) =>
162
+ accounts.some((a) => a.provider === provider && a.usable) &&
163
+ projection.loginBindings.some((b) => b.providerId === provider && b.status === "active");
164
+ const count = (["credential", "google", "github"] as const).filter(
165
+ (p) => connected(p) && configured(deps, p),
166
+ ).length;
167
+ return context.json(
168
+ ManagedSignInMethods.parse({
169
+ ...user,
170
+ identityId: projection.activeIdentity.id,
171
+ identityRevision: projection.activeIdentity.identityRevision,
172
+ freshAuthenticationRequired: !user.fresh,
173
+ methods: (["credential", "google", "github"] as const).map((provider) => ({
174
+ provider,
175
+ connected: connected(provider),
176
+ available: configured(deps, provider),
177
+ canDisconnect:
178
+ provider !== "credential" &&
179
+ connected(provider) &&
180
+ count - (configured(deps, provider) ? 1 : 0) > 0,
181
+ implicitRelinkingSuppressed:
182
+ !connected(provider) &&
183
+ projection.loginBindings.some(
184
+ (b) => b.providerId === provider && b.status !== "active",
185
+ ),
186
+ })),
187
+ }),
188
+ );
189
+ });
190
+ for (const kind of ["connect", "disconnect", "password"] as const) {
191
+ app.post(`${base}/${kind}`, async (context) => {
192
+ await admitManagedSignInMutation(context, deps);
193
+ const actor = await identity(context, deps);
194
+ const actorFence = getManagedAuthRequestActorLeaseStamp(context.req.raw);
195
+ if (deps.settings.managedAuthSessionSetMode !== "legacy" && !actorFence)
196
+ throw new HTTPException(409, { message: "Managed actor fence required" });
197
+ const schema =
198
+ kind === "password" ? ManagedSignInPasswordMutation : ManagedSignInProviderMutation;
199
+ const parsed = schema.safeParse(await context.req.json().catch(() => null));
200
+ if (!parsed.success)
201
+ throw new HTTPException(422, { message: "Invalid sign-in method request" });
202
+ const body = parsed.data;
203
+ const currentIdentity = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
204
+ if (currentIdentity.activeIdentity.id !== body.expectedIdentityId)
205
+ return context.json(
206
+ {
207
+ code: "SIGN_IN_METHOD_IDENTITY_CHANGED",
208
+ message: "The signed-in account changed; reload security settings",
209
+ },
210
+ 409,
211
+ );
212
+ const [user] = await rows<{ email: string; token: string }>(
213
+ deps,
214
+ sql`select u.email,s.token from auth_users u join auth_sessions s on s.user_id=u.id where u.id=${actor.authUserId} and s.id=${actor.authSessionId}`,
215
+ );
216
+ if (!user) throw new HTTPException(401);
217
+ const request: Record<string, unknown> = {
218
+ kind,
219
+ operationId: body.operationId,
220
+ requestDigest: managedAuthSecretRequestDigest(deps.settings.betterAuthSecret!, {
221
+ kind,
222
+ ...body,
223
+ }),
224
+ expectedIdentityRevision: body.expectedIdentityRevision,
225
+ expectedIdentityId: body.expectedIdentityId,
226
+ usableProviders: (["credential", "google", "github"] as const).filter((p) =>
227
+ configured(deps, p),
228
+ ),
229
+ ...(actorFence ? { actorFence } : {}),
230
+ };
231
+ try {
232
+ const [prior] = await rows<{ result: unknown }>(
233
+ deps,
234
+ sql`select replay_managed_sign_in_method(${actor.authUserId},${actor.authSessionId},${JSON.stringify(request)}::jsonb) result`,
235
+ );
236
+ if (prior?.result)
237
+ return context.json(
238
+ kind === "connect"
239
+ ? {
240
+ url: new URL(
241
+ "/settings/security?signInMethod=connected",
242
+ deps.settings.publicBaseUrl!,
243
+ ).toString(),
244
+ }
245
+ : ManagedSignInMutationResponse.parse(prior.result),
246
+ );
247
+ } catch (error) {
248
+ return mutationError(context, error);
249
+ }
250
+ // Per-human, cross-replica admission before password hashing or creating
251
+ // OAuth state. Successful committed-result replay does not spend a slot.
252
+ const [limit] = await rows<{ count: number }>(
253
+ deps,
254
+ sql`
255
+ insert into auth_rate_limits(id,key,count,last_request)
256
+ values(${`sign-in-methods:${actor.authUserId}`},${`sign-in-methods:${actor.authUserId}`},1,(extract(epoch from clock_timestamp())*1000)::bigint)
257
+ on conflict(key) do update set
258
+ count=case when auth_rate_limits.last_request < (extract(epoch from clock_timestamp())*1000)::bigint-60000 then 1 else least(auth_rate_limits.count+1,11) end,
259
+ last_request=case when auth_rate_limits.last_request < (extract(epoch from clock_timestamp())*1000)::bigint-60000 then (extract(epoch from clock_timestamp())*1000)::bigint else auth_rate_limits.last_request end
260
+ returning count`,
261
+ );
262
+ if (!limit || limit.count > 10)
263
+ return context.json(
264
+ {
265
+ code: "SIGN_IN_METHOD_RATE_LIMITED",
266
+ message: "Too many sign-in method attempts; try again in one minute",
267
+ },
268
+ 429,
269
+ );
270
+ if ("provider" in body) {
271
+ request.provider = body.provider;
272
+ if (kind === "connect" && !configured(deps, body.provider))
273
+ throw new HTTPException(409, { message: "Sign-in provider is not configured" });
274
+ } else {
275
+ const [credential] = await rows<{ password: string | null }>(
276
+ deps,
277
+ sql`select password from auth_identities where user_id=${actor.authUserId} and provider_id='credential'`,
278
+ );
279
+ if (
280
+ credential?.password &&
281
+ (!body.currentPassword ||
282
+ !(await verifyManagedAuthPassword(body.currentPassword, credential.password)))
283
+ ) {
284
+ return context.json(
285
+ {
286
+ code: "SIGN_IN_METHOD_CURRENT_PASSWORD_REQUIRED",
287
+ message: "Enter your current password",
288
+ },
289
+ 403,
290
+ );
291
+ }
292
+ request.expectedPasswordHash = credential?.password ?? null;
293
+ request.passwordHash = await hashManagedAuthPassword(body.newPassword);
294
+ }
295
+ try {
296
+ await rows(
297
+ deps,
298
+ sql`select mutate_managed_sign_in_method(${actor.authUserId},${actor.authSessionId},${JSON.stringify(request)}::jsonb)`,
299
+ );
300
+ if (kind === "connect" && "provider" in body) {
301
+ const headers = await managedAuthSelectedProofHeaders(deps.managedAuth!, user.token);
302
+ headers.set("origin", new URL(deps.settings.publicBaseUrl!).origin);
303
+ const callbackURL = new URL(
304
+ "/settings/security?signInMethod=connected",
305
+ deps.settings.publicBaseUrl!,
306
+ ).toString();
307
+ const errorCallbackURL = new URL(
308
+ "/settings/security?signInMethod=error",
309
+ deps.settings.publicBaseUrl!,
310
+ ).toString();
311
+ const result = await deps.managedAuth!.api.linkSocialAccount({
312
+ headers,
313
+ returnHeaders: true,
314
+ body: {
315
+ provider: body.provider,
316
+ callbackURL,
317
+ errorCallbackURL,
318
+ disableRedirect: true,
319
+ additionalData: {
320
+ opengeniSignInMethod: { intentId: body.operationId, provider: body.provider },
321
+ },
322
+ },
323
+ });
324
+ for (const cookie of result.headers.getSetCookie())
325
+ context.header("set-cookie", cookie, { append: true });
326
+ return context.json({ url: result.response.url });
327
+ }
328
+ if (actorFence) markManagedAuthRequestActorTransitionApplied(context.req.raw);
329
+ return context.json({
330
+ reauthenticationRequired: true,
331
+ notification: await deliverManagedSignInNotification(
332
+ deps.db,
333
+ deps.managedEmailTransport,
334
+ body.operationId,
335
+ ),
336
+ });
337
+ } catch (error) {
338
+ return mutationError(context, error);
339
+ }
340
+ });
341
+ }
342
+ }
343
+
344
+ /** Intercept only database-backed, product-issued explicit connect states. */
345
+ export async function handleManagedSignInConnectCallback(
346
+ context: Context,
347
+ deps: ApiRouteDeps,
348
+ provider: "google" | "github",
349
+ ): Promise<Response | null> {
350
+ const state = context.req.query("state");
351
+ if (!state || !deps.managedAuth) return null;
352
+ const verification = await (
353
+ await deps.managedAuth.$context
354
+ ).internalAdapter.findVerificationValue(state);
355
+ if (!verification?.value) return null;
356
+ let rawState: unknown;
357
+ try {
358
+ rawState = JSON.parse(verification.value);
359
+ } catch {
360
+ return null;
361
+ }
362
+ if (!rawState || typeof rawState !== "object" || !("opengeniSignInMethod" in rawState))
363
+ return null;
364
+ const parsed = ConnectState.safeParse(rawState);
365
+ if (!parsed.success)
366
+ throw new HTTPException(403, { message: "Invalid sign-in method OAuth proof" });
367
+ const value = parsed.data,
368
+ proof = value.opengeniSignInMethod;
369
+ if (
370
+ proof.provider !== provider ||
371
+ value.callbackURL !==
372
+ new URL(
373
+ "/settings/security?signInMethod=connected",
374
+ deps.settings.publicBaseUrl!,
375
+ ).toString() ||
376
+ value.errorURL !==
377
+ new URL("/settings/security?signInMethod=error", deps.settings.publicBaseUrl!).toString()
378
+ )
379
+ throw new HTTPException(403);
380
+ const [intent] = await rows<{ value: string }>(
381
+ deps,
382
+ sql`select value from auth_verifications where identifier=${`managed-sign-in:${proof.intentId}`} and expires_at>clock_timestamp()`,
383
+ );
384
+ if (!intent) throw new HTTPException(403, { message: "Sign-in method connection expired" });
385
+ const request = ConnectIntent.parse(JSON.parse(intent.value));
386
+ const authority = getCookie(context, MANAGED_AUTH_SESSION_SET_COOKIE);
387
+ if (!request.actorFence) {
388
+ const ambient = await deps.managedAuthSessionAdapter?.resolveAmbientSession(
389
+ context.req.raw.headers,
390
+ );
391
+ if (
392
+ !ambient ||
393
+ ambient.session.id !== request.authSessionId ||
394
+ ambient.user.id !== request.authUserId
395
+ ) {
396
+ return context.redirect(
397
+ new URL(
398
+ "/settings/security?signInMethod=error&error=SIGN_IN_METHOD_IDENTITY_CHANGED",
399
+ deps.settings.publicBaseUrl!,
400
+ ).toString(),
401
+ );
402
+ }
403
+ }
404
+ if (
405
+ request.actorFence &&
406
+ (!authority || managedAuthSha256(authority) !== request.actorFence.authorityHash)
407
+ )
408
+ throw new HTTPException(403);
409
+ if (value.link?.userId !== request.authUserId || value.link?.email === undefined)
410
+ throw new HTTPException(403);
411
+ const isolated = await isolatedManagedAuthOAuthCallbackRequest(deps.managedAuth, context.req.raw);
412
+ const response = await runManagedSignInConnect(proof.intentId, () =>
413
+ deps.managedAuth!.handler(isolated.request),
414
+ );
415
+ const [linked] = await rows<{ email: string }>(
416
+ deps,
417
+ sql`select u.email from auth_identities a join auth_users u on u.id=a.user_id where a.managed_link_intent_id=${proof.intentId}`,
418
+ );
419
+ if (linked)
420
+ await deliverManagedSignInNotification(deps.db, deps.managedEmailTransport, proof.intentId);
421
+ if (response.status >= 400 || !linked) {
422
+ return context.redirect(
423
+ new URL(
424
+ "/settings/security?signInMethod=error&error=SIGN_IN_METHOD_CONNECT_FAILED",
425
+ deps.settings.publicBaseUrl!,
426
+ ).toString(),
427
+ );
428
+ }
429
+ return scrubManagedAuthProviderResponse(response, {
430
+ replacementCookies: [],
431
+ preserveCookieNames: [isolated.stateCookieName],
432
+ });
433
+ }
@@ -48,6 +48,7 @@ import {
48
48
  getConnectionMetadata,
49
49
  getInstalledPluginPackage,
50
50
  getPluginPackageUninstallPreview,
51
+ PluginUninstallPreviewChangedError,
51
52
  integrationBindingKey,
52
53
  installApiIntegration,
53
54
  installPluginMcpReference,
@@ -349,9 +350,14 @@ export function registerPluginRoutes(
349
350
 
350
351
  app.get("/v1/workspaces/:workspaceId/plugins/:pluginKey/uninstall-preview", async (c) => {
351
352
  const workspaceId = c.req.param("workspaceId");
352
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
353
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
353
354
  const pluginKey = decodeURIComponent(c.req.param("pluginKey"));
354
- const preview = await getPluginPackageUninstallPreview(deps.db, workspaceId, pluginKey);
355
+ const preview = await getPluginPackageUninstallPreview(
356
+ deps.db,
357
+ workspaceId,
358
+ pluginKey,
359
+ grant.subjectId,
360
+ );
355
361
  return c.json(PluginUninstallPreview.parse({ pluginKey, ...preview }));
356
362
  });
357
363
 
@@ -378,11 +384,33 @@ export function registerPluginRoutes(
378
384
  subjectId: grant.subjectId,
379
385
  pluginKey,
380
386
  expectedInstallationVersion: payload.expectedInstallationVersion,
387
+ ...(payload.expectedPreviewToken
388
+ ? { expectedPreviewToken: payload.expectedPreviewToken }
389
+ : {}),
381
390
  idempotencyKey: payload.idempotencyKey,
382
391
  })),
383
392
  }),
384
393
  );
385
394
  } catch (error) {
395
+ if (error instanceof PluginUninstallPreviewChangedError) {
396
+ const preview = error.preview ?? {
397
+ pluginKey,
398
+ ...(await getPluginPackageUninstallPreview(
399
+ deps.db,
400
+ workspaceId,
401
+ pluginKey,
402
+ grant.subjectId,
403
+ )),
404
+ };
405
+ return c.json(
406
+ {
407
+ code: error.code,
408
+ message: error.message,
409
+ preview: PluginUninstallPreview.parse(preview),
410
+ },
411
+ 409,
412
+ );
413
+ }
386
414
  throw pluginMutationHttpError(error);
387
415
  }
388
416
  });