@voyant-travel/apps 0.2.0 → 0.3.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 +29 -0
- package/dist/access-boundary.js +33 -0
- package/dist/consent.d.ts +15 -0
- package/dist/consent.js +50 -0
- package/dist/consent.test.d.ts +1 -0
- package/dist/consent.test.js +80 -0
- package/dist/contracts.d.ts +79 -0
- package/dist/contracts.js +50 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/installation-reconciliation.d.ts +1 -1
- package/dist/installation-reconciliation.js +5 -1
- package/dist/installation-service.d.ts +12 -1
- package/dist/installation-service.js +22 -7
- package/dist/oauth-crypto.d.ts +8 -0
- package/dist/oauth-crypto.js +23 -0
- package/dist/oauth-crypto.test.d.ts +1 -0
- package/dist/oauth-crypto.test.js +11 -0
- package/dist/oauth-service.d.ts +66 -0
- package/dist/oauth-service.js +398 -0
- package/dist/oauth-service.test.d.ts +1 -0
- package/dist/oauth-service.test.js +60 -0
- package/dist/routes.d.ts +8 -1
- package/dist/routes.js +70 -1
- package/dist/schema.d.ts +536 -4
- package/dist/schema.js +52 -1
- package/dist/service.d.ts +16 -16
- package/migrations/20260717143000_app_oauth_authorization.sql +47 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +24 -3
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { VoyantAuthContext } from "@voyant-travel/core";
|
|
2
|
+
import type { AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
3
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
|
+
export interface AppOAuthServiceOptions {
|
|
5
|
+
accessCatalog: AccessCatalog;
|
|
6
|
+
deploymentId: string;
|
|
7
|
+
now?: () => Date;
|
|
8
|
+
}
|
|
9
|
+
export interface AuthorizeAppInput {
|
|
10
|
+
appId: string;
|
|
11
|
+
releaseId: string;
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
state: string;
|
|
14
|
+
codeChallenge: string;
|
|
15
|
+
codeChallengeMethod: "S256";
|
|
16
|
+
actorId: string;
|
|
17
|
+
operatorGrantedScopes: readonly string[];
|
|
18
|
+
grantedOptionalScopes?: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
export interface TokenCodeInput {
|
|
21
|
+
grantType: "authorization_code";
|
|
22
|
+
code: string;
|
|
23
|
+
redirectUri: string;
|
|
24
|
+
codeVerifier: string;
|
|
25
|
+
clientId: string;
|
|
26
|
+
clientSecret?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface RefreshTokenInput {
|
|
29
|
+
grantType: "refresh_token";
|
|
30
|
+
refreshToken: string;
|
|
31
|
+
clientId: string;
|
|
32
|
+
clientSecret?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface TokenExchangeInput {
|
|
35
|
+
grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
|
|
36
|
+
installationId: string;
|
|
37
|
+
viewerId: string;
|
|
38
|
+
viewerScopes: readonly string[];
|
|
39
|
+
contextualScopes?: readonly string[];
|
|
40
|
+
clientId: string;
|
|
41
|
+
clientSecret?: string;
|
|
42
|
+
}
|
|
43
|
+
export type AppTokenInput = TokenCodeInput | RefreshTokenInput | TokenExchangeInput;
|
|
44
|
+
export declare function createAppOAuthService(options: AppOAuthServiceOptions): {
|
|
45
|
+
authorize: (db: PostgresJsDatabase, input: AuthorizeAppInput) => Promise<{
|
|
46
|
+
code: string;
|
|
47
|
+
state: string;
|
|
48
|
+
redirectUri: string;
|
|
49
|
+
expiresAt: Date;
|
|
50
|
+
}>;
|
|
51
|
+
token: (db: PostgresJsDatabase, input: AppTokenInput) => Promise<{
|
|
52
|
+
refreshToken?: string | undefined;
|
|
53
|
+
accessToken: string;
|
|
54
|
+
tokenType: "Bearer";
|
|
55
|
+
expiresIn: number;
|
|
56
|
+
scope: string;
|
|
57
|
+
tokenMode: "offline" | "online";
|
|
58
|
+
}>;
|
|
59
|
+
resolveAccessToken: (db: PostgresJsDatabase, rawToken: string) => Promise<VoyantAuthContext | null>;
|
|
60
|
+
revokeInstallationCredentials: (db: PostgresJsDatabase, installationId: string, actorId: string) => Promise<{
|
|
61
|
+
installationId: string;
|
|
62
|
+
generation: number;
|
|
63
|
+
}>;
|
|
64
|
+
};
|
|
65
|
+
export declare function intersectAppTokenScopes(...sets: readonly (readonly string[])[]): string[];
|
|
66
|
+
export declare function readStoredScopes(metadata: Record<string, unknown>): string[] | null;
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { ApiHttpError } from "@voyant-travel/hono";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { computeAppConsent } from "./consent.js";
|
|
4
|
+
import { APP_ACCESS_TOKEN_PREFIX, APP_AUTH_CODE_PREFIX, APP_REFRESH_TOKEN_PREFIX, constantTimeEqual, randomToken, sha256Hex, verifyPkceS256, } from "./oauth-crypto.js";
|
|
5
|
+
import { appAccessCredentials, appAuditEvents, appCredentials, appGrants, appInstallations, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleases, apps, } from "./schema.js";
|
|
6
|
+
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
7
|
+
const OFFLINE_ACCESS_TTL_MS = 60 * 60 * 1000;
|
|
8
|
+
const ONLINE_ACCESS_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
10
|
+
export function createAppOAuthService(options) {
|
|
11
|
+
const now = () => options.now?.() ?? new Date();
|
|
12
|
+
async function authorize(db, input) {
|
|
13
|
+
assertState(input.state);
|
|
14
|
+
if (input.codeChallengeMethod !== "S256") {
|
|
15
|
+
throw oauthError("invalid_request", "PKCE S256 is required");
|
|
16
|
+
}
|
|
17
|
+
const release = await requireRelease(db, input.appId, input.releaseId);
|
|
18
|
+
await requireExactRedirectUri(db, input.appId, input.redirectUri);
|
|
19
|
+
const consent = computeAppConsent({
|
|
20
|
+
release,
|
|
21
|
+
accessCatalog: options.accessCatalog,
|
|
22
|
+
operatorGrantedScopes: input.operatorGrantedScopes,
|
|
23
|
+
grantedOptionalScopes: input.grantedOptionalScopes,
|
|
24
|
+
});
|
|
25
|
+
const installation = await ensureAuthorizedInstallation(db, {
|
|
26
|
+
appId: input.appId,
|
|
27
|
+
releaseId: input.releaseId,
|
|
28
|
+
deploymentId: options.deploymentId,
|
|
29
|
+
actorId: input.actorId,
|
|
30
|
+
grantedScopes: consent.grantedScopes,
|
|
31
|
+
deniedOptionalScopes: consent.deniedOptionalScopes,
|
|
32
|
+
});
|
|
33
|
+
const code = randomToken(APP_AUTH_CODE_PREFIX);
|
|
34
|
+
const expiresAt = new Date(now().getTime() + CODE_TTL_MS);
|
|
35
|
+
await db.insert(appOAuthAuthorizationCodes).values({
|
|
36
|
+
appId: input.appId,
|
|
37
|
+
installationId: installation.id,
|
|
38
|
+
releaseId: input.releaseId,
|
|
39
|
+
deploymentId: options.deploymentId,
|
|
40
|
+
codeHash: sha256Hex(code),
|
|
41
|
+
stateHash: sha256Hex(input.state),
|
|
42
|
+
redirectUri: input.redirectUri,
|
|
43
|
+
codeChallenge: input.codeChallenge,
|
|
44
|
+
codeChallengeMethod: input.codeChallengeMethod,
|
|
45
|
+
requestedScopes: [...consent.requiredScopes, ...consent.optionalScopes].sort(),
|
|
46
|
+
grantedScopes: consent.grantedScopes,
|
|
47
|
+
deniedOptionalScopes: consent.deniedOptionalScopes,
|
|
48
|
+
actorId: input.actorId,
|
|
49
|
+
expiresAt,
|
|
50
|
+
});
|
|
51
|
+
await audit(db, installation, input.actorId, "consent", "oauth.consent.recorded", {
|
|
52
|
+
grantedScopes: consent.grantedScopes,
|
|
53
|
+
deniedOptionalScopes: consent.deniedOptionalScopes,
|
|
54
|
+
});
|
|
55
|
+
return { code, state: input.state, redirectUri: input.redirectUri, expiresAt };
|
|
56
|
+
}
|
|
57
|
+
async function token(db, input) {
|
|
58
|
+
await authenticateClient(db, input.clientId, input.clientSecret);
|
|
59
|
+
if (input.grantType === "authorization_code")
|
|
60
|
+
return exchangeCode(db, input);
|
|
61
|
+
if (input.grantType === "refresh_token")
|
|
62
|
+
return refresh(db, input);
|
|
63
|
+
return exchangeActorToken(db, input);
|
|
64
|
+
}
|
|
65
|
+
async function resolveAccessToken(db, rawToken) {
|
|
66
|
+
if (!rawToken.startsWith(APP_ACCESS_TOKEN_PREFIX))
|
|
67
|
+
return null;
|
|
68
|
+
const [credential] = await db
|
|
69
|
+
.select()
|
|
70
|
+
.from(appAccessCredentials)
|
|
71
|
+
.where(and(eq(appAccessCredentials.credentialHash, sha256Hex(rawToken)), eq(appAccessCredentials.status, "active")))
|
|
72
|
+
.limit(1);
|
|
73
|
+
if (!credential || (credential.expiresAt && credential.expiresAt <= now()))
|
|
74
|
+
return null;
|
|
75
|
+
const installation = await selectInstallation(db, credential.installationId);
|
|
76
|
+
if (!installation || !isInstallationUsable(installation, credential.generation))
|
|
77
|
+
return null;
|
|
78
|
+
// Online tokens resolve to the scope set minted at exchange (viewer/context
|
|
79
|
+
// intersection); recomputing from grants would silently widen them. Offline
|
|
80
|
+
// tokens track live grants so revoking a scope applies immediately. A
|
|
81
|
+
// missing stored set on an online token fails closed to no scopes.
|
|
82
|
+
const scopes = credential.tokenMode === "online"
|
|
83
|
+
? (readStoredScopes(credential.encryptedMetadata) ?? [])
|
|
84
|
+
: await grantedScopes(db, installation.id);
|
|
85
|
+
return {
|
|
86
|
+
callerType: "app",
|
|
87
|
+
actor: "staff",
|
|
88
|
+
audience: "staff",
|
|
89
|
+
appId: installation.appId,
|
|
90
|
+
appInstallationId: installation.id,
|
|
91
|
+
appReleaseId: installation.releaseId,
|
|
92
|
+
appCredentialGeneration: credential.generation,
|
|
93
|
+
appTokenMode: credential.tokenMode,
|
|
94
|
+
appViewerId: credential.viewerId ?? undefined,
|
|
95
|
+
scopes,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function revokeInstallationCredentials(db, installationId, actorId) {
|
|
99
|
+
return db.transaction(async (tx) => {
|
|
100
|
+
const installation = await requireInstallation(tx, installationId);
|
|
101
|
+
const generation = installation.credentialGeneration + 1;
|
|
102
|
+
await tx
|
|
103
|
+
.update(appInstallations)
|
|
104
|
+
.set({ credentialGeneration: generation, updatedAt: now() })
|
|
105
|
+
.where(eq(appInstallations.id, installation.id));
|
|
106
|
+
await tx
|
|
107
|
+
.update(appAccessCredentials)
|
|
108
|
+
.set({ status: "revoked", deactivatedAt: now() })
|
|
109
|
+
.where(eq(appAccessCredentials.installationId, installation.id));
|
|
110
|
+
await tx
|
|
111
|
+
.update(appOAuthRefreshTokens)
|
|
112
|
+
.set({ status: "revoked", revokedAt: now() })
|
|
113
|
+
.where(eq(appOAuthRefreshTokens.installationId, installation.id));
|
|
114
|
+
await audit(tx, installation, actorId, "credential", "credential.revoked", { generation });
|
|
115
|
+
return { installationId: installation.id, generation };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { authorize, token, resolveAccessToken, revokeInstallationCredentials };
|
|
119
|
+
async function exchangeCode(db, input) {
|
|
120
|
+
return db.transaction(async (tx) => {
|
|
121
|
+
const codeHash = sha256Hex(input.code);
|
|
122
|
+
const [code] = await tx
|
|
123
|
+
.select()
|
|
124
|
+
.from(appOAuthAuthorizationCodes)
|
|
125
|
+
.where(eq(appOAuthAuthorizationCodes.codeHash, codeHash))
|
|
126
|
+
.for("update")
|
|
127
|
+
.limit(1);
|
|
128
|
+
if (!code || code.consumedAt || code.expiresAt <= now()) {
|
|
129
|
+
throw oauthError("invalid_grant", "Authorization code is invalid, expired, or consumed");
|
|
130
|
+
}
|
|
131
|
+
if (code.appId !== input.clientId || code.redirectUri !== input.redirectUri) {
|
|
132
|
+
throw oauthError("invalid_grant", "Authorization code was issued to different client data");
|
|
133
|
+
}
|
|
134
|
+
if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
|
|
135
|
+
throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
|
|
136
|
+
}
|
|
137
|
+
await tx
|
|
138
|
+
.update(appOAuthAuthorizationCodes)
|
|
139
|
+
.set({ consumedAt: now() })
|
|
140
|
+
.where(eq(appOAuthAuthorizationCodes.id, code.id));
|
|
141
|
+
const installation = await requireInstallation(tx, code.installationId);
|
|
142
|
+
assertActiveInstallation(installation);
|
|
143
|
+
const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes);
|
|
144
|
+
await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
|
|
145
|
+
return tokens;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async function refresh(db, input) {
|
|
149
|
+
return db.transaction(async (tx) => {
|
|
150
|
+
const [tokenRow] = await tx
|
|
151
|
+
.select()
|
|
152
|
+
.from(appOAuthRefreshTokens)
|
|
153
|
+
.where(eq(appOAuthRefreshTokens.tokenHash, sha256Hex(input.refreshToken)))
|
|
154
|
+
.for("update")
|
|
155
|
+
.limit(1);
|
|
156
|
+
if (tokenRow?.status !== "active" || isExpired(tokenRow.expiresAt)) {
|
|
157
|
+
throw oauthError("invalid_grant", "Refresh token is invalid");
|
|
158
|
+
}
|
|
159
|
+
const installation = await requireInstallation(tx, tokenRow.installationId);
|
|
160
|
+
assertTokenClient(installation, input.clientId);
|
|
161
|
+
assertActiveInstallation(installation);
|
|
162
|
+
if (installation.credentialGeneration !== tokenRow.generation) {
|
|
163
|
+
throw oauthError("invalid_grant", "Refresh token generation was revoked");
|
|
164
|
+
}
|
|
165
|
+
await tx
|
|
166
|
+
.update(appOAuthRefreshTokens)
|
|
167
|
+
.set({ status: "inactive", revokedAt: now() })
|
|
168
|
+
.where(eq(appOAuthRefreshTokens.id, tokenRow.id));
|
|
169
|
+
const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), {
|
|
170
|
+
rotatedFromId: tokenRow.id,
|
|
171
|
+
});
|
|
172
|
+
await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
|
|
173
|
+
generation: installation.credentialGeneration,
|
|
174
|
+
});
|
|
175
|
+
return tokens;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function exchangeActorToken(db, input) {
|
|
179
|
+
const installation = await requireInstallation(db, input.installationId);
|
|
180
|
+
assertTokenClient(installation, input.clientId);
|
|
181
|
+
assertActiveInstallation(installation);
|
|
182
|
+
const grants = await grantedScopes(db, installation.id);
|
|
183
|
+
const contextual = input.contextualScopes ?? grants;
|
|
184
|
+
const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
|
|
185
|
+
const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
|
|
186
|
+
const expiresAt = new Date(now().getTime() + ONLINE_ACCESS_TTL_MS);
|
|
187
|
+
await db.insert(appAccessCredentials).values({
|
|
188
|
+
installationId: installation.id,
|
|
189
|
+
generation: installation.credentialGeneration,
|
|
190
|
+
tokenMode: "online",
|
|
191
|
+
credentialHash: sha256Hex(accessToken),
|
|
192
|
+
// Online tokens are intentionally narrowed; the resolver must honor the
|
|
193
|
+
// minted set rather than recomputing from installation grants.
|
|
194
|
+
encryptedMetadata: { scopeCount: scopes.length, scopes: [...scopes] },
|
|
195
|
+
status: "active",
|
|
196
|
+
actorId: input.viewerId,
|
|
197
|
+
viewerId: input.viewerId,
|
|
198
|
+
expiresAt,
|
|
199
|
+
});
|
|
200
|
+
await audit(db, installation, input.viewerId, "token", "oauth.actor_token.exchanged", {
|
|
201
|
+
grantedScopes: scopes,
|
|
202
|
+
});
|
|
203
|
+
return tokenResponse(accessToken, null, expiresAt, scopes, "online");
|
|
204
|
+
}
|
|
205
|
+
async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
|
|
206
|
+
const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
|
|
207
|
+
const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
|
|
208
|
+
const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
|
|
209
|
+
const refreshExpiresAt = new Date(now().getTime() + REFRESH_TTL_MS);
|
|
210
|
+
const generation = installation.credentialGeneration;
|
|
211
|
+
await db.insert(appAccessCredentials).values({
|
|
212
|
+
installationId: installation.id,
|
|
213
|
+
generation,
|
|
214
|
+
tokenMode: "offline",
|
|
215
|
+
credentialHash: sha256Hex(accessToken),
|
|
216
|
+
encryptedMetadata: { scopeCount: scopes.length },
|
|
217
|
+
status: "active",
|
|
218
|
+
actorId,
|
|
219
|
+
viewerId,
|
|
220
|
+
expiresAt: accessExpiresAt,
|
|
221
|
+
});
|
|
222
|
+
await db.insert(appOAuthRefreshTokens).values({
|
|
223
|
+
installationId: installation.id,
|
|
224
|
+
tokenHash: sha256Hex(refreshToken),
|
|
225
|
+
generation,
|
|
226
|
+
rotatedFromId: refreshOptions.rotatedFromId,
|
|
227
|
+
expiresAt: refreshExpiresAt,
|
|
228
|
+
});
|
|
229
|
+
return tokenResponse(accessToken, refreshToken, accessExpiresAt, scopes, "offline");
|
|
230
|
+
}
|
|
231
|
+
function isExpired(expiresAt) {
|
|
232
|
+
return Boolean(expiresAt && expiresAt <= now());
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
|
|
236
|
+
return {
|
|
237
|
+
accessToken,
|
|
238
|
+
tokenType: "Bearer",
|
|
239
|
+
expiresIn: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
|
|
240
|
+
scope: scopes.join(" "),
|
|
241
|
+
tokenMode,
|
|
242
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function authenticateClient(db, appId, clientSecret) {
|
|
246
|
+
const [secret] = await db
|
|
247
|
+
.select()
|
|
248
|
+
.from(appCredentials)
|
|
249
|
+
.where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
|
|
250
|
+
.limit(1);
|
|
251
|
+
if (!secret)
|
|
252
|
+
return;
|
|
253
|
+
if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
|
|
254
|
+
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
255
|
+
}
|
|
256
|
+
const expected = secret.kmsKeyRef.slice("sha256:".length);
|
|
257
|
+
if (!constantTimeEqual(sha256Hex(clientSecret), expected)) {
|
|
258
|
+
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function requireRelease(db, appId, releaseId) {
|
|
262
|
+
const [release] = await db
|
|
263
|
+
.select()
|
|
264
|
+
.from(appReleases)
|
|
265
|
+
.where(and(eq(appReleases.id, releaseId), eq(appReleases.appId, appId)))
|
|
266
|
+
.limit(1);
|
|
267
|
+
if (!release)
|
|
268
|
+
throw oauthError("invalid_request", "App release not found", 404);
|
|
269
|
+
if (release.state !== "available") {
|
|
270
|
+
throw oauthError("invalid_request", "App release is not available for authorization", 409);
|
|
271
|
+
}
|
|
272
|
+
return release;
|
|
273
|
+
}
|
|
274
|
+
async function requireExactRedirectUri(db, appId, redirectUri) {
|
|
275
|
+
const [row] = await db
|
|
276
|
+
.select()
|
|
277
|
+
.from(appRedirectUris)
|
|
278
|
+
.where(and(eq(appRedirectUris.appId, appId), eq(appRedirectUris.redirectUri, redirectUri)))
|
|
279
|
+
.limit(1);
|
|
280
|
+
if (!row)
|
|
281
|
+
throw oauthError("invalid_request", "Redirect URI is not registered for this app");
|
|
282
|
+
}
|
|
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
|
+
export function intersectAppTokenScopes(...sets) {
|
|
372
|
+
const [first = [], ...rest] = sets;
|
|
373
|
+
return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
|
|
374
|
+
}
|
|
375
|
+
function assertState(state) {
|
|
376
|
+
if (!state.trim())
|
|
377
|
+
throw oauthError("invalid_request", "OAuth state is required");
|
|
378
|
+
}
|
|
379
|
+
export function readStoredScopes(metadata) {
|
|
380
|
+
const stored = metadata.scopes;
|
|
381
|
+
if (!Array.isArray(stored))
|
|
382
|
+
return null;
|
|
383
|
+
return stored.filter((scope) => typeof scope === "string");
|
|
384
|
+
}
|
|
385
|
+
function oauthError(error, description, status = 400) {
|
|
386
|
+
return new ApiHttpError(description, { status, code: error });
|
|
387
|
+
}
|
|
388
|
+
async function audit(db, installation, actorId, kind, action, details) {
|
|
389
|
+
await db.insert(appAuditEvents).values({
|
|
390
|
+
installationId: installation.id,
|
|
391
|
+
appId: installation.appId,
|
|
392
|
+
deploymentId: installation.deploymentId,
|
|
393
|
+
actorId,
|
|
394
|
+
kind,
|
|
395
|
+
action,
|
|
396
|
+
details,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppOAuthService, intersectAppTokenScopes, readStoredScopes, } from "./oauth-service.js";
|
|
3
|
+
import { appReleases } from "./schema.js";
|
|
4
|
+
describe("app OAuth online token scope intersection", () => {
|
|
5
|
+
it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
|
|
6
|
+
expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
|
|
7
|
+
expect(intersectAppTokenScopes(["bookings:read"], ["bookings:read", "invoices:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
describe("stored online token scopes", () => {
|
|
11
|
+
it("round-trips the minted scope set and ignores malformed metadata", () => {
|
|
12
|
+
expect(readStoredScopes({ scopeCount: 1, scopes: ["bookings:read"] })).toEqual([
|
|
13
|
+
"bookings:read",
|
|
14
|
+
]);
|
|
15
|
+
expect(readStoredScopes({ scopes: ["bookings:read", 7, null] })).toEqual(["bookings:read"]);
|
|
16
|
+
expect(readStoredScopes({ scopeCount: 2 })).toBeNull();
|
|
17
|
+
expect(readStoredScopes({ scopes: "bookings:read" })).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("authorization release lifecycle", () => {
|
|
21
|
+
function dbWithRelease(state) {
|
|
22
|
+
const release = {
|
|
23
|
+
id: "apprel_1",
|
|
24
|
+
appId: "app_1",
|
|
25
|
+
state,
|
|
26
|
+
releaseVersion: "1.0.0",
|
|
27
|
+
};
|
|
28
|
+
const db = Object.create(null);
|
|
29
|
+
Object.assign(db, {
|
|
30
|
+
select: () => ({
|
|
31
|
+
from: (table) => ({
|
|
32
|
+
where: () => ({
|
|
33
|
+
limit: async () => (table === appReleases ? [release] : []),
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
return db;
|
|
39
|
+
}
|
|
40
|
+
const authorizeInput = {
|
|
41
|
+
appId: "app_1",
|
|
42
|
+
releaseId: "apprel_1",
|
|
43
|
+
redirectUri: "https://app.example.com/callback",
|
|
44
|
+
state: "state-1",
|
|
45
|
+
codeChallenge: "c".repeat(43),
|
|
46
|
+
codeChallengeMethod: "S256",
|
|
47
|
+
actorId: "user_1",
|
|
48
|
+
operatorGrantedScopes: [],
|
|
49
|
+
grantedOptionalScopes: [],
|
|
50
|
+
};
|
|
51
|
+
it.each(["suspended", "yanked"])("rejects a %s release before any side effect", async (state) => {
|
|
52
|
+
const service = createAppOAuthService({
|
|
53
|
+
accessCatalog: { resources: [], presets: [] },
|
|
54
|
+
deploymentId: "dep_1",
|
|
55
|
+
});
|
|
56
|
+
await expect(service.authorize(dbWithRelease(state), authorizeInput)).rejects.toMatchObject({
|
|
57
|
+
status: 409,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
1
2
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
3
|
import { Hono } from "hono";
|
|
3
4
|
import { type AppsServiceOptions } from "./service.js";
|
|
@@ -6,5 +7,11 @@ type Env = {
|
|
|
6
7
|
db: PostgresJsDatabase;
|
|
7
8
|
};
|
|
8
9
|
};
|
|
9
|
-
export
|
|
10
|
+
export interface AppsAdminRouteOptions extends AppsServiceOptions {
|
|
11
|
+
oauth?: {
|
|
12
|
+
accessCatalog: AccessCatalog;
|
|
13
|
+
deploymentId: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
|
|
10
17
|
export {};
|
package/dist/routes.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { parseJsonBody, parseQuery, RequestValidationError } from "@voyant-travel/hono";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { appListQuerySchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
4
|
+
import { appCredentialRevocationSchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
|
|
5
|
+
import { createAppOAuthService } from "./oauth-service.js";
|
|
5
6
|
import { createAppsService } from "./service.js";
|
|
6
7
|
import { listAppWebhookHealth, replayAppWebhookDelivery } from "./webhook-delivery.js";
|
|
7
8
|
const appIdParamSchema = z.object({ appId: z.string().min(1) });
|
|
@@ -9,6 +10,7 @@ const installationIdParamSchema = z.object({ installationId: z.string().min(1) }
|
|
|
9
10
|
export function createAppsAdminRoutes(options = {}) {
|
|
10
11
|
const routes = new Hono();
|
|
11
12
|
const service = createAppsService(options);
|
|
13
|
+
const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
|
|
12
14
|
routes.get("/", async (c) => {
|
|
13
15
|
const query = parseQuery(c, appListQuerySchema);
|
|
14
16
|
return c.json(await service.list(c.get("db"), query), 200);
|
|
@@ -18,6 +20,67 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
18
20
|
const app = await service.createCustomApp(c.get("db"), body);
|
|
19
21
|
return c.json({ data: app }, 201);
|
|
20
22
|
});
|
|
23
|
+
// Consent approval mutates state (installation, grants, authorization code),
|
|
24
|
+
// so it must never be reachable through read-scoped GET requests. The admin
|
|
25
|
+
// consent UI submits the approval and performs the redirect itself.
|
|
26
|
+
routes.post("/oauth/authorize", async (c) => {
|
|
27
|
+
if (!oauth)
|
|
28
|
+
return c.json({ error: "App OAuth is not configured" }, 501);
|
|
29
|
+
const body = await parseJsonBody(c, appOAuthAuthorizeQuerySchema);
|
|
30
|
+
const result = await oauth.authorize(c.get("db"), {
|
|
31
|
+
appId: body.client_id,
|
|
32
|
+
releaseId: body.release_id,
|
|
33
|
+
redirectUri: body.redirect_uri,
|
|
34
|
+
state: body.state,
|
|
35
|
+
codeChallenge: body.code_challenge,
|
|
36
|
+
codeChallengeMethod: body.code_challenge_method,
|
|
37
|
+
actorId: body.actor_id,
|
|
38
|
+
operatorGrantedScopes: splitScopes(body.operator_scopes),
|
|
39
|
+
grantedOptionalScopes: splitScopes(body.optional_scopes),
|
|
40
|
+
});
|
|
41
|
+
const redirectUrl = new URL(result.redirectUri);
|
|
42
|
+
redirectUrl.searchParams.set("code", result.code);
|
|
43
|
+
redirectUrl.searchParams.set("state", result.state);
|
|
44
|
+
return c.json({ data: { redirectUrl: redirectUrl.toString(), state: result.state } }, 200);
|
|
45
|
+
});
|
|
46
|
+
routes.post("/oauth/token", async (c) => {
|
|
47
|
+
if (!oauth)
|
|
48
|
+
return c.json({ error: "App OAuth is not configured" }, 501);
|
|
49
|
+
const body = await parseJsonBody(c, appOAuthTokenSchema);
|
|
50
|
+
const token = body.grant_type === "authorization_code"
|
|
51
|
+
? await oauth.token(c.get("db"), {
|
|
52
|
+
grantType: "authorization_code",
|
|
53
|
+
code: body.code,
|
|
54
|
+
redirectUri: body.redirect_uri,
|
|
55
|
+
codeVerifier: body.code_verifier,
|
|
56
|
+
clientId: body.client_id,
|
|
57
|
+
clientSecret: body.client_secret,
|
|
58
|
+
})
|
|
59
|
+
: body.grant_type === "refresh_token"
|
|
60
|
+
? await oauth.token(c.get("db"), {
|
|
61
|
+
grantType: "refresh_token",
|
|
62
|
+
refreshToken: body.refresh_token,
|
|
63
|
+
clientId: body.client_id,
|
|
64
|
+
clientSecret: body.client_secret,
|
|
65
|
+
})
|
|
66
|
+
: await oauth.token(c.get("db"), {
|
|
67
|
+
grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
|
|
68
|
+
installationId: body.installation_id,
|
|
69
|
+
viewerId: body.viewer_id,
|
|
70
|
+
viewerScopes: body.viewer_scopes,
|
|
71
|
+
contextualScopes: body.contextual_scopes,
|
|
72
|
+
clientId: body.client_id,
|
|
73
|
+
clientSecret: body.client_secret,
|
|
74
|
+
});
|
|
75
|
+
return c.json(token, 200);
|
|
76
|
+
});
|
|
77
|
+
routes.post("/oauth/revoke-installation", async (c) => {
|
|
78
|
+
if (!oauth)
|
|
79
|
+
return c.json({ error: "App OAuth is not configured" }, 501);
|
|
80
|
+
const body = await parseJsonBody(c, appCredentialRevocationSchema);
|
|
81
|
+
const result = await oauth.revokeInstallationCredentials(c.get("db"), body.installationId, body.actorId);
|
|
82
|
+
return c.json(result, 200);
|
|
83
|
+
});
|
|
21
84
|
routes.get("/:appId", async (c) => {
|
|
22
85
|
const { appId } = parseParams(c.req.param());
|
|
23
86
|
const app = await service.get(c.get("db"), appId);
|
|
@@ -51,6 +114,12 @@ export function createAppsAdminRoutes(options = {}) {
|
|
|
51
114
|
});
|
|
52
115
|
return routes;
|
|
53
116
|
}
|
|
117
|
+
function splitScopes(value) {
|
|
118
|
+
return value
|
|
119
|
+
.split(/[,\s]+/)
|
|
120
|
+
.map((scope) => scope.trim())
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
}
|
|
54
123
|
function parseParams(input) {
|
|
55
124
|
const parsed = appIdParamSchema.safeParse(input);
|
|
56
125
|
if (!parsed.success)
|