@opengeni/api-router 3.0.1 → 4.0.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/admission-trace.d.ts +8 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +2 -0
- package/dist/auth/managed-auth.d.ts +1 -0
- package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
- package/dist/{chunk-AJ62T5QN.js → chunk-E7TZFJ65.js} +22300 -21113
- package/dist/{chunk-AJ62T5QN.js.map → chunk-E7TZFJ65.js.map} +1 -1
- package/dist/connection-ownership.d.ts +1 -2
- package/dist/fatal-process-boundary.d.ts +1 -1
- package/dist/index.js +24 -6
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +8 -3
- package/dist/integrations/fiken.d.ts +3 -1
- package/dist/integrations/github-lens-connect.d.ts +5 -65
- package/dist/integrations/google-drive.d.ts +2 -0
- package/dist/integrations/integration-commit-authority.d.ts +13 -0
- package/dist/integrations/oauth-client.d.ts +119 -11
- package/dist/integrations/oauth-profiles.d.ts +8 -13
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/capability-account-readiness.d.ts +7 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
- package/dist/mcp-oauth.d.ts +22 -1
- package/dist/routes/api-integrations.d.ts +1 -0
- package/dist/routes/codex.d.ts +28 -0
- package/dist/routes/managed-sign-in-methods.d.ts +6 -0
- package/dist/routes/organization-integration-policy.d.ts +3 -0
- package/dist/routes/sessions.d.ts +24 -2
- package/dist/scheduled-task-deletion.d.ts +3 -4
- package/dist/workspace-tool-gateway.d.ts +3 -1
- package/package.json +15 -15
- package/src/admission-trace.ts +14 -0
- package/src/app.ts +141 -94
- package/src/auth/managed-auth-attempt-context.ts +9 -0
- package/src/auth/managed-auth-session-adapter.ts +11 -0
- package/src/auth/managed-auth.ts +39 -2
- package/src/auth/managed-sign-in-notifications.ts +81 -0
- package/src/connection-ownership.ts +2 -9
- package/src/editable-artifact-production.ts +6 -0
- package/src/fatal-process-boundary.ts +17 -2
- package/src/index.ts +3 -4
- package/src/integrations/atlassian.ts +184 -76
- package/src/integrations/fiken.ts +57 -35
- package/src/integrations/github-app-connect.ts +51 -15
- package/src/integrations/github-lens-connect.ts +7 -28
- package/src/integrations/google-drive.ts +296 -125
- package/src/integrations/integration-commit-authority.ts +69 -0
- package/src/integrations/oauth-client.ts +231 -132
- package/src/integrations/oauth-profiles.ts +10 -45
- package/src/integrations/personal-github.ts +55 -31
- package/src/integrations/provider-oauth.ts +57 -82
- package/src/integrations/social-oauth.ts +83 -56
- package/src/mcp/capability-account-readiness.ts +17 -0
- package/src/mcp/editable-artifacts.ts +7 -2
- package/src/mcp/first-party-tool-permissions.ts +1 -0
- package/src/mcp/knowledge-entries.ts +25 -8
- package/src/mcp/server.ts +135 -43
- package/src/mcp-oauth.ts +336 -34
- package/src/routes/api-integrations.ts +116 -4
- package/src/routes/automations.ts +5 -13
- package/src/routes/billing.ts +52 -0
- package/src/routes/canonical-human-identities.ts +61 -0
- package/src/routes/capabilities.ts +51 -1
- package/src/routes/codex.ts +4 -2
- package/src/routes/connect.ts +90 -31
- package/src/routes/connections.ts +487 -307
- package/src/routes/files.ts +21 -12
- package/src/routes/github.ts +48 -19
- package/src/routes/insights.ts +17 -7
- package/src/routes/integration-facets.ts +66 -25
- package/src/routes/knowledge.ts +48 -1
- package/src/routes/managed-sign-in-methods.ts +433 -0
- package/src/routes/organization-integration-policy.ts +113 -0
- package/src/routes/organization-memberships.ts +22 -2
- package/src/routes/plugins.ts +42 -4
- package/src/routes/pr-review-github.ts +67 -57
- package/src/routes/pr-review.ts +74 -71
- package/src/routes/rigs.ts +32 -13
- package/src/routes/scheduled-tasks.ts +6 -6
- package/src/routes/sessions.ts +233 -11
- package/src/routes/skill-content.ts +19 -3
- package/src/routes/social.ts +24 -13
- package/src/sandbox/channel-a.ts +7 -1
- package/src/sandbox/rematerialize.ts +2 -1
- package/src/scheduled-task-deletion.ts +13 -3
- package/src/workspace-tool-gateway.ts +31 -31
- package/dist/connection-authority-owner.d.ts +0 -59
- package/dist/routes/connection-authorities.d.ts +0 -3
- package/dist/routes/host-mcp-bindings.d.ts +0 -5
- package/dist/routes/host-mcp-resolvers.d.ts +0 -3
- package/dist/routes/packs.d.ts +0 -3
- package/src/connection-authority-owner.ts +0 -61
- package/src/routes/connection-authorities.ts +0 -139
- package/src/routes/host-mcp-bindings.ts +0 -122
- package/src/routes/host-mcp-resolvers.ts +0 -41
- package/src/routes/packs.ts +0 -784
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { UpdateOrganizationIntegrationPolicyRequest } from "@opengeni/contracts";
|
|
2
|
+
import {
|
|
3
|
+
accountScopedApiKeyWorkspaceAuthority,
|
|
4
|
+
organizationIntegrationCatalog,
|
|
5
|
+
requireAccessContext,
|
|
6
|
+
requireCanonicalLocalAccountAdministrator,
|
|
7
|
+
type ApiRouteDeps,
|
|
8
|
+
} from "@opengeni/core";
|
|
9
|
+
import {
|
|
10
|
+
getOrganizationIntegrationPolicy,
|
|
11
|
+
updateOrganizationIntegrationPolicy,
|
|
12
|
+
} from "@opengeni/db/organization-integration-policy";
|
|
13
|
+
import { nestedPostgresSqlState } from "@opengeni/db";
|
|
14
|
+
import type { Context, Hono } from "hono";
|
|
15
|
+
import { HTTPException } from "hono/http-exception";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { requireSameOriginBrowserMutation } from "./codex";
|
|
18
|
+
import { managedCookieHuman } from "./supergrok";
|
|
19
|
+
|
|
20
|
+
/** The database rechecks current organization membership/key authority at commit. */
|
|
21
|
+
async function authorizeAdministration(
|
|
22
|
+
context: Context,
|
|
23
|
+
deps: ApiRouteDeps,
|
|
24
|
+
accountId: string,
|
|
25
|
+
mutation: boolean,
|
|
26
|
+
): Promise<{ accountId: string; subjectId: string }> {
|
|
27
|
+
const access = await requireAccessContext(context, deps);
|
|
28
|
+
const service = accountScopedApiKeyWorkspaceAuthority(access);
|
|
29
|
+
if (service) {
|
|
30
|
+
if (service.accountId !== accountId || !service.permissions.includes("workspace:admin")) {
|
|
31
|
+
throw new HTTPException(403, { message: "Organization administration required" });
|
|
32
|
+
}
|
|
33
|
+
return { accountId, subjectId: access.subjectId };
|
|
34
|
+
}
|
|
35
|
+
if (deps.settings.productAccessMode === "local") {
|
|
36
|
+
const local = await requireCanonicalLocalAccountAdministrator(context, deps, accountId);
|
|
37
|
+
if (mutation) requireSameOriginBrowserMutation(context, deps);
|
|
38
|
+
return { accountId, subjectId: local.subjectId };
|
|
39
|
+
}
|
|
40
|
+
const human = await managedCookieHuman(context, deps);
|
|
41
|
+
if (!human || human.subjectId !== access.subjectId) {
|
|
42
|
+
throw new HTTPException(403, { message: "Organization administration required" });
|
|
43
|
+
}
|
|
44
|
+
if (mutation) requireSameOriginBrowserMutation(context, deps);
|
|
45
|
+
return { accountId, subjectId: human.subjectId };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function policyResponse<T>(readOrWrite: () => Promise<T>): Promise<T> {
|
|
49
|
+
try {
|
|
50
|
+
return await readOrWrite();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof HTTPException) throw error;
|
|
53
|
+
const state = nestedPostgresSqlState(error);
|
|
54
|
+
if (state === "42501") {
|
|
55
|
+
throw new HTTPException(403, { message: "Organization administration required" });
|
|
56
|
+
}
|
|
57
|
+
if (state === "40001" || state === "23505") {
|
|
58
|
+
throw new HTTPException(409, {
|
|
59
|
+
message: "Integration policy changed; refresh before saving",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (state === "22023") {
|
|
63
|
+
throw new HTTPException(422, { message: "Invalid integration policy" });
|
|
64
|
+
}
|
|
65
|
+
throw new HTTPException(503, { message: "Integration policy is temporarily unavailable" });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function registerOrganizationIntegrationPolicyRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
70
|
+
const path = "/v1/organizations/:organizationId/integration-policy";
|
|
71
|
+
const organizationId = (context: Context) => {
|
|
72
|
+
const parsed = z.string().uuid().safeParse(context.req.param("organizationId"));
|
|
73
|
+
if (!parsed.success) throw new HTTPException(422, { message: "Invalid organization identity" });
|
|
74
|
+
return parsed.data.toLowerCase();
|
|
75
|
+
};
|
|
76
|
+
app.get(`${path}/catalog`, async (context) => {
|
|
77
|
+
const accountId = organizationId(context);
|
|
78
|
+
// The same live administration check as policy reads, including workspace-free service access.
|
|
79
|
+
await policyResponse(() =>
|
|
80
|
+
getOrganizationIntegrationPolicy(deps.db, { accountId }, () =>
|
|
81
|
+
authorizeAdministration(context, deps, accountId, false),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
context.header("cache-control", "private, no-store");
|
|
85
|
+
return context.json(organizationIntegrationCatalog());
|
|
86
|
+
});
|
|
87
|
+
app.get(path, async (context) => {
|
|
88
|
+
context.header("cache-control", "private, no-store");
|
|
89
|
+
const accountId = organizationId(context);
|
|
90
|
+
return context.json(
|
|
91
|
+
await policyResponse(() =>
|
|
92
|
+
getOrganizationIntegrationPolicy(deps.db, { accountId }, () =>
|
|
93
|
+
authorizeAdministration(context, deps, accountId, false),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
app.put(path, async (context) => {
|
|
99
|
+
context.header("cache-control", "private, no-store");
|
|
100
|
+
const accountId = organizationId(context);
|
|
101
|
+
const parsed = UpdateOrganizationIntegrationPolicyRequest.safeParse(
|
|
102
|
+
await context.req.json().catch(() => null),
|
|
103
|
+
);
|
|
104
|
+
if (!parsed.success) throw new HTTPException(422, { message: "Invalid integration policy" });
|
|
105
|
+
return context.json(
|
|
106
|
+
await policyResponse(() =>
|
|
107
|
+
updateOrganizationIntegrationPolicy(deps.db, { accountId }, parsed.data, () =>
|
|
108
|
+
authorizeAdministration(context, deps, accountId, true),
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
} from "@opengeni/contracts";
|
|
39
39
|
import {
|
|
40
40
|
getManagedSession,
|
|
41
|
+
accountScopedApiKeyWorkspaceAuthority,
|
|
42
|
+
requireAccessContext,
|
|
41
43
|
organizationMembershipHttpStatus,
|
|
42
44
|
requireCanonicalLocalAccountAdministrator,
|
|
43
45
|
updateExternalIdentityMembershipForRequest,
|
|
@@ -88,6 +90,24 @@ const WorkspaceId = z.string().uuid();
|
|
|
88
90
|
const MembershipId = z.string().uuid();
|
|
89
91
|
const InvitationId = z.string().uuid();
|
|
90
92
|
|
|
93
|
+
// Organization keys administer settings, not another person's session contents.
|
|
94
|
+
// The database independently fences current key authority, including on replay.
|
|
95
|
+
async function requirePrivateSessionAdministrator(
|
|
96
|
+
context: Context,
|
|
97
|
+
deps: ApiRouteDeps,
|
|
98
|
+
organizationId: string,
|
|
99
|
+
): Promise<{ subjectId: string }> {
|
|
100
|
+
if (!context.req.header("authorization")) return requireManagedHuman(context, deps);
|
|
101
|
+
const access = await requireAccessContext(context, deps);
|
|
102
|
+
const key = accountScopedApiKeyWorkspaceAuthority(access);
|
|
103
|
+
if (!key || key.accountId !== organizationId || !key.permissions.includes("workspace:admin")) {
|
|
104
|
+
throw new HTTPException(403, {
|
|
105
|
+
message: "organization administration required",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return { subjectId: access.subjectId };
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
async function requireManagedHuman(context: Context, deps: ApiRouteDeps) {
|
|
92
112
|
if (
|
|
93
113
|
deps.settings.productAccessMode !== "managed" ||
|
|
@@ -527,12 +547,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
527
547
|
);
|
|
528
548
|
|
|
529
549
|
app.get("/v1/organizations/:organizationId/private-session-settings", async (context) => {
|
|
530
|
-
const { subjectId } = await requireManagedHuman(context, deps);
|
|
531
550
|
const organizationId = parseId(
|
|
532
551
|
OrganizationId,
|
|
533
552
|
context.req.param("organizationId"),
|
|
534
553
|
"organization id",
|
|
535
554
|
);
|
|
555
|
+
const { subjectId } = await requirePrivateSessionAdministrator(context, deps, organizationId);
|
|
536
556
|
try {
|
|
537
557
|
return context.json(
|
|
538
558
|
OrganizationPrivateSessionSettings.parse(
|
|
@@ -548,12 +568,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
548
568
|
});
|
|
549
569
|
|
|
550
570
|
app.patch("/v1/organizations/:organizationId/private-session-settings", async (context) => {
|
|
551
|
-
const { subjectId } = await requireManagedHuman(context, deps);
|
|
552
571
|
const organizationId = parseId(
|
|
553
572
|
OrganizationId,
|
|
554
573
|
context.req.param("organizationId"),
|
|
555
574
|
"organization id",
|
|
556
575
|
);
|
|
576
|
+
const { subjectId } = await requirePrivateSessionAdministrator(context, deps, organizationId);
|
|
557
577
|
const payload = await parseBody(context, UpdateOrganizationPrivateSessionSettingsRequest);
|
|
558
578
|
try {
|
|
559
579
|
return context.json(
|