@opengeni/api-router 0.26.1 → 0.30.1
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.d.ts +1 -0
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +19 -1
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/google-drive.d.ts +6 -6
- package/dist/integrations/provider-oauth.d.ts +0 -2
- package/dist/integrations/slack-bot.d.ts +171 -1
- package/dist/integrations/slack-interactions.d.ts +17 -5
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/sessions.d.ts +2 -1
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +8 -1
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/viewer.d.ts +3 -1
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +17 -16
- package/src/app.ts +352 -49
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +50 -1
- package/src/browser-network-route.ts +34 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/http/auth.ts +27 -9
- package/src/http/sse.ts +229 -5
- package/src/integrations/api-integrations.ts +57 -42
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/google-drive.ts +31 -26
- package/src/integrations/provider-oauth.ts +70 -91
- package/src/integrations/slack-bot.ts +638 -9
- package/src/integrations/slack-interactions.ts +1583 -73
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +115 -6
- package/src/mcp/server.ts +865 -242
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +32 -29
- package/src/routes/browser-identities.ts +24 -0
- package/src/routes/browser-sessions.ts +2122 -183
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/computer-sessions.ts +441 -86
- package/src/routes/connections.ts +141 -4
- package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/plugins.ts +2 -1
- package/src/routes/sessions.ts +419 -43
- package/src/routes/skills.ts +92 -10
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +34 -4
- package/src/routes/workspaces.ts +22 -4
- package/src/sandbox/channel-a.ts +29 -2
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/viewer.ts +10 -3
- package/src/slack-reaction-files.ts +181 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-JIKNR5YL.js.map +0 -1
- package/dist/routes/integration-features.d.ts +0 -3
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CanonicalHumanIdentityStatus,
|
|
3
|
+
CanonicalHumanLoginBindingStatus,
|
|
4
|
+
CanonicalHumanRecoveryState,
|
|
5
|
+
} from "@opengeni/contracts/canonical-human-identities";
|
|
6
|
+
|
|
7
|
+
export type CanonicalHumanSessionAdmissionIntent =
|
|
8
|
+
| "ordinary_session"
|
|
9
|
+
| "binding_synchronization"
|
|
10
|
+
| "recovery_completion";
|
|
11
|
+
|
|
12
|
+
export type CanonicalHumanIdentityAdmissionSnapshot = {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly status: CanonicalHumanIdentityStatus;
|
|
15
|
+
readonly recoveryState: CanonicalHumanRecoveryState;
|
|
16
|
+
readonly activeLoginBindingId: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CanonicalHumanLoginBindingAdmissionSnapshot = {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly identityId: string;
|
|
22
|
+
readonly status: CanonicalHumanLoginBindingStatus;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CanonicalHumanSessionAdmissionDenialCode =
|
|
26
|
+
| "identity_binding_mismatch"
|
|
27
|
+
| "ordinary_identity_not_active"
|
|
28
|
+
| "ordinary_identity_not_ready"
|
|
29
|
+
| "ordinary_binding_missing"
|
|
30
|
+
| "ordinary_binding_not_active"
|
|
31
|
+
| "ordinary_active_binding_missing"
|
|
32
|
+
| "recovery_identity_not_required"
|
|
33
|
+
| "recovery_state_not_required"
|
|
34
|
+
| "recovery_active_binding_present"
|
|
35
|
+
| "recovery_binding_missing"
|
|
36
|
+
| "recovery_binding_not_pending";
|
|
37
|
+
|
|
38
|
+
export type CanonicalHumanSessionAdmissionDecision =
|
|
39
|
+
| {
|
|
40
|
+
allowed: true;
|
|
41
|
+
intent: CanonicalHumanSessionAdmissionIntent;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
allowed: false;
|
|
45
|
+
intent: CanonicalHumanSessionAdmissionIntent;
|
|
46
|
+
code: CanonicalHumanSessionAdmissionDenialCode;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function decideCanonicalHumanSessionAdmission(input: {
|
|
50
|
+
intent: CanonicalHumanSessionAdmissionIntent;
|
|
51
|
+
identity: CanonicalHumanIdentityAdmissionSnapshot;
|
|
52
|
+
binding: CanonicalHumanLoginBindingAdmissionSnapshot | null;
|
|
53
|
+
}): CanonicalHumanSessionAdmissionDecision {
|
|
54
|
+
if (input.binding && input.binding.identityId !== input.identity.id) {
|
|
55
|
+
return denied(input.intent, "identity_binding_mismatch");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (input.intent === "recovery_completion") {
|
|
59
|
+
return decideRecoveryCompletion(input.identity, input.binding);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (input.identity.status !== "active") {
|
|
63
|
+
return denied(input.intent, "ordinary_identity_not_active");
|
|
64
|
+
}
|
|
65
|
+
if (input.identity.recoveryState !== "ready") {
|
|
66
|
+
return denied(input.intent, "ordinary_identity_not_ready");
|
|
67
|
+
}
|
|
68
|
+
if (input.intent === "binding_synchronization" && input.binding === null) {
|
|
69
|
+
return allowed(input.intent);
|
|
70
|
+
}
|
|
71
|
+
if (input.binding === null) {
|
|
72
|
+
return denied(input.intent, "ordinary_binding_missing");
|
|
73
|
+
}
|
|
74
|
+
if (input.binding.status !== "active") {
|
|
75
|
+
return denied(input.intent, "ordinary_binding_not_active");
|
|
76
|
+
}
|
|
77
|
+
if (input.identity.activeLoginBindingId === null) {
|
|
78
|
+
return denied(input.intent, "ordinary_active_binding_missing");
|
|
79
|
+
}
|
|
80
|
+
return allowed(input.intent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decideRecoveryCompletion(
|
|
84
|
+
identity: CanonicalHumanIdentityAdmissionSnapshot,
|
|
85
|
+
binding: CanonicalHumanLoginBindingAdmissionSnapshot | null,
|
|
86
|
+
): CanonicalHumanSessionAdmissionDecision {
|
|
87
|
+
if (identity.status !== "recovery_required") {
|
|
88
|
+
return denied("recovery_completion", "recovery_identity_not_required");
|
|
89
|
+
}
|
|
90
|
+
if (identity.recoveryState !== "recovery_required" && identity.recoveryState !== "lost_factor") {
|
|
91
|
+
return denied("recovery_completion", "recovery_state_not_required");
|
|
92
|
+
}
|
|
93
|
+
if (identity.activeLoginBindingId !== null) {
|
|
94
|
+
return denied("recovery_completion", "recovery_active_binding_present");
|
|
95
|
+
}
|
|
96
|
+
if (binding === null) {
|
|
97
|
+
return denied("recovery_completion", "recovery_binding_missing");
|
|
98
|
+
}
|
|
99
|
+
if (binding.status !== "recovery_pending") {
|
|
100
|
+
return denied("recovery_completion", "recovery_binding_not_pending");
|
|
101
|
+
}
|
|
102
|
+
return allowed("recovery_completion");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function allowed(
|
|
106
|
+
intent: CanonicalHumanSessionAdmissionIntent,
|
|
107
|
+
): CanonicalHumanSessionAdmissionDecision {
|
|
108
|
+
return { allowed: true, intent };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function denied(
|
|
112
|
+
intent: CanonicalHumanSessionAdmissionIntent,
|
|
113
|
+
code: CanonicalHumanSessionAdmissionDenialCode,
|
|
114
|
+
): CanonicalHumanSessionAdmissionDecision {
|
|
115
|
+
return { allowed: false, intent, code };
|
|
116
|
+
}
|
package/src/auth/managed-auth.ts
CHANGED
|
@@ -2,11 +2,18 @@ import type { Settings } from "@opengeni/config";
|
|
|
2
2
|
import { type ManagedAuth } from "@opengeni/core";
|
|
3
3
|
import type { Database } from "@opengeni/db";
|
|
4
4
|
import { ensureManagedAccessForUser } from "@opengeni/db";
|
|
5
|
+
import {
|
|
6
|
+
ensureCanonicalHumanIdentityForAuthUser,
|
|
7
|
+
getCanonicalHumanIdentityProjection,
|
|
8
|
+
synchronizeCanonicalHumanLoginBindings,
|
|
9
|
+
} from "@opengeni/db/canonical-human-identities";
|
|
5
10
|
import { betterAuth } from "better-auth";
|
|
6
11
|
import { createEmailVerificationToken } from "better-auth/api";
|
|
7
12
|
import { Pool } from "pg";
|
|
8
13
|
import { Resend } from "resend";
|
|
9
14
|
|
|
15
|
+
import { decideCanonicalHumanSessionAdmission } from "./canonical-human-session-admission";
|
|
16
|
+
|
|
10
17
|
// `ManagedAuth` (the Better Auth `Auth<any>` alias) is owned by @opengeni/core
|
|
11
18
|
// (`managed-auth-type.ts`) — `dependencies.ts`/`access` reference it as a
|
|
12
19
|
// type-only slot. We re-export it from this construction site so existing
|
|
@@ -65,6 +72,28 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
|
|
|
65
72
|
createdAt: "created_at",
|
|
66
73
|
updatedAt: "updated_at",
|
|
67
74
|
},
|
|
75
|
+
additionalFields: {
|
|
76
|
+
identityId: {
|
|
77
|
+
type: "string",
|
|
78
|
+
fieldName: "identity_id",
|
|
79
|
+
input: false,
|
|
80
|
+
returned: false,
|
|
81
|
+
},
|
|
82
|
+
identityRevision: {
|
|
83
|
+
type: "number",
|
|
84
|
+
fieldName: "identity_revision",
|
|
85
|
+
input: false,
|
|
86
|
+
returned: false,
|
|
87
|
+
bigint: true,
|
|
88
|
+
},
|
|
89
|
+
authRevision: {
|
|
90
|
+
type: "number",
|
|
91
|
+
fieldName: "auth_revision",
|
|
92
|
+
input: false,
|
|
93
|
+
returned: false,
|
|
94
|
+
bigint: true,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
68
97
|
},
|
|
69
98
|
account: {
|
|
70
99
|
modelName: "auth_identities",
|
|
@@ -136,6 +165,78 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
|
|
|
136
165
|
},
|
|
137
166
|
},
|
|
138
167
|
databaseHooks: {
|
|
168
|
+
session: {
|
|
169
|
+
create: {
|
|
170
|
+
before: async (session) => {
|
|
171
|
+
await ensureCanonicalHumanIdentityForAuthUser(db, session.userId);
|
|
172
|
+
const preflightProjection = await getCanonicalHumanIdentityProjection(
|
|
173
|
+
db,
|
|
174
|
+
session.userId,
|
|
175
|
+
);
|
|
176
|
+
const preflight = decideCanonicalHumanSessionAdmission({
|
|
177
|
+
intent: "binding_synchronization",
|
|
178
|
+
identity: preflightProjection.activeIdentity,
|
|
179
|
+
binding: null,
|
|
180
|
+
});
|
|
181
|
+
if (!preflight.allowed) {
|
|
182
|
+
const recoveryBinding = preflightProjection.loginBindings.find(
|
|
183
|
+
(binding) => binding.status === "recovery_pending",
|
|
184
|
+
);
|
|
185
|
+
const recoveryAdmission = decideCanonicalHumanSessionAdmission({
|
|
186
|
+
intent: "recovery_completion",
|
|
187
|
+
identity: preflightProjection.activeIdentity,
|
|
188
|
+
binding: recoveryBinding
|
|
189
|
+
? {
|
|
190
|
+
id: recoveryBinding.id,
|
|
191
|
+
identityId: preflightProjection.activeIdentity.id,
|
|
192
|
+
status: recoveryBinding.status,
|
|
193
|
+
}
|
|
194
|
+
: null,
|
|
195
|
+
});
|
|
196
|
+
if (!recoveryAdmission.allowed) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
data: {
|
|
201
|
+
...session,
|
|
202
|
+
identityId: preflightProjection.activeIdentity.id,
|
|
203
|
+
identityRevision: preflightProjection.activeIdentity.identityRevision,
|
|
204
|
+
authRevision: preflightProjection.activeIdentity.authRevision,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await synchronizeCanonicalHumanLoginBindings(db, session.userId);
|
|
210
|
+
const projection = await getCanonicalHumanIdentityProjection(db, session.userId);
|
|
211
|
+
const activeBinding = projection.loginBindings.find(
|
|
212
|
+
(binding) => binding.id === projection.activeIdentity.activeLoginBindingId,
|
|
213
|
+
);
|
|
214
|
+
const admission = decideCanonicalHumanSessionAdmission({
|
|
215
|
+
intent: "ordinary_session",
|
|
216
|
+
identity: projection.activeIdentity,
|
|
217
|
+
binding: activeBinding
|
|
218
|
+
? {
|
|
219
|
+
id: activeBinding.id,
|
|
220
|
+
identityId: projection.activeIdentity.id,
|
|
221
|
+
status: activeBinding.status,
|
|
222
|
+
}
|
|
223
|
+
: null,
|
|
224
|
+
});
|
|
225
|
+
if (!admission.allowed) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
data: {
|
|
231
|
+
...session,
|
|
232
|
+
identityId: projection.activeIdentity.id,
|
|
233
|
+
identityRevision: projection.activeIdentity.identityRevision,
|
|
234
|
+
authRevision: projection.activeIdentity.authRevision,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
139
240
|
user: {
|
|
140
241
|
create: {
|
|
141
242
|
after: async (user) => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
BrowserProtectedAuthFieldValue,
|
|
4
|
+
ProtectedAuthField,
|
|
5
|
+
SiteAuthAuthority,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
7
|
+
|
|
8
|
+
type ConnectionFieldsAuthority = Extract<SiteAuthAuthority, { kind: "connection_fields" }>;
|
|
9
|
+
|
|
10
|
+
export class BrowserAuthCredentialError extends Error {
|
|
11
|
+
readonly name = "BrowserAuthCredentialError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveProtectedAuthFieldValues(input: {
|
|
15
|
+
authority: ConnectionFieldsAuthority;
|
|
16
|
+
requestedFields: readonly ProtectedAuthField[];
|
|
17
|
+
credential: Readonly<Record<string, unknown>>;
|
|
18
|
+
nowMs?: number;
|
|
19
|
+
}): BrowserProtectedAuthFieldValue[] {
|
|
20
|
+
const configured = new Map(input.authority.fields.map((field) => [field.id, field]));
|
|
21
|
+
return input.requestedFields.map((requested) => {
|
|
22
|
+
const field = configured.get(requested.fieldId);
|
|
23
|
+
if (!field) {
|
|
24
|
+
throw new BrowserAuthCredentialError(
|
|
25
|
+
`Protected auth field ${requested.fieldId} is not configured`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const stored = Object.prototype.hasOwnProperty.call(input.credential, field.credentialKey)
|
|
29
|
+
? input.credential[field.credentialKey]
|
|
30
|
+
: undefined;
|
|
31
|
+
if (typeof stored !== "string" || stored.length === 0) {
|
|
32
|
+
throw new BrowserAuthCredentialError(
|
|
33
|
+
`Credential field ${field.credentialKey} is unavailable`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const value =
|
|
37
|
+
field.purpose === "totp"
|
|
38
|
+
? totpCode(stored, {
|
|
39
|
+
...(field.digits === undefined ? {} : { digits: field.digits }),
|
|
40
|
+
...(field.periodSeconds === undefined ? {} : { periodSeconds: field.periodSeconds }),
|
|
41
|
+
...(field.algorithm === undefined ? {} : { algorithm: field.algorithm }),
|
|
42
|
+
...(input.nowMs === undefined ? {} : { nowMs: input.nowMs }),
|
|
43
|
+
})
|
|
44
|
+
: stored;
|
|
45
|
+
return {
|
|
46
|
+
fieldId: requested.fieldId,
|
|
47
|
+
locator: requested.locator,
|
|
48
|
+
purpose: field.purpose,
|
|
49
|
+
value,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function totpCode(
|
|
55
|
+
secretValue: string,
|
|
56
|
+
options: {
|
|
57
|
+
digits?: number;
|
|
58
|
+
periodSeconds?: number;
|
|
59
|
+
algorithm?: "sha1" | "sha256" | "sha512";
|
|
60
|
+
nowMs?: number;
|
|
61
|
+
} = {},
|
|
62
|
+
): string {
|
|
63
|
+
const uri = secretValue.startsWith("otpauth://") ? parseOtpAuthUri(secretValue) : null;
|
|
64
|
+
const digits = options.digits ?? uri?.digits ?? 6;
|
|
65
|
+
const periodSeconds = options.periodSeconds ?? uri?.periodSeconds ?? 30;
|
|
66
|
+
const algorithm = options.algorithm ?? uri?.algorithm ?? "sha1";
|
|
67
|
+
if (!Number.isInteger(digits) || digits < 6 || digits > 10) {
|
|
68
|
+
throw new BrowserAuthCredentialError("TOTP digits must be between 6 and 10");
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isInteger(periodSeconds) || periodSeconds < 15 || periodSeconds > 300) {
|
|
71
|
+
throw new BrowserAuthCredentialError("TOTP period is outside the supported range");
|
|
72
|
+
}
|
|
73
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
74
|
+
if (!Number.isFinite(nowMs) || nowMs < 0) {
|
|
75
|
+
throw new BrowserAuthCredentialError("TOTP time is invalid");
|
|
76
|
+
}
|
|
77
|
+
const secret = decodeBase32(uri?.secret ?? secretValue);
|
|
78
|
+
const counter = Buffer.alloc(8);
|
|
79
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(nowMs / (periodSeconds * 1_000))));
|
|
80
|
+
const digest = createHmac(algorithm, secret).update(counter).digest();
|
|
81
|
+
const offset = digest[digest.length - 1]! & 0x0f;
|
|
82
|
+
const binary =
|
|
83
|
+
((digest[offset]! & 0x7f) << 24) |
|
|
84
|
+
(digest[offset + 1]! << 16) |
|
|
85
|
+
(digest[offset + 2]! << 8) |
|
|
86
|
+
digest[offset + 3]!;
|
|
87
|
+
const code = String(binary % 10 ** digits).padStart(digits, "0");
|
|
88
|
+
secret.fill(0);
|
|
89
|
+
counter.fill(0);
|
|
90
|
+
digest.fill(0);
|
|
91
|
+
return code;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseOtpAuthUri(value: string): {
|
|
95
|
+
secret: string;
|
|
96
|
+
digits?: number;
|
|
97
|
+
periodSeconds?: number;
|
|
98
|
+
algorithm?: "sha1" | "sha256" | "sha512";
|
|
99
|
+
} {
|
|
100
|
+
let url: URL;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(value);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new BrowserAuthCredentialError("TOTP credential contains an invalid otpauth URI");
|
|
105
|
+
}
|
|
106
|
+
if (url.protocol !== "otpauth:" || url.hostname !== "totp") {
|
|
107
|
+
throw new BrowserAuthCredentialError("Only otpauth TOTP credentials are supported");
|
|
108
|
+
}
|
|
109
|
+
const secret = url.searchParams.get("secret")?.trim();
|
|
110
|
+
if (!secret) throw new BrowserAuthCredentialError("TOTP credential has no secret");
|
|
111
|
+
const digits = optionalIntegerParameter(url, "digits");
|
|
112
|
+
const periodSeconds = optionalIntegerParameter(url, "period");
|
|
113
|
+
const rawAlgorithm = url.searchParams.get("algorithm")?.toLowerCase();
|
|
114
|
+
if (rawAlgorithm && !["sha1", "sha256", "sha512"].includes(rawAlgorithm)) {
|
|
115
|
+
throw new BrowserAuthCredentialError("TOTP credential uses an unsupported algorithm");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
secret,
|
|
119
|
+
...(digits === undefined ? {} : { digits }),
|
|
120
|
+
...(periodSeconds === undefined ? {} : { periodSeconds }),
|
|
121
|
+
...(rawAlgorithm ? { algorithm: rawAlgorithm as "sha1" | "sha256" | "sha512" } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function optionalIntegerParameter(url: URL, name: string): number | undefined {
|
|
126
|
+
const value = url.searchParams.get(name);
|
|
127
|
+
if (value === null) return undefined;
|
|
128
|
+
if (!/^\d+$/u.test(value)) {
|
|
129
|
+
throw new BrowserAuthCredentialError(`TOTP ${name} is invalid`);
|
|
130
|
+
}
|
|
131
|
+
return Number(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function decodeBase32(value: string): Buffer {
|
|
135
|
+
const normalized = value.toUpperCase().replace(/[\s=-]/gu, "");
|
|
136
|
+
if (!normalized || /[^A-Z2-7]/u.test(normalized)) {
|
|
137
|
+
throw new BrowserAuthCredentialError("TOTP secret is not valid Base32");
|
|
138
|
+
}
|
|
139
|
+
let accumulator = 0;
|
|
140
|
+
let bits = 0;
|
|
141
|
+
const bytes: number[] = [];
|
|
142
|
+
for (const character of normalized) {
|
|
143
|
+
const code = character.charCodeAt(0);
|
|
144
|
+
const digit = code >= 65 && code <= 90 ? code - 65 : code - 50 + 26;
|
|
145
|
+
accumulator = (accumulator << 5) | digit;
|
|
146
|
+
bits += 5;
|
|
147
|
+
if (bits >= 8) {
|
|
148
|
+
bits -= 8;
|
|
149
|
+
bytes.push((accumulator >>> bits) & 0xff);
|
|
150
|
+
accumulator &= (1 << bits) - 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (bytes.length === 0) throw new BrowserAuthCredentialError("TOTP secret is empty");
|
|
154
|
+
return Buffer.from(bytes);
|
|
155
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
InteractionPlacement,
|
|
4
|
+
NetworkRouteConfiguration,
|
|
5
|
+
NetworkRouteConsistency,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
3
7
|
|
|
4
8
|
type BrowserControllerAuthorityScope = {
|
|
5
9
|
rootSecret: string;
|
|
@@ -54,6 +58,35 @@ export function deriveBrowserSessionControllerTokens(scope: BrowserSessionAuthor
|
|
|
54
58
|
};
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** Secret-safe durable identity for one exact route launch. The digest binds
|
|
62
|
+
* the route snapshot and proxy credential without making a password-verifier
|
|
63
|
+
* hash available to database readers. */
|
|
64
|
+
export function deriveBrowserNetworkRouteAuthorityDigest(scope: {
|
|
65
|
+
rootSecret: string;
|
|
66
|
+
accountId: string;
|
|
67
|
+
workspaceId: string;
|
|
68
|
+
browserSessionId: string;
|
|
69
|
+
routeId: string;
|
|
70
|
+
routeVersion: number;
|
|
71
|
+
credentialVersion: number | null;
|
|
72
|
+
configuration: NetworkRouteConfiguration;
|
|
73
|
+
consistency: NetworkRouteConsistency;
|
|
74
|
+
proxyCredential: { username: string; password: string } | null;
|
|
75
|
+
}): string {
|
|
76
|
+
return derive(scope.rootSecret, "network-route-launch.v1", [
|
|
77
|
+
scope.accountId,
|
|
78
|
+
scope.workspaceId,
|
|
79
|
+
scope.browserSessionId,
|
|
80
|
+
scope.routeId,
|
|
81
|
+
scope.routeVersion,
|
|
82
|
+
scope.credentialVersion ?? "none",
|
|
83
|
+
canonicalJson(scope.configuration),
|
|
84
|
+
canonicalJson(scope.consistency),
|
|
85
|
+
scope.proxyCredential?.username ?? "none",
|
|
86
|
+
scope.proxyCredential?.password ?? "none",
|
|
87
|
+
]).replace(/^ogb\./u, "ogr.");
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
export function deriveBrowserViewGrantToken(
|
|
58
91
|
scope: BrowserSessionAuthorityScope & { grantId: string; expiresAt: string },
|
|
59
92
|
): string {
|
|
@@ -123,6 +156,22 @@ function writeField(hmac: ReturnType<typeof createHmac>, value: string): void {
|
|
|
123
156
|
hmac.update(bytes);
|
|
124
157
|
}
|
|
125
158
|
|
|
159
|
+
function canonicalJson(value: unknown): string {
|
|
160
|
+
return JSON.stringify(canonicalJsonValue(value));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function canonicalJsonValue(value: unknown): unknown {
|
|
164
|
+
if (Array.isArray(value)) return value.map(canonicalJsonValue);
|
|
165
|
+
if (value && typeof value === "object") {
|
|
166
|
+
return Object.fromEntries(
|
|
167
|
+
Object.entries(value as Record<string, unknown>)
|
|
168
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
169
|
+
.map(([key, entry]) => [key, canonicalJsonValue(entry)]),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
126
175
|
function placementKey(placement: InteractionPlacement): string {
|
|
127
176
|
switch (placement.kind) {
|
|
128
177
|
case "sandbox_group":
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InteractionPlacement,
|
|
3
|
+
NetworkRouteConfiguration,
|
|
4
|
+
NetworkRouteConsistency,
|
|
5
|
+
} from "@opengeni/contracts";
|
|
6
|
+
import { networkRoutePlacementCompatibilityIssue } from "@opengeni/contracts";
|
|
7
|
+
import { BrowserSessionStateError } from "@opengeni/db";
|
|
8
|
+
import type { PlacementBrowserNetworkRoute } from "@opengeni/runtime/sandbox";
|
|
9
|
+
|
|
10
|
+
/** Resolve one provider-neutral managed route into the exact private selector
|
|
11
|
+
* accepted by the chosen external browser placement. Unsupported guarantees
|
|
12
|
+
* fail before lifecycle dispatch; no provider API key enters this value. */
|
|
13
|
+
export function managedNetworkRouteForPlacement(
|
|
14
|
+
configuration: Extract<NetworkRouteConfiguration, { kind: "managed" }>,
|
|
15
|
+
consistency: NetworkRouteConsistency,
|
|
16
|
+
placement: InteractionPlacement,
|
|
17
|
+
): NonNullable<PlacementBrowserNetworkRoute["providerRoute"]> {
|
|
18
|
+
const compatibilityIssue = networkRoutePlacementCompatibilityIssue(
|
|
19
|
+
configuration,
|
|
20
|
+
consistency,
|
|
21
|
+
placement,
|
|
22
|
+
);
|
|
23
|
+
if (compatibilityIssue) throw new BrowserSessionStateError(compatibilityIssue);
|
|
24
|
+
if (placement.kind !== "external_provider") throw new Error("unreachable managed placement");
|
|
25
|
+
if (configuration.providerId !== "browserbase" && configuration.providerId !== "kernel") {
|
|
26
|
+
throw new Error("unreachable managed provider");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
providerId: configuration.providerId,
|
|
30
|
+
routeId: configuration.routeId,
|
|
31
|
+
egressClass: configuration.egressClass,
|
|
32
|
+
region: configuration.region,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BrowserControlRequestError,
|
|
3
|
+
BrowserControlTransportError,
|
|
4
|
+
} from "@opengeni/runtime/sandbox";
|
|
5
|
+
|
|
6
|
+
/** Prefer a lease-fenced controller endpoint for the idempotent create path.
|
|
7
|
+
* Only a transport-class failure invalidates the cache and provisions once;
|
|
8
|
+
* semantic failures belong to the caller and must never be replayed. */
|
|
9
|
+
export async function withCachedController<C, T>(options: {
|
|
10
|
+
cachedUrl: string | null;
|
|
11
|
+
createCachedClient: (url: string) => C;
|
|
12
|
+
prepareCachedClient?: (client: C) => Promise<void>;
|
|
13
|
+
invalidateCachedUrl: () => Promise<unknown>;
|
|
14
|
+
provisionClient: () => Promise<C>;
|
|
15
|
+
use: (client: C) => Promise<T>;
|
|
16
|
+
}): Promise<T> {
|
|
17
|
+
if (options.cachedUrl) {
|
|
18
|
+
try {
|
|
19
|
+
const client = options.createCachedClient(options.cachedUrl);
|
|
20
|
+
await options.prepareCachedClient?.(client);
|
|
21
|
+
return await options.use(client);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (!isRetryableControllerTransport(error)) throw error;
|
|
24
|
+
await options.invalidateCachedUrl().catch(() => undefined);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return await options.use(await options.provisionClient());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isRetryableControllerTransport(error: unknown): boolean {
|
|
31
|
+
return (
|
|
32
|
+
error instanceof BrowserControlTransportError ||
|
|
33
|
+
(error instanceof BrowserControlRequestError && error.retryable)
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { resolveFirstPartyDelegationSecret, type Settings } from "@opengeni/config";
|
|
2
|
+
import { verifyDelegatedAccessToken } from "@opengeni/contracts";
|
|
2
3
|
import type { Context, MiddlewareHandler } from "hono";
|
|
3
4
|
import { installExactPaths, isInstallRedirectPath } from "../routes/install";
|
|
4
5
|
|
|
@@ -17,7 +18,7 @@ export function requireAccessKey(settings: Settings): MiddlewareHandler {
|
|
|
17
18
|
await next();
|
|
18
19
|
return;
|
|
19
20
|
}
|
|
20
|
-
if (isAuthorized(c, settings
|
|
21
|
+
if (await isAuthorized(c, settings)) {
|
|
21
22
|
await next();
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
@@ -55,7 +56,10 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
55
56
|
path === "/v1/integrations/slack/callback" ||
|
|
56
57
|
path === "/v1/integrations/slack/events" ||
|
|
57
58
|
path === "/v1/integrations/slack/commands" ||
|
|
58
|
-
path === "/v1/integrations/slack/interactions"
|
|
59
|
+
path === "/v1/integrations/slack/interactions" ||
|
|
60
|
+
// Fiken OAuth browser redirect: exact path only, protected by signed
|
|
61
|
+
// single-use state plus a callback-time grant recheck.
|
|
62
|
+
path === "/v1/integrations/fiken/callback"
|
|
59
63
|
) {
|
|
60
64
|
return true;
|
|
61
65
|
}
|
|
@@ -100,19 +104,33 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
100
104
|
return false;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
function isAuthorized(c: Context,
|
|
104
|
-
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
+
async function isAuthorized(c: Context, settings: Settings): Promise<boolean> {
|
|
108
|
+
const expected = settings.accessKey;
|
|
107
109
|
const explicit = c.req.header("x-opengeni-access-key");
|
|
108
|
-
if (constantTimeEqual(explicit, expected)) {
|
|
110
|
+
if (expected && constantTimeEqual(explicit, expected)) {
|
|
109
111
|
return true;
|
|
110
112
|
}
|
|
111
113
|
const authorization = c.req.header("authorization");
|
|
112
114
|
const bearer = authorization?.startsWith("Bearer ")
|
|
113
115
|
? authorization.slice("Bearer ".length)
|
|
114
116
|
: undefined;
|
|
115
|
-
|
|
117
|
+
if (expected && constantTimeEqual(bearer, expected)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// This middleware is only the deployment perimeter. A valid first-party
|
|
122
|
+
// delegated bearer is already authenticated with the deployment's separate
|
|
123
|
+
// delegation authority, so admit it only to the product API's route-level
|
|
124
|
+
// access resolver. Deployment-only surfaces such as /metrics still require
|
|
125
|
+
// the static deployment key. The resolver enforces exact account, workspace,
|
|
126
|
+
// principal, and permissions; attempt-bound surfaces add live-attempt fences.
|
|
127
|
+
const delegationSecret = resolveFirstPartyDelegationSecret(settings);
|
|
128
|
+
return Boolean(
|
|
129
|
+
c.req.path.startsWith("/v1/") &&
|
|
130
|
+
bearer &&
|
|
131
|
+
delegationSecret &&
|
|
132
|
+
(await verifyDelegatedAccessToken(delegationSecret, bearer)),
|
|
133
|
+
);
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
function constantTimeEqual(actual: string | undefined, expected: string): boolean {
|