@voyant-travel/apps 0.1.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/api-runtime.d.ts +10 -0
- package/dist/api-runtime.js +8 -0
- package/dist/app-api-contracts.d.ts +51 -0
- package/dist/app-api-contracts.js +50 -0
- package/dist/app-api-routes.d.ts +26 -0
- package/dist/app-api-routes.js +133 -0
- package/dist/app-api-service.d.ts +1263 -0
- package/dist/app-api-service.js +231 -0
- package/dist/app-api-service.test.d.ts +1 -0
- package/dist/app-api-service.test.js +105 -0
- package/dist/compiler.d.ts +38 -0
- package/dist/compiler.js +118 -0
- package/dist/compiler.test.d.ts +1 -0
- package/dist/compiler.test.js +99 -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 +263 -0
- package/dist/contracts.js +273 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/ingestion.d.ts +15 -0
- package/dist/ingestion.js +232 -0
- package/dist/ingestion.test.d.ts +1 -0
- package/dist/ingestion.test.js +47 -0
- package/dist/installation-reconciliation.d.ts +18 -0
- package/dist/installation-reconciliation.js +254 -0
- package/dist/installation-service.d.ts +351 -0
- package/dist/installation-service.js +328 -0
- package/dist/installation-state.d.ts +15 -0
- package/dist/installation-state.js +17 -0
- package/dist/installation-state.test.d.ts +1 -0
- package/dist/installation-state.test.js +38 -0
- 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 +65 -0
- package/dist/oauth-service.js +381 -0
- package/dist/oauth-service.test.d.ts +1 -0
- package/dist/oauth-service.test.js +8 -0
- package/dist/routes.d.ts +17 -0
- package/dist/routes.js +131 -0
- package/dist/schema.d.ts +2937 -0
- package/dist/schema.js +342 -0
- package/dist/service.d.ts +95 -0
- package/dist/service.js +172 -0
- package/dist/service.test.d.ts +1 -0
- package/dist/service.test.js +178 -0
- package/dist/test-fixtures.d.ts +80 -0
- package/dist/test-fixtures.js +78 -0
- package/dist/voyant.d.ts +2 -0
- package/dist/voyant.js +115 -0
- package/dist/webhook-delivery.d.ts +69 -0
- package/dist/webhook-delivery.js +262 -0
- package/migrations/20260717000100_app_registry_foundation.sql +87 -0
- package/migrations/20260717111938_breezy_karma.sql +136 -0
- package/migrations/20260717123000_app_webhook_delivery_health.sql +4 -0
- package/migrations/20260717143000_app_oauth_authorization.sql +47 -0
- package/migrations/meta/_journal.json +34 -0
- package/package.json +159 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ApiHttpError } from "@voyant-travel/hono";
|
|
2
|
+
export function planLifecycleTransition(current, allowedFrom, to, action) {
|
|
3
|
+
if (current === to)
|
|
4
|
+
return { outcome: "unchanged", status: current };
|
|
5
|
+
if (!allowedFrom.includes(current))
|
|
6
|
+
throw invalidTransition(current, action);
|
|
7
|
+
return { outcome: "updated", from: current, to };
|
|
8
|
+
}
|
|
9
|
+
export function canInstallOver(status) {
|
|
10
|
+
return status === "uninstalled" || status === "revoked";
|
|
11
|
+
}
|
|
12
|
+
export function invalidTransition(from, action) {
|
|
13
|
+
return new ApiHttpError(`Cannot ${action} app installation from ${from}`, {
|
|
14
|
+
status: 409,
|
|
15
|
+
code: "app_installation_invalid_transition",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { canInstallOver, planLifecycleTransition } from "./installation-state.js";
|
|
3
|
+
describe("app installation lifecycle state", () => {
|
|
4
|
+
it("treats repeated pause, resume, and uninstall actions as idempotent", () => {
|
|
5
|
+
expect(planLifecycleTransition("paused", ["active", "degraded"], "paused", "pause")).toEqual({
|
|
6
|
+
outcome: "unchanged",
|
|
7
|
+
status: "paused",
|
|
8
|
+
});
|
|
9
|
+
expect(planLifecycleTransition("active", ["paused"], "active", "resume")).toEqual({
|
|
10
|
+
outcome: "unchanged",
|
|
11
|
+
status: "active",
|
|
12
|
+
});
|
|
13
|
+
expect(planLifecycleTransition("uninstalled", ["active", "paused", "degraded"], "uninstalled", "uninstall")).toEqual({ outcome: "unchanged", status: "uninstalled" });
|
|
14
|
+
});
|
|
15
|
+
it("allows the expected active, paused, resumed, and uninstalled path", () => {
|
|
16
|
+
expect(planLifecycleTransition("active", ["active", "degraded"], "paused", "pause")).toEqual({
|
|
17
|
+
outcome: "updated",
|
|
18
|
+
from: "active",
|
|
19
|
+
to: "paused",
|
|
20
|
+
});
|
|
21
|
+
expect(planLifecycleTransition("paused", ["paused"], "active", "resume")).toEqual({
|
|
22
|
+
outcome: "updated",
|
|
23
|
+
from: "paused",
|
|
24
|
+
to: "active",
|
|
25
|
+
});
|
|
26
|
+
expect(planLifecycleTransition("active", ["active", "paused", "degraded"], "uninstalled", "uninstall")).toEqual({ outcome: "updated", from: "active", to: "uninstalled" });
|
|
27
|
+
});
|
|
28
|
+
it("rejects invalid lifecycle transitions", () => {
|
|
29
|
+
expect(() => planLifecycleTransition("pending", ["active", "degraded"], "paused", "pause")).toThrow("Cannot pause app installation from pending");
|
|
30
|
+
expect(() => planLifecycleTransition("revoked", ["paused"], "active", "resume")).toThrow("Cannot resume app installation from revoked");
|
|
31
|
+
});
|
|
32
|
+
it("only reinstalls over terminal retained rows", () => {
|
|
33
|
+
expect(canInstallOver("uninstalled")).toBe(true);
|
|
34
|
+
expect(canInstallOver("revoked")).toBe(true);
|
|
35
|
+
expect(canInstallOver("active")).toBe(false);
|
|
36
|
+
expect(canInstallOver("paused")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const APP_ACCESS_TOKEN_PREFIX = "vapp_";
|
|
2
|
+
export declare const APP_REFRESH_TOKEN_PREFIX = "vappr_";
|
|
3
|
+
export declare const APP_AUTH_CODE_PREFIX = "vappc_";
|
|
4
|
+
export declare function randomToken(prefix: string, bytes?: number): string;
|
|
5
|
+
export declare function sha256Hex(value: string): string;
|
|
6
|
+
export declare function sha256Base64Url(value: string): string;
|
|
7
|
+
export declare function constantTimeEqual(left: string, right: string): boolean;
|
|
8
|
+
export declare function verifyPkceS256(verifier: string, challenge: string): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export const APP_ACCESS_TOKEN_PREFIX = "vapp_";
|
|
3
|
+
export const APP_REFRESH_TOKEN_PREFIX = "vappr_";
|
|
4
|
+
export const APP_AUTH_CODE_PREFIX = "vappc_";
|
|
5
|
+
export function randomToken(prefix, bytes = 32) {
|
|
6
|
+
return `${prefix}${randomBytes(bytes).toString("base64url")}`;
|
|
7
|
+
}
|
|
8
|
+
export function sha256Hex(value) {
|
|
9
|
+
return createHash("sha256").update(value).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
export function sha256Base64Url(value) {
|
|
12
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
13
|
+
}
|
|
14
|
+
export function constantTimeEqual(left, right) {
|
|
15
|
+
const leftHash = Buffer.from(sha256Hex(left), "hex");
|
|
16
|
+
const rightHash = Buffer.from(sha256Hex(right), "hex");
|
|
17
|
+
return timingSafeEqual(leftHash, rightHash);
|
|
18
|
+
}
|
|
19
|
+
export function verifyPkceS256(verifier, challenge) {
|
|
20
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
|
|
21
|
+
return false;
|
|
22
|
+
return constantTimeEqual(sha256Base64Url(verifier), challenge);
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sha256Base64Url, verifyPkceS256 } from "./oauth-crypto.js";
|
|
3
|
+
describe("app OAuth PKCE", () => {
|
|
4
|
+
it("accepts only S256 challenges derived from the verifier", () => {
|
|
5
|
+
const verifier = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ_0123456789-1234567890";
|
|
6
|
+
const challenge = sha256Base64Url(verifier);
|
|
7
|
+
expect(verifyPkceS256(verifier, challenge)).toBe(true);
|
|
8
|
+
expect(verifyPkceS256(`${verifier}x`, challenge)).toBe(false);
|
|
9
|
+
expect(verifyPkceS256("too-short", challenge)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
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[];
|
|
@@ -0,0 +1,381 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
});
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AccessCatalog } from "@voyant-travel/types/api-keys";
|
|
2
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { type AppsServiceOptions } from "./service.js";
|
|
5
|
+
type Env = {
|
|
6
|
+
Variables: {
|
|
7
|
+
db: PostgresJsDatabase;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
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, "/">;
|
|
17
|
+
export {};
|