@voyant-travel/apps 0.1.0 → 0.2.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/LICENSE +201 -0
- package/dist/contracts.d.ts +8 -87
- package/dist/contracts.js +0 -50
- package/dist/index.d.ts +1 -5
- package/dist/index.js +1 -5
- package/dist/installation-reconciliation.d.ts +1 -1
- package/dist/installation-reconciliation.js +1 -5
- package/dist/installation-service.d.ts +14 -25
- package/dist/installation-service.js +7 -22
- package/dist/routes.d.ts +1 -8
- package/dist/routes.js +1 -67
- package/dist/schema.d.ts +4 -536
- package/dist/schema.js +1 -52
- package/dist/service.d.ts +18 -18
- package/migrations/meta/_journal.json +0 -7
- package/package.json +78 -116
- package/dist/access-boundary.d.ts +0 -29
- package/dist/access-boundary.js +0 -33
- package/dist/app-api-contracts.d.ts +0 -51
- package/dist/app-api-contracts.js +0 -50
- package/dist/app-api-routes.d.ts +0 -26
- package/dist/app-api-routes.js +0 -133
- package/dist/app-api-service.d.ts +0 -1263
- package/dist/app-api-service.js +0 -231
- package/dist/app-api-service.test.d.ts +0 -1
- package/dist/app-api-service.test.js +0 -105
- package/dist/consent.d.ts +0 -15
- package/dist/consent.js +0 -50
- package/dist/consent.test.d.ts +0 -1
- package/dist/consent.test.js +0 -80
- package/dist/oauth-crypto.d.ts +0 -8
- package/dist/oauth-crypto.js +0 -23
- package/dist/oauth-crypto.test.d.ts +0 -1
- package/dist/oauth-crypto.test.js +0 -11
- package/dist/oauth-service.d.ts +0 -65
- package/dist/oauth-service.js +0 -381
- package/dist/oauth-service.test.d.ts +0 -1
- package/dist/oauth-service.test.js +0 -8
- package/migrations/20260717143000_app_oauth_authorization.sql +0 -47
package/dist/oauth-service.js
DELETED
|
@@ -1,381 +0,0 @@
|
|
|
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
|
-
const scopes = await grantedScopes(db, installation.id);
|
|
79
|
-
return {
|
|
80
|
-
callerType: "app",
|
|
81
|
-
actor: "staff",
|
|
82
|
-
audience: "staff",
|
|
83
|
-
appId: installation.appId,
|
|
84
|
-
appInstallationId: installation.id,
|
|
85
|
-
appReleaseId: installation.releaseId,
|
|
86
|
-
appCredentialGeneration: credential.generation,
|
|
87
|
-
appTokenMode: credential.tokenMode,
|
|
88
|
-
appViewerId: credential.viewerId ?? undefined,
|
|
89
|
-
scopes,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
async function revokeInstallationCredentials(db, installationId, actorId) {
|
|
93
|
-
return db.transaction(async (tx) => {
|
|
94
|
-
const installation = await requireInstallation(tx, installationId);
|
|
95
|
-
const generation = installation.credentialGeneration + 1;
|
|
96
|
-
await tx
|
|
97
|
-
.update(appInstallations)
|
|
98
|
-
.set({ credentialGeneration: generation, updatedAt: now() })
|
|
99
|
-
.where(eq(appInstallations.id, installation.id));
|
|
100
|
-
await tx
|
|
101
|
-
.update(appAccessCredentials)
|
|
102
|
-
.set({ status: "revoked", deactivatedAt: now() })
|
|
103
|
-
.where(eq(appAccessCredentials.installationId, installation.id));
|
|
104
|
-
await tx
|
|
105
|
-
.update(appOAuthRefreshTokens)
|
|
106
|
-
.set({ status: "revoked", revokedAt: now() })
|
|
107
|
-
.where(eq(appOAuthRefreshTokens.installationId, installation.id));
|
|
108
|
-
await audit(tx, installation, actorId, "credential", "credential.revoked", { generation });
|
|
109
|
-
return { installationId: installation.id, generation };
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
return { authorize, token, resolveAccessToken, revokeInstallationCredentials };
|
|
113
|
-
async function exchangeCode(db, input) {
|
|
114
|
-
return db.transaction(async (tx) => {
|
|
115
|
-
const codeHash = sha256Hex(input.code);
|
|
116
|
-
const [code] = await tx
|
|
117
|
-
.select()
|
|
118
|
-
.from(appOAuthAuthorizationCodes)
|
|
119
|
-
.where(eq(appOAuthAuthorizationCodes.codeHash, codeHash))
|
|
120
|
-
.for("update")
|
|
121
|
-
.limit(1);
|
|
122
|
-
if (!code || code.consumedAt || code.expiresAt <= now()) {
|
|
123
|
-
throw oauthError("invalid_grant", "Authorization code is invalid, expired, or consumed");
|
|
124
|
-
}
|
|
125
|
-
if (code.appId !== input.clientId || code.redirectUri !== input.redirectUri) {
|
|
126
|
-
throw oauthError("invalid_grant", "Authorization code was issued to different client data");
|
|
127
|
-
}
|
|
128
|
-
if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
|
|
129
|
-
throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
|
|
130
|
-
}
|
|
131
|
-
await tx
|
|
132
|
-
.update(appOAuthAuthorizationCodes)
|
|
133
|
-
.set({ consumedAt: now() })
|
|
134
|
-
.where(eq(appOAuthAuthorizationCodes.id, code.id));
|
|
135
|
-
const installation = await requireInstallation(tx, code.installationId);
|
|
136
|
-
assertActiveInstallation(installation);
|
|
137
|
-
const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes);
|
|
138
|
-
await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
|
|
139
|
-
return tokens;
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
async function refresh(db, input) {
|
|
143
|
-
return db.transaction(async (tx) => {
|
|
144
|
-
const [tokenRow] = await tx
|
|
145
|
-
.select()
|
|
146
|
-
.from(appOAuthRefreshTokens)
|
|
147
|
-
.where(eq(appOAuthRefreshTokens.tokenHash, sha256Hex(input.refreshToken)))
|
|
148
|
-
.for("update")
|
|
149
|
-
.limit(1);
|
|
150
|
-
if (tokenRow?.status !== "active" || isExpired(tokenRow.expiresAt)) {
|
|
151
|
-
throw oauthError("invalid_grant", "Refresh token is invalid");
|
|
152
|
-
}
|
|
153
|
-
const installation = await requireInstallation(tx, tokenRow.installationId);
|
|
154
|
-
assertTokenClient(installation, input.clientId);
|
|
155
|
-
assertActiveInstallation(installation);
|
|
156
|
-
if (installation.credentialGeneration !== tokenRow.generation) {
|
|
157
|
-
throw oauthError("invalid_grant", "Refresh token generation was revoked");
|
|
158
|
-
}
|
|
159
|
-
await tx
|
|
160
|
-
.update(appOAuthRefreshTokens)
|
|
161
|
-
.set({ status: "inactive", revokedAt: now() })
|
|
162
|
-
.where(eq(appOAuthRefreshTokens.id, tokenRow.id));
|
|
163
|
-
const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), {
|
|
164
|
-
rotatedFromId: tokenRow.id,
|
|
165
|
-
});
|
|
166
|
-
await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
|
|
167
|
-
generation: installation.credentialGeneration,
|
|
168
|
-
});
|
|
169
|
-
return tokens;
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
async function exchangeActorToken(db, input) {
|
|
173
|
-
const installation = await requireInstallation(db, input.installationId);
|
|
174
|
-
assertTokenClient(installation, input.clientId);
|
|
175
|
-
assertActiveInstallation(installation);
|
|
176
|
-
const grants = await grantedScopes(db, installation.id);
|
|
177
|
-
const contextual = input.contextualScopes ?? grants;
|
|
178
|
-
const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
|
|
179
|
-
const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
|
|
180
|
-
const expiresAt = new Date(now().getTime() + ONLINE_ACCESS_TTL_MS);
|
|
181
|
-
await db.insert(appAccessCredentials).values({
|
|
182
|
-
installationId: installation.id,
|
|
183
|
-
generation: installation.credentialGeneration,
|
|
184
|
-
tokenMode: "online",
|
|
185
|
-
credentialHash: sha256Hex(accessToken),
|
|
186
|
-
encryptedMetadata: { scopeCount: scopes.length },
|
|
187
|
-
status: "active",
|
|
188
|
-
actorId: input.viewerId,
|
|
189
|
-
viewerId: input.viewerId,
|
|
190
|
-
expiresAt,
|
|
191
|
-
});
|
|
192
|
-
await audit(db, installation, input.viewerId, "token", "oauth.actor_token.exchanged", {
|
|
193
|
-
grantedScopes: scopes,
|
|
194
|
-
});
|
|
195
|
-
return tokenResponse(accessToken, null, expiresAt, scopes, "online");
|
|
196
|
-
}
|
|
197
|
-
async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
|
|
198
|
-
const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
|
|
199
|
-
const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
|
|
200
|
-
const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
|
|
201
|
-
const refreshExpiresAt = new Date(now().getTime() + REFRESH_TTL_MS);
|
|
202
|
-
const generation = installation.credentialGeneration;
|
|
203
|
-
await db.insert(appAccessCredentials).values({
|
|
204
|
-
installationId: installation.id,
|
|
205
|
-
generation,
|
|
206
|
-
tokenMode: "offline",
|
|
207
|
-
credentialHash: sha256Hex(accessToken),
|
|
208
|
-
encryptedMetadata: { scopeCount: scopes.length },
|
|
209
|
-
status: "active",
|
|
210
|
-
actorId,
|
|
211
|
-
viewerId,
|
|
212
|
-
expiresAt: accessExpiresAt,
|
|
213
|
-
});
|
|
214
|
-
await db.insert(appOAuthRefreshTokens).values({
|
|
215
|
-
installationId: installation.id,
|
|
216
|
-
tokenHash: sha256Hex(refreshToken),
|
|
217
|
-
generation,
|
|
218
|
-
rotatedFromId: refreshOptions.rotatedFromId,
|
|
219
|
-
expiresAt: refreshExpiresAt,
|
|
220
|
-
});
|
|
221
|
-
return tokenResponse(accessToken, refreshToken, accessExpiresAt, scopes, "offline");
|
|
222
|
-
}
|
|
223
|
-
function isExpired(expiresAt) {
|
|
224
|
-
return Boolean(expiresAt && expiresAt <= now());
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
|
|
228
|
-
return {
|
|
229
|
-
accessToken,
|
|
230
|
-
tokenType: "Bearer",
|
|
231
|
-
expiresIn: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
|
|
232
|
-
scope: scopes.join(" "),
|
|
233
|
-
tokenMode,
|
|
234
|
-
...(refreshToken ? { refreshToken } : {}),
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
async function authenticateClient(db, appId, clientSecret) {
|
|
238
|
-
const [secret] = await db
|
|
239
|
-
.select()
|
|
240
|
-
.from(appCredentials)
|
|
241
|
-
.where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
|
|
242
|
-
.limit(1);
|
|
243
|
-
if (!secret)
|
|
244
|
-
return;
|
|
245
|
-
if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
|
|
246
|
-
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
247
|
-
}
|
|
248
|
-
const expected = secret.kmsKeyRef.slice("sha256:".length);
|
|
249
|
-
if (!constantTimeEqual(sha256Hex(clientSecret), expected)) {
|
|
250
|
-
throw oauthError("invalid_client", "Client authentication failed", 401);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
async function requireRelease(db, appId, releaseId) {
|
|
254
|
-
const [release] = await db
|
|
255
|
-
.select()
|
|
256
|
-
.from(appReleases)
|
|
257
|
-
.where(and(eq(appReleases.id, releaseId), eq(appReleases.appId, appId)))
|
|
258
|
-
.limit(1);
|
|
259
|
-
if (!release)
|
|
260
|
-
throw oauthError("invalid_request", "App release not found", 404);
|
|
261
|
-
return release;
|
|
262
|
-
}
|
|
263
|
-
async function requireExactRedirectUri(db, appId, redirectUri) {
|
|
264
|
-
const [row] = await db
|
|
265
|
-
.select()
|
|
266
|
-
.from(appRedirectUris)
|
|
267
|
-
.where(and(eq(appRedirectUris.appId, appId), eq(appRedirectUris.redirectUri, redirectUri)))
|
|
268
|
-
.limit(1);
|
|
269
|
-
if (!row)
|
|
270
|
-
throw oauthError("invalid_request", "Redirect URI is not registered for this app");
|
|
271
|
-
}
|
|
272
|
-
async function ensureAuthorizedInstallation(db, input) {
|
|
273
|
-
return db.transaction(async (tx) => {
|
|
274
|
-
const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
|
|
275
|
-
if (!app)
|
|
276
|
-
throw oauthError("invalid_request", "App registration not found", 404);
|
|
277
|
-
const [existing] = await tx
|
|
278
|
-
.select()
|
|
279
|
-
.from(appInstallations)
|
|
280
|
-
.where(and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
|
|
281
|
-
.limit(1);
|
|
282
|
-
const installation = existing ??
|
|
283
|
-
(await tx
|
|
284
|
-
.insert(appInstallations)
|
|
285
|
-
.values({
|
|
286
|
-
appId: input.appId,
|
|
287
|
-
deploymentId: input.deploymentId,
|
|
288
|
-
releaseId: input.releaseId,
|
|
289
|
-
status: "active",
|
|
290
|
-
namespace: app.platformNamespace,
|
|
291
|
-
installedBy: input.actorId,
|
|
292
|
-
authorizedAt: new Date(),
|
|
293
|
-
activatedAt: new Date(),
|
|
294
|
-
})
|
|
295
|
-
.returning())[0];
|
|
296
|
-
if (!installation)
|
|
297
|
-
throw oauthError("server_error", "Could not create installation", 500);
|
|
298
|
-
for (const scope of input.grantedScopes) {
|
|
299
|
-
await tx
|
|
300
|
-
.insert(appGrants)
|
|
301
|
-
.values({
|
|
302
|
-
installationId: installation.id,
|
|
303
|
-
scope,
|
|
304
|
-
status: "granted",
|
|
305
|
-
optional: false,
|
|
306
|
-
grantedAt: new Date(),
|
|
307
|
-
})
|
|
308
|
-
.onConflictDoUpdate({
|
|
309
|
-
target: [appGrants.installationId, appGrants.scope],
|
|
310
|
-
set: { status: "granted", grantedAt: new Date(), revokedAt: null },
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
for (const scope of input.deniedOptionalScopes) {
|
|
314
|
-
await tx
|
|
315
|
-
.insert(appGrants)
|
|
316
|
-
.values({ installationId: installation.id, scope, status: "optional", optional: true })
|
|
317
|
-
.onConflictDoUpdate({
|
|
318
|
-
target: [appGrants.installationId, appGrants.scope],
|
|
319
|
-
set: { status: "optional", optional: true },
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
return installation;
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
async function requireInstallation(db, installationId) {
|
|
326
|
-
const installation = await selectInstallation(db, installationId);
|
|
327
|
-
if (!installation)
|
|
328
|
-
throw oauthError("invalid_grant", "App installation not found", 404);
|
|
329
|
-
return installation;
|
|
330
|
-
}
|
|
331
|
-
async function selectInstallation(db, installationId) {
|
|
332
|
-
const [installation] = await db
|
|
333
|
-
.select()
|
|
334
|
-
.from(appInstallations)
|
|
335
|
-
.where(eq(appInstallations.id, installationId))
|
|
336
|
-
.limit(1);
|
|
337
|
-
return installation ?? null;
|
|
338
|
-
}
|
|
339
|
-
async function grantedScopes(db, installationId) {
|
|
340
|
-
const rows = await db
|
|
341
|
-
.select({ scope: appGrants.scope })
|
|
342
|
-
.from(appGrants)
|
|
343
|
-
.where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
|
|
344
|
-
.orderBy(appGrants.scope);
|
|
345
|
-
return rows.map((row) => row.scope);
|
|
346
|
-
}
|
|
347
|
-
function assertActiveInstallation(installation) {
|
|
348
|
-
if (installation.status !== "active") {
|
|
349
|
-
throw oauthError("invalid_grant", "App installation is not active");
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
function isInstallationUsable(installation, generation) {
|
|
353
|
-
return installation?.status === "active" && installation.credentialGeneration === generation;
|
|
354
|
-
}
|
|
355
|
-
function assertTokenClient(installation, clientId) {
|
|
356
|
-
if (installation.appId !== clientId) {
|
|
357
|
-
throw oauthError("invalid_grant", "Token belongs to a different app");
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
export function intersectAppTokenScopes(...sets) {
|
|
361
|
-
const [first = [], ...rest] = sets;
|
|
362
|
-
return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
|
|
363
|
-
}
|
|
364
|
-
function assertState(state) {
|
|
365
|
-
if (!state.trim())
|
|
366
|
-
throw oauthError("invalid_request", "OAuth state is required");
|
|
367
|
-
}
|
|
368
|
-
function oauthError(error, description, status = 400) {
|
|
369
|
-
return new ApiHttpError(description, { status, code: error });
|
|
370
|
-
}
|
|
371
|
-
async function audit(db, installation, actorId, kind, action, details) {
|
|
372
|
-
await db.insert(appAuditEvents).values({
|
|
373
|
-
installationId: installation.id,
|
|
374
|
-
appId: installation.appId,
|
|
375
|
-
deploymentId: installation.deploymentId,
|
|
376
|
-
actorId,
|
|
377
|
-
kind,
|
|
378
|
-
action,
|
|
379
|
-
details,
|
|
380
|
-
});
|
|
381
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { intersectAppTokenScopes } from "./oauth-service.js";
|
|
3
|
-
describe("app OAuth online token scope intersection", () => {
|
|
4
|
-
it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
|
|
5
|
-
expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
|
|
6
|
-
expect(intersectAppTokenScopes(["bookings:read"], ["bookings:read", "invoices:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
|
|
7
|
-
});
|
|
8
|
-
});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
ALTER TYPE "public"."app_audit_event_kind" ADD VALUE IF NOT EXISTS 'consent';--> statement-breakpoint
|
|
2
|
-
ALTER TYPE "public"."app_audit_event_kind" ADD VALUE IF NOT EXISTS 'token';--> statement-breakpoint
|
|
3
|
-
CREATE TYPE "public"."app_access_token_mode" AS ENUM('offline', 'online');--> statement-breakpoint
|
|
4
|
-
ALTER TABLE "app_installations" ADD COLUMN "credential_generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
5
|
-
ALTER TABLE "app_access_credentials" ADD COLUMN "token_mode" "app_access_token_mode" DEFAULT 'offline' NOT NULL;--> statement-breakpoint
|
|
6
|
-
ALTER TABLE "app_access_credentials" ADD COLUMN "actor_id" text;--> statement-breakpoint
|
|
7
|
-
ALTER TABLE "app_access_credentials" ADD COLUMN "viewer_id" text;--> statement-breakpoint
|
|
8
|
-
DROP INDEX "public"."uidx_app_access_credentials_generation";--> statement-breakpoint
|
|
9
|
-
CREATE INDEX "idx_app_access_credentials_generation" ON "app_access_credentials" USING btree ("installation_id","token_mode","generation");--> statement-breakpoint
|
|
10
|
-
CREATE TABLE "app_oauth_authorization_codes" (
|
|
11
|
-
"id" text PRIMARY KEY NOT NULL,
|
|
12
|
-
"app_id" text NOT NULL,
|
|
13
|
-
"installation_id" text NOT NULL,
|
|
14
|
-
"release_id" text NOT NULL,
|
|
15
|
-
"deployment_id" text NOT NULL,
|
|
16
|
-
"code_hash" text NOT NULL,
|
|
17
|
-
"state_hash" text NOT NULL,
|
|
18
|
-
"redirect_uri" text NOT NULL,
|
|
19
|
-
"code_challenge" text NOT NULL,
|
|
20
|
-
"code_challenge_method" text NOT NULL,
|
|
21
|
-
"requested_scopes" jsonb NOT NULL,
|
|
22
|
-
"granted_scopes" jsonb NOT NULL,
|
|
23
|
-
"denied_optional_scopes" jsonb NOT NULL,
|
|
24
|
-
"actor_id" text NOT NULL,
|
|
25
|
-
"expires_at" timestamp with time zone NOT NULL,
|
|
26
|
-
"consumed_at" timestamp with time zone,
|
|
27
|
-
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
28
|
-
);--> statement-breakpoint
|
|
29
|
-
CREATE TABLE "app_oauth_refresh_tokens" (
|
|
30
|
-
"id" text PRIMARY KEY NOT NULL,
|
|
31
|
-
"installation_id" text NOT NULL,
|
|
32
|
-
"token_hash" text NOT NULL,
|
|
33
|
-
"generation" integer NOT NULL,
|
|
34
|
-
"status" "app_access_credential_status" DEFAULT 'active' NOT NULL,
|
|
35
|
-
"rotated_from_id" text,
|
|
36
|
-
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
37
|
-
"expires_at" timestamp with time zone,
|
|
38
|
-
"revoked_at" timestamp with time zone
|
|
39
|
-
);--> statement-breakpoint
|
|
40
|
-
ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_authorization_codes_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
41
|
-
ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_authorization_codes_installation_id_app_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."app_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
42
|
-
ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_authorization_codes_release_id_app_releases_id_fk" FOREIGN KEY ("release_id") REFERENCES "public"."app_releases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
43
|
-
ALTER TABLE "app_oauth_refresh_tokens" ADD CONSTRAINT "app_oauth_refresh_tokens_installation_id_app_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."app_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
44
|
-
CREATE UNIQUE INDEX "uidx_app_oauth_codes_hash" ON "app_oauth_authorization_codes" USING btree ("code_hash");--> statement-breakpoint
|
|
45
|
-
CREATE INDEX "idx_app_oauth_codes_installation" ON "app_oauth_authorization_codes" USING btree ("installation_id","expires_at");--> statement-breakpoint
|
|
46
|
-
CREATE UNIQUE INDEX "uidx_app_oauth_refresh_tokens_hash" ON "app_oauth_refresh_tokens" USING btree ("token_hash");--> statement-breakpoint
|
|
47
|
-
CREATE INDEX "idx_app_oauth_refresh_tokens_installation" ON "app_oauth_refresh_tokens" USING btree ("installation_id","status");
|