@voyant-travel/apps 0.7.0 → 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 +38 -1
- package/dist/api-runtime.test.d.ts +1 -0
- package/dist/api-runtime.test.js +54 -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 +31 -1
- package/dist/app-api-service.js +162 -0
- package/dist/consent.d.ts +1 -0
- package/dist/consent.js +2 -2
- package/dist/contracts.d.ts +1 -25
- 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 +95 -1
- package/package.json +8 -3
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
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface AppsManagedAuthRuntime {
|
|
2
|
+
/** Stable audience shared by authorization, installations, and session tokens. */
|
|
3
|
+
runtimeAudience: string;
|
|
4
|
+
/** Host-owned HMAC material for short-lived extension session tokens. */
|
|
5
|
+
sessionTokenSigningSecret: string;
|
|
6
|
+
sessionTokenTtlSeconds?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const appsManagedAuthRuntimePort: import("@voyant-travel/core/project").VoyantPort<AppsManagedAuthRuntime>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { definePort } from "@voyant-travel/core/project";
|
|
2
|
+
export const appsManagedAuthRuntimePort = definePort({
|
|
3
|
+
id: "apps.managed-auth",
|
|
4
|
+
test(runtime) {
|
|
5
|
+
if (!runtime || typeof runtime !== "object") {
|
|
6
|
+
throw new TypeError("apps.managed-auth must be an object.");
|
|
7
|
+
}
|
|
8
|
+
if (!runtime.runtimeAudience?.trim()) {
|
|
9
|
+
throw new TypeError("apps.managed-auth runtimeAudience must be a non-empty string.");
|
|
10
|
+
}
|
|
11
|
+
if (typeof runtime.sessionTokenSigningSecret !== "string" ||
|
|
12
|
+
runtime.sessionTokenSigningSecret.length < 32) {
|
|
13
|
+
throw new TypeError("apps.managed-auth sessionTokenSigningSecret must contain at least 32 characters.");
|
|
14
|
+
}
|
|
15
|
+
if (runtime.sessionTokenTtlSeconds !== undefined &&
|
|
16
|
+
(!Number.isInteger(runtime.sessionTokenTtlSeconds) ||
|
|
17
|
+
runtime.sessionTokenTtlSeconds <= 0 ||
|
|
18
|
+
runtime.sessionTokenTtlSeconds > 300)) {
|
|
19
|
+
throw new TypeError("apps.managed-auth sessionTokenTtlSeconds must be an integer from 1 through 300 when provided.");
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -15,6 +15,8 @@ export interface AppSessionTokenServiceOptions {
|
|
|
15
15
|
export interface IssueAppSessionTokenInput {
|
|
16
16
|
installationId: string;
|
|
17
17
|
viewerId: string;
|
|
18
|
+
/** Current host-authenticated viewer permissions. */
|
|
19
|
+
viewerScopes?: readonly string[];
|
|
18
20
|
entity?: AppSessionTokenEntity | null;
|
|
19
21
|
slot?: string | null;
|
|
20
22
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Both paths write `app_audit_events` so issuance and exchange are auditable.
|
|
14
14
|
*/
|
|
15
15
|
import { ApiHttpError } from "@voyant-travel/hono";
|
|
16
|
-
import { and, eq, isNull } from "drizzle-orm";
|
|
16
|
+
import { and, eq, gt, isNull } from "drizzle-orm";
|
|
17
17
|
import { appAuditEvents, appInstallations, appSessionTokens, } from "./schema.js";
|
|
18
18
|
import { signAppSessionToken, verifyAppSessionToken, } from "./session-token.js";
|
|
19
19
|
export function createAppSessionTokenService(options) {
|
|
@@ -25,6 +25,7 @@ export function createAppSessionTokenService(options) {
|
|
|
25
25
|
installationId: installation.id,
|
|
26
26
|
deploymentId: installation.deploymentId,
|
|
27
27
|
viewerId: input.viewerId,
|
|
28
|
+
viewerScopes: input.viewerScopes ?? [],
|
|
28
29
|
entity: input.entity ?? null,
|
|
29
30
|
slot: input.slot ?? null,
|
|
30
31
|
};
|
|
@@ -63,37 +64,53 @@ export function createAppSessionTokenService(options) {
|
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
const { claims } = verified;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
return db.transaction(async (tx) => {
|
|
68
|
+
const installation = await requireActiveInstallation(tx, claims.installationId);
|
|
69
|
+
if (installation.appId !== claims.aud || installation.deploymentId !== claims.deploymentId) {
|
|
70
|
+
throw new ApiHttpError("Session token installation context changed", {
|
|
71
|
+
status: 401,
|
|
72
|
+
code: "app_session_token_installation_mismatch",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Client authentication and token construction happen before consumption,
|
|
76
|
+
// but in the same transaction. If authentication/minting fails, or another
|
|
77
|
+
// exchange wins the JTI race, every credential side effect rolls back.
|
|
78
|
+
const tokens = await options.oauth.token(tx, {
|
|
79
|
+
grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
|
|
80
|
+
installationId: installation.id,
|
|
81
|
+
viewerId: claims.sub,
|
|
82
|
+
viewerScopes: intersectRequestedScopes(claims.viewerScopes, input.viewerScopes),
|
|
83
|
+
contextualScopes: input.contextualScopes,
|
|
84
|
+
contextConstraint: { entity: claims.entity, slot: claims.slot },
|
|
85
|
+
clientId: input.clientId,
|
|
86
|
+
clientSecret: input.clientSecret,
|
|
77
87
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
const consumed = await tx
|
|
89
|
+
.update(appSessionTokens)
|
|
90
|
+
.set({ consumedAt: now(), consumedByActorId: claims.sub })
|
|
91
|
+
.where(and(eq(appSessionTokens.jti, claims.jti), eq(appSessionTokens.installationId, installation.id), eq(appSessionTokens.appId, claims.aud), eq(appSessionTokens.deploymentId, claims.deploymentId), eq(appSessionTokens.viewerId, claims.sub), isNull(appSessionTokens.consumedAt), gt(appSessionTokens.expiresAt, now())))
|
|
92
|
+
.returning({ id: appSessionTokens.id });
|
|
93
|
+
if (consumed.length === 0) {
|
|
94
|
+
throw new ApiHttpError("Session token has already been used", {
|
|
95
|
+
status: 401,
|
|
96
|
+
code: "app_session_token_replayed",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
await audit(tx, installation, claims.sub, "session-token.exchanged", {
|
|
100
|
+
jti: claims.jti,
|
|
101
|
+
slot: claims.slot,
|
|
102
|
+
entityType: claims.entity?.type ?? null,
|
|
103
|
+
entityId: claims.entity?.id ?? null,
|
|
104
|
+
});
|
|
105
|
+
return tokens;
|
|
92
106
|
});
|
|
93
|
-
return tokens;
|
|
94
107
|
}
|
|
95
108
|
return { issue, exchange };
|
|
96
109
|
}
|
|
110
|
+
function intersectRequestedScopes(trustedViewerScopes, requestedScopes) {
|
|
111
|
+
const trusted = new Set(trustedViewerScopes);
|
|
112
|
+
return Array.from(new Set(requestedScopes.filter((scope) => trusted.has(scope)))).sort();
|
|
113
|
+
}
|
|
97
114
|
async function requireActiveInstallation(db, installationId) {
|
|
98
115
|
const [installation] = await db
|
|
99
116
|
.select()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|