feat(cli,webapp): allow deploys with environment API keys (#4561)

This commit is contained in:
Chris Arderne
2026-08-12 10:11:31 +01:00
committed by GitHub
parent 26a730f908
commit 7b390e5984
21 changed files with 933 additions and 152 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Allow `trigger deploy` to authenticate with an environment API key from `TRIGGER_ACCESS_TOKEN`.
@@ -874,7 +874,7 @@ const API_KEY_EXPIRATIONS = [
{ value: "never", label: "Never" },
];
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "envvars";
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "branches" | "envvars";
// Capability rows shown in the scope pane, in a fixed order so two presets read
// as a diff of the same list rather than a reshuffled one.
@@ -884,6 +884,7 @@ const SCOPE_CAPABILITIES: [CapId, string][] = [
["batches", "Batches"],
["queues", "Queues"],
["deployments", "Deployments"],
["branches", "Preview branches"],
["envvars", "Environment variables"],
];
@@ -920,6 +921,7 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
"write:queues": ["queues", 2],
"read:deployments": ["deployments", 1],
"write:deployments": ["deployments", 2],
"write:branches": ["branches", 3],
"read:envvars": ["envvars", 1],
"write:envvars": ["envvars", 2],
};
@@ -8,9 +8,9 @@ import {
} from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import {
authenticateEnvironmentScopedApiRequest,
apiKeyForProjectEnvironmentBootstrap,
authenticateEnvironmentBootstrapRequest,
authorizePatEnvironmentAccess,
presentedApiKeyFromAuthentication,
} from "~/services/environmentVariableApiAccess.server";
const ParamsSchema = z.object({
@@ -30,9 +30,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const { projectRef, env } = parsedParams.data;
try {
// PAT/OAT authenticate on the legacy path; machine API keys go through
// the RBAC controller so additional keys (and their grants) are enforced.
const authResult = await authenticateEnvironmentScopedApiRequest(request, "read", "apiKeys");
// PAT/OAT authenticate on the legacy path; machine API keys only need to
// prove they are valid because bootstrap echoes the same key back.
const authResult = await authenticateEnvironmentBootstrapRequest(request);
if (!authResult.ok) {
return json({ error: authResult.error }, { status: authResult.status });
}
@@ -46,29 +46,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
);
// User tokens bootstrap the environment's secret key, so gate them on
// env-tier read:apiKeys. Machine credentials are checked against the same
// permission before their presented key is returned below.
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
ability:
authenticationResult.type === "apiKey" && authenticationResult.result.ok
? authenticationResult.result.ability
: undefined,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "read",
});
if (denied) return denied;
// API-key callers already possess a valid environment credential. Reuse
// exactly what they presented instead of exchanging it for the root key.
const presentedApiKey = presentedApiKeyFromAuthentication(authenticationResult);
// env-tier read:apiKeys. A machine credential never receives that root key.
if (authenticationResult.type !== "apiKey") {
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "read",
});
if (denied) return denied;
}
const result: GetProjectEnvResponse = {
apiKey: presentedApiKey ?? environment.apiKey,
apiKey: apiKeyForProjectEnvironmentBootstrap(authenticationResult, environment.apiKey),
name: environment.project.name,
apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN,
projectId: environment.project.id,
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
import { ArchiveBranchService } from "~/services/archiveBranch.server";
import { logger } from "~/services/logger.server";
import { toBranchableEnvironmentType } from "~/utils/branchableEnvironment";
@@ -24,15 +24,25 @@ export async function action({ request, params }: ActionFunctionArgs) {
logger.info("Archive branch", { url: request.url, params });
const authenticationResult = await authenticateRequest(request, {
const authentication = await authenticateRequestWithScopedApiKey(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
apiKey: {
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
},
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
if (!authentication.ok) {
return json({ error: authentication.error }, { status: authentication.status });
}
const authenticationResult = authentication.authentication;
const apiKeyEnvironment =
authenticationResult.type === "apiKey" && authenticationResult.result.ok
? authenticationResult.result.environment
: undefined;
const parsedParams = ParamsSchema.safeParse(params);
@@ -54,26 +64,52 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { env, branch } = parsed.data;
// API keys can only archive Preview branches
if (
authenticationResult.type === "apiKey" &&
(!apiKeyEnvironment ||
apiKeyEnvironment.type !== "PREVIEW" ||
apiKeyEnvironment.parentEnvironmentId !== null ||
env !== "preview")
) {
return json(
{ error: "API keys must belong to the parent Preview environment." },
{ status: 403 }
);
}
// API keys can only act on their own project
if (
authenticationResult.type === "apiKey" &&
apiKeyEnvironment?.project.externalRef !== projectRef
) {
return json({ error: "Project not found" }, { status: 404 });
}
const environmentType = toBranchableEnvironmentType(env);
const organizationFilter =
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: authenticationResult.type === "apiKey"
? { id: apiKeyEnvironment!.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
};
const environments = await prisma.runtimeEnvironment.findMany({
select: {
id: true,
archivedAt: true,
},
where: {
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
organization: organizationFilter,
// Dev branches are per-org-member: only the owner may archive their own.
...(authenticationResult.type !== "organizationAccessToken" &&
environmentType === "DEVELOPMENT"
...(authenticationResult.type === "personalAccessToken" && environmentType === "DEVELOPMENT"
? { orgMember: { userId: authenticationResult.result.userId } }
: {}),
project: {
@@ -91,7 +127,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const activeEnvironments = environments.filter((env) => env.archivedAt === null);
if (
authenticationResult.type === "organizationAccessToken" &&
authenticationResult.type !== "personalAccessToken" &&
environmentType === "DEVELOPMENT" &&
activeEnvironments.length > 1
) {
@@ -110,15 +146,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Branch already archived" }, { status: 400 });
}
let orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string };
if (authenticationResult.type === "personalAccessToken") {
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
} else if (authenticationResult.type === "organizationAccessToken") {
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
} else {
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment!.organizationId };
}
const service = new ArchiveBranchService();
const result = await service.call(
authenticationResult.type === "organizationAccessToken"
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
environmentId: environment.id,
}
);
const result = await service.call(orgFilter, {
environmentId: environment.id,
});
if (result.success) {
return json(result);
@@ -3,7 +3,7 @@ import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
@@ -21,14 +21,24 @@ export async function action({ request, params }: ActionFunctionArgs) {
logger.info("project upsert branch", { url: request.url });
const authenticationResult = await authenticateRequest(request, {
const authentication = await authenticateRequestWithScopedApiKey(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
apiKey: {
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
},
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
if (!authentication.ok) {
return json({ error: authentication.error }, { status: authentication.status });
}
const authenticationResult = authentication.authentication;
const apiKeyEnvironment =
authenticationResult.type === "apiKey" && authenticationResult.result.ok
? authenticationResult.result.environment
: undefined;
const parsedParams = ParamsSchema.safeParse(params);
@@ -38,24 +48,32 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { projectRef } = parsedParams.data;
const project = await prisma.project.findFirst({
select: {
id: true,
},
where: {
externalRef: projectRef,
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
let project: { id: string } | null | undefined;
if (authenticationResult.type === "apiKey") {
project =
apiKeyEnvironment?.project.externalRef === projectRef
? { id: apiKeyEnvironment.project.id }
: undefined;
} else {
project = await prisma.project.findFirst({
select: {
id: true,
},
where: {
externalRef: projectRef,
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
},
},
});
},
});
}
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
@@ -72,13 +90,30 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { branch, env, git } = parsed.data;
if (env === "development" && authenticationResult.type === "organizationAccessToken") {
if (env === "development" && authenticationResult.type !== "personalAccessToken") {
return json(
{ error: "Cannot create dev branches with organization access tokens." },
{
error:
authenticationResult.type === "apiKey"
? "API keys can only create Preview branches."
: "Cannot create dev branches with organization access tokens.",
},
{ status: 400 }
);
}
if (
authenticationResult.type === "apiKey" &&
(!apiKeyEnvironment ||
apiKeyEnvironment.type !== "PREVIEW" ||
apiKeyEnvironment.parentEnvironmentId !== null)
) {
return json(
{ error: "API keys must belong to the parent Preview environment." },
{ status: 403 }
);
}
if (env === "development" && isDefaultDevBranch(branch)) {
return json(
{ error: `Cannot create dev branch with name '${DEFAULT_DEV_BRANCH}'.` },
@@ -86,24 +121,33 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
}
const service = new UpsertBranchService();
const result = await service.call(
authenticationResult.type === "organizationAccessToken"
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
env,
branchName: branch,
projectId: project.id,
git,
let orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string };
if (authenticationResult.type === "personalAccessToken") {
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
} else if (authenticationResult.type === "organizationAccessToken") {
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
} else {
if (!apiKeyEnvironment) {
return json({ error: "Invalid API key" }, { status: 401 });
}
);
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment.organizationId };
}
const service = new UpsertBranchService();
const result = await service.call(orgFilter, {
env,
branchName: branch,
projectId: project.id,
git,
});
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
return json(result.branch);
return json({ id: result.branch.id });
}
export async function loader({ request, params }: LoaderFunctionArgs) {
+106 -18
View File
@@ -13,7 +13,12 @@ import {
findEnvironmentByPublicApiKey,
toAuthenticated,
} from "~/models/runtimeEnvironment.server";
import type { RbacAbility, RbacResource, UserActorClaims } from "@trigger.dev/rbac";
import type {
BearerAuthOptions,
RbacAbility,
RbacResource,
UserActorClaims,
} from "@trigger.dev/rbac";
import { assertUserActorEnvironment } from "./userActorEnvironment.server";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
@@ -33,6 +38,7 @@ import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import {
authenticateAuthorizeBearerWithTelemetry,
authenticateBearerWithTelemetry,
observeLegacyBearerAuthentication,
} from "~/services/authTelemetry.server";
@@ -295,23 +301,11 @@ async function authenticateApiKeyWithFailure(
}
}
/**
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
* needs to accept granular additional keys, then enforce that the key's ability
* authorizes `action` on `resource`. Root keys (and grace-window root keys)
* carry the unrestricted `admin` ability, preserving pre-granular behavior.
*
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
* this for routes previously guarded by a bare `authenticateApiRequest` call.
*/
export async function authenticateApiKeyWithScope(
/** Authenticate a private API-key request without requiring a resource scope. */
export async function authenticateApiKeyRequest(
request: Request,
{
action,
resource,
allowJWT = false,
}: { action: string; resource: RbacResource; allowJWT?: boolean },
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
options: BearerAuthOptions = {},
authenticateBearer: typeof authenticateBearerWithTelemetry = authenticateBearerWithTelemetry
): Promise<
| { ok: true; authentication: ApiAuthenticationResultSuccess }
| { ok: false; status: 401 | 403; error: string }
@@ -321,7 +315,7 @@ export async function authenticateApiKeyWithScope(
return { ok: false, status: 401, error: "Invalid or Missing API key" };
}
const result = await authorizeBearer(request, { action, resource }, { allowJWT });
const result = await authenticateBearer(request, options);
if (!result.ok) {
return result;
}
@@ -338,6 +332,100 @@ export async function authenticateApiKeyWithScope(
};
}
/**
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
* needs to accept granular additional keys, then enforce that the key's ability
* authorizes `action` on `resource`. Root keys (and grace-window root keys)
* carry the unrestricted `admin` ability, preserving pre-granular behavior.
*
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
* this for routes previously guarded by a bare `authenticateApiRequest` call.
*/
export type ApiKeyScopeAuthorization = {
action: string;
resource: RbacResource;
allowJWT?: boolean;
allowPreviewParent?: boolean;
};
export async function authenticateApiKeyWithScope(
request: Request,
{ action, resource, allowJWT = false, allowPreviewParent = false }: ApiKeyScopeAuthorization,
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
): Promise<
| { ok: true; authentication: ApiAuthenticationResultSuccess }
| { ok: false; status: 401 | 403; error: string }
> {
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
if (!apiKey) {
return { ok: false, status: 401, error: "Invalid or Missing API key" };
}
const result = await authorizeBearer(
request,
{ action, resource },
{ allowJWT, allowPreviewParent }
);
if (!result.ok) {
return result;
}
return {
ok: true,
authentication: {
ok: true,
apiKey,
type: "PRIVATE",
environment: result.environment,
ability: result.ability,
},
};
}
export type ScopedApiKeyAuthenticationDependencies = {
authenticateRequest: typeof authenticateRequest;
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
};
export async function authenticateRequestWithScopedApiKey(
request: Request,
{
personalAccessToken,
organizationAccessToken,
apiKey,
}: {
personalAccessToken: true;
organizationAccessToken: true;
apiKey: ApiKeyScopeAuthorization;
},
dependencies: ScopedApiKeyAuthenticationDependencies = {
authenticateRequest,
authenticateApiKeyWithScope,
}
): Promise<
| { ok: true; authentication: AuthenticationResult }
| { ok: false; status: 401 | 403; error: string }
> {
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
personalAccessToken,
organizationAccessToken,
apiKey: false,
});
if (userOrOrganizationAuthentication) {
return { ok: true, authentication: userOrOrganizationAuthentication };
}
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, apiKey);
if (!apiKeyAuthentication.ok) {
return apiKeyAuthentication;
}
return {
ok: true,
authentication: { type: "apiKey", result: apiKeyAuthentication.authentication },
};
}
export async function authenticateAuthorizationHeader(
authorization: string,
{
@@ -1,6 +1,7 @@
import { getMeter } from "@internal/tracing";
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
import type { BearerAuthOptions } from "@trigger.dev/plugins";
import type {
BearerCredentialKind,
BearerLookupPath,
@@ -39,10 +40,10 @@ const telemetry = singleton("apiAuthTelemetry", () => {
export async function authenticateBearerWithTelemetry(
request: Request,
options: { allowJWT: boolean }
options: BearerAuthOptions
): Promise<HostBearerAuthResult> {
const startedAt = performance.now();
const classified = classifyCredential(request, options.allowJWT);
const classified = classifyCredential(request, options.allowJWT ?? false);
let final = { ...classified, result: "error" as ApiAuthResult };
try {
@@ -79,7 +80,7 @@ export async function authenticateBearerWithTelemetry(
export async function authenticateAuthorizeBearerWithTelemetry(
request: Request,
check: { action: string; resource: RbacResource },
options: { allowJWT: boolean }
options: BearerAuthOptions
) {
// Keep authentication telemetry consistent with apiBuilder: a valid
// credential records a successful authentication even when the subsequent
@@ -3,9 +3,11 @@ import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { isUserActorToken } from "@trigger.dev/rbac";
import type { RbacAbility } from "@trigger.dev/rbac";
import {
authenticateApiKeyWithScope,
authenticateApiKeyRequest,
authenticateRequest,
authenticateRequestWithScopedApiKey,
type AuthenticationResult,
type ScopedApiKeyAuthenticationDependencies,
} from "~/services/apiAuth.server";
import { rbac } from "~/services/rbac.server";
@@ -28,20 +30,52 @@ export function presentedApiKeyFromAuthentication(
: undefined;
}
export function apiKeyForProjectEnvironmentBootstrap(
authentication: AuthenticationResult,
rootApiKey: string
): string {
return presentedApiKeyFromAuthentication(authentication) ?? rootApiKey;
}
/**
* Keep PAT/OAT authentication on the legacy path while routing machine API
* keys through the RBAC controller, where plugin grants are applied.
*/
type AuthenticationDependencies = {
type AuthenticationDependencies = ScopedApiKeyAuthenticationDependencies;
type BootstrapAuthenticationDependencies = {
authenticateRequest: typeof authenticateRequest;
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
authenticateApiKeyRequest: typeof authenticateApiKeyRequest;
};
export async function authenticateEnvironmentScopedApiRequest(
request: Request,
action: "read" | "write",
resource: EnvironmentScopedResource,
dependencies: AuthenticationDependencies = { authenticateRequest, authenticateApiKeyWithScope }
dependencies?: AuthenticationDependencies
): Promise<EnvironmentScopedAuthentication> {
return authenticateRequestWithScopedApiKey(
request,
{
personalAccessToken: true,
organizationAccessToken: true,
apiKey: { action, resource: { type: resource } },
},
dependencies
);
}
/**
* Bootstrap accepts any valid private environment key. Unlike env-var routes,
* it intentionally does not require a resource scope because it only echoes
* the credential the caller already presented.
*/
export async function authenticateEnvironmentBootstrapRequest(
request: Request,
dependencies: BootstrapAuthenticationDependencies = {
authenticateRequest,
authenticateApiKeyRequest,
}
): Promise<EnvironmentScopedAuthentication> {
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
personalAccessToken: true,
@@ -52,9 +86,8 @@ export async function authenticateEnvironmentScopedApiRequest(
return { ok: true, authentication: userOrOrganizationAuthentication };
}
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, {
action,
resource: { type: resource },
const apiKeyAuthentication = await dependencies.authenticateApiKeyRequest(request, {
allowPreviewParent: true,
});
if (!apiKeyAuthentication.ok) {
return apiKeyAuthentication;
+122 -3
View File
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
authenticateApiKeyRequest,
authenticateApiKeyWithScope,
authenticateRequestWithScopedApiKey,
} from "~/services/apiAuth.server";
const authorizeBearer = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe("authenticateApiKeyWithScope", () => {
it("returns 401 without a bearer credential", async () => {
const result = await authenticateApiKeyWithScope(
@@ -59,7 +67,7 @@ describe("authenticateApiKeyWithScope", () => {
expect(authorizeBearer).toHaveBeenCalledWith(
request,
{ action: "read", resource: { type: "envvars" } },
{ allowJWT: true }
{ allowJWT: true, allowPreviewParent: false }
);
expect(result).toEqual({
ok: true,
@@ -73,6 +81,59 @@ describe("authenticateApiKeyWithScope", () => {
});
});
it("allows branch creation to authenticate against the Preview parent", async () => {
const environment = { id: "env_preview" };
const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) };
authorizeBearer.mockResolvedValueOnce({
ok: true,
environment,
ability,
subject: { type: "apiKey", apiKeyId: "key_123" },
});
const request = new Request("https://example.com", {
headers: { Authorization: "Bearer tr_preview_sk_test" },
});
await expect(
authenticateApiKeyWithScope(
request,
{
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
},
authorizeBearer
)
).resolves.toMatchObject({ ok: true });
expect(authorizeBearer).toHaveBeenCalledWith(
request,
{ action: "write", resource: { type: "branches" } },
{ allowJWT: false, allowPreviewParent: true }
);
});
it("authenticates a valid API key without a resource check", async () => {
const environment = { id: "env_123" };
const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) };
const authenticateBearer = vi.fn().mockResolvedValueOnce({
ok: true,
environment,
ability,
subject: { type: "apiKey", apiKeyId: "key_123" },
});
const request = new Request("https://example.com", {
headers: { Authorization: "Bearer tr_prod_sk_test" },
});
await expect(
authenticateApiKeyRequest(request, { allowPreviewParent: true }, authenticateBearer)
).resolves.toMatchObject({
ok: true,
authentication: { apiKey: "tr_prod_sk_test", environment },
});
expect(ability.can).not.toHaveBeenCalled();
});
it("returns authorization failures from the controller", async () => {
const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) };
authorizeBearer.mockResolvedValueOnce({
@@ -94,3 +155,61 @@ describe("authenticateApiKeyWithScope", () => {
expect(ability.can).not.toHaveBeenCalled();
});
});
describe("authenticateRequestWithScopedApiKey", () => {
const options = {
personalAccessToken: true as const,
organizationAccessToken: true as const,
apiKey: {
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
},
};
it("keeps user and organization tokens on the legacy path", async () => {
const authentication = {
type: "personalAccessToken",
result: { userId: "user_123" },
} as const;
const authenticateRequest = vi.fn().mockResolvedValueOnce(authentication);
const authenticateApiKeyWithScope = vi.fn();
await expect(
authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, {
authenticateRequest,
authenticateApiKeyWithScope,
})
).resolves.toEqual({ ok: true, authentication });
expect(authenticateRequest).toHaveBeenCalledWith(expect.any(Request), {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
expect(authenticateApiKeyWithScope).not.toHaveBeenCalled();
});
it("uses scoped RBAC authentication for API keys", async () => {
const apiKeyAuthentication = {
ok: true,
apiKey: "tr_preview_sk_test",
type: "PRIVATE",
environment: {},
} as const;
const authenticateRequest = vi.fn().mockResolvedValueOnce(undefined);
const authenticateApiKeyWithScope = vi
.fn()
.mockResolvedValueOnce({ ok: true, authentication: apiKeyAuthentication });
await expect(
authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, {
authenticateRequest,
authenticateApiKeyWithScope,
})
).resolves.toEqual({
ok: true,
authentication: { type: "apiKey", result: apiKeyAuthentication },
});
expect(authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), options.apiKey);
});
});
@@ -1,10 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import {
apiKeyForProjectEnvironmentBootstrap,
authenticateEnvironmentBootstrapRequest,
authenticateEnvVarApiRequest,
presentedApiKeyFromAuthentication,
} from "~/services/environmentVariableApiAccess.server";
const authenticateRequest = vi.fn();
const authenticateApiKeyRequest = vi.fn();
const authenticateApiKeyWithScope = vi.fn();
const dependencies = { authenticateRequest, authenticateApiKeyWithScope };
@@ -31,6 +34,61 @@ describe("presentedApiKeyFromAuthentication", () => {
})
).toBeUndefined();
});
it("echoes only the presented API key during bootstrap", () => {
expect(
apiKeyForProjectEnvironmentBootstrap(
{
type: "apiKey",
result: {
ok: true,
apiKey: "tr_prod_sk_presented",
type: "PRIVATE",
environment: {},
},
},
"tr_prod_root"
)
).toBe("tr_prod_sk_presented");
});
it("returns the root key to an authorized user token", () => {
expect(
apiKeyForProjectEnvironmentBootstrap(
{
type: "personalAccessToken",
result: { userId: "user_123" },
},
"tr_prod_root"
)
).toBe("tr_prod_root");
});
});
describe("authenticateEnvironmentBootstrapRequest", () => {
it("authenticates API keys without requiring an API-key scope", async () => {
authenticateRequest.mockResolvedValueOnce(undefined);
const authentication = {
ok: true,
apiKey: "tr_preview_sk_presented",
type: "PRIVATE",
environment: {},
};
authenticateApiKeyRequest.mockResolvedValueOnce({ ok: true, authentication });
await expect(
authenticateEnvironmentBootstrapRequest(new Request("https://example.com"), {
authenticateRequest,
authenticateApiKeyRequest,
})
).resolves.toEqual({
ok: true,
authentication: { type: "apiKey", result: authentication },
});
expect(authenticateApiKeyRequest).toHaveBeenCalledWith(expect.any(Request), {
allowPreviewParent: true,
});
});
});
describe("authenticateEnvVarApiRequest", () => {
@@ -1,18 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticateEnvironmentScopedApiRequest: vi.fn<(...args: any[]) => Promise<any>>(),
authenticateEnvironmentBootstrapRequest: vi.fn<(...args: any[]) => Promise<any>>(),
authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise<any>>(),
authenticatedEnvironmentForAuthentication: vi.fn<(...args: any[]) => Promise<any>>(),
}));
vi.mock("~/services/environmentVariableApiAccess.server", () => ({
authenticateEnvironmentScopedApiRequest: mocks.authenticateEnvironmentScopedApiRequest,
authenticateEnvironmentBootstrapRequest: mocks.authenticateEnvironmentBootstrapRequest,
authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess,
presentedApiKeyFromAuthentication: (authentication: any) =>
apiKeyForProjectEnvironmentBootstrap: (authentication: any, rootApiKey: string) =>
authentication.type === "apiKey" && authentication.result.ok
? authentication.result.apiKey
: undefined,
: rootApiKey,
}));
vi.mock("~/services/apiAuth.server", () => ({
authenticatedEnvironmentForAuthentication: mocks.authenticatedEnvironmentForAuthentication,
@@ -54,7 +54,7 @@ async function responseJson(response: Response) {
describe("project environment credential response", () => {
beforeEach(() => {
mocks.authenticateEnvironmentScopedApiRequest.mockReset();
mocks.authenticateEnvironmentBootstrapRequest.mockReset();
mocks.authorizePatEnvironmentAccess.mockReset();
mocks.authenticatedEnvironmentForAuthentication.mockReset();
@@ -63,7 +63,7 @@ describe("project environment credential response", () => {
});
it("returns the presented API key", async () => {
mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({
mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({
ok: true,
authentication: {
type: "apiKey",
@@ -83,10 +83,11 @@ describe("project environment credential response", () => {
apiKey: "tr_prod_sk_presented",
projectId: "proj_123",
});
expect(mocks.authorizePatEnvironmentAccess).not.toHaveBeenCalled();
});
it("does not exchange a grace-window root key for the current root key", async () => {
mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({
mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({
ok: true,
authentication: {
type: "apiKey",
@@ -109,7 +110,7 @@ describe("project environment credential response", () => {
});
it("returns the root key to an authorized user token", async () => {
mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({
mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({
ok: true,
authentication: {
type: "personalAccessToken",
@@ -123,5 +124,6 @@ describe("project environment credential response", () => {
await expect(responseJson(response)).resolves.toMatchObject({
apiKey: "tr_prod_root_secret",
});
expect(mocks.authorizePatEnvironmentAccess).toHaveBeenCalledOnce();
});
});
@@ -195,6 +195,49 @@ describe("RBAC fallback — additional keys", () => {
expect(apiKeyFind).not.toHaveBeenCalled();
});
postgresTest("rejects revoked and expired additional keys", async ({ prisma }) => {
const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const environment = await createEnv(prisma, project.id, organization.id, {
type: "PRODUCTION",
orgMemberId: orgMember.id,
});
const revoked = generateAdditionalApiKey("PRODUCTION").apiKey;
const expired = generateAdditionalApiKey("PRODUCTION").apiKey;
await prisma.apiKey.createMany({
data: [
{
name: "Revoked deploy key",
keyHash: createHash("sha256").update(revoked).digest("hex"),
lastFour: revoked.slice(-4),
runtimeEnvironmentId: environment.id,
createdByUserId: user.id,
scopes: ["admin"],
revokedAt: new Date(),
},
{
name: "Expired deploy key",
keyHash: createHash("sha256").update(expired).digest("hex"),
lastFour: expired.slice(-4),
runtimeEnvironmentId: environment.id,
createdByUserId: user.id,
scopes: ["admin"],
expiresAt: new Date(Date.now() - 1_000),
},
],
});
await expect(rbac.authenticateBearer(bearerRequest(revoked))).resolves.toMatchObject({
ok: false,
status: 401,
});
await expect(rbac.authenticateBearer(bearerRequest(expired))).resolves.toMatchObject({
ok: false,
status: 401,
});
});
postgresTest("authenticates an additional key and records its use", async ({ prisma }) => {
const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
@@ -551,6 +594,41 @@ describe("RBAC fallback — branch header guards", () => {
});
});
postgresTest(
"allows branch management to authenticate against the preview parent",
async ({ prisma }) => {
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const previewParent = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
isBranchableEnvironment: true,
});
const additional = generateAdditionalApiKey("PREVIEW").apiKey;
await prisma.apiKey.create({
data: {
name: "Deploy key",
keyHash: createHash("sha256").update(additional).digest("hex"),
lastFour: additional.slice(-4),
runtimeEnvironmentId: previewParent.id,
createdByUserId: user.id,
presetId: "DEPLOY_ONLY",
scopes: ["write:branches"],
},
});
const result = await rbac.authenticateBearer(bearerRequest(additional), {
allowPreviewParent: true,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.environment.id).toBe(previewParent.id);
expect(result.ability.can("write", { type: "branches" })).toBe(true);
expect(result.ability.can("write", { type: "deployments" })).toBe(false);
}
);
// The "default" sentinel is DEVELOPMENT-only: it maps the dev root env to its
// (branchless) self. For PREVIEW, "default" is an ordinary branch name, so a
// PREVIEW branch literally named "default" is reachable and the request pivots
@@ -5,6 +5,7 @@ import type { Prisma, PrismaClient } from "@trigger.dev/database";
import {
scopesGrantFullAccess,
type AuthenticatedEnvironment,
type BearerAuthOptions,
type BearerAuthResult,
} from "@trigger.dev/plugins";
import { createHash } from "node:crypto";
@@ -80,7 +81,7 @@ export class BearerCredentialResolver {
async authenticate(
request: Request,
options?: { allowJWT?: boolean }
options?: BearerAuthOptions
): Promise<BearerCredentialResult> {
// Deprecated public API keys (`pk_*` minted long before public JWTs
// landed) are intentionally NOT handled here. That token format hasn't
@@ -221,15 +222,16 @@ export class BearerCredentialResolver {
};
}
return this.resolveAdditionalKey(rawToken, branchName);
return this.resolveAdditionalKey(rawToken, branchName, options?.allowPreviewParent);
}
return this.resolveRootKey(rawToken, branchName);
return this.resolveRootKey(rawToken, branchName, options?.allowPreviewParent);
}
private async resolveRootKey(
rawToken: string,
branchName: string | null
branchName: string | null,
allowPreviewParent = false
): Promise<BearerCredentialResult> {
const include = environmentInclude(branchName);
const now = new Date();
@@ -270,7 +272,7 @@ export class BearerCredentialResolver {
};
}
const [branchError, resolvedEnvironment] = resolveBranch(env, branchName);
const [branchError, resolvedEnvironment] = resolveBranch(env, branchName, allowPreviewParent);
if (branchError !== null) {
return {
ok: false,
@@ -296,7 +298,8 @@ export class BearerCredentialResolver {
private async resolveAdditionalKey(
rawToken: string,
branchName: string | null
branchName: string | null,
allowPreviewParent = false
): Promise<BearerCredentialResult> {
const resolution: BearerResolution = {
credentialKind: "additional_api_key",
@@ -321,7 +324,11 @@ export class BearerCredentialResolver {
return { ok: false, status: 401, error: "Invalid API key", resolution };
}
const [branchError, resolvedEnvironment] = resolveBranch(match.runtimeEnvironment, branchName);
const [branchError, resolvedEnvironment] = resolveBranch(
match.runtimeEnvironment,
branchName,
allowPreviewParent
);
if (branchError !== null) {
return {
ok: false,
@@ -387,9 +394,10 @@ type BranchResolution =
function resolveBranch(
environment: EnvironmentWithBranches,
branchName: string | null
branchName: string | null,
allowPreviewParent: boolean
): BranchResolution {
if (environment.type === "PREVIEW" && !branchName) {
if (environment.type === "PREVIEW" && !branchName && !allowPreviewParent) {
return ["x-trigger-branch header required for preview env", null];
}
+3 -2
View File
@@ -4,6 +4,7 @@ import type {
RbacUser,
RbacSubject,
RbacResource,
BearerAuthOptions,
BearerAuthResult,
PatAuthResult,
SessionAuthResult,
@@ -87,7 +88,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
async authenticateBearer(
request: Request,
options?: { allowJWT?: boolean }
options?: BearerAuthOptions
): Promise<BearerAuthResult> {
return this.bearer.authenticate(request, options);
}
@@ -174,7 +175,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
async authenticateAuthorizeBearer(
request: Request,
check: { action: string; resource: RbacResource | RbacResource[] },
options?: { allowJWT?: boolean }
options?: BearerAuthOptions
): Promise<BearerAuthResult> {
const auth = await this.authenticateBearer(request, options);
if (!auth.ok) return auth;
+8 -5
View File
@@ -1,6 +1,7 @@
import type {
ApiKeyPolicyDescription,
ApiKeyPreset,
BearerAuthOptions,
BearerAuthResult,
Permission,
PrepareApiKeyPolicyResult,
@@ -22,7 +23,12 @@ import { RoleBaseAccessFallback } from "./fallback.js";
// exchange route imports it to project requested scopes against it.
export { CAPLESS_USER_ACTOR_SCOPES } from "./fallback.js";
import { BearerCredentialResolver, type BearerResolution } from "./bearerCredentials.js";
export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins";
export type {
BearerAuthOptions,
RoleBaseAccessController,
RbacAbility,
RbacResource,
} from "@trigger.dev/plugins";
export type {
BearerCredentialKind,
BearerLookupPath,
@@ -42,10 +48,7 @@ export type {
export type HostBearerAuthResult = BearerAuthResult & { resolution: BearerResolution };
export type HostRbacController = Omit<Required<RoleBaseAccessController>, "authenticateBearer"> & {
authenticateBearer(
request: Request,
options?: { allowJWT?: boolean }
): Promise<HostBearerAuthResult>;
authenticateBearer(request: Request, options?: BearerAuthOptions): Promise<HostBearerAuthResult>;
};
export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins";
export { buildJwtAbility, scopesWithinAbility } from "./ability.js";
+7 -5
View File
@@ -30,6 +30,7 @@ import {
wrapCommandAction,
} from "../cli/common.js";
import { loadConfig } from "../config.js";
import { authenticateForDeploy, userIdForDeploy } from "../deploy/auth.js";
import { buildImage } from "../deploy/buildImage.js";
import {
checkLogsForErrors,
@@ -268,11 +269,12 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
verifyDirectory(dir, projectPath);
const authorization = await login({
embedded: true,
defaultApiUrl: options.apiUrl,
const authorization = await authenticateForDeploy({
accessToken: process.env.TRIGGER_ACCESS_TOKEN,
apiUrl: process.env.TRIGGER_API_URL ?? options.apiUrl,
profile: options.profile,
silent: options.plain,
login,
});
if (!authorization.ok) {
@@ -368,7 +370,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
config: resolvedConfig,
dashboardUrl: authorization.dashboardUrl,
options,
userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined,
userId: userIdForDeploy(authorization),
gitMeta,
});
return;
@@ -424,7 +426,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
projectClient.client,
{
contentHash: buildManifest.contentHash,
userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined,
userId: userIdForDeploy(authorization),
gitMeta,
type: features.run_engine_v2 ? "MANAGED" : "V1",
runtime: buildManifest.runtime,
+1 -2
View File
@@ -14,7 +14,6 @@ import { loadConfig } from "../config.js";
import { createGitMeta } from "../utilities/gitMeta.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
import type { LoginResultOk } from "../utilities/session.js";
import { spinner } from "../utilities/windows.js";
import { verifyDirectory } from "./deploy.js";
import { login } from "./login.js";
@@ -129,7 +128,7 @@ async function _previewArchiveCommand(dir: string, options: PreviewCommandOption
}
export async function archivePreviewBranch(
authorization: LoginResultOk,
authorization: { auth: { apiUrl: string; accessToken: string } },
branch: string,
project: string
) {
+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../utilities/configFiles.js", () => ({
readAuthConfigProfile: vi.fn(() => undefined),
}));
import { authenticateForDeploy, userIdForDeploy } from "./auth.js";
import { readAuthConfigProfile } from "../utilities/configFiles.js";
describe("authenticateForDeploy", () => {
it("uses an API key from TRIGGER_ACCESS_TOKEN without logging in", async () => {
let loginCalled = false;
const result = await authenticateForDeploy({
accessToken: "tr_prod_sk_deploy",
apiUrl: "https://example.trigger.dev",
profile: "default",
silent: true,
login: async () => {
loginCalled = true;
return { ok: false, error: "should not be called" };
},
});
expect(loginCalled).toBe(false);
expect(result).toEqual({
ok: true,
profile: "default",
dashboardUrl: "https://example.trigger.dev",
auth: {
apiUrl: "https://example.trigger.dev",
accessToken: "tr_prod_sk_deploy",
tokenType: "apiKey",
},
});
});
it("derives hosted dashboard links without user metadata", async () => {
const result = await authenticateForDeploy({
accessToken: "tr_preview_sk_deploy",
apiUrl: "https://api.example.trigger.dev",
profile: "default",
silent: true,
login: async () => ({ ok: false, error: "should not be called" }),
});
expect(result).toMatchObject({
dashboardUrl: "https://example.trigger.dev",
auth: { apiUrl: "https://api.example.trigger.dev" },
});
});
it("uses the normal cloud URLs when no API URL is set", async () => {
const result = await authenticateForDeploy({
accessToken: "tr_prod_sk_deploy",
profile: "default",
silent: true,
login: async () => ({ ok: false, error: "should not be called" }),
});
expect(result).toMatchObject({
dashboardUrl: "https://cloud.trigger.dev",
auth: { apiUrl: "https://api.trigger.dev" },
});
});
it("falls back to the saved profile's API URL for self-hosted instances", async () => {
vi.mocked(readAuthConfigProfile).mockReturnValueOnce({
apiUrl: "https://trigger.internal.example.com",
});
const result = await authenticateForDeploy({
accessToken: "tr_prod_sk_deploy",
profile: "selfhosted",
silent: true,
login: async () => ({ ok: false, error: "should not be called" }),
});
expect(result).toMatchObject({
dashboardUrl: "https://trigger.internal.example.com",
auth: { apiUrl: "https://trigger.internal.example.com" },
});
});
it("prefers an explicit API URL over the saved profile", async () => {
vi.mocked(readAuthConfigProfile).mockReturnValueOnce({
apiUrl: "https://trigger.internal.example.com",
});
const result = await authenticateForDeploy({
accessToken: "tr_prod_sk_deploy",
apiUrl: "https://api.trigger.dev",
profile: "selfhosted",
silent: true,
login: async () => ({ ok: false, error: "should not be called" }),
});
expect(result).toMatchObject({
auth: { apiUrl: "https://api.trigger.dev" },
});
});
it("passes a PAT through to the login path", async () => {
let loginOptions: unknown;
const result = await authenticateForDeploy({
accessToken: "tr_pat_abc123",
apiUrl: "https://example.trigger.dev",
profile: "ci",
silent: false,
login: async (options) => {
loginOptions = options;
return { ok: false, error: "login result" };
},
});
expect(loginOptions).toEqual({
embedded: true,
defaultApiUrl: "https://example.trigger.dev",
profile: "ci",
silent: false,
});
expect(result).toEqual({ ok: false, error: "login result" });
});
it("passes an OAT through to the login path", async () => {
let loginOptions: unknown;
const result = await authenticateForDeploy({
accessToken: "tr_oat_abc123",
apiUrl: "https://example.trigger.dev",
profile: "ci",
silent: false,
login: async (options) => {
loginOptions = options;
return { ok: false, error: "login result" };
},
});
expect(loginOptions).toEqual({
embedded: true,
defaultApiUrl: "https://example.trigger.dev",
profile: "ci",
silent: false,
});
expect(result).toEqual({ ok: false, error: "login result" });
});
it("keeps login authentication when no access token is set", async () => {
let loginOptions: unknown;
const result = await authenticateForDeploy({
apiUrl: "https://example.trigger.dev",
profile: "ci",
silent: false,
login: async (options) => {
loginOptions = options;
return { ok: false, error: "login result" };
},
});
expect(loginOptions).toEqual({
embedded: true,
defaultApiUrl: "https://example.trigger.dev",
profile: "ci",
silent: false,
});
expect(result).toEqual({ ok: false, error: "login result" });
});
it("throws a descriptive error for an invalid API URL", async () => {
await expect(
authenticateForDeploy({
accessToken: "tr_prod_sk_deploy",
apiUrl: "not-a-url",
profile: "default",
silent: true,
login: async () => ({ ok: false, error: "should not be called" }),
})
).rejects.toThrow(
'Invalid API URL "not-a-url". Check your TRIGGER_API_URL environment variable or --api-url flag.'
);
});
});
describe("userIdForDeploy", () => {
it("omits user attribution for API-key deployments", () => {
expect(
userIdForDeploy({
ok: true,
profile: "default",
dashboardUrl: "https://cloud.trigger.dev",
auth: {
apiUrl: "https://api.trigger.dev",
accessToken: "tr_prod_sk_deploy",
tokenType: "apiKey",
},
})
).toBeUndefined();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { CLOUD_API_URL, CLOUD_WEB_URL } from "../consts.js";
import { readAuthConfigProfile } from "../utilities/configFiles.js";
import type { LoginResult, LoginResultOk } from "../utilities/session.js";
const personalTokenPrefix = "tr_pat_";
const organizationTokenPrefix = "tr_oat_";
const apiKeyPrefix = "tr_";
export type DeployAuthorization =
| LoginResultOk
| {
ok: true;
profile: string;
dashboardUrl: string;
auth: {
apiUrl: string;
accessToken: string;
tokenType: "apiKey";
};
};
type LoginForDeploy = (options: {
embedded: true;
defaultApiUrl: string;
profile: string;
silent: boolean;
}) => Promise<LoginResult>;
export async function authenticateForDeploy({
accessToken,
apiUrl,
profile,
silent,
login,
}: {
accessToken?: string;
apiUrl?: string;
profile: string;
silent: boolean;
login: LoginForDeploy;
}): Promise<LoginResult | DeployAuthorization> {
const authConfig = readAuthConfigProfile(profile);
const resolvedApiUrl = apiUrl ?? authConfig?.apiUrl ?? CLOUD_API_URL;
const isApiKey =
!!accessToken &&
accessToken.startsWith(apiKeyPrefix) &&
!accessToken.startsWith(personalTokenPrefix) &&
!accessToken.startsWith(organizationTokenPrefix);
if (!isApiKey) {
return login({
embedded: true,
defaultApiUrl: resolvedApiUrl,
profile,
silent,
});
}
return {
ok: true,
profile,
dashboardUrl: dashboardUrlForApiUrl(resolvedApiUrl),
auth: {
apiUrl: resolvedApiUrl,
accessToken,
tokenType: "apiKey",
},
};
}
function dashboardUrlForApiUrl(apiUrl: string): string {
if (apiUrl === CLOUD_API_URL) {
return CLOUD_WEB_URL;
}
try {
const url = new URL(apiUrl);
if (url.hostname.startsWith("api.") && url.hostname.endsWith(".trigger.dev")) {
url.hostname = url.hostname.slice(4);
return url.toString().replace(/\/$/, "");
}
} catch {
throw new Error(
`Invalid API URL "${apiUrl}". Check your TRIGGER_API_URL environment variable or --api-url flag.`
);
}
return apiUrl;
}
export function userIdForDeploy(authorization: DeployAuthorization): string | undefined {
return authorization.auth.tokenType === "personal" && "userId" in authorization
? authorization.userId
: undefined;
}
+1
View File
@@ -25,6 +25,7 @@ export type {
AuthenticatedEnvironment,
RbacScopeAction,
RbacScopeResourceType,
BearerAuthOptions,
} from "./rbac.js";
export {
+10 -2
View File
@@ -198,6 +198,7 @@ export type RbacScopeResourceType =
| "deployments"
| "envvars"
| "apiKeys"
| "branches"
| "sessions"
| "waitpoints"
| "tags"
@@ -381,6 +382,13 @@ export type BearerAuthResult =
jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean; act?: { sub: string } };
};
export type BearerAuthOptions = {
allowJWT?: boolean;
// Branch creation authenticates against the branchable Preview parent before
// the requested child environment exists.
allowPreviewParent?: boolean;
};
export type SessionAuthResult =
| { ok: false; reason: "unauthenticated" | "unauthorized" }
| { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
@@ -433,7 +441,7 @@ export interface RoleBaseAccessController {
// API routes (Bearer token): one DB query → identity + pre-built ability
// options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise<BearerAuthResult>;
authenticateBearer(request: Request, options?: BearerAuthOptions): Promise<BearerAuthResult>;
// Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability.
// The caller resolves `userId` from the session cookie and passes it in.
@@ -481,7 +489,7 @@ export interface RoleBaseAccessController {
authenticateAuthorizeBearer(
request: Request,
check: { action: string; resource: RbacResource | RbacResource[] },
options?: { allowJWT?: boolean }
options?: BearerAuthOptions
): Promise<BearerAuthResult>;
authenticateAuthorizeSession(