@voyant-travel/apps 0.7.0 → 0.9.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/access-boundary.d.ts +2 -0
- package/dist/api-runtime.d.ts +23 -11
- package/dist/api-runtime.js +40 -1
- package/dist/api-runtime.test.d.ts +1 -0
- package/dist/api-runtime.test.js +58 -0
- package/dist/app-api-contracts.d.ts +58 -8
- package/dist/app-api-contracts.js +102 -9
- package/dist/app-api-finance-routes.test.js +482 -2
- package/dist/app-api-routes.d.ts +6 -5
- package/dist/app-api-routes.js +131 -22
- package/dist/app-api-service.d.ts +33 -1
- package/dist/app-api-service.js +162 -0
- package/dist/compiler.test.js +7 -0
- package/dist/consent.d.ts +1 -0
- package/dist/consent.js +2 -2
- package/dist/contracts.d.ts +2 -26
- package/dist/contracts.js +4 -11
- 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 +9 -1
- package/dist/oauth-service.js +111 -97
- package/dist/oauth-service.test.js +121 -2
- package/dist/routes-openapi.d.ts +1 -25
- package/dist/routes-viewer-scopes.test.d.ts +1 -0
- package/dist/routes-viewer-scopes.test.js +46 -0
- package/dist/routes.d.ts +8 -5
- package/dist/routes.js +43 -24
- package/dist/runtime-contributor.d.ts +13 -1
- package/dist/runtime-contributor.js +9 -1
- package/dist/runtime-contributor.test.d.ts +1 -0
- package/dist/runtime-contributor.test.js +55 -0
- package/dist/runtime-port.d.ts +29 -0
- package/dist/runtime-port.js +28 -0
- package/dist/schema.d.ts +170 -0
- package/dist/schema.js +18 -0
- package/dist/session-token-service.d.ts +4 -0
- package/dist/session-token-service.js +90 -28
- package/dist/session-token-service.test.d.ts +1 -0
- package/dist/session-token-service.test.js +187 -0
- package/dist/session-token.d.ts +13 -2
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +51 -0
- package/dist/voyant.js +95 -1
- package/migrations/20260718205748_managed_installation_binding.sql +16 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +10 -5
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,
|
|
@@ -55,7 +61,7 @@ export function createAppOAuthService(options) {
|
|
|
55
61
|
return { code, state: input.state, redirectUri: input.redirectUri, expiresAt };
|
|
56
62
|
}
|
|
57
63
|
async function token(db, input) {
|
|
58
|
-
await authenticateClient(db, input.clientId, input.clientSecret);
|
|
64
|
+
await authenticateClient(db, input.clientId, input.clientSecret, options.clientAuthentication === "required");
|
|
59
65
|
if (input.grantType === "authorization_code")
|
|
60
66
|
return exchangeCode(db, input);
|
|
61
67
|
if (input.grantType === "refresh_token")
|
|
@@ -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
|
|
@@ -82,6 +97,13 @@ export function createAppOAuthService(options) {
|
|
|
82
97
|
const scopes = credential.tokenMode === "online"
|
|
83
98
|
? (readStoredScopes(credential.encryptedMetadata) ?? [])
|
|
84
99
|
: await grantedScopes(db, installation.id);
|
|
100
|
+
const appContextConstraint = credential.tokenMode === "online"
|
|
101
|
+
? readStoredAppContextConstraint(credential.encryptedMetadata)
|
|
102
|
+
: undefined;
|
|
103
|
+
// Every online actor token is minted from an extension session. Missing or
|
|
104
|
+
// malformed context metadata must invalidate it rather than silently widen it.
|
|
105
|
+
if (credential.tokenMode === "online" && !appContextConstraint)
|
|
106
|
+
return null;
|
|
85
107
|
return {
|
|
86
108
|
callerType: "app",
|
|
87
109
|
actor: "staff",
|
|
@@ -90,14 +112,23 @@ export function createAppOAuthService(options) {
|
|
|
90
112
|
appInstallationId: installation.id,
|
|
91
113
|
appReleaseId: installation.releaseId,
|
|
92
114
|
appCredentialGeneration: credential.generation,
|
|
115
|
+
...(effectiveManagedBinding
|
|
116
|
+
? {
|
|
117
|
+
appWorkloadEnvironmentId: effectiveManagedBinding.workloadEnvironmentId,
|
|
118
|
+
appContractGeneration: effectiveManagedBinding.contractGeneration,
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
93
121
|
appTokenMode: credential.tokenMode,
|
|
94
122
|
appViewerId: credential.viewerId ?? undefined,
|
|
123
|
+
...(appContextConstraint ? { appContextConstraint } : {}),
|
|
95
124
|
scopes,
|
|
96
125
|
};
|
|
97
126
|
}
|
|
98
127
|
async function revokeInstallationCredentials(db, installationId, actorId) {
|
|
99
128
|
return db.transaction(async (tx) => {
|
|
100
129
|
const installation = await requireInstallation(tx, installationId);
|
|
130
|
+
const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
|
|
131
|
+
assertManagedBinding(installation, managedBinding);
|
|
101
132
|
const generation = installation.credentialGeneration + 1;
|
|
102
133
|
await tx
|
|
103
134
|
.update(appInstallations)
|
|
@@ -134,13 +165,19 @@ export function createAppOAuthService(options) {
|
|
|
134
165
|
if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
|
|
135
166
|
throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
|
|
136
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);
|
|
137
173
|
await tx
|
|
138
174
|
.update(appOAuthAuthorizationCodes)
|
|
139
175
|
.set({ consumedAt: now() })
|
|
140
176
|
.where(eq(appOAuthAuthorizationCodes.id, code.id));
|
|
141
177
|
const installation = await requireInstallation(tx, code.installationId);
|
|
142
178
|
assertActiveInstallation(installation);
|
|
143
|
-
|
|
179
|
+
assertManagedBinding(installation, managedBinding);
|
|
180
|
+
const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes, managedBinding);
|
|
144
181
|
await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
|
|
145
182
|
return tokens;
|
|
146
183
|
});
|
|
@@ -157,8 +194,11 @@ export function createAppOAuthService(options) {
|
|
|
157
194
|
throw oauthError("invalid_grant", "Refresh token is invalid");
|
|
158
195
|
}
|
|
159
196
|
const installation = await requireInstallation(tx, tokenRow.installationId);
|
|
197
|
+
const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
|
|
160
198
|
assertTokenClient(installation, input.clientId);
|
|
161
199
|
assertActiveInstallation(installation);
|
|
200
|
+
assertManagedBinding(tokenRow, managedBinding);
|
|
201
|
+
assertManagedBinding(installation, managedBinding);
|
|
162
202
|
if (installation.credentialGeneration !== tokenRow.generation) {
|
|
163
203
|
throw oauthError("invalid_grant", "Refresh token generation was revoked");
|
|
164
204
|
}
|
|
@@ -166,7 +206,7 @@ export function createAppOAuthService(options) {
|
|
|
166
206
|
.update(appOAuthRefreshTokens)
|
|
167
207
|
.set({ status: "inactive", revokedAt: now() })
|
|
168
208
|
.where(eq(appOAuthRefreshTokens.id, tokenRow.id));
|
|
169
|
-
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, {
|
|
170
210
|
rotatedFromId: tokenRow.id,
|
|
171
211
|
});
|
|
172
212
|
await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
|
|
@@ -177,8 +217,10 @@ export function createAppOAuthService(options) {
|
|
|
177
217
|
}
|
|
178
218
|
async function exchangeActorToken(db, input) {
|
|
179
219
|
const installation = await requireInstallation(db, input.installationId);
|
|
220
|
+
const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
|
|
180
221
|
assertTokenClient(installation, input.clientId);
|
|
181
222
|
assertActiveInstallation(installation);
|
|
223
|
+
assertManagedBinding(installation, managedBinding);
|
|
182
224
|
const grants = await grantedScopes(db, installation.id);
|
|
183
225
|
const contextual = input.contextualScopes ?? grants;
|
|
184
226
|
const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
|
|
@@ -187,11 +229,17 @@ export function createAppOAuthService(options) {
|
|
|
187
229
|
await db.insert(appAccessCredentials).values({
|
|
188
230
|
installationId: installation.id,
|
|
189
231
|
generation: installation.credentialGeneration,
|
|
232
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
233
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
190
234
|
tokenMode: "online",
|
|
191
235
|
credentialHash: sha256Hex(accessToken),
|
|
192
236
|
// Online tokens are intentionally narrowed; the resolver must honor the
|
|
193
237
|
// minted set rather than recomputing from installation grants.
|
|
194
|
-
encryptedMetadata: {
|
|
238
|
+
encryptedMetadata: {
|
|
239
|
+
scopeCount: scopes.length,
|
|
240
|
+
scopes: [...scopes],
|
|
241
|
+
contextConstraint: input.contextConstraint,
|
|
242
|
+
},
|
|
195
243
|
status: "active",
|
|
196
244
|
actorId: input.viewerId,
|
|
197
245
|
viewerId: input.viewerId,
|
|
@@ -202,7 +250,7 @@ export function createAppOAuthService(options) {
|
|
|
202
250
|
});
|
|
203
251
|
return tokenResponse(accessToken, null, expiresAt, scopes, "online");
|
|
204
252
|
}
|
|
205
|
-
async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
|
|
253
|
+
async function mintTokens(db, installation, actorId, viewerId, scopes, managedBinding, refreshOptions = {}) {
|
|
206
254
|
const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
|
|
207
255
|
const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
|
|
208
256
|
const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
|
|
@@ -211,6 +259,8 @@ export function createAppOAuthService(options) {
|
|
|
211
259
|
await db.insert(appAccessCredentials).values({
|
|
212
260
|
installationId: installation.id,
|
|
213
261
|
generation,
|
|
262
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
263
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
214
264
|
tokenMode: "offline",
|
|
215
265
|
credentialHash: sha256Hex(accessToken),
|
|
216
266
|
encryptedMetadata: { scopeCount: scopes.length },
|
|
@@ -223,6 +273,8 @@ export function createAppOAuthService(options) {
|
|
|
223
273
|
installationId: installation.id,
|
|
224
274
|
tokenHash: sha256Hex(refreshToken),
|
|
225
275
|
generation,
|
|
276
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
277
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
226
278
|
rotatedFromId: refreshOptions.rotatedFromId,
|
|
227
279
|
expiresAt: refreshExpiresAt,
|
|
228
280
|
});
|
|
@@ -231,6 +283,31 @@ export function createAppOAuthService(options) {
|
|
|
231
283
|
function isExpired(expiresAt) {
|
|
232
284
|
return Boolean(expiresAt && expiresAt <= now());
|
|
233
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
|
+
}
|
|
234
311
|
}
|
|
235
312
|
function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
|
|
236
313
|
return {
|
|
@@ -242,14 +319,17 @@ function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode)
|
|
|
242
319
|
...(refreshToken ? { refreshToken } : {}),
|
|
243
320
|
};
|
|
244
321
|
}
|
|
245
|
-
async function authenticateClient(db, appId, clientSecret) {
|
|
322
|
+
async function authenticateClient(db, appId, clientSecret, required) {
|
|
246
323
|
const [secret] = await db
|
|
247
324
|
.select()
|
|
248
325
|
.from(appCredentials)
|
|
249
326
|
.where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
|
|
250
327
|
.limit(1);
|
|
251
|
-
if (!secret)
|
|
328
|
+
if (!secret) {
|
|
329
|
+
if (required)
|
|
330
|
+
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
252
331
|
return;
|
|
332
|
+
}
|
|
253
333
|
if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
|
|
254
334
|
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
255
335
|
}
|
|
@@ -280,94 +360,6 @@ async function requireExactRedirectUri(db, appId, redirectUri) {
|
|
|
280
360
|
if (!row)
|
|
281
361
|
throw oauthError("invalid_request", "Redirect URI is not registered for this app");
|
|
282
362
|
}
|
|
283
|
-
async function ensureAuthorizedInstallation(db, input) {
|
|
284
|
-
return db.transaction(async (tx) => {
|
|
285
|
-
const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
|
|
286
|
-
if (!app)
|
|
287
|
-
throw oauthError("invalid_request", "App registration not found", 404);
|
|
288
|
-
const [existing] = await tx
|
|
289
|
-
.select()
|
|
290
|
-
.from(appInstallations)
|
|
291
|
-
.where(and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
|
|
292
|
-
.limit(1);
|
|
293
|
-
const installation = existing ??
|
|
294
|
-
(await tx
|
|
295
|
-
.insert(appInstallations)
|
|
296
|
-
.values({
|
|
297
|
-
appId: input.appId,
|
|
298
|
-
deploymentId: input.deploymentId,
|
|
299
|
-
releaseId: input.releaseId,
|
|
300
|
-
status: "active",
|
|
301
|
-
namespace: app.platformNamespace,
|
|
302
|
-
installedBy: input.actorId,
|
|
303
|
-
authorizedAt: new Date(),
|
|
304
|
-
activatedAt: new Date(),
|
|
305
|
-
})
|
|
306
|
-
.returning())[0];
|
|
307
|
-
if (!installation)
|
|
308
|
-
throw oauthError("server_error", "Could not create installation", 500);
|
|
309
|
-
for (const scope of input.grantedScopes) {
|
|
310
|
-
await tx
|
|
311
|
-
.insert(appGrants)
|
|
312
|
-
.values({
|
|
313
|
-
installationId: installation.id,
|
|
314
|
-
scope,
|
|
315
|
-
status: "granted",
|
|
316
|
-
optional: false,
|
|
317
|
-
grantedAt: new Date(),
|
|
318
|
-
})
|
|
319
|
-
.onConflictDoUpdate({
|
|
320
|
-
target: [appGrants.installationId, appGrants.scope],
|
|
321
|
-
set: { status: "granted", grantedAt: new Date(), revokedAt: null },
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
for (const scope of input.deniedOptionalScopes) {
|
|
325
|
-
await tx
|
|
326
|
-
.insert(appGrants)
|
|
327
|
-
.values({ installationId: installation.id, scope, status: "optional", optional: true })
|
|
328
|
-
.onConflictDoUpdate({
|
|
329
|
-
target: [appGrants.installationId, appGrants.scope],
|
|
330
|
-
set: { status: "optional", optional: true },
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
return installation;
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
async function requireInstallation(db, installationId) {
|
|
337
|
-
const installation = await selectInstallation(db, installationId);
|
|
338
|
-
if (!installation)
|
|
339
|
-
throw oauthError("invalid_grant", "App installation not found", 404);
|
|
340
|
-
return installation;
|
|
341
|
-
}
|
|
342
|
-
async function selectInstallation(db, installationId) {
|
|
343
|
-
const [installation] = await db
|
|
344
|
-
.select()
|
|
345
|
-
.from(appInstallations)
|
|
346
|
-
.where(eq(appInstallations.id, installationId))
|
|
347
|
-
.limit(1);
|
|
348
|
-
return installation ?? null;
|
|
349
|
-
}
|
|
350
|
-
async function grantedScopes(db, installationId) {
|
|
351
|
-
const rows = await db
|
|
352
|
-
.select({ scope: appGrants.scope })
|
|
353
|
-
.from(appGrants)
|
|
354
|
-
.where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
|
|
355
|
-
.orderBy(appGrants.scope);
|
|
356
|
-
return rows.map((row) => row.scope);
|
|
357
|
-
}
|
|
358
|
-
function assertActiveInstallation(installation) {
|
|
359
|
-
if (installation.status !== "active") {
|
|
360
|
-
throw oauthError("invalid_grant", "App installation is not active");
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
function isInstallationUsable(installation, generation) {
|
|
364
|
-
return installation?.status === "active" && installation.credentialGeneration === generation;
|
|
365
|
-
}
|
|
366
|
-
function assertTokenClient(installation, clientId) {
|
|
367
|
-
if (installation.appId !== clientId) {
|
|
368
|
-
throw oauthError("invalid_grant", "Token belongs to a different app");
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
363
|
export function intersectAppTokenScopes(...sets) {
|
|
372
364
|
const [first = [], ...rest] = sets;
|
|
373
365
|
return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
|
|
@@ -382,6 +374,28 @@ export function readStoredScopes(metadata) {
|
|
|
382
374
|
return null;
|
|
383
375
|
return stored.filter((scope) => typeof scope === "string");
|
|
384
376
|
}
|
|
377
|
+
export function readStoredAppContextConstraint(metadata) {
|
|
378
|
+
const value = metadata.contextConstraint;
|
|
379
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
380
|
+
return null;
|
|
381
|
+
const record = value;
|
|
382
|
+
const slot = record.slot;
|
|
383
|
+
if (slot !== null && (typeof slot !== "string" || slot.length === 0))
|
|
384
|
+
return null;
|
|
385
|
+
const entity = record.entity;
|
|
386
|
+
if (entity === null)
|
|
387
|
+
return { entity: null, slot };
|
|
388
|
+
if (!entity || typeof entity !== "object" || Array.isArray(entity))
|
|
389
|
+
return null;
|
|
390
|
+
const entityRecord = entity;
|
|
391
|
+
if (typeof entityRecord.type !== "string" ||
|
|
392
|
+
entityRecord.type.length === 0 ||
|
|
393
|
+
typeof entityRecord.id !== "string" ||
|
|
394
|
+
entityRecord.id.length === 0) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return { entity: { type: entityRecord.type, id: entityRecord.id }, slot };
|
|
398
|
+
}
|
|
385
399
|
function oauthError(error, description, status = 400) {
|
|
386
400
|
return new ApiHttpError(description, { status, code: error });
|
|
387
401
|
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createAppOAuthService, intersectAppTokenScopes, readStoredScopes, } from "./oauth-service.js";
|
|
3
|
-
import { appReleases } from "./schema.js";
|
|
2
|
+
import { createAppOAuthService, intersectAppTokenScopes, readStoredAppContextConstraint, readStoredScopes, } from "./oauth-service.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"]);
|
|
@@ -16,6 +34,22 @@ describe("stored online token scopes", () => {
|
|
|
16
34
|
expect(readStoredScopes({ scopeCount: 2 })).toBeNull();
|
|
17
35
|
expect(readStoredScopes({ scopes: "bookings:read" })).toBeNull();
|
|
18
36
|
});
|
|
37
|
+
it("fails closed unless immutable extension context metadata is complete", () => {
|
|
38
|
+
expect(readStoredAppContextConstraint({
|
|
39
|
+
contextConstraint: {
|
|
40
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
41
|
+
slot: "invoice.details.after-summary",
|
|
42
|
+
},
|
|
43
|
+
})).toEqual({
|
|
44
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
45
|
+
slot: "invoice.details.after-summary",
|
|
46
|
+
});
|
|
47
|
+
expect(readStoredAppContextConstraint({})).toBeNull();
|
|
48
|
+
expect(readStoredAppContextConstraint({ contextConstraint: { entity: { type: "invoice" } } })).toBeNull();
|
|
49
|
+
expect(readStoredAppContextConstraint({
|
|
50
|
+
contextConstraint: { entity: { type: "invoice", id: "invoice_1" }, slot: "" },
|
|
51
|
+
})).toBeNull();
|
|
52
|
+
});
|
|
19
53
|
});
|
|
20
54
|
describe("authorization release lifecycle", () => {
|
|
21
55
|
function dbWithRelease(state) {
|
|
@@ -58,3 +92,88 @@ describe("authorization release lifecycle", () => {
|
|
|
58
92
|
});
|
|
59
93
|
});
|
|
60
94
|
});
|
|
95
|
+
describe("managed client authentication", () => {
|
|
96
|
+
it("fails closed when confidential-client provisioning is absent", async () => {
|
|
97
|
+
const db = Object.assign(Object.create(null), {
|
|
98
|
+
select: () => ({
|
|
99
|
+
from: (table) => ({
|
|
100
|
+
where: () => ({ limit: async () => (table === appCredentials ? [] : []) }),
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const service = createAppOAuthService({
|
|
105
|
+
accessCatalog: { resources: [], presets: [] },
|
|
106
|
+
deploymentId: "dep_1",
|
|
107
|
+
clientAuthentication: "required",
|
|
108
|
+
});
|
|
109
|
+
await expect(service.token(db, {
|
|
110
|
+
grantType: "refresh_token",
|
|
111
|
+
refreshToken: "app_refresh_fixture",
|
|
112
|
+
clientId: "app_1",
|
|
113
|
+
})).rejects.toMatchObject({ status: 401, code: "invalid_client" });
|
|
114
|
+
});
|
|
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-openapi.d.ts
CHANGED
|
@@ -91,7 +91,7 @@ export declare const authorizeAppOAuthRoute: {
|
|
|
91
91
|
state: z.ZodString;
|
|
92
92
|
code_challenge: z.ZodString;
|
|
93
93
|
code_challenge_method: z.ZodLiteral<"S256">;
|
|
94
|
-
actor_id: z.ZodString
|
|
94
|
+
actor_id: z.ZodOptional<z.ZodString>;
|
|
95
95
|
operator_scopes: z.ZodDefault<z.ZodString>;
|
|
96
96
|
optional_scopes: z.ZodDefault<z.ZodString>;
|
|
97
97
|
}, z.core.$strict>;
|
|
@@ -147,14 +147,6 @@ export declare const issueAppOAuthTokenRoute: {
|
|
|
147
147
|
refresh_token: z.ZodString;
|
|
148
148
|
client_id: z.ZodString;
|
|
149
149
|
client_secret: z.ZodOptional<z.ZodString>;
|
|
150
|
-
}, z.core.$strip>, z.ZodObject<{
|
|
151
|
-
grant_type: z.ZodLiteral<"urn:voyant:params:oauth:grant-type:actor-token-exchange">;
|
|
152
|
-
installation_id: z.ZodString;
|
|
153
|
-
viewer_id: z.ZodString;
|
|
154
|
-
viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
155
|
-
contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
156
|
-
client_id: z.ZodString;
|
|
157
|
-
client_secret: z.ZodOptional<z.ZodString>;
|
|
158
150
|
}, z.core.$strip>], "grant_type">, z.ZodTransform<{
|
|
159
151
|
client_secret: string | undefined;
|
|
160
152
|
grant_type: "authorization_code";
|
|
@@ -167,14 +159,6 @@ export declare const issueAppOAuthTokenRoute: {
|
|
|
167
159
|
grant_type: "refresh_token";
|
|
168
160
|
refresh_token: string;
|
|
169
161
|
client_id: string;
|
|
170
|
-
} | {
|
|
171
|
-
client_secret: string | undefined;
|
|
172
|
-
grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
|
|
173
|
-
installation_id: string;
|
|
174
|
-
viewer_id: string;
|
|
175
|
-
viewer_scopes: string[];
|
|
176
|
-
client_id: string;
|
|
177
|
-
contextual_scopes?: string[] | undefined;
|
|
178
162
|
}, {
|
|
179
163
|
grant_type: "authorization_code";
|
|
180
164
|
code: string;
|
|
@@ -187,14 +171,6 @@ export declare const issueAppOAuthTokenRoute: {
|
|
|
187
171
|
refresh_token: string;
|
|
188
172
|
client_id: string;
|
|
189
173
|
client_secret?: string | undefined;
|
|
190
|
-
} | {
|
|
191
|
-
grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
|
|
192
|
-
installation_id: string;
|
|
193
|
-
viewer_id: string;
|
|
194
|
-
viewer_scopes: string[];
|
|
195
|
-
client_id: string;
|
|
196
|
-
contextual_scopes?: string[] | undefined;
|
|
197
|
-
client_secret?: string | undefined;
|
|
198
174
|
}>>;
|
|
199
175
|
};
|
|
200
176
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveOperatorGrantableRemoteAppScopes, resolveViewerRemoteAppScopes } from "./routes.js";
|
|
3
|
+
const catalog = {
|
|
4
|
+
resources: [
|
|
5
|
+
{
|
|
6
|
+
id: "finance-artifacts",
|
|
7
|
+
unitId: "apps",
|
|
8
|
+
resource: "finance-document-artifacts",
|
|
9
|
+
label: "Artifacts",
|
|
10
|
+
description: "Artifacts",
|
|
11
|
+
wildcard: "explicit-resource",
|
|
12
|
+
remoteSafe: true,
|
|
13
|
+
actions: [
|
|
14
|
+
{
|
|
15
|
+
action: "write",
|
|
16
|
+
label: "Write",
|
|
17
|
+
description: "Write",
|
|
18
|
+
wildcard: "explicit",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "apps-admin",
|
|
24
|
+
unitId: "apps",
|
|
25
|
+
resource: "apps",
|
|
26
|
+
label: "Apps",
|
|
27
|
+
description: "Apps",
|
|
28
|
+
wildcard: "allow",
|
|
29
|
+
actions: [{ action: "write", label: "Write", description: "Write" }],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
presets: [],
|
|
33
|
+
};
|
|
34
|
+
describe("viewer scope projection for remote extensions", () => {
|
|
35
|
+
it("projects only host-authorized remote-safe permissions", () => {
|
|
36
|
+
expect(resolveViewerRemoteAppScopes(["finance-document-artifacts:write", "apps:write"], catalog)).toEqual(["finance-document-artifacts:write"]);
|
|
37
|
+
});
|
|
38
|
+
it("does not treat a generic wildcard as an explicit sensitive action", () => {
|
|
39
|
+
expect(resolveViewerRemoteAppScopes(["*"], catalog)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
it("derives consent authority from the staff grants and remote-safe catalog", () => {
|
|
42
|
+
expect(resolveOperatorGrantableRemoteAppScopes(["*"], catalog)).toEqual([]);
|
|
43
|
+
expect(resolveOperatorGrantableRemoteAppScopes(["finance-document-artifacts:write", "apps:write"], catalog)).toEqual(["finance-document-artifacts:write"]);
|
|
44
|
+
expect(resolveOperatorGrantableRemoteAppScopes(["apps:write"], catalog)).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { EventBus } from "@voyant-travel/core/events";
|
|
3
3
|
import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
|
|
4
|
-
import type
|
|
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 = {
|
|
9
|
-
Bindings: {
|
|
10
|
-
/** Deployment identity; the install lifecycle is scoped to it. */
|
|
11
|
-
VOYANT_CLOUD_DEPLOYMENT_ID?: string;
|
|
12
|
-
};
|
|
13
10
|
Variables: {
|
|
14
11
|
db: PostgresJsDatabase;
|
|
12
|
+
scopes?: string[];
|
|
15
13
|
};
|
|
16
14
|
};
|
|
17
15
|
export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
18
16
|
oauth?: {
|
|
19
17
|
accessCatalog: AccessCatalog;
|
|
20
18
|
deploymentId: string;
|
|
19
|
+
managedInstallation?: ManagedAppInstallationAuthority;
|
|
20
|
+
clientAuthentication?: "optional" | "required";
|
|
21
21
|
};
|
|
22
22
|
/**
|
|
23
23
|
* Enables the iframe session-token broker. Requires {@link oauth} for the
|
|
@@ -26,6 +26,7 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
26
26
|
*/
|
|
27
27
|
sessionToken?: {
|
|
28
28
|
secret: string;
|
|
29
|
+
managedInstallation?: ManagedAppInstallationAuthority;
|
|
29
30
|
ttlSeconds?: number;
|
|
30
31
|
};
|
|
31
32
|
/**
|
|
@@ -39,4 +40,6 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
39
40
|
customFields?: CustomFieldsService;
|
|
40
41
|
}
|
|
41
42
|
export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): OpenAPIHono<Env, {}, "/">;
|
|
43
|
+
export declare function resolveViewerRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog | undefined): string[];
|
|
44
|
+
export declare function resolveOperatorGrantableRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog): string[];
|
|
42
45
|
export {};
|