@voyant-travel/apps 0.8.0 → 0.9.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/access-boundary.d.ts +2 -0
- package/dist/api-runtime.js +2 -0
- package/dist/api-runtime.test.js +4 -0
- package/dist/app-api-service.d.ts +2 -0
- package/dist/compiler.test.js +7 -0
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/installation-service.d.ts +27 -3
- package/dist/installation-service.js +72 -3
- package/dist/oauth-installation.d.ts +101 -0
- package/dist/oauth-installation.js +143 -0
- package/dist/oauth-service.d.ts +3 -0
- package/dist/oauth-service.js +70 -93
- package/dist/oauth-service.test.js +83 -1
- package/dist/routes.d.ts +3 -0
- package/dist/routes.js +2 -0
- package/dist/runtime-contributor.d.ts +5 -1
- package/dist/runtime-contributor.js +6 -21
- package/dist/runtime-contributor.test.js +28 -25
- package/dist/runtime-port.d.ts +21 -0
- package/dist/runtime-port.js +6 -0
- package/dist/schema.d.ts +170 -0
- package/dist/schema.js +18 -0
- package/dist/session-token-service.d.ts +2 -0
- package/dist/session-token-service.js +48 -3
- package/dist/session-token-service.test.js +31 -1
- package/dist/session-token.d.ts +8 -1
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +46 -0
- package/migrations/20260718205748_managed_installation_binding.sql +16 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +6 -6
package/dist/oauth-service.js
CHANGED
|
@@ -2,12 +2,14 @@ import { ApiHttpError } from "@voyant-travel/hono";
|
|
|
2
2
|
import { and, eq } from "drizzle-orm";
|
|
3
3
|
import { computeAppConsent } from "./consent.js";
|
|
4
4
|
import { APP_ACCESS_TOKEN_PREFIX, APP_AUTH_CODE_PREFIX, APP_REFRESH_TOKEN_PREFIX, constantTimeEqual, randomToken, sha256Hex, verifyPkceS256, } from "./oauth-crypto.js";
|
|
5
|
-
import {
|
|
5
|
+
import { assertActiveInstallation, assertManagedBinding, assertManagedInstallationAuthority, assertTokenClient, ensureAuthorizedInstallation, grantedScopes, isInstallationUsable, managedBindingMatches, requireInstallation, selectInstallation, } from "./oauth-installation.js";
|
|
6
|
+
import { appAccessCredentials, appAuditEvents, appCredentials, appInstallations, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleases, } from "./schema.js";
|
|
6
7
|
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
7
8
|
const OFFLINE_ACCESS_TTL_MS = 60 * 60 * 1000;
|
|
8
9
|
const ONLINE_ACCESS_TTL_MS = 10 * 60 * 1000;
|
|
9
10
|
const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
10
11
|
export function createAppOAuthService(options) {
|
|
12
|
+
assertManagedInstallationAuthority(options.managedInstallation);
|
|
11
13
|
const now = () => options.now?.() ?? new Date();
|
|
12
14
|
async function authorize(db, input) {
|
|
13
15
|
assertState(input.state);
|
|
@@ -22,10 +24,12 @@ export function createAppOAuthService(options) {
|
|
|
22
24
|
operatorGrantedScopes: input.operatorGrantedScopes,
|
|
23
25
|
grantedOptionalScopes: input.grantedOptionalScopes,
|
|
24
26
|
});
|
|
27
|
+
const managedBinding = await resolveManagedBinding(input.appId, input.releaseId);
|
|
25
28
|
const installation = await ensureAuthorizedInstallation(db, {
|
|
26
29
|
appId: input.appId,
|
|
27
30
|
releaseId: input.releaseId,
|
|
28
31
|
deploymentId: options.deploymentId,
|
|
32
|
+
managedBinding,
|
|
29
33
|
actorId: input.actorId,
|
|
30
34
|
grantedScopes: consent.grantedScopes,
|
|
31
35
|
deniedOptionalScopes: consent.deniedOptionalScopes,
|
|
@@ -37,6 +41,8 @@ export function createAppOAuthService(options) {
|
|
|
37
41
|
installationId: installation.id,
|
|
38
42
|
releaseId: input.releaseId,
|
|
39
43
|
deploymentId: options.deploymentId,
|
|
44
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
45
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
40
46
|
codeHash: sha256Hex(code),
|
|
41
47
|
stateHash: sha256Hex(input.state),
|
|
42
48
|
redirectUri: input.redirectUri,
|
|
@@ -73,8 +79,17 @@ export function createAppOAuthService(options) {
|
|
|
73
79
|
if (!credential || (credential.expiresAt && credential.expiresAt <= now()))
|
|
74
80
|
return null;
|
|
75
81
|
const installation = await selectInstallation(db, credential.installationId);
|
|
76
|
-
if (!installation
|
|
82
|
+
if (!installation)
|
|
83
|
+
return null;
|
|
84
|
+
const managedBinding = await resolveManagedBindingOrNull(installation.appId, installation.releaseId);
|
|
85
|
+
if (options.managedInstallation && !managedBinding)
|
|
86
|
+
return null;
|
|
87
|
+
const effectiveManagedBinding = managedBinding ?? undefined;
|
|
88
|
+
if (!isInstallationUsable(installation, credential.generation) ||
|
|
89
|
+
!managedBindingMatches(installation, effectiveManagedBinding) ||
|
|
90
|
+
!managedBindingMatches(credential, effectiveManagedBinding)) {
|
|
77
91
|
return null;
|
|
92
|
+
}
|
|
78
93
|
// Online tokens resolve to the scope set minted at exchange (viewer/context
|
|
79
94
|
// intersection); recomputing from grants would silently widen them. Offline
|
|
80
95
|
// tokens track live grants so revoking a scope applies immediately. A
|
|
@@ -97,6 +112,12 @@ export function createAppOAuthService(options) {
|
|
|
97
112
|
appInstallationId: installation.id,
|
|
98
113
|
appReleaseId: installation.releaseId,
|
|
99
114
|
appCredentialGeneration: credential.generation,
|
|
115
|
+
...(effectiveManagedBinding
|
|
116
|
+
? {
|
|
117
|
+
appWorkloadEnvironmentId: effectiveManagedBinding.workloadEnvironmentId,
|
|
118
|
+
appContractGeneration: effectiveManagedBinding.contractGeneration,
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
100
121
|
appTokenMode: credential.tokenMode,
|
|
101
122
|
appViewerId: credential.viewerId ?? undefined,
|
|
102
123
|
...(appContextConstraint ? { appContextConstraint } : {}),
|
|
@@ -106,6 +127,8 @@ export function createAppOAuthService(options) {
|
|
|
106
127
|
async function revokeInstallationCredentials(db, installationId, actorId) {
|
|
107
128
|
return db.transaction(async (tx) => {
|
|
108
129
|
const installation = await requireInstallation(tx, installationId);
|
|
130
|
+
const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
|
|
131
|
+
assertManagedBinding(installation, managedBinding);
|
|
109
132
|
const generation = installation.credentialGeneration + 1;
|
|
110
133
|
await tx
|
|
111
134
|
.update(appInstallations)
|
|
@@ -142,13 +165,19 @@ export function createAppOAuthService(options) {
|
|
|
142
165
|
if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
|
|
143
166
|
throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
|
|
144
167
|
}
|
|
168
|
+
if (!options.managedInstallation && code.deploymentId !== options.deploymentId) {
|
|
169
|
+
throw oauthError("invalid_grant", "Authorization code belongs to a different runtime");
|
|
170
|
+
}
|
|
171
|
+
const managedBinding = await resolveManagedBinding(code.appId, code.releaseId);
|
|
172
|
+
assertManagedBinding(code, managedBinding);
|
|
145
173
|
await tx
|
|
146
174
|
.update(appOAuthAuthorizationCodes)
|
|
147
175
|
.set({ consumedAt: now() })
|
|
148
176
|
.where(eq(appOAuthAuthorizationCodes.id, code.id));
|
|
149
177
|
const installation = await requireInstallation(tx, code.installationId);
|
|
150
178
|
assertActiveInstallation(installation);
|
|
151
|
-
|
|
179
|
+
assertManagedBinding(installation, managedBinding);
|
|
180
|
+
const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes, managedBinding);
|
|
152
181
|
await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
|
|
153
182
|
return tokens;
|
|
154
183
|
});
|
|
@@ -165,8 +194,11 @@ export function createAppOAuthService(options) {
|
|
|
165
194
|
throw oauthError("invalid_grant", "Refresh token is invalid");
|
|
166
195
|
}
|
|
167
196
|
const installation = await requireInstallation(tx, tokenRow.installationId);
|
|
197
|
+
const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
|
|
168
198
|
assertTokenClient(installation, input.clientId);
|
|
169
199
|
assertActiveInstallation(installation);
|
|
200
|
+
assertManagedBinding(tokenRow, managedBinding);
|
|
201
|
+
assertManagedBinding(installation, managedBinding);
|
|
170
202
|
if (installation.credentialGeneration !== tokenRow.generation) {
|
|
171
203
|
throw oauthError("invalid_grant", "Refresh token generation was revoked");
|
|
172
204
|
}
|
|
@@ -174,7 +206,7 @@ export function createAppOAuthService(options) {
|
|
|
174
206
|
.update(appOAuthRefreshTokens)
|
|
175
207
|
.set({ status: "inactive", revokedAt: now() })
|
|
176
208
|
.where(eq(appOAuthRefreshTokens.id, tokenRow.id));
|
|
177
|
-
const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), {
|
|
209
|
+
const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), managedBinding, {
|
|
178
210
|
rotatedFromId: tokenRow.id,
|
|
179
211
|
});
|
|
180
212
|
await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
|
|
@@ -185,8 +217,10 @@ export function createAppOAuthService(options) {
|
|
|
185
217
|
}
|
|
186
218
|
async function exchangeActorToken(db, input) {
|
|
187
219
|
const installation = await requireInstallation(db, input.installationId);
|
|
220
|
+
const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
|
|
188
221
|
assertTokenClient(installation, input.clientId);
|
|
189
222
|
assertActiveInstallation(installation);
|
|
223
|
+
assertManagedBinding(installation, managedBinding);
|
|
190
224
|
const grants = await grantedScopes(db, installation.id);
|
|
191
225
|
const contextual = input.contextualScopes ?? grants;
|
|
192
226
|
const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
|
|
@@ -195,6 +229,8 @@ export function createAppOAuthService(options) {
|
|
|
195
229
|
await db.insert(appAccessCredentials).values({
|
|
196
230
|
installationId: installation.id,
|
|
197
231
|
generation: installation.credentialGeneration,
|
|
232
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
233
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
198
234
|
tokenMode: "online",
|
|
199
235
|
credentialHash: sha256Hex(accessToken),
|
|
200
236
|
// Online tokens are intentionally narrowed; the resolver must honor the
|
|
@@ -214,7 +250,7 @@ export function createAppOAuthService(options) {
|
|
|
214
250
|
});
|
|
215
251
|
return tokenResponse(accessToken, null, expiresAt, scopes, "online");
|
|
216
252
|
}
|
|
217
|
-
async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
|
|
253
|
+
async function mintTokens(db, installation, actorId, viewerId, scopes, managedBinding, refreshOptions = {}) {
|
|
218
254
|
const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
|
|
219
255
|
const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
|
|
220
256
|
const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
|
|
@@ -223,6 +259,8 @@ export function createAppOAuthService(options) {
|
|
|
223
259
|
await db.insert(appAccessCredentials).values({
|
|
224
260
|
installationId: installation.id,
|
|
225
261
|
generation,
|
|
262
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
263
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
226
264
|
tokenMode: "offline",
|
|
227
265
|
credentialHash: sha256Hex(accessToken),
|
|
228
266
|
encryptedMetadata: { scopeCount: scopes.length },
|
|
@@ -235,6 +273,8 @@ export function createAppOAuthService(options) {
|
|
|
235
273
|
installationId: installation.id,
|
|
236
274
|
tokenHash: sha256Hex(refreshToken),
|
|
237
275
|
generation,
|
|
276
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
277
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
238
278
|
rotatedFromId: refreshOptions.rotatedFromId,
|
|
239
279
|
expiresAt: refreshExpiresAt,
|
|
240
280
|
});
|
|
@@ -243,6 +283,31 @@ export function createAppOAuthService(options) {
|
|
|
243
283
|
function isExpired(expiresAt) {
|
|
244
284
|
return Boolean(expiresAt && expiresAt <= now());
|
|
245
285
|
}
|
|
286
|
+
async function resolveManagedBinding(appId, releaseId) {
|
|
287
|
+
if (!options.managedInstallation)
|
|
288
|
+
return undefined;
|
|
289
|
+
const contract = await options.managedInstallation.resolveInstallationContract({
|
|
290
|
+
appId,
|
|
291
|
+
releaseId,
|
|
292
|
+
});
|
|
293
|
+
if (!contract ||
|
|
294
|
+
!Number.isSafeInteger(contract.contractGeneration) ||
|
|
295
|
+
contract.contractGeneration <= 0) {
|
|
296
|
+
throw oauthError("invalid_grant", "Managed app installation contract is unavailable");
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
workloadEnvironmentId: options.managedInstallation.workloadEnvironmentId,
|
|
300
|
+
contractGeneration: contract.contractGeneration,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async function resolveManagedBindingOrNull(appId, releaseId) {
|
|
304
|
+
try {
|
|
305
|
+
return await resolveManagedBinding(appId, releaseId);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
246
311
|
}
|
|
247
312
|
function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
|
|
248
313
|
return {
|
|
@@ -295,94 +360,6 @@ async function requireExactRedirectUri(db, appId, redirectUri) {
|
|
|
295
360
|
if (!row)
|
|
296
361
|
throw oauthError("invalid_request", "Redirect URI is not registered for this app");
|
|
297
362
|
}
|
|
298
|
-
async function ensureAuthorizedInstallation(db, input) {
|
|
299
|
-
return db.transaction(async (tx) => {
|
|
300
|
-
const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
|
|
301
|
-
if (!app)
|
|
302
|
-
throw oauthError("invalid_request", "App registration not found", 404);
|
|
303
|
-
const [existing] = await tx
|
|
304
|
-
.select()
|
|
305
|
-
.from(appInstallations)
|
|
306
|
-
.where(and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
|
|
307
|
-
.limit(1);
|
|
308
|
-
const installation = existing ??
|
|
309
|
-
(await tx
|
|
310
|
-
.insert(appInstallations)
|
|
311
|
-
.values({
|
|
312
|
-
appId: input.appId,
|
|
313
|
-
deploymentId: input.deploymentId,
|
|
314
|
-
releaseId: input.releaseId,
|
|
315
|
-
status: "active",
|
|
316
|
-
namespace: app.platformNamespace,
|
|
317
|
-
installedBy: input.actorId,
|
|
318
|
-
authorizedAt: new Date(),
|
|
319
|
-
activatedAt: new Date(),
|
|
320
|
-
})
|
|
321
|
-
.returning())[0];
|
|
322
|
-
if (!installation)
|
|
323
|
-
throw oauthError("server_error", "Could not create installation", 500);
|
|
324
|
-
for (const scope of input.grantedScopes) {
|
|
325
|
-
await tx
|
|
326
|
-
.insert(appGrants)
|
|
327
|
-
.values({
|
|
328
|
-
installationId: installation.id,
|
|
329
|
-
scope,
|
|
330
|
-
status: "granted",
|
|
331
|
-
optional: false,
|
|
332
|
-
grantedAt: new Date(),
|
|
333
|
-
})
|
|
334
|
-
.onConflictDoUpdate({
|
|
335
|
-
target: [appGrants.installationId, appGrants.scope],
|
|
336
|
-
set: { status: "granted", grantedAt: new Date(), revokedAt: null },
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
for (const scope of input.deniedOptionalScopes) {
|
|
340
|
-
await tx
|
|
341
|
-
.insert(appGrants)
|
|
342
|
-
.values({ installationId: installation.id, scope, status: "optional", optional: true })
|
|
343
|
-
.onConflictDoUpdate({
|
|
344
|
-
target: [appGrants.installationId, appGrants.scope],
|
|
345
|
-
set: { status: "optional", optional: true },
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
return installation;
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
async function requireInstallation(db, installationId) {
|
|
352
|
-
const installation = await selectInstallation(db, installationId);
|
|
353
|
-
if (!installation)
|
|
354
|
-
throw oauthError("invalid_grant", "App installation not found", 404);
|
|
355
|
-
return installation;
|
|
356
|
-
}
|
|
357
|
-
async function selectInstallation(db, installationId) {
|
|
358
|
-
const [installation] = await db
|
|
359
|
-
.select()
|
|
360
|
-
.from(appInstallations)
|
|
361
|
-
.where(eq(appInstallations.id, installationId))
|
|
362
|
-
.limit(1);
|
|
363
|
-
return installation ?? null;
|
|
364
|
-
}
|
|
365
|
-
async function grantedScopes(db, installationId) {
|
|
366
|
-
const rows = await db
|
|
367
|
-
.select({ scope: appGrants.scope })
|
|
368
|
-
.from(appGrants)
|
|
369
|
-
.where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
|
|
370
|
-
.orderBy(appGrants.scope);
|
|
371
|
-
return rows.map((row) => row.scope);
|
|
372
|
-
}
|
|
373
|
-
function assertActiveInstallation(installation) {
|
|
374
|
-
if (installation.status !== "active") {
|
|
375
|
-
throw oauthError("invalid_grant", "App installation is not active");
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
function isInstallationUsable(installation, generation) {
|
|
379
|
-
return installation?.status === "active" && installation.credentialGeneration === generation;
|
|
380
|
-
}
|
|
381
|
-
function assertTokenClient(installation, clientId) {
|
|
382
|
-
if (installation.appId !== clientId) {
|
|
383
|
-
throw oauthError("invalid_grant", "Token belongs to a different app");
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
363
|
export function intersectAppTokenScopes(...sets) {
|
|
387
364
|
const [first = [], ...rest] = sets;
|
|
388
365
|
return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { createAppOAuthService, intersectAppTokenScopes, readStoredAppContextConstraint, readStoredScopes, } from "./oauth-service.js";
|
|
3
|
-
import { appCredentials, appReleases } from "./schema.js";
|
|
3
|
+
import { appAccessCredentials, appCredentials, appGrants, appInstallations, appReleases, } from "./schema.js";
|
|
4
|
+
function accessTokenDatabase(installation, credential) {
|
|
5
|
+
return Object.assign(Object.create(null), {
|
|
6
|
+
select: () => ({
|
|
7
|
+
from: (table) => {
|
|
8
|
+
if (table === appAccessCredentials) {
|
|
9
|
+
return { where: () => ({ limit: async () => [credential] }) };
|
|
10
|
+
}
|
|
11
|
+
if (table === appInstallations) {
|
|
12
|
+
return { where: () => ({ limit: async () => [installation] }) };
|
|
13
|
+
}
|
|
14
|
+
if (table === appGrants) {
|
|
15
|
+
return { where: () => ({ orderBy: async () => [] }) };
|
|
16
|
+
}
|
|
17
|
+
return { where: () => ({ limit: async () => [] }) };
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
4
22
|
describe("app OAuth online token scope intersection", () => {
|
|
5
23
|
it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
|
|
6
24
|
expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
|
|
@@ -95,3 +113,67 @@ describe("managed client authentication", () => {
|
|
|
95
113
|
})).rejects.toMatchObject({ status: 401, code: "invalid_client" });
|
|
96
114
|
});
|
|
97
115
|
});
|
|
116
|
+
describe("managed installation token binding", () => {
|
|
117
|
+
const baseInstallation = {
|
|
118
|
+
id: "inst_1",
|
|
119
|
+
appId: "app_1",
|
|
120
|
+
releaseId: "release_1",
|
|
121
|
+
deploymentId: "deployment_revision_1",
|
|
122
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
123
|
+
contractGeneration: 4,
|
|
124
|
+
credentialGeneration: 2,
|
|
125
|
+
status: "active",
|
|
126
|
+
};
|
|
127
|
+
const baseCredential = {
|
|
128
|
+
id: "credential_1",
|
|
129
|
+
installationId: "inst_1",
|
|
130
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
131
|
+
contractGeneration: 4,
|
|
132
|
+
generation: 2,
|
|
133
|
+
tokenMode: "offline",
|
|
134
|
+
encryptedMetadata: {},
|
|
135
|
+
status: "active",
|
|
136
|
+
expiresAt: new Date("2026-07-19T00:00:00Z"),
|
|
137
|
+
viewerId: null,
|
|
138
|
+
};
|
|
139
|
+
it("resolves the per-app generation into the runtime auth context", async () => {
|
|
140
|
+
const generations = new Map([
|
|
141
|
+
["app_1", 4],
|
|
142
|
+
["app_2", 11],
|
|
143
|
+
]);
|
|
144
|
+
const service = createAppOAuthService({
|
|
145
|
+
accessCatalog: { resources: [], presets: [] },
|
|
146
|
+
deploymentId: "deployment_revision_2",
|
|
147
|
+
managedInstallation: {
|
|
148
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
149
|
+
resolveInstallationContract: async ({ appId }) => ({
|
|
150
|
+
contractGeneration: generations.get(appId) ?? 0,
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
now: () => new Date("2026-07-18T12:00:00Z"),
|
|
154
|
+
});
|
|
155
|
+
await expect(service.resolveAccessToken(accessTokenDatabase(baseInstallation, baseCredential), "vapp_fixture")).resolves.toMatchObject({
|
|
156
|
+
appWorkloadEnvironmentId: "workload_environment_1",
|
|
157
|
+
appContractGeneration: 4,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it.each([
|
|
161
|
+
["workload_environment_other", 4],
|
|
162
|
+
["workload_environment_1", 3],
|
|
163
|
+
])("rejects credential binding workload=%s generation=%s", async (workloadEnvironmentId, contractGeneration) => {
|
|
164
|
+
const service = createAppOAuthService({
|
|
165
|
+
accessCatalog: { resources: [], presets: [] },
|
|
166
|
+
deploymentId: "deployment_revision_2",
|
|
167
|
+
managedInstallation: {
|
|
168
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
169
|
+
resolveInstallationContract: async () => ({ contractGeneration: 4 }),
|
|
170
|
+
},
|
|
171
|
+
now: () => new Date("2026-07-18T12:00:00Z"),
|
|
172
|
+
});
|
|
173
|
+
await expect(service.resolveAccessToken(accessTokenDatabase(baseInstallation, {
|
|
174
|
+
...baseCredential,
|
|
175
|
+
workloadEnvironmentId,
|
|
176
|
+
contractGeneration,
|
|
177
|
+
}), "vapp_fixture")).resolves.toBeNull();
|
|
178
|
+
});
|
|
179
|
+
});
|
package/dist/routes.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { EventBus } from "@voyant-travel/core/events";
|
|
|
3
3
|
import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
|
|
4
4
|
import { type AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
5
5
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
6
|
+
import type { ManagedAppInstallationAuthority } from "./runtime-port.js";
|
|
6
7
|
import { type AppsServiceOptions } from "./service.js";
|
|
7
8
|
type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
|
|
8
9
|
type Env = {
|
|
@@ -15,6 +16,7 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
15
16
|
oauth?: {
|
|
16
17
|
accessCatalog: AccessCatalog;
|
|
17
18
|
deploymentId: string;
|
|
19
|
+
managedInstallation?: ManagedAppInstallationAuthority;
|
|
18
20
|
clientAuthentication?: "optional" | "required";
|
|
19
21
|
};
|
|
20
22
|
/**
|
|
@@ -24,6 +26,7 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
24
26
|
*/
|
|
25
27
|
sessionToken?: {
|
|
26
28
|
secret: string;
|
|
29
|
+
managedInstallation?: ManagedAppInstallationAuthority;
|
|
27
30
|
ttlSeconds?: number;
|
|
28
31
|
};
|
|
29
32
|
/**
|
package/dist/routes.js
CHANGED
|
@@ -18,6 +18,7 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
18
18
|
const service = createAppsService(options);
|
|
19
19
|
const installations = createAppInstallationService({
|
|
20
20
|
deploymentId: options.oauth?.deploymentId ?? options.deploymentId,
|
|
21
|
+
managedInstallation: options.oauth?.managedInstallation,
|
|
21
22
|
platformApiVersion: options.platformApiVersion,
|
|
22
23
|
eventBus: options.eventBus,
|
|
23
24
|
customFields: options.customFields,
|
|
@@ -29,6 +30,7 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
29
30
|
secret: options.sessionToken.secret,
|
|
30
31
|
ttlSeconds: options.sessionToken.ttlSeconds,
|
|
31
32
|
deploymentId: options.oauth.deploymentId,
|
|
33
|
+
managedInstallation: options.sessionToken.managedInstallation ?? options.oauth.managedInstallation,
|
|
32
34
|
oauth,
|
|
33
35
|
})
|
|
34
36
|
: null;
|
|
@@ -5,5 +5,9 @@ export interface AppsRuntimeContributorHost {
|
|
|
5
5
|
id: string;
|
|
6
6
|
}): boolean;
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Managed installation contracts are host authority and cannot be reconstructed
|
|
10
|
+
* from scalar environment values. Hosts contribute `apps.managed-auth`
|
|
11
|
+
* explicitly; self-hosted runtimes keep the port absent.
|
|
12
|
+
*/
|
|
9
13
|
export declare function createAppsRuntimePortContribution(host: AppsRuntimeContributorHost): Readonly<Record<string, unknown>>;
|
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
import { appsManagedAuthRuntimePort } from "./runtime-port.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return trimmed || undefined;
|
|
8
|
-
}
|
|
9
|
-
/** Package-owned, provider-neutral managed-auth configuration. */
|
|
2
|
+
/**
|
|
3
|
+
* Managed installation contracts are host authority and cannot be reconstructed
|
|
4
|
+
* from scalar environment values. Hosts contribute `apps.managed-auth`
|
|
5
|
+
* explicitly; self-hosted runtimes keep the port absent.
|
|
6
|
+
*/
|
|
10
7
|
export function createAppsRuntimePortContribution(host) {
|
|
11
8
|
if (host.hasRuntimePort?.(appsManagedAuthRuntimePort))
|
|
12
9
|
return {};
|
|
13
|
-
|
|
14
|
-
const sessionTokenSigningSecret = readString(host, "VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET");
|
|
15
|
-
if (!runtimeAudience || !sessionTokenSigningSecret)
|
|
16
|
-
return {};
|
|
17
|
-
const ttlInput = readString(host, "VOYANT_APP_SESSION_TOKEN_TTL_SECONDS");
|
|
18
|
-
const sessionTokenTtlSeconds = ttlInput === undefined ? undefined : Number(ttlInput);
|
|
19
|
-
const runtime = {
|
|
20
|
-
runtimeAudience,
|
|
21
|
-
sessionTokenSigningSecret,
|
|
22
|
-
...(sessionTokenTtlSeconds === undefined ? {} : { sessionTokenTtlSeconds }),
|
|
23
|
-
};
|
|
24
|
-
appsManagedAuthRuntimePort.test(runtime);
|
|
25
|
-
return { [appsManagedAuthRuntimePort.id]: runtime };
|
|
10
|
+
return {};
|
|
26
11
|
}
|
|
@@ -10,23 +10,12 @@ function host(values) {
|
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
12
|
describe("createAppsRuntimePortContribution", () => {
|
|
13
|
-
it("stays off
|
|
13
|
+
it("stays off because scalar environment values cannot authorize managed installations", () => {
|
|
14
14
|
expect(createAppsRuntimePortContribution(host({}))).toEqual({});
|
|
15
|
-
expect(createAppsRuntimePortContribution(host({
|
|
16
|
-
|
|
17
|
-
it("contributes validated provider-neutral managed-auth configuration", () => {
|
|
18
|
-
const contribution = createAppsRuntimePortContribution(host({
|
|
19
|
-
VOYANT_APP_RUNTIME_AUDIENCE: " deployment-1 ",
|
|
15
|
+
expect(createAppsRuntimePortContribution(host({
|
|
16
|
+
VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
|
|
20
17
|
VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
|
|
21
|
-
|
|
22
|
-
}));
|
|
23
|
-
expect(contribution).toEqual({
|
|
24
|
-
[appsManagedAuthRuntimePort.id]: {
|
|
25
|
-
runtimeAudience: "deployment-1",
|
|
26
|
-
sessionTokenSigningSecret: "s".repeat(32),
|
|
27
|
-
sessionTokenTtlSeconds: 180,
|
|
28
|
-
},
|
|
29
|
-
});
|
|
18
|
+
}))).toEqual({});
|
|
30
19
|
});
|
|
31
20
|
it("does not replace an explicitly host-provided managed-auth port", () => {
|
|
32
21
|
const contribution = createAppsRuntimePortContribution({
|
|
@@ -38,15 +27,29 @@ describe("createAppsRuntimePortContribution", () => {
|
|
|
38
27
|
});
|
|
39
28
|
expect(contribution).toEqual({});
|
|
40
29
|
});
|
|
41
|
-
it("
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
30
|
+
it("resolves independent per-app generations within one workload environment", async () => {
|
|
31
|
+
const generations = new Map([
|
|
32
|
+
["app_1", 2],
|
|
33
|
+
["app_2", 9],
|
|
34
|
+
]);
|
|
35
|
+
const runtime = {
|
|
36
|
+
runtimeAudience: "runtime-audience",
|
|
37
|
+
installationAuthority: {
|
|
38
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
39
|
+
resolveInstallationContract: async ({ appId }) => ({
|
|
40
|
+
contractGeneration: generations.get(appId) ?? 0,
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
sessionTokenSigningSecret: "s".repeat(32),
|
|
44
|
+
};
|
|
45
|
+
expect(() => appsManagedAuthRuntimePort.test(runtime)).not.toThrow();
|
|
46
|
+
await expect(runtime.installationAuthority.resolveInstallationContract({
|
|
47
|
+
appId: "app_1",
|
|
48
|
+
releaseId: "release_1",
|
|
49
|
+
})).resolves.toEqual({ contractGeneration: 2 });
|
|
50
|
+
await expect(runtime.installationAuthority.resolveInstallationContract({
|
|
51
|
+
appId: "app_2",
|
|
52
|
+
releaseId: "release_2",
|
|
53
|
+
})).resolves.toEqual({ contractGeneration: 9 });
|
|
51
54
|
});
|
|
52
55
|
});
|
package/dist/runtime-port.d.ts
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
|
+
/** Persisted host contract for one managed app installation. */
|
|
2
|
+
export interface ManagedAppInstallationBinding {
|
|
3
|
+
workloadEnvironmentId: string;
|
|
4
|
+
contractGeneration: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ManagedAppInstallationContractInput {
|
|
7
|
+
appId: string;
|
|
8
|
+
releaseId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ManagedAppInstallationContract {
|
|
11
|
+
/** Per-app monotonic generation. Advancing it invalidates prior app credentials. */
|
|
12
|
+
contractGeneration: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ManagedAppInstallationAuthority {
|
|
15
|
+
/** Stable opaque workload-environment identity across runtime rollouts. */
|
|
16
|
+
workloadEnvironmentId: string;
|
|
17
|
+
/** Resolve the current admitted contract for this specific app release. */
|
|
18
|
+
resolveInstallationContract(input: ManagedAppInstallationContractInput): Promise<ManagedAppInstallationContract | null>;
|
|
19
|
+
}
|
|
1
20
|
export interface AppsManagedAuthRuntime {
|
|
2
21
|
/** Stable audience shared by authorization, installations, and session tokens. */
|
|
3
22
|
runtimeAudience: string;
|
|
23
|
+
/** Provider-neutral managed installation authority supplied by the host. */
|
|
24
|
+
installationAuthority: ManagedAppInstallationAuthority;
|
|
4
25
|
/** Host-owned HMAC material for short-lived extension session tokens. */
|
|
5
26
|
sessionTokenSigningSecret: string;
|
|
6
27
|
sessionTokenTtlSeconds?: number;
|
package/dist/runtime-port.js
CHANGED
|
@@ -8,6 +8,12 @@ export const appsManagedAuthRuntimePort = definePort({
|
|
|
8
8
|
if (!runtime.runtimeAudience?.trim()) {
|
|
9
9
|
throw new TypeError("apps.managed-auth runtimeAudience must be a non-empty string.");
|
|
10
10
|
}
|
|
11
|
+
if (!runtime.installationAuthority?.workloadEnvironmentId?.trim()) {
|
|
12
|
+
throw new TypeError("apps.managed-auth installationAuthority.workloadEnvironmentId must be a non-empty string.");
|
|
13
|
+
}
|
|
14
|
+
if (typeof runtime.installationAuthority.resolveInstallationContract !== "function") {
|
|
15
|
+
throw new TypeError("apps.managed-auth installationAuthority.resolveInstallationContract must be a function.");
|
|
16
|
+
}
|
|
11
17
|
if (typeof runtime.sessionTokenSigningSecret !== "string" ||
|
|
12
18
|
runtime.sessionTokenSigningSecret.length < 32) {
|
|
13
19
|
throw new TypeError("apps.managed-auth sessionTokenSigningSecret must contain at least 32 characters.");
|