e4981d1b11
## Summary
Consolidates the webapp's authentication and authorization into a small
set of route helpers, replacing the ad-hoc `requireUser` /
`requireUserId` / `authenticatedEnvironmentForAuthentication` calls
scattered across routes. Same security model, but the per-request flow
(authenticate → authorize → load) now lives in one place per route
family.
Introduces a plugin seam (`@trigger.dev/plugins`) that lets the cloud
build install a richer RBAC implementation without touching webapp code.
The OSS fallback keeps the pre-RBAC permissive behaviour intact, so
self-hosted deployments work unchanged.
Adds a comprehensive end-to-end auth test suite that didn't exist before
— 193 `it()` blocks (vitest reports ~199 after `it.each` expansion)
covering API key, PAT and JWT auth across the public API surface, plus
dashboard session auth for admin pages.
## Changes
### Plugin contract — `@trigger.dev/plugins`
`RoleBaseAccessController` interface authoritative for both OSS
(fallback) and cloud (enterprise plugin):
- `authenticateBearer(request, { allowJWT? })` — API-key / public-JWT
auth, returns env + ability
- `authenticateSession(request, { userId, organizationId?, projectId?
})` — dashboard auth, caller resolves `userId` from the session cookie
and passes it in (no `helpers.getSessionUserId` callback — decouples the
plugin host from session-cookie code)
- `authenticatePat(request, { organizationId?, projectId? })` — PAT
auth, returns identity + `lastAccessedAt` so the host can throttle the
per-request update
- `authenticateAuthorize*` variants for the auth-and-check-in-one-call
cases
- `isUsingPlugin(): Promise<boolean>` — capability flag for UI /
branching where plugin-present-ness matters; replaces the
sentinel-string coupling that had `personalAccessToken.server` matching
`"RBAC plugin not installed"` literally
### Dashboard auth (started, partial rollout)
Admin and settings pages migrated to a unified `dashboardLoader` /
`dashboardAction` helper that authenticates the session, runs an
authorization check, and exposes the result to the route. Other
dashboard routes still on the old pattern; remaining migration tracked
in TRI-8730.
Migrated routes:
- `admin.*` (14 admin / back-office / feature-flags / LLM-models /
notifications / orgs / concurrency pages)
- `_app.orgs.$organizationSlug.settings.team`
- `_app.orgs.$organizationSlug.settings.roles`
### API / realtime / engine auth (complete for the migrated families)
71 routes migrated to a unified `apiBuilder` that centralizes Bearer /
PAT / Public-JWT authentication and applies the per-route authorization
check before the handler runs. Includes:
- `api.v1.*` and `api.v2.*` and `api.v3.*` — tasks, runs, batches,
queues, prompts, deployments, query, sessions, waitpoints, packets,
workers, idempotency keys
- `realtime.v1.*` — runs, batches, sessions, streams
- `engine.v1.*` — dev / worker-action protocols
29 routes still on the legacy `authenticateApiRequest*` helpers —
tracked as a post-deploy follow-up in TRI-9228.
Multi-resource auth direction is now explicit at the call site via
`anyResource(...)` (OR) and `everyResource(...)` (AND). Bare arrays no
longer typecheck — fixes a class of bug where a JWT scoped to one
resource could implicitly access others under OR semantics.
PAT auth path consolidated: was three DB queries per request (legacy
`authenticateApiRequestWithPersonalAccessToken` findFirst +
`rbac.authenticatePat` join + `lastAccessedAt` update). Now one query in
the steady state — plugin returns `lastAccessedAt`, host smart-skips the
update via JS-side throttle when fresh.
Side effect: action aliases preserved historic JWT scope semantics where
the new model is stricter (e.g. a `write:tasks` JWT now also satisfies
`trigger` / `batchTrigger` / `update` actions on the same resource —
matched at the auth boundary, not in the route handler).
### Backwards-compat fixes
The strict-match model regressed several real-world JWT shapes. Each
preserved via explicit `anyResource(...)` entries in the route's authz
block:
- **Batch retrieve routes** (`api.v1.batches.$batchId`, `api.v2.*`,
`realtime.v1.batches.*`) accept `read:runs` JWTs again (pre-RBAC
literal-match superScope behaviour)
- **Runs list routes** (`api.v1.runs`, `realtime.v1.runs`) accept
type-level `read:tasks` / `read:tags` on unfiltered queries (matched the
legacy `Object.keys` iteration semantic)
- **PAT/OAT auth shape** normalized through `toAuthenticated` so all
auth methods return the same slim `AuthenticatedEnvironment` (was:
API-key returned the slim shape but PAT/OAT returned raw Prisma
`Decimal` / no `orgMember`)
- **Scope `:` preservation** in resource ids — `read:tags:env:staging`
now correctly identifies the tag id as `env:staging`, not `env`
### Slim `AuthenticatedEnvironment`
Extracted to `@trigger.dev/core/v3/auth/environment` — a structural
shape independent of `@trigger.dev/database`. The plugin contract
returns this; webapp consumers import from there; the cloud plugin
(Drizzle) returns the same shape without Prisma's `Decimal` class
leaking into the public surface. Lets internal-packages (run-engine,
etc.) refer to `AuthenticatedEnvironment` without pulling Prisma in.
### Auth test suite (new — `*.e2e.full.test.ts`)
193 e2e tests run against a real spawned webapp + Postgres (no mocks).
Coverage matrix:
- **API key auth** — read / write / trigger / batchTrigger / deploy
actions across runs, batches, deployments, prompts, queues, query,
sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key
resources (a run carries batch / tag / task identifiers — auth must
accept any matching scope)
- **Personal Access Token auth** — comprehensive matrix: scope match,
scope mismatch, missing scope, expired token, malformed token
- **Public JWT auth** — sub-vs-URL environment resolution, expired JWTs,
signature verification, scope checking, otu (one-time-use) token
semantics, branch-environment signing-key fallback
- **Dashboard session auth** — admin-only pages reject non-admins;
per-action gating
- **Cross-cutting edge cases** — revoked API key grace window, JWT
cross-environment isolation, MissingResource branch behaviour
### Hygiene cleanups
- Deleted dead `app/services/authorization.server.ts` (legacy
`checkAuthorization` + types — no live consumers post-migration) and its
orphaned test
- Dropped the never-populated `scopes` field from
`ApiAuthenticationResultSuccess`
- `scheduleEmail` moved out of `email.server.ts` into its own module —
breaks a `commonWorker → marqs/V1` import chain that was poisoning the
auth test graph
- OSS Roles page shows a deployment-aware empty state ("Roles aren't
available in this self-hosted deployment" vs the plan-upsell copy) via
`rbac.isUsingPlugin()`
- Team action handler: explicit per-intent ability gates
(`manage:billing` for purchase-seats, `manage:members` for set-role +
remove-member with self-leave carve-out)
### Cross-repo coordination
All public-package contract changes paired in `triggerdotdev/cloud#763`
(rbac-packages branch) — the enterprise plugin implements the same
`RoleBaseAccessController` interface against Drizzle.
## Test plan
- [x] `pnpm run typecheck --filter webapp` clean
- [x] `pnpm --filter webapp exec vitest run --config
vitest.e2e.full.config.ts` — 193/193 pass (requires Docker for
testcontainers)
- [x] Spot-check an authed API endpoint with a valid + invalid API key
against a local stack
- [x] Spot-check the migrated admin pages render and gate non-admins
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
764 lines
21 KiB
TypeScript
764 lines
21 KiB
TypeScript
import { json } from "@remix-run/server-runtime";
|
|
import { SignJWT, errors, jwtVerify } from "jose";
|
|
import { z } from "zod";
|
|
|
|
import { $replica } from "~/db.server";
|
|
import { env } from "~/env.server";
|
|
import { findProjectByRef } from "~/models/project.server";
|
|
import {
|
|
authIncludeBase,
|
|
authIncludeWithParent,
|
|
findEnvironmentByApiKey,
|
|
findEnvironmentByPublicApiKey,
|
|
toAuthenticated,
|
|
} from "~/models/runtimeEnvironment.server";
|
|
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
|
import { logger } from "./logger.server";
|
|
import {
|
|
type PersonalAccessTokenAuthenticationResult,
|
|
authenticateApiRequestWithPersonalAccessToken,
|
|
isPersonalAccessToken,
|
|
} from "./personalAccessToken.server";
|
|
import {
|
|
type OrganizationAccessTokenAuthenticationResult,
|
|
authenticateApiRequestWithOrganizationAccessToken,
|
|
isOrganizationAccessToken,
|
|
} from "./organizationAccessToken.server";
|
|
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
|
|
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
|
|
|
const ClaimsSchema = z.object({
|
|
scopes: z.array(z.string()).optional(),
|
|
// One-time use token
|
|
otu: z.boolean().optional(),
|
|
realtime: z
|
|
.object({
|
|
skipColumns: z.array(z.string()).optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
// Re-export the slim shape defined in @trigger.dev/core. Single source of
|
|
// truth across the auth boundary (RBAC plugin contract → webapp handlers).
|
|
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
|
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
|
|
|
export type ApiAuthenticationResult =
|
|
| ApiAuthenticationResultSuccess
|
|
| ApiAuthenticationResultFailure;
|
|
|
|
export type ApiAuthenticationResultSuccess = {
|
|
ok: true;
|
|
apiKey: string;
|
|
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
|
environment: AuthenticatedEnvironment;
|
|
oneTimeUse?: boolean;
|
|
realtime?: {
|
|
skipColumns?: string[];
|
|
};
|
|
};
|
|
|
|
export type ApiAuthenticationResultFailure = {
|
|
ok: false;
|
|
error: string;
|
|
};
|
|
|
|
/**
|
|
* @deprecated Use `authenticateApiRequestWithFailure` instead.
|
|
*/
|
|
export async function authenticateApiRequest(
|
|
request: Request,
|
|
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
|
): Promise<ApiAuthenticationResultSuccess | undefined> {
|
|
const { apiKey, branchName } = getApiKeyFromRequest(request);
|
|
|
|
if (!apiKey) {
|
|
return;
|
|
}
|
|
|
|
const authentication = await authenticateApiKey(apiKey, { ...options, branchName });
|
|
|
|
return authentication;
|
|
}
|
|
|
|
/**
|
|
* This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined.
|
|
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
|
|
*/
|
|
export async function authenticateApiRequestWithFailure(
|
|
request: Request,
|
|
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
|
): Promise<ApiAuthenticationResult> {
|
|
const { apiKey, branchName } = getApiKeyFromRequest(request);
|
|
|
|
if (!apiKey) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid API Key",
|
|
};
|
|
}
|
|
|
|
const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName });
|
|
|
|
return authentication;
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use `authenticateApiKeyWithFailure` instead.
|
|
*/
|
|
export async function authenticateApiKey(
|
|
apiKey: string,
|
|
options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {}
|
|
): Promise<ApiAuthenticationResultSuccess | undefined> {
|
|
const result = getApiKeyResult(apiKey);
|
|
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
if (!options.allowPublicKey && result.type === "PUBLIC") {
|
|
return;
|
|
}
|
|
|
|
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
|
|
return;
|
|
}
|
|
|
|
switch (result.type) {
|
|
case "PUBLIC": {
|
|
const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
|
|
if (!environment) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
environment,
|
|
};
|
|
}
|
|
case "PRIVATE": {
|
|
const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
|
|
if (!environment) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
environment,
|
|
};
|
|
}
|
|
case "PUBLIC_JWT": {
|
|
const validationResults = await validatePublicJwtKey(result.apiKey);
|
|
|
|
if (!validationResults.ok) {
|
|
return;
|
|
}
|
|
|
|
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
environment: validationResults.environment,
|
|
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
|
|
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
|
|
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
|
|
*/
|
|
async function authenticateApiKeyWithFailure(
|
|
apiKey: string,
|
|
options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {}
|
|
): Promise<ApiAuthenticationResult> {
|
|
const result = getApiKeyResult(apiKey);
|
|
|
|
if (!result) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid API Key",
|
|
};
|
|
}
|
|
|
|
if (!options.allowPublicKey && result.type === "PUBLIC") {
|
|
return {
|
|
ok: false,
|
|
error: "Public API keys are not allowed for this request",
|
|
};
|
|
}
|
|
|
|
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
|
|
return {
|
|
ok: false,
|
|
error: "Public JWT API keys are not allowed for this request",
|
|
};
|
|
}
|
|
|
|
switch (result.type) {
|
|
case "PUBLIC": {
|
|
const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
|
|
if (!environment) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid API Key",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
environment,
|
|
};
|
|
}
|
|
case "PRIVATE": {
|
|
const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
|
|
if (!environment) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid API Key",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
environment,
|
|
};
|
|
}
|
|
case "PUBLIC_JWT": {
|
|
const validationResults = await validatePublicJwtKey(result.apiKey);
|
|
|
|
if (!validationResults.ok) {
|
|
return validationResults;
|
|
}
|
|
|
|
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
environment: validationResults.environment,
|
|
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
|
|
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function authenticateAuthorizationHeader(
|
|
authorization: string,
|
|
{
|
|
allowPublicKey = false,
|
|
allowJWT = false,
|
|
}: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
|
): Promise<ApiAuthenticationResult | undefined> {
|
|
const apiKey = getApiKeyFromHeader(authorization);
|
|
|
|
if (!apiKey) {
|
|
return;
|
|
}
|
|
|
|
return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
|
|
}
|
|
|
|
function isPublicApiKey(key: string) {
|
|
return key.startsWith("pk_");
|
|
}
|
|
|
|
function isSecretApiKey(key: string) {
|
|
return key.startsWith("tr_");
|
|
}
|
|
|
|
export function branchNameFromRequest(request: Request): string | undefined {
|
|
return request.headers.get("x-trigger-branch") ?? undefined;
|
|
}
|
|
|
|
function getApiKeyFromRequest(request: Request): {
|
|
apiKey: string | undefined;
|
|
branchName: string | undefined;
|
|
} {
|
|
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
|
|
const branchName = branchNameFromRequest(request);
|
|
|
|
return { apiKey, branchName };
|
|
}
|
|
|
|
function getApiKeyFromHeader(authorization?: string | null) {
|
|
if (typeof authorization !== "string" || !authorization) {
|
|
return;
|
|
}
|
|
|
|
const apiKey = authorization.replace(/^Bearer /, "");
|
|
return apiKey;
|
|
}
|
|
|
|
function getApiKeyResult(apiKey: string): {
|
|
apiKey: string;
|
|
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
|
} {
|
|
const type = isPublicApiKey(apiKey)
|
|
? "PUBLIC"
|
|
: isSecretApiKey(apiKey)
|
|
? "PRIVATE"
|
|
: isPublicJWT(apiKey)
|
|
? "PUBLIC_JWT"
|
|
: "PRIVATE"; // Fallback to private key
|
|
return { apiKey, type };
|
|
}
|
|
|
|
export type AuthenticationResult =
|
|
| {
|
|
type: "personalAccessToken";
|
|
result: PersonalAccessTokenAuthenticationResult;
|
|
}
|
|
| {
|
|
type: "organizationAccessToken";
|
|
result: OrganizationAccessTokenAuthenticationResult;
|
|
}
|
|
| {
|
|
type: "apiKey";
|
|
result: ApiAuthenticationResult;
|
|
};
|
|
|
|
type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | "apiKey";
|
|
|
|
type AllowedAuthenticationMethods = Record<AuthenticationMethod, boolean> &
|
|
({ personalAccessToken: true } | { organizationAccessToken: true } | { apiKey: true });
|
|
|
|
const defaultAllowedAuthenticationMethods: AllowedAuthenticationMethods = {
|
|
personalAccessToken: true,
|
|
organizationAccessToken: true,
|
|
apiKey: true,
|
|
};
|
|
|
|
type FilteredAuthenticationResult<
|
|
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods
|
|
> =
|
|
| (T["personalAccessToken"] extends true
|
|
? Extract<AuthenticationResult, { type: "personalAccessToken" }>
|
|
: never)
|
|
| (T["organizationAccessToken"] extends true
|
|
? Extract<AuthenticationResult, { type: "organizationAccessToken" }>
|
|
: never)
|
|
| (T["apiKey"] extends true ? Extract<AuthenticationResult, { type: "apiKey" }> : never);
|
|
|
|
/**
|
|
* Authenticates an incoming request by checking for various token types.
|
|
*
|
|
* Supports personal access tokens, organization access tokens, and API keys.
|
|
* Returns the appropriate authentication result based on the token type found.
|
|
*
|
|
* This method currently only allows private keys for the `apiKey` authentication method.
|
|
*
|
|
* @template T - The allowed authentication methods configuration type
|
|
* @param request - The incoming HTTP request containing authentication headers
|
|
* @param allowedAuthenticationMethods - Configuration object specifying which authentication methods are allowed.
|
|
* At least one method must be set to `true`. Defaults to allowing all methods.
|
|
* @returns Authentication result with only the enabled auth method types, or undefined if no valid token found
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Only allow personal access tokens
|
|
* const result = await authenticateRequest(request, {
|
|
* personalAccessToken: true,
|
|
* organizationAccessToken: false,
|
|
* apiKey: false,
|
|
* });
|
|
* // result type: { type: "personalAccessToken"; result: PersonalAccessTokenAuthenticationResult } | undefined
|
|
* ```
|
|
*/
|
|
export async function authenticateRequest<
|
|
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods
|
|
>(
|
|
request: Request,
|
|
allowedAuthenticationMethods?: T
|
|
): Promise<FilteredAuthenticationResult<T> | undefined> {
|
|
const allowedMethods = allowedAuthenticationMethods ?? defaultAllowedAuthenticationMethods;
|
|
|
|
const { apiKey, branchName } = getApiKeyFromRequest(request);
|
|
if (!apiKey) {
|
|
return;
|
|
}
|
|
|
|
if (allowedMethods.personalAccessToken && isPersonalAccessToken(apiKey)) {
|
|
const result = await authenticateApiRequestWithPersonalAccessToken(request);
|
|
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
type: "personalAccessToken",
|
|
result,
|
|
} satisfies Extract<
|
|
AuthenticationResult,
|
|
{ type: "personalAccessToken" }
|
|
> as FilteredAuthenticationResult<T>;
|
|
}
|
|
|
|
if (allowedMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) {
|
|
const result = await authenticateApiRequestWithOrganizationAccessToken(request);
|
|
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
type: "organizationAccessToken",
|
|
result,
|
|
} satisfies Extract<
|
|
AuthenticationResult,
|
|
{ type: "organizationAccessToken" }
|
|
> as FilteredAuthenticationResult<T>;
|
|
}
|
|
|
|
if (allowedMethods.apiKey) {
|
|
const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName });
|
|
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
type: "apiKey",
|
|
result,
|
|
} satisfies Extract<
|
|
AuthenticationResult,
|
|
{ type: "apiKey" }
|
|
> as FilteredAuthenticationResult<T>;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
export async function authenticatedEnvironmentForAuthentication(
|
|
auth: AuthenticationResult,
|
|
projectRef: string,
|
|
slug: string,
|
|
branch?: string
|
|
): Promise<AuthenticatedEnvironment> {
|
|
if (slug === "staging") {
|
|
slug = "stg";
|
|
}
|
|
|
|
switch (auth.type) {
|
|
case "apiKey": {
|
|
if (!auth.result.ok) {
|
|
throw json({ error: auth.result.error }, { status: 401 });
|
|
}
|
|
|
|
if (auth.result.environment.project.externalRef !== projectRef) {
|
|
throw json(
|
|
{
|
|
error:
|
|
"Invalid project ref for this API key. Make sure you are using an API key associated with that project.",
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
if (auth.result.environment.slug !== slug && auth.result.environment.branchName !== branch) {
|
|
throw json(
|
|
{
|
|
error:
|
|
"Invalid environment slug for this API key. Make sure you are using an API key associated with that environment.",
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
return auth.result.environment;
|
|
}
|
|
case "personalAccessToken": {
|
|
const user = await $replica.user.findUnique({
|
|
where: {
|
|
id: auth.result.userId,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw json({ error: "Invalid or missing personal access token" }, { status: 401 });
|
|
}
|
|
|
|
const project = await findProjectByRef(projectRef, user.id);
|
|
|
|
if (!project) {
|
|
throw json({ error: "Project not found" }, { status: 404 });
|
|
}
|
|
|
|
const sanitizedBranch = sanitizeBranchName(branch);
|
|
|
|
if (!sanitizedBranch) {
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
slug: slug,
|
|
...(slug === "dev"
|
|
? {
|
|
orgMember: {
|
|
userId: user.id,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
include: authIncludeBase,
|
|
});
|
|
|
|
if (!environment) {
|
|
throw json({ error: "Environment not found" }, { status: 404 });
|
|
}
|
|
|
|
return toAuthenticated(environment);
|
|
}
|
|
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
type: "PREVIEW",
|
|
branchName: sanitizedBranch,
|
|
archivedAt: null,
|
|
},
|
|
include: authIncludeWithParent,
|
|
});
|
|
|
|
if (!environment) {
|
|
throw json({ error: "Branch not found" }, { status: 404 });
|
|
}
|
|
|
|
if (!environment.parentEnvironment) {
|
|
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
|
|
}
|
|
|
|
// PREVIEW envs reuse the parent's apiKey for downstream auth flows
|
|
// (signed JWTs, internal-fetch helpers). Override before mapping so
|
|
// the slim shape carries the parent's key.
|
|
return toAuthenticated({
|
|
...environment,
|
|
apiKey: environment.parentEnvironment.apiKey,
|
|
});
|
|
}
|
|
case "organizationAccessToken": {
|
|
const organization = await $replica.organization.findUnique({
|
|
where: {
|
|
id: auth.result.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!organization) {
|
|
throw json({ error: "Invalid or missing organization access token" }, { status: 401 });
|
|
}
|
|
|
|
const project = await $replica.project.findFirst({
|
|
where: {
|
|
organizationId: organization.id,
|
|
externalRef: projectRef,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw json({ error: "Project not found" }, { status: 404 });
|
|
}
|
|
|
|
const sanitizedBranch = sanitizeBranchName(branch);
|
|
|
|
if (!sanitizedBranch) {
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
slug: slug,
|
|
},
|
|
include: authIncludeBase,
|
|
});
|
|
|
|
if (!environment) {
|
|
throw json({ error: "Environment not found" }, { status: 404 });
|
|
}
|
|
|
|
return toAuthenticated(environment);
|
|
}
|
|
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
type: "PREVIEW",
|
|
branchName: sanitizedBranch,
|
|
archivedAt: null,
|
|
},
|
|
include: authIncludeWithParent,
|
|
});
|
|
|
|
if (!environment) {
|
|
throw json({ error: "Branch not found" }, { status: 404 });
|
|
}
|
|
|
|
if (!environment.parentEnvironment) {
|
|
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
|
|
}
|
|
|
|
return toAuthenticated({
|
|
...environment,
|
|
apiKey: environment.parentEnvironment.apiKey,
|
|
});
|
|
}
|
|
default: {
|
|
auth satisfies never;
|
|
throw json({ error: "Invalid authentication result" }, { status: 401 });
|
|
}
|
|
}
|
|
}
|
|
|
|
const JWT_SECRET = new TextEncoder().encode(env.SESSION_SECRET);
|
|
const JWT_ALGORITHM = "HS256";
|
|
const DEFAULT_JWT_EXPIRATION_IN_MS = 1000 * 60 * 60; // 1 hour
|
|
|
|
export async function generateJWTTokenForEnvironment(
|
|
environment: RuntimeEnvironmentForEnvRepo,
|
|
payload: Record<string, string>
|
|
) {
|
|
const jwt = await new SignJWT({
|
|
environment_id: environment.id,
|
|
org_id: environment.organizationId,
|
|
project_id: environment.projectId,
|
|
...payload,
|
|
})
|
|
.setProtectedHeader({ alg: JWT_ALGORITHM })
|
|
.setIssuedAt()
|
|
.setIssuer("https://id.trigger.dev")
|
|
.setAudience("https://api.trigger.dev")
|
|
.setExpirationTime(calculateJWTExpiration())
|
|
.sign(JWT_SECRET);
|
|
|
|
return jwt;
|
|
}
|
|
|
|
export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
|
|
request: Request,
|
|
payloadSchema: T
|
|
): Promise<{ payload: z.infer<T>; jwt: string } | undefined> {
|
|
try {
|
|
const jwt = request.headers.get("x-trigger-jwt");
|
|
|
|
if (!jwt) {
|
|
logger.debug("Missing JWT token in request", {
|
|
headers: Object.fromEntries(request.headers),
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const { payload: rawPayload } = await jwtVerify(jwt, JWT_SECRET, {
|
|
issuer: "https://id.trigger.dev",
|
|
audience: "https://api.trigger.dev",
|
|
});
|
|
|
|
const payload = payloadSchema.safeParse(rawPayload);
|
|
|
|
if (!payload.success) {
|
|
logger.error("Failed to validate JWT", { payload: rawPayload, issues: payload.error.issues });
|
|
|
|
return;
|
|
}
|
|
|
|
const renewedJwt = await renewJWTToken(payload.data);
|
|
|
|
return {
|
|
payload: payload.data,
|
|
jwt: renewedJwt,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof errors.JWTExpired) {
|
|
// Now we need to try and renew the token using the API key auth
|
|
const authenticatedEnv = await authenticateApiRequest(request);
|
|
|
|
if (!authenticatedEnv) {
|
|
logger.error("Failed to renew JWT token, missing or invalid Authorization header", {
|
|
error: error.message,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (!authenticatedEnv.ok) {
|
|
logger.error("Failed to renew JWT token, invalid API key", {
|
|
error: error.message,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const payload = payloadSchema.safeParse(error.payload);
|
|
|
|
if (!payload.success) {
|
|
logger.error("Failed to parse jwt payload after expired", {
|
|
payload: error.payload,
|
|
issues: payload.error.issues,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const renewedJwt = await generateJWTTokenForEnvironment(authenticatedEnv.environment, {
|
|
...payload.data,
|
|
});
|
|
|
|
logger.debug("Renewed JWT token from Authorization header API Key", {
|
|
environment: authenticatedEnv.environment,
|
|
payload: payload.data,
|
|
});
|
|
|
|
return {
|
|
payload: payload.data,
|
|
jwt: renewedJwt,
|
|
};
|
|
}
|
|
|
|
logger.error("Failed to validate JWT token", { error });
|
|
}
|
|
}
|
|
|
|
async function renewJWTToken(payload: Record<string, string>) {
|
|
const jwt = await new SignJWT(payload)
|
|
.setProtectedHeader({ alg: JWT_ALGORITHM })
|
|
.setIssuedAt()
|
|
.setIssuer("https://id.trigger.dev")
|
|
.setAudience("https://api.trigger.dev")
|
|
.setExpirationTime(calculateJWTExpiration())
|
|
.sign(JWT_SECRET);
|
|
|
|
return jwt;
|
|
}
|
|
|
|
function calculateJWTExpiration() {
|
|
if (env.PROD_USAGE_HEARTBEAT_INTERVAL_MS) {
|
|
return (
|
|
(Date.now() + Math.max(DEFAULT_JWT_EXPIRATION_IN_MS, env.PROD_USAGE_HEARTBEAT_INTERVAL_MS)) /
|
|
1000
|
|
);
|
|
}
|
|
|
|
return (Date.now() + DEFAULT_JWT_EXPIRATION_IN_MS) / 1000;
|
|
}
|
|
|
|
export async function getOneTimeUseToken(
|
|
auth: ApiAuthenticationResultSuccess
|
|
): Promise<string | undefined> {
|
|
if (auth.type !== "PUBLIC_JWT") {
|
|
return;
|
|
}
|
|
|
|
if (!auth.oneTimeUse) {
|
|
return;
|
|
}
|
|
|
|
// Hash the API key to make it unique
|
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth.apiKey));
|
|
|
|
return Buffer.from(hash).toString("hex");
|
|
}
|