@voyant-travel/apps 0.6.3 → 0.8.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/api-runtime.d.ts +23 -11
- package/dist/api-runtime.js +44 -2
- package/dist/api-runtime.test.d.ts +1 -0
- package/dist/api-runtime.test.js +54 -0
- package/dist/app-api-contracts.d.ts +84 -7
- package/dist/app-api-contracts.js +130 -9
- package/dist/app-api-finance-routes.test.d.ts +1 -0
- package/dist/app-api-finance-routes.test.js +633 -0
- package/dist/app-api-routes.d.ts +6 -5
- package/dist/app-api-routes.js +144 -22
- package/dist/app-api-service.d.ts +49 -4
- package/dist/app-api-service.js +237 -0
- package/dist/app-api-service.test.js +52 -0
- package/dist/consent.d.ts +1 -0
- package/dist/consent.js +2 -2
- package/dist/contracts.d.ts +4 -28
- package/dist/contracts.js +3 -10
- package/dist/oauth-service.d.ts +6 -1
- package/dist/oauth-service.js +41 -4
- package/dist/oauth-service.test.js +39 -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 +5 -5
- package/dist/routes.js +41 -24
- package/dist/runtime-contributor.d.ts +9 -1
- package/dist/runtime-contributor.js +25 -2
- package/dist/runtime-contributor.test.d.ts +1 -0
- package/dist/runtime-contributor.test.js +52 -0
- package/dist/runtime-port.d.ts +8 -0
- package/dist/runtime-port.js +22 -0
- package/dist/session-token-service.d.ts +2 -0
- package/dist/session-token-service.js +44 -27
- package/dist/session-token-service.test.d.ts +1 -0
- package/dist/session-token-service.test.js +157 -0
- package/dist/session-token.d.ts +5 -1
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +5 -0
- package/dist/voyant.js +136 -1
- package/package.json +9 -3
package/dist/consent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ApiHttpError } from "@voyant-travel/hono";
|
|
2
2
|
export function computeAppConsent(input) {
|
|
3
3
|
const normalized = parseReleaseScopes(input.release.normalizedRecord);
|
|
4
|
-
const remoteSafe =
|
|
4
|
+
const remoteSafe = grantableRemoteAppScopes(input.accessCatalog);
|
|
5
5
|
const operatorGranted = new Set(input.operatorGrantedScopes);
|
|
6
6
|
const optionalGrantRequest = new Set(input.grantedOptionalScopes ?? []);
|
|
7
7
|
const requiredScopes = normalized.requestedScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
|
|
@@ -27,7 +27,7 @@ export function computeAppConsent(input) {
|
|
|
27
27
|
function canGrantScope(scope, remoteSafe, operatorGranted) {
|
|
28
28
|
return remoteSafe.has(scope) && operatorGranted.has(scope);
|
|
29
29
|
}
|
|
30
|
-
function
|
|
30
|
+
export function grantableRemoteAppScopes(catalog) {
|
|
31
31
|
const catalogScopes = new Set(catalog.resources.flatMap((resource) => resource.actions.map((action) => `${resource.resource}:${action.action}`)));
|
|
32
32
|
const remoteSafe = new Set();
|
|
33
33
|
// A scope is remote-safe when its owning resource is flagged remoteSafe, or
|
package/dist/contracts.d.ts
CHANGED
|
@@ -29,8 +29,8 @@ export declare const appOwnedCustomFieldDeclarationSchema: z.ZodObject<{
|
|
|
29
29
|
}, z.core.$strip>>>>;
|
|
30
30
|
logicalNamespace: z.ZodOptional<z.ZodString>;
|
|
31
31
|
dataClassification: z.ZodDefault<z.ZodEnum<{
|
|
32
|
-
internal: "internal";
|
|
33
32
|
public: "public";
|
|
33
|
+
internal: "internal";
|
|
34
34
|
confidential: "confidential";
|
|
35
35
|
restricted: "restricted";
|
|
36
36
|
personal: "personal";
|
|
@@ -106,8 +106,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
|
|
|
106
106
|
}, z.core.$strip>>>>;
|
|
107
107
|
logicalNamespace: z.ZodOptional<z.ZodString>;
|
|
108
108
|
dataClassification: z.ZodDefault<z.ZodEnum<{
|
|
109
|
-
internal: "internal";
|
|
110
109
|
public: "public";
|
|
110
|
+
internal: "internal";
|
|
111
111
|
confidential: "confidential";
|
|
112
112
|
restricted: "restricted";
|
|
113
113
|
personal: "personal";
|
|
@@ -134,8 +134,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
|
|
|
134
134
|
}, z.core.$strict>;
|
|
135
135
|
data: z.ZodObject<{
|
|
136
136
|
classifications: z.ZodArray<z.ZodEnum<{
|
|
137
|
-
internal: "internal";
|
|
138
137
|
public: "public";
|
|
138
|
+
internal: "internal";
|
|
139
139
|
confidential: "confidential";
|
|
140
140
|
restricted: "restricted";
|
|
141
141
|
personal: "personal";
|
|
@@ -222,7 +222,7 @@ export declare const appOAuthAuthorizeQuerySchema: z.ZodObject<{
|
|
|
222
222
|
state: z.ZodString;
|
|
223
223
|
code_challenge: z.ZodString;
|
|
224
224
|
code_challenge_method: z.ZodLiteral<"S256">;
|
|
225
|
-
actor_id: z.ZodString
|
|
225
|
+
actor_id: z.ZodOptional<z.ZodString>;
|
|
226
226
|
operator_scopes: z.ZodDefault<z.ZodString>;
|
|
227
227
|
optional_scopes: z.ZodDefault<z.ZodString>;
|
|
228
228
|
}, z.core.$strict>;
|
|
@@ -238,14 +238,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
|
|
|
238
238
|
refresh_token: z.ZodString;
|
|
239
239
|
client_id: z.ZodString;
|
|
240
240
|
client_secret: z.ZodOptional<z.ZodString>;
|
|
241
|
-
}, z.core.$strip>, z.ZodObject<{
|
|
242
|
-
grant_type: z.ZodLiteral<"urn:voyant:params:oauth:grant-type:actor-token-exchange">;
|
|
243
|
-
installation_id: z.ZodString;
|
|
244
|
-
viewer_id: z.ZodString;
|
|
245
|
-
viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
246
|
-
contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
247
|
-
client_id: z.ZodString;
|
|
248
|
-
client_secret: z.ZodOptional<z.ZodString>;
|
|
249
241
|
}, z.core.$strip>], "grant_type">, z.ZodTransform<{
|
|
250
242
|
client_secret: string | undefined;
|
|
251
243
|
grant_type: "authorization_code";
|
|
@@ -258,14 +250,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
|
|
|
258
250
|
grant_type: "refresh_token";
|
|
259
251
|
refresh_token: string;
|
|
260
252
|
client_id: string;
|
|
261
|
-
} | {
|
|
262
|
-
client_secret: string | undefined;
|
|
263
|
-
grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
|
|
264
|
-
installation_id: string;
|
|
265
|
-
viewer_id: string;
|
|
266
|
-
viewer_scopes: string[];
|
|
267
|
-
client_id: string;
|
|
268
|
-
contextual_scopes?: string[] | undefined;
|
|
269
253
|
}, {
|
|
270
254
|
grant_type: "authorization_code";
|
|
271
255
|
code: string;
|
|
@@ -278,14 +262,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
|
|
|
278
262
|
refresh_token: string;
|
|
279
263
|
client_id: string;
|
|
280
264
|
client_secret?: string | undefined;
|
|
281
|
-
} | {
|
|
282
|
-
grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
|
|
283
|
-
installation_id: string;
|
|
284
|
-
viewer_id: string;
|
|
285
|
-
viewer_scopes: string[];
|
|
286
|
-
client_id: string;
|
|
287
|
-
contextual_scopes?: string[] | undefined;
|
|
288
|
-
client_secret?: string | undefined;
|
|
289
265
|
}>>;
|
|
290
266
|
export declare const appCredentialRevocationSchema: z.ZodObject<{
|
|
291
267
|
installationId: z.ZodString;
|
package/dist/contracts.js
CHANGED
|
@@ -271,7 +271,9 @@ export const appOAuthAuthorizeQuerySchema = z
|
|
|
271
271
|
state: z.string().trim().min(1),
|
|
272
272
|
code_challenge: z.string().trim().min(1),
|
|
273
273
|
code_challenge_method: z.literal("S256"),
|
|
274
|
-
|
|
274
|
+
/** Deprecated input; the route derives the actor from the staff session. */
|
|
275
|
+
actor_id: z.string().trim().min(1).optional(),
|
|
276
|
+
/** Deprecated input; the route derives the grant ceiling from staff scopes. */
|
|
275
277
|
operator_scopes: z.string().trim().default(""),
|
|
276
278
|
optional_scopes: z.string().trim().default(""),
|
|
277
279
|
})
|
|
@@ -292,15 +294,6 @@ export const appOAuthTokenSchema = z
|
|
|
292
294
|
client_id: z.string().trim().min(1),
|
|
293
295
|
client_secret: z.string().trim().optional(),
|
|
294
296
|
}),
|
|
295
|
-
z.object({
|
|
296
|
-
grant_type: z.literal("urn:voyant:params:oauth:grant-type:actor-token-exchange"),
|
|
297
|
-
installation_id: z.string().trim().min(1),
|
|
298
|
-
viewer_id: z.string().trim().min(1),
|
|
299
|
-
viewer_scopes: z.array(scopeSchema).default([]),
|
|
300
|
-
contextual_scopes: z.array(scopeSchema).optional(),
|
|
301
|
-
client_id: z.string().trim().min(1),
|
|
302
|
-
client_secret: z.string().trim().optional(),
|
|
303
|
-
}),
|
|
304
297
|
])
|
|
305
298
|
.transform((input) => ({
|
|
306
299
|
...input,
|
package/dist/oauth-service.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { VoyantAuthContext } from "@voyant-travel/core";
|
|
1
|
+
import type { VoyantAppContextConstraint, VoyantAuthContext } from "@voyant-travel/core";
|
|
2
2
|
import type { AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
3
3
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
4
|
export interface AppOAuthServiceOptions {
|
|
5
5
|
accessCatalog: AccessCatalog;
|
|
6
6
|
deploymentId: string;
|
|
7
|
+
/** Managed confidential runtimes fail closed unless a client secret is registered. */
|
|
8
|
+
clientAuthentication?: "optional" | "required";
|
|
7
9
|
now?: () => Date;
|
|
8
10
|
}
|
|
9
11
|
export interface AuthorizeAppInput {
|
|
@@ -37,6 +39,8 @@ export interface TokenExchangeInput {
|
|
|
37
39
|
viewerId: string;
|
|
38
40
|
viewerScopes: readonly string[];
|
|
39
41
|
contextualScopes?: readonly string[];
|
|
42
|
+
/** Immutable host context signed into the extension session token. */
|
|
43
|
+
contextConstraint: VoyantAppContextConstraint;
|
|
40
44
|
clientId: string;
|
|
41
45
|
clientSecret?: string;
|
|
42
46
|
}
|
|
@@ -64,3 +68,4 @@ export declare function createAppOAuthService(options: AppOAuthServiceOptions):
|
|
|
64
68
|
};
|
|
65
69
|
export declare function intersectAppTokenScopes(...sets: readonly (readonly string[])[]): string[];
|
|
66
70
|
export declare function readStoredScopes(metadata: Record<string, unknown>): string[] | null;
|
|
71
|
+
export declare function readStoredAppContextConstraint(metadata: Record<string, unknown>): VoyantAppContextConstraint | null;
|
package/dist/oauth-service.js
CHANGED
|
@@ -55,7 +55,7 @@ export function createAppOAuthService(options) {
|
|
|
55
55
|
return { code, state: input.state, redirectUri: input.redirectUri, expiresAt };
|
|
56
56
|
}
|
|
57
57
|
async function token(db, input) {
|
|
58
|
-
await authenticateClient(db, input.clientId, input.clientSecret);
|
|
58
|
+
await authenticateClient(db, input.clientId, input.clientSecret, options.clientAuthentication === "required");
|
|
59
59
|
if (input.grantType === "authorization_code")
|
|
60
60
|
return exchangeCode(db, input);
|
|
61
61
|
if (input.grantType === "refresh_token")
|
|
@@ -82,6 +82,13 @@ export function createAppOAuthService(options) {
|
|
|
82
82
|
const scopes = credential.tokenMode === "online"
|
|
83
83
|
? (readStoredScopes(credential.encryptedMetadata) ?? [])
|
|
84
84
|
: await grantedScopes(db, installation.id);
|
|
85
|
+
const appContextConstraint = credential.tokenMode === "online"
|
|
86
|
+
? readStoredAppContextConstraint(credential.encryptedMetadata)
|
|
87
|
+
: undefined;
|
|
88
|
+
// Every online actor token is minted from an extension session. Missing or
|
|
89
|
+
// malformed context metadata must invalidate it rather than silently widen it.
|
|
90
|
+
if (credential.tokenMode === "online" && !appContextConstraint)
|
|
91
|
+
return null;
|
|
85
92
|
return {
|
|
86
93
|
callerType: "app",
|
|
87
94
|
actor: "staff",
|
|
@@ -92,6 +99,7 @@ export function createAppOAuthService(options) {
|
|
|
92
99
|
appCredentialGeneration: credential.generation,
|
|
93
100
|
appTokenMode: credential.tokenMode,
|
|
94
101
|
appViewerId: credential.viewerId ?? undefined,
|
|
102
|
+
...(appContextConstraint ? { appContextConstraint } : {}),
|
|
95
103
|
scopes,
|
|
96
104
|
};
|
|
97
105
|
}
|
|
@@ -191,7 +199,11 @@ export function createAppOAuthService(options) {
|
|
|
191
199
|
credentialHash: sha256Hex(accessToken),
|
|
192
200
|
// Online tokens are intentionally narrowed; the resolver must honor the
|
|
193
201
|
// minted set rather than recomputing from installation grants.
|
|
194
|
-
encryptedMetadata: {
|
|
202
|
+
encryptedMetadata: {
|
|
203
|
+
scopeCount: scopes.length,
|
|
204
|
+
scopes: [...scopes],
|
|
205
|
+
contextConstraint: input.contextConstraint,
|
|
206
|
+
},
|
|
195
207
|
status: "active",
|
|
196
208
|
actorId: input.viewerId,
|
|
197
209
|
viewerId: input.viewerId,
|
|
@@ -242,14 +254,17 @@ function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode)
|
|
|
242
254
|
...(refreshToken ? { refreshToken } : {}),
|
|
243
255
|
};
|
|
244
256
|
}
|
|
245
|
-
async function authenticateClient(db, appId, clientSecret) {
|
|
257
|
+
async function authenticateClient(db, appId, clientSecret, required) {
|
|
246
258
|
const [secret] = await db
|
|
247
259
|
.select()
|
|
248
260
|
.from(appCredentials)
|
|
249
261
|
.where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
|
|
250
262
|
.limit(1);
|
|
251
|
-
if (!secret)
|
|
263
|
+
if (!secret) {
|
|
264
|
+
if (required)
|
|
265
|
+
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
252
266
|
return;
|
|
267
|
+
}
|
|
253
268
|
if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
|
|
254
269
|
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
255
270
|
}
|
|
@@ -382,6 +397,28 @@ export function readStoredScopes(metadata) {
|
|
|
382
397
|
return null;
|
|
383
398
|
return stored.filter((scope) => typeof scope === "string");
|
|
384
399
|
}
|
|
400
|
+
export function readStoredAppContextConstraint(metadata) {
|
|
401
|
+
const value = metadata.contextConstraint;
|
|
402
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
403
|
+
return null;
|
|
404
|
+
const record = value;
|
|
405
|
+
const slot = record.slot;
|
|
406
|
+
if (slot !== null && (typeof slot !== "string" || slot.length === 0))
|
|
407
|
+
return null;
|
|
408
|
+
const entity = record.entity;
|
|
409
|
+
if (entity === null)
|
|
410
|
+
return { entity: null, slot };
|
|
411
|
+
if (!entity || typeof entity !== "object" || Array.isArray(entity))
|
|
412
|
+
return null;
|
|
413
|
+
const entityRecord = entity;
|
|
414
|
+
if (typeof entityRecord.type !== "string" ||
|
|
415
|
+
entityRecord.type.length === 0 ||
|
|
416
|
+
typeof entityRecord.id !== "string" ||
|
|
417
|
+
entityRecord.id.length === 0) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
return { entity: { type: entityRecord.type, id: entityRecord.id }, slot };
|
|
421
|
+
}
|
|
385
422
|
function oauthError(error, description, status = 400) {
|
|
386
423
|
return new ApiHttpError(description, { status, code: error });
|
|
387
424
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
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 { appCredentials, appReleases } from "./schema.js";
|
|
4
4
|
describe("app OAuth online token scope intersection", () => {
|
|
5
5
|
it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
|
|
6
6
|
expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
|
|
@@ -16,6 +16,22 @@ describe("stored online token scopes", () => {
|
|
|
16
16
|
expect(readStoredScopes({ scopeCount: 2 })).toBeNull();
|
|
17
17
|
expect(readStoredScopes({ scopes: "bookings:read" })).toBeNull();
|
|
18
18
|
});
|
|
19
|
+
it("fails closed unless immutable extension context metadata is complete", () => {
|
|
20
|
+
expect(readStoredAppContextConstraint({
|
|
21
|
+
contextConstraint: {
|
|
22
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
23
|
+
slot: "invoice.details.after-summary",
|
|
24
|
+
},
|
|
25
|
+
})).toEqual({
|
|
26
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
27
|
+
slot: "invoice.details.after-summary",
|
|
28
|
+
});
|
|
29
|
+
expect(readStoredAppContextConstraint({})).toBeNull();
|
|
30
|
+
expect(readStoredAppContextConstraint({ contextConstraint: { entity: { type: "invoice" } } })).toBeNull();
|
|
31
|
+
expect(readStoredAppContextConstraint({
|
|
32
|
+
contextConstraint: { entity: { type: "invoice", id: "invoice_1" }, slot: "" },
|
|
33
|
+
})).toBeNull();
|
|
34
|
+
});
|
|
19
35
|
});
|
|
20
36
|
describe("authorization release lifecycle", () => {
|
|
21
37
|
function dbWithRelease(state) {
|
|
@@ -58,3 +74,24 @@ describe("authorization release lifecycle", () => {
|
|
|
58
74
|
});
|
|
59
75
|
});
|
|
60
76
|
});
|
|
77
|
+
describe("managed client authentication", () => {
|
|
78
|
+
it("fails closed when confidential-client provisioning is absent", async () => {
|
|
79
|
+
const db = Object.assign(Object.create(null), {
|
|
80
|
+
select: () => ({
|
|
81
|
+
from: (table) => ({
|
|
82
|
+
where: () => ({ limit: async () => (table === appCredentials ? [] : []) }),
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const service = createAppOAuthService({
|
|
87
|
+
accessCatalog: { resources: [], presets: [] },
|
|
88
|
+
deploymentId: "dep_1",
|
|
89
|
+
clientAuthentication: "required",
|
|
90
|
+
});
|
|
91
|
+
await expect(service.token(db, {
|
|
92
|
+
grantType: "refresh_token",
|
|
93
|
+
refreshToken: "app_refresh_fixture",
|
|
94
|
+
clientId: "app_1",
|
|
95
|
+
})).rejects.toMatchObject({ status: 401, code: "invalid_client" });
|
|
96
|
+
});
|
|
97
|
+
});
|
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,21 @@
|
|
|
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
6
|
import { type AppsServiceOptions } from "./service.js";
|
|
7
7
|
type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
|
|
8
8
|
type Env = {
|
|
9
|
-
Bindings: {
|
|
10
|
-
/** Deployment identity; the install lifecycle is scoped to it. */
|
|
11
|
-
VOYANT_CLOUD_DEPLOYMENT_ID?: string;
|
|
12
|
-
};
|
|
13
9
|
Variables: {
|
|
14
10
|
db: PostgresJsDatabase;
|
|
11
|
+
scopes?: string[];
|
|
15
12
|
};
|
|
16
13
|
};
|
|
17
14
|
export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
18
15
|
oauth?: {
|
|
19
16
|
accessCatalog: AccessCatalog;
|
|
20
17
|
deploymentId: string;
|
|
18
|
+
clientAuthentication?: "optional" | "required";
|
|
21
19
|
};
|
|
22
20
|
/**
|
|
23
21
|
* Enables the iframe session-token broker. Requires {@link oauth} for the
|
|
@@ -39,4 +37,6 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
|
39
37
|
customFields?: CustomFieldsService;
|
|
40
38
|
}
|
|
41
39
|
export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): OpenAPIHono<Env, {}, "/">;
|
|
40
|
+
export declare function resolveViewerRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog | undefined): string[];
|
|
41
|
+
export declare function resolveOperatorGrantableRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog): string[];
|
|
42
42
|
export {};
|
package/dist/routes.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import { openApiValidationHook, parseJsonBody, parseQuery, RequestValidationError, requireUserId, } from "@voyant-travel/hono";
|
|
3
|
+
import { hasApiKeyPermission, permissionStringsToPermissions, } from "@voyant-travel/types/api-keys";
|
|
3
4
|
import { z } from "zod";
|
|
5
|
+
import { grantableRemoteAppScopes } from "./consent.js";
|
|
4
6
|
import { activateInstallationBodySchema, appCredentialRevocationSchema, appInstallationAuditQuerySchema, appInstallationListQuerySchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, installAppSchema, lifecycleActionBodySchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
5
7
|
import { listAppReleases, listInstallationAudit, listInstallationSummaries, loadInstallationDetail, } from "./installation-read-model.js";
|
|
6
8
|
import { createAppInstallationService } from "./installation-service.js";
|
|
@@ -21,6 +23,7 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
21
23
|
customFields: options.customFields,
|
|
22
24
|
});
|
|
23
25
|
const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
|
|
26
|
+
const oauthAccessCatalog = options.oauth?.accessCatalog;
|
|
24
27
|
const sessionTokens = oauth && options.oauth && options.sessionToken
|
|
25
28
|
? createAppSessionTokenService({
|
|
26
29
|
secret: options.sessionToken.secret,
|
|
@@ -42,8 +45,9 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
42
45
|
// so it must never be reachable through read-scoped GET requests. The admin
|
|
43
46
|
// consent UI submits the approval and performs the redirect itself.
|
|
44
47
|
routes.openapi(authorizeAppOAuthRoute, async (c) => {
|
|
45
|
-
if (!oauth)
|
|
48
|
+
if (!oauth || !oauthAccessCatalog) {
|
|
46
49
|
return c.json({ error: "App OAuth is not configured" }, 501);
|
|
50
|
+
}
|
|
47
51
|
const body = await parseJsonBody(c, appOAuthAuthorizeQuerySchema);
|
|
48
52
|
const result = await oauth.authorize(c.get("db"), {
|
|
49
53
|
appId: body.client_id,
|
|
@@ -52,8 +56,8 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
52
56
|
state: body.state,
|
|
53
57
|
codeChallenge: body.code_challenge,
|
|
54
58
|
codeChallengeMethod: body.code_challenge_method,
|
|
55
|
-
actorId:
|
|
56
|
-
operatorGrantedScopes:
|
|
59
|
+
actorId: requireUserId(c),
|
|
60
|
+
operatorGrantedScopes: resolveOperatorGrantableRemoteAppScopes(c.get("scopes") ?? [], oauthAccessCatalog),
|
|
57
61
|
grantedOptionalScopes: splitScopes(body.optional_scopes),
|
|
58
62
|
});
|
|
59
63
|
const redirectUrl = new URL(result.redirectUri);
|
|
@@ -74,22 +78,12 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
74
78
|
clientId: body.client_id,
|
|
75
79
|
clientSecret: body.client_secret,
|
|
76
80
|
})
|
|
77
|
-
:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
84
|
-
: await oauth.token(c.get("db"), {
|
|
85
|
-
grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
|
|
86
|
-
installationId: body.installation_id,
|
|
87
|
-
viewerId: body.viewer_id,
|
|
88
|
-
viewerScopes: body.viewer_scopes,
|
|
89
|
-
contextualScopes: body.contextual_scopes,
|
|
90
|
-
clientId: body.client_id,
|
|
91
|
-
clientSecret: body.client_secret,
|
|
92
|
-
});
|
|
81
|
+
: await oauth.token(c.get("db"), {
|
|
82
|
+
grantType: "refresh_token",
|
|
83
|
+
refreshToken: body.refresh_token,
|
|
84
|
+
clientId: body.client_id,
|
|
85
|
+
clientSecret: body.client_secret,
|
|
86
|
+
});
|
|
93
87
|
return c.json(token, 200);
|
|
94
88
|
});
|
|
95
89
|
routes.openapi(revokeAppInstallationCredentialsRoute, async (c) => {
|
|
@@ -111,6 +105,7 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
111
105
|
const issued = await sessionTokens.issue(c.get("db"), {
|
|
112
106
|
installationId,
|
|
113
107
|
viewerId,
|
|
108
|
+
viewerScopes: resolveViewerRemoteAppScopes(c.get("scopes") ?? [], options.oauth?.accessCatalog),
|
|
114
109
|
entity: body.entity ?? null,
|
|
115
110
|
slot: body.slot ?? null,
|
|
116
111
|
});
|
|
@@ -133,11 +128,10 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
133
128
|
});
|
|
134
129
|
routes.openapi(installAppRoute, async (c) => {
|
|
135
130
|
const body = await parseJsonBody(c, installAppSchema);
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
const deploymentId = body.deploymentId ?? c.env?.VOYANT_CLOUD_DEPLOYMENT_ID?.trim() ?? options.deploymentId;
|
|
131
|
+
// A configured managed runtime audience is authoritative so installation
|
|
132
|
+
// identity cannot diverge from later OAuth token audiences. Direct/custom
|
|
133
|
+
// hosts without managed auth may still supply an explicit deployment ID.
|
|
134
|
+
const deploymentId = options.oauth?.deploymentId ?? body.deploymentId ?? options.deploymentId;
|
|
141
135
|
const result = await installations.install(c.get("db"), {
|
|
142
136
|
appId: body.appId,
|
|
143
137
|
releaseId: body.releaseId,
|
|
@@ -248,6 +242,29 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
248
242
|
});
|
|
249
243
|
return routes;
|
|
250
244
|
}
|
|
245
|
+
export function resolveViewerRemoteAppScopes(scopes, catalog) {
|
|
246
|
+
if (!catalog)
|
|
247
|
+
return [];
|
|
248
|
+
const permissions = permissionStringsToPermissions(scopes);
|
|
249
|
+
return catalog.resources
|
|
250
|
+
.flatMap((resource) => resource.actions
|
|
251
|
+
.filter((action) => (resource.remoteSafe || action.remoteSafe) &&
|
|
252
|
+
hasApiKeyPermission(permissions, resource.resource, action.action, catalog))
|
|
253
|
+
.map((action) => `${resource.resource}:${action.action}`))
|
|
254
|
+
.sort();
|
|
255
|
+
}
|
|
256
|
+
export function resolveOperatorGrantableRemoteAppScopes(scopes, catalog) {
|
|
257
|
+
const grantable = grantableRemoteAppScopes(catalog);
|
|
258
|
+
const permissions = permissionStringsToPermissions(scopes);
|
|
259
|
+
return [...grantable]
|
|
260
|
+
.filter((scope) => {
|
|
261
|
+
const separator = scope.lastIndexOf(":");
|
|
262
|
+
if (separator <= 0)
|
|
263
|
+
return false;
|
|
264
|
+
return hasApiKeyPermission(permissions, scope.slice(0, separator), scope.slice(separator + 1), catalog);
|
|
265
|
+
})
|
|
266
|
+
.sort();
|
|
267
|
+
}
|
|
251
268
|
function splitScopes(value) {
|
|
252
269
|
return value
|
|
253
270
|
.split(/[,\s]+/)
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import type { VoyantRuntimeHostPrimitives } from "@voyant-travel/core";
|
|
2
|
+
export interface AppsRuntimeContributorHost {
|
|
3
|
+
primitives: Pick<VoyantRuntimeHostPrimitives, "config">;
|
|
4
|
+
hasRuntimePort?(port: {
|
|
5
|
+
id: string;
|
|
6
|
+
}): boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Package-owned, provider-neutral managed-auth configuration. */
|
|
9
|
+
export declare function createAppsRuntimePortContribution(host: AppsRuntimeContributorHost): Readonly<Record<string, unknown>>;
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { appsManagedAuthRuntimePort } from "./runtime-port.js";
|
|
2
|
+
function readString(host, key) {
|
|
3
|
+
const value = host.primitives.config.read(undefined, key);
|
|
4
|
+
if (typeof value !== "string")
|
|
5
|
+
return undefined;
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
return trimmed || undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Package-owned, provider-neutral managed-auth configuration. */
|
|
10
|
+
export function createAppsRuntimePortContribution(host) {
|
|
11
|
+
if (host.hasRuntimePort?.(appsManagedAuthRuntimePort))
|
|
12
|
+
return {};
|
|
13
|
+
const runtimeAudience = readString(host, "VOYANT_APP_RUNTIME_AUDIENCE");
|
|
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 };
|
|
3
26
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppsRuntimePortContribution } from "./runtime-contributor.js";
|
|
3
|
+
import { appsManagedAuthRuntimePort } from "./runtime-port.js";
|
|
4
|
+
function host(values) {
|
|
5
|
+
return {
|
|
6
|
+
hasRuntimePort: () => false,
|
|
7
|
+
primitives: {
|
|
8
|
+
config: { read: (_bindings, key) => values[key] },
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe("createAppsRuntimePortContribution", () => {
|
|
13
|
+
it("stays off unless both managed-auth inputs are present", () => {
|
|
14
|
+
expect(createAppsRuntimePortContribution(host({}))).toEqual({});
|
|
15
|
+
expect(createAppsRuntimePortContribution(host({ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1" }))).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
it("contributes validated provider-neutral managed-auth configuration", () => {
|
|
18
|
+
const contribution = createAppsRuntimePortContribution(host({
|
|
19
|
+
VOYANT_APP_RUNTIME_AUDIENCE: " deployment-1 ",
|
|
20
|
+
VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
|
|
21
|
+
VOYANT_APP_SESSION_TOKEN_TTL_SECONDS: "180",
|
|
22
|
+
}));
|
|
23
|
+
expect(contribution).toEqual({
|
|
24
|
+
[appsManagedAuthRuntimePort.id]: {
|
|
25
|
+
runtimeAudience: "deployment-1",
|
|
26
|
+
sessionTokenSigningSecret: "s".repeat(32),
|
|
27
|
+
sessionTokenTtlSeconds: 180,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
it("does not replace an explicitly host-provided managed-auth port", () => {
|
|
32
|
+
const contribution = createAppsRuntimePortContribution({
|
|
33
|
+
...host({
|
|
34
|
+
VOYANT_APP_RUNTIME_AUDIENCE: "deployment-from-env",
|
|
35
|
+
VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
|
|
36
|
+
}),
|
|
37
|
+
hasRuntimePort: (port) => port.id === appsManagedAuthRuntimePort.id,
|
|
38
|
+
});
|
|
39
|
+
expect(contribution).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
it("rejects weak signing material and long-lived session tokens", () => {
|
|
42
|
+
expect(() => createAppsRuntimePortContribution(host({
|
|
43
|
+
VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
|
|
44
|
+
VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "test-secret",
|
|
45
|
+
}))).toThrow(/at least 32 characters/);
|
|
46
|
+
expect(() => createAppsRuntimePortContribution(host({
|
|
47
|
+
VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
|
|
48
|
+
VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
|
|
49
|
+
VOYANT_APP_SESSION_TOKEN_TTL_SECONDS: "301",
|
|
50
|
+
}))).toThrow(/1 through 300/);
|
|
51
|
+
});
|
|
52
|
+
});
|