Skip to content

Commit e441a06

Browse files
committed
refactor(webapp): share scoped API key authentication
1 parent 1360422 commit e441a06

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
@@ -341,19 +341,16 @@ export async function authenticateApiKeyRequest(
341341
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
342342
* this for routes previously guarded by a bare `authenticateApiRequest` call.
343343
*/
344+
export type ApiKeyScopeAuthorization = {
345+
action: string;
346+
resource: RbacResource;
347+
allowJWT?: boolean;
348+
allowPreviewParent?: boolean;
349+
};
350+
344351
export async function authenticateApiKeyWithScope(
345352
request: Request,
346-
{
347-
action,
348-
resource,
349-
allowJWT = false,
350-
allowPreviewParent = false,
351-
}: {
352-
action: string;
353-
resource: RbacResource;
354-
allowJWT?: boolean;
355-
allowPreviewParent?: boolean;
356-
},
353+
{ action, resource, allowJWT = false, allowPreviewParent = false }: ApiKeyScopeAuthorization,
357354
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
358355
): Promise<
359356
| { ok: true; authentication: ApiAuthenticationResultSuccess }
@@ -385,6 +382,50 @@ export async function authenticateApiKeyWithScope(
385382
};
386383
}
387384

385+
export type ScopedApiKeyAuthenticationDependencies = {
386+
authenticateRequest: typeof authenticateRequest;
387+
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
388+
};
389+
390+
export async function authenticateRequestWithScopedApiKey(
391+
request: Request,
392+
{
393+
personalAccessToken,
394+
organizationAccessToken,
395+
apiKey,
396+
}: {
397+
personalAccessToken: true;
398+
organizationAccessToken: true;
399+
apiKey: ApiKeyScopeAuthorization;
400+
},
401+
dependencies: ScopedApiKeyAuthenticationDependencies = {
402+
authenticateRequest,
403+
authenticateApiKeyWithScope,
404+
}
405+
): Promise<
406+
| { ok: true; authentication: AuthenticationResult }
407+
| { ok: false; status: 401 | 403; error: string }
408+
> {
409+
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
410+
personalAccessToken,
411+
organizationAccessToken,
412+
apiKey: false,
413+
});
414+
if (userOrOrganizationAuthentication) {
415+
return { ok: true, authentication: userOrOrganizationAuthentication };
416+
}
417+
418+
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, apiKey);
419+
if (!apiKeyAuthentication.ok) {
420+
return apiKeyAuthentication;
421+
}
422+
423+
return {
424+
ok: true,
425+
authentication: { type: "apiKey", result: apiKeyAuthentication.authentication },
426+
};
427+
}
428+
388429
export async function authenticateAuthorizationHeader(
389430
authorization: string,
390431
{

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)