Skip to content

Commit 74249ca

Browse files
committed
refactor(webapp): share scoped API key authentication
1 parent cca1ea6 commit 74249ca

4 files changed

Lines changed: 136 additions & 61 deletions

File tree

apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@ import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
33
import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
44
import { z } from "zod";
55
import { prisma } from "~/db.server";
6-
import {
7-
authenticateApiKeyWithScope,
8-
authenticateRequest,
9-
type AuthenticationResult,
10-
} from "~/services/apiAuth.server";
6+
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
117
import { logger } from "~/services/logger.server";
128
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
139
import { UpsertBranchService } from "~/services/upsertBranch.server";
@@ -25,29 +21,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
2521

2622
logger.info("project upsert branch", { url: request.url });
2723

28-
const userOrOrganizationAuthentication = await authenticateRequest(request, {
24+
const authentication = await authenticateRequestWithScopedApiKey(request, {
2925
personalAccessToken: true,
3026
organizationAccessToken: true,
31-
apiKey: false,
32-
});
33-
34-
let authenticationResult: AuthenticationResult;
35-
if (userOrOrganizationAuthentication) {
36-
authenticationResult = userOrOrganizationAuthentication;
37-
} else {
38-
const apiKeyAuthentication = await authenticateApiKeyWithScope(request, {
27+
apiKey: {
3928
action: "write",
4029
resource: { type: "branches" },
4130
allowPreviewParent: true,
42-
});
43-
if (!apiKeyAuthentication.ok) {
44-
return json({ error: apiKeyAuthentication.error }, { status: apiKeyAuthentication.status });
45-
}
46-
authenticationResult = {
47-
type: "apiKey",
48-
result: apiKeyAuthentication.authentication,
49-
};
31+
},
32+
});
33+
if (!authentication.ok) {
34+
return json({ error: authentication.error }, { status: authentication.status });
5035
}
36+
const authenticationResult = authentication.authentication;
5137

5238
const apiKeyEnvironment =
5339
authenticationResult.type === "apiKey" && authenticationResult.result.ok

apps/webapp/app/services/apiAuth.server.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -325,19 +325,16 @@ export async function authenticateApiKeyRequest(
325325
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
326326
* this for routes previously guarded by a bare `authenticateApiRequest` call.
327327
*/
328+
export type ApiKeyScopeAuthorization = {
329+
action: string;
330+
resource: RbacResource;
331+
allowJWT?: boolean;
332+
allowPreviewParent?: boolean;
333+
};
334+
328335
export async function authenticateApiKeyWithScope(
329336
request: Request,
330-
{
331-
action,
332-
resource,
333-
allowJWT = false,
334-
allowPreviewParent = false,
335-
}: {
336-
action: string;
337-
resource: RbacResource;
338-
allowJWT?: boolean;
339-
allowPreviewParent?: boolean;
340-
},
337+
{ action, resource, allowJWT = false, allowPreviewParent = false }: ApiKeyScopeAuthorization,
341338
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
342339
): Promise<
343340
| { ok: true; authentication: ApiAuthenticationResultSuccess }
@@ -369,6 +366,50 @@ export async function authenticateApiKeyWithScope(
369366
};
370367
}
371368

369+
export type ScopedApiKeyAuthenticationDependencies = {
370+
authenticateRequest: typeof authenticateRequest;
371+
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
372+
};
373+
374+
export async function authenticateRequestWithScopedApiKey(
375+
request: Request,
376+
{
377+
personalAccessToken,
378+
organizationAccessToken,
379+
apiKey,
380+
}: {
381+
personalAccessToken: true;
382+
organizationAccessToken: true;
383+
apiKey: ApiKeyScopeAuthorization;
384+
},
385+
dependencies: ScopedApiKeyAuthenticationDependencies = {
386+
authenticateRequest,
387+
authenticateApiKeyWithScope,
388+
}
389+
): Promise<
390+
| { ok: true; authentication: AuthenticationResult }
391+
| { ok: false; status: 401 | 403; error: string }
392+
> {
393+
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
394+
personalAccessToken,
395+
organizationAccessToken,
396+
apiKey: false,
397+
});
398+
if (userOrOrganizationAuthentication) {
399+
return { ok: true, authentication: userOrOrganizationAuthentication };
400+
}
401+
402+
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, apiKey);
403+
if (!apiKeyAuthentication.ok) {
404+
return apiKeyAuthentication;
405+
}
406+
407+
return {
408+
ok: true,
409+
authentication: { type: "apiKey", result: apiKeyAuthentication.authentication },
410+
};
411+
}
412+
372413
export async function authenticateAuthorizationHeader(
373414
authorization: string,
374415
{

apps/webapp/app/services/environmentVariableApiAccess.server.ts

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { isUserActorToken } from "@trigger.dev/rbac";
44
import type { RbacAbility } from "@trigger.dev/rbac";
55
import {
66
authenticateApiKeyRequest,
7-
authenticateApiKeyWithScope,
87
authenticateRequest,
8+
authenticateRequestWithScopedApiKey,
99
type AuthenticationResult,
10+
type ScopedApiKeyAuthenticationDependencies,
1011
} from "~/services/apiAuth.server";
1112
import { rbac } from "~/services/rbac.server";
1213

@@ -40,10 +41,7 @@ export function apiKeyForProjectEnvironmentBootstrap(
4041
* Keep PAT/OAT authentication on the legacy path while routing machine API
4142
* keys through the RBAC controller, where plugin grants are applied.
4243
*/
43-
type AuthenticationDependencies = {
44-
authenticateRequest: typeof authenticateRequest;
45-
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
46-
};
44+
type AuthenticationDependencies = ScopedApiKeyAuthenticationDependencies;
4745

4846
type BootstrapAuthenticationDependencies = {
4947
authenticateRequest: typeof authenticateRequest;
@@ -54,29 +52,17 @@ export async function authenticateEnvironmentScopedApiRequest(
5452
request: Request,
5553
action: "read" | "write",
5654
resource: EnvironmentScopedResource,
57-
dependencies: AuthenticationDependencies = { authenticateRequest, authenticateApiKeyWithScope }
55+
dependencies?: AuthenticationDependencies
5856
): Promise<EnvironmentScopedAuthentication> {
59-
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
60-
personalAccessToken: true,
61-
organizationAccessToken: true,
62-
apiKey: false,
63-
});
64-
if (userOrOrganizationAuthentication) {
65-
return { ok: true, authentication: userOrOrganizationAuthentication };
66-
}
67-
68-
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, {
69-
action,
70-
resource: { type: resource },
71-
});
72-
if (!apiKeyAuthentication.ok) {
73-
return apiKeyAuthentication;
74-
}
75-
76-
return {
77-
ok: true,
78-
authentication: { type: "apiKey", result: apiKeyAuthentication.authentication },
79-
};
57+
return authenticateRequestWithScopedApiKey(
58+
request,
59+
{
60+
personalAccessToken: true,
61+
organizationAccessToken: true,
62+
apiKey: { action, resource: { type: resource } },
63+
},
64+
dependencies
65+
);
8066
}
8167

8268
/**

apps/webapp/test/apiAuthScope.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
import { authenticateApiKeyRequest, authenticateApiKeyWithScope } from "~/services/apiAuth.server";
2+
import {
3+
authenticateApiKeyRequest,
4+
authenticateApiKeyWithScope,
5+
authenticateRequestWithScopedApiKey,
6+
} from "~/services/apiAuth.server";
37

48
const authorizeBearer = vi.fn();
59

@@ -151,3 +155,61 @@ describe("authenticateApiKeyWithScope", () => {
151155
expect(ability.can).not.toHaveBeenCalled();
152156
});
153157
});
158+
159+
describe("authenticateRequestWithScopedApiKey", () => {
160+
const options = {
161+
personalAccessToken: true as const,
162+
organizationAccessToken: true as const,
163+
apiKey: {
164+
action: "write",
165+
resource: { type: "branches" },
166+
allowPreviewParent: true,
167+
},
168+
};
169+
170+
it("keeps user and organization tokens on the legacy path", async () => {
171+
const authentication = {
172+
type: "personalAccessToken",
173+
result: { userId: "user_123" },
174+
} as const;
175+
const authenticateRequest = vi.fn().mockResolvedValueOnce(authentication);
176+
const authenticateApiKeyWithScope = vi.fn();
177+
178+
await expect(
179+
authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, {
180+
authenticateRequest,
181+
authenticateApiKeyWithScope,
182+
})
183+
).resolves.toEqual({ ok: true, authentication });
184+
expect(authenticateRequest).toHaveBeenCalledWith(expect.any(Request), {
185+
personalAccessToken: true,
186+
organizationAccessToken: true,
187+
apiKey: false,
188+
});
189+
expect(authenticateApiKeyWithScope).not.toHaveBeenCalled();
190+
});
191+
192+
it("uses scoped RBAC authentication for API keys", async () => {
193+
const apiKeyAuthentication = {
194+
ok: true,
195+
apiKey: "tr_preview_sk_test",
196+
type: "PRIVATE",
197+
environment: {},
198+
} as const;
199+
const authenticateRequest = vi.fn().mockResolvedValueOnce(undefined);
200+
const authenticateApiKeyWithScope = vi
201+
.fn()
202+
.mockResolvedValueOnce({ ok: true, authentication: apiKeyAuthentication });
203+
204+
await expect(
205+
authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, {
206+
authenticateRequest,
207+
authenticateApiKeyWithScope,
208+
})
209+
).resolves.toEqual({
210+
ok: true,
211+
authentication: { type: "apiKey", result: apiKeyAuthentication },
212+
});
213+
expect(authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), options.apiKey);
214+
});
215+
});

0 commit comments

Comments
 (0)