155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
import { type ActionFunctionArgs, json } from "@remix-run/node";
|
|
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
|
|
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
|
|
import { z } from "zod";
|
|
import {
|
|
authenticatedEnvironmentForAuthentication,
|
|
authenticateRequest,
|
|
branchNameFromRequest,
|
|
type AuthenticationResult,
|
|
} from "~/services/apiAuth.server";
|
|
import { env as appEnv } from "~/env.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
|
|
|
|
const ParamsSchema = z.object({
|
|
projectRef: z.string(),
|
|
env: z.enum(["dev", "staging", "prod", "preview"]),
|
|
});
|
|
|
|
const RequestBodySchema = z.object({
|
|
claims: z
|
|
.object({
|
|
scopes: z.array(z.string()).default([]),
|
|
})
|
|
.optional(),
|
|
expirationTime: z.union([z.number(), z.string()]).optional(),
|
|
});
|
|
|
|
export async function action({ request, params }: ActionFunctionArgs) {
|
|
try {
|
|
const bearer = request.headers
|
|
.get("Authorization")
|
|
?.replace(/^Bearer /, "")
|
|
.trim();
|
|
const isUat = !!bearer && isUserActorToken(bearer);
|
|
|
|
// A delegated user-actor token authenticates as its user, like a PAT. We
|
|
// resolve it here (not through authenticateRequest) so the exchange stays
|
|
// scoped to this route — UATs deliberately aren't accepted on every
|
|
// PAT route. `uatCap` (the token's optional scope cap) ceilings the
|
|
// minted env JWT below.
|
|
let uatCap: string[] | undefined;
|
|
let userActorId: string | undefined;
|
|
let authenticationResult: AuthenticationResult | undefined;
|
|
if (isUat) {
|
|
const claims = await verifyUserActorToken(appEnv.SESSION_SECRET, bearer!);
|
|
if (!claims) {
|
|
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
|
}
|
|
uatCap = claims.cap;
|
|
userActorId = claims.userId;
|
|
// The env lookup keys purely on the user, identical to a PAT.
|
|
authenticationResult = {
|
|
type: "personalAccessToken",
|
|
result: { userId: claims.userId },
|
|
};
|
|
} else {
|
|
authenticationResult = await authenticateRequest(request, {
|
|
personalAccessToken: true,
|
|
organizationAccessToken: true,
|
|
apiKey: false,
|
|
});
|
|
}
|
|
|
|
if (!authenticationResult) {
|
|
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
|
}
|
|
|
|
const parsedParams = ParamsSchema.safeParse(params);
|
|
|
|
if (!parsedParams.success) {
|
|
return json({ error: "Invalid Params" }, { status: 400 });
|
|
}
|
|
|
|
const { projectRef, env } = parsedParams.data;
|
|
const triggerBranch = branchNameFromRequest(request);
|
|
|
|
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
|
|
authenticationResult,
|
|
projectRef,
|
|
env,
|
|
triggerBranch
|
|
);
|
|
|
|
// This mints a JWT signed with the environment's secret key. For a PAT
|
|
// (a user), gate it on env-tier read:apiKeys so a restricted role can't
|
|
// obtain deployed-environment credentials (and therefore can't deploy).
|
|
const denied = await authorizePatEnvironmentAccess({
|
|
request,
|
|
authType: authenticationResult.type,
|
|
organizationId: runtimeEnv.organizationId,
|
|
projectId: runtimeEnv.project.id,
|
|
envType: runtimeEnv.type,
|
|
resource: "apiKeys",
|
|
action: "read",
|
|
});
|
|
if (denied) return denied;
|
|
|
|
const parsedBody = RequestBodySchema.safeParse(await request.json());
|
|
|
|
if (!parsedBody.success) {
|
|
return json(
|
|
{ error: "Invalid request body", issues: parsedBody.error.issues },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// The env JWT carries scopes only — downstream auth builds its ability
|
|
// from them with no role context. So for a user-actor token we ceiling
|
|
// the scopes by the token's own cap here (a read-only agent token can't
|
|
// widen its grant through the exchange) and stamp the user via `act` so
|
|
// the minted env JWT stays attributable. The cap is a ceiling, not a
|
|
// replacement: intersect what the caller asked for with the cap (or use
|
|
// the full cap if they asked for nothing). No cap → the request passes
|
|
// through, same as a PAT.
|
|
const requestedScopes = parsedBody.data.claims?.scopes;
|
|
const scopes =
|
|
isUat && uatCap
|
|
? requestedScopes && requestedScopes.length > 0
|
|
? requestedScopes.filter((scope) => uatCap.includes(scope))
|
|
: uatCap
|
|
: requestedScopes;
|
|
|
|
// Attribution: stamp the acting user on the minted env JWT. A UAT carries
|
|
// its user as `userActorId`; a PAT exchange resolves the user from the
|
|
// authentication result. Either way downstream handlers read `act.sub`
|
|
// (e.g. the errors API records who resolved/ignored an error). An org
|
|
// access token has no user, so `act` is omitted.
|
|
const actorUserId =
|
|
userActorId ??
|
|
(authenticationResult.type === "personalAccessToken"
|
|
? authenticationResult.result.userId
|
|
: undefined);
|
|
|
|
const claims = {
|
|
sub: runtimeEnv.id,
|
|
pub: true,
|
|
...(scopes ? { scopes } : {}),
|
|
...(actorUserId ? { act: { sub: actorUserId } } : {}),
|
|
};
|
|
|
|
const jwt = await internal_generateJWT({
|
|
secretKey: runtimeEnv.apiKey,
|
|
payload: claims,
|
|
expirationTime: parsedBody.data.expirationTime ?? "1h",
|
|
});
|
|
|
|
return json({ token: jwt });
|
|
} catch (error) {
|
|
if (error instanceof Response) throw error;
|
|
logger.error("Failed to generate env JWT", { error });
|
|
return json({ error: "Internal Server Error" }, { status: 500 });
|
|
}
|
|
}
|