From 1b27cfbd7a9a707e38586e5ddce21cd14f0f5323 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 11:08:58 +0200 Subject: [PATCH 01/21] Create schema and migration for organization access tokens --- .../migration.sql | 19 +++++++++++++ .../database/prisma/schema.prisma | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql b/internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql new file mode 100644 index 0000000000..cd8a4e115c --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql @@ -0,0 +1,19 @@ +CREATE TABLE "OrganizationAccessToken" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "encryptedToken" JSONB NOT NULL, + "obfuscatedToken" TEXT NOT NULL, + "hashedToken" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "lastAccessedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationAccessToken_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "OrganizationAccessToken_hashedToken_key" ON "OrganizationAccessToken"("hashedToken"); + +ALTER TABLE "OrganizationAccessToken" ADD CONSTRAINT "OrganizationAccessToken_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1f0397904d..64c101da45 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -133,6 +133,33 @@ model PersonalAccessToken { authorizationCodes AuthorizationCode[] } +model OrganizationAccessToken { + id String @id @default(cuid()) + + /// User-provided name for the token + name String + + /// This is the token encrypted using the ENCRYPTION_KEY + encryptedToken Json + + /// This is shown in the UI, with ******** + obfuscatedToken String + + /// This is used to find the token in the database + hashedToken String @unique + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String + + /// Optional expiration date for the token + expiresAt DateTime? + revokedAt DateTime? + lastAccessedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Organization { id String @id @default(cuid()) slug String @unique @@ -174,6 +201,7 @@ model Organization { members OrgMember[] invites OrgMemberInvite[] organizationIntegrations OrganizationIntegration[] + organizationAccessTokens OrganizationAccessToken[] workerGroups WorkerInstanceGroup[] workerInstances WorkerInstance[] executionSnapshots TaskRunExecutionSnapshot[] From 0a82a56a6566fdd92203af5a275a8732effdab45 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 11:11:25 +0200 Subject: [PATCH 02/21] Add helpers for creating and authenticating OATs --- .../organizationAccessToken.server.ts | 205 ++++++++++++++++++ .../services/personalAccessToken.server.ts | 42 +--- apps/webapp/app/utils/tokens.ts | 39 ++++ .../src/utilities/isPersonalAccessToken.ts | 30 ++- 4 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 apps/webapp/app/services/organizationAccessToken.server.ts create mode 100644 apps/webapp/app/utils/tokens.ts diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts new file mode 100644 index 0000000000..4fea24bd1d --- /dev/null +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -0,0 +1,205 @@ +import { type OrganizationAccessToken } from "@trigger.dev/database"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { logger } from "./logger.server"; +import { decryptToken, encryptToken, hashToken } from "~/utils/tokens"; + +const tokenValueLength = 40; +//lowercase only, removed 0 and l to avoid confusion +const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); + +type CreateOrganizationAccessTokenOptions = { + name: string; + organizationId: string; + expiresAt?: Date; +}; + +export async function getValidOrganizationAccessTokens(organizationId: string) { + const organizationAccessTokens = await prisma.organizationAccessToken.findMany({ + select: { + id: true, + name: true, + obfuscatedToken: true, + createdAt: true, + lastAccessedAt: true, + expiresAt: true, + }, + where: { + organizationId, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }], + }, + }); + + return organizationAccessTokens.map((oat) => ({ + id: oat.id, + name: oat.name, + obfuscatedToken: oat.obfuscatedToken, + createdAt: oat.createdAt, + lastAccessedAt: oat.lastAccessedAt, + expiresAt: oat.expiresAt, + })); +} + +export type ObfuscatedOrganizationAccessToken = Awaited< + ReturnType +>[number]; + +export async function revokeOrganizationAccessToken(tokenId: string) { + await prisma.organizationAccessToken.update({ + where: { + id: tokenId, + }, + data: { + revokedAt: new Date(), + }, + }); +} + +export type OrganizationAccessTokenAuthenticationResult = { + organizationId: string; +}; + +const EncryptedSecretValueSchema = z.object({ + nonce: z.string(), + ciphertext: z.string(), + tag: z.string(), +}); + +const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/); + +export async function authenticateApiRequestWithOrganizationAccessToken( + request: Request +): Promise { + const token = getOrganizationAccessTokenFromRequest(request); + if (!token) { + return; + } + + return authenticateOrganizationAccessToken(token); +} + +function getOrganizationAccessTokenFromRequest(request: Request) { + const rawAuthorization = request.headers.get("Authorization"); + + const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization); + if (!authorization.success) { + return; + } + + const organizationAccessToken = authorization.data.replace(/^Bearer /, ""); + return organizationAccessToken; +} + +export async function authenticateOrganizationAccessToken( + token: string +): Promise { + if (!token.startsWith(tokenPrefix)) { + logger.warn(`OAT doesn't start with ${tokenPrefix}`); + return; + } + + const hashedToken = hashToken(token); + + const organizationAccessToken = await prisma.organizationAccessToken.findFirst({ + where: { + hashedToken, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }], + }, + }); + + if (!organizationAccessToken) { + return; + } + + await prisma.organizationAccessToken.update({ + where: { + id: organizationAccessToken.id, + }, + data: { + lastAccessedAt: new Date(), + }, + }); + + const decryptedToken = decryptOrganizationAccessToken(organizationAccessToken); + + if (decryptedToken !== token) { + logger.error( + `OrganizationAccessToken with id: ${organizationAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.` + ); + return; + } + + return { + organizationId: organizationAccessToken.organizationId, + }; +} + +export function isOrganizationAccessToken(token: string) { + return token.startsWith(tokenPrefix); +} + +export async function createOrganizationAccessToken({ + name, + organizationId, + expiresAt, +}: CreateOrganizationAccessTokenOptions) { + const token = createToken(); + const encryptedToken = encryptToken(token); + + const organizationAccessToken = await prisma.organizationAccessToken.create({ + data: { + name, + organizationId, + encryptedToken, + obfuscatedToken: obfuscateToken(token), + hashedToken: hashToken(token), + expiresAt, + }, + }); + + return { + id: organizationAccessToken.id, + name, + organizationId, + token, + obfuscatedToken: organizationAccessToken.obfuscatedToken, + expiresAt: organizationAccessToken.expiresAt, + }; +} + +export type CreatedOrganizationAccessToken = Awaited< + ReturnType +>; + +const tokenPrefix = "tr_oat_"; + +function createToken() { + return `${tokenPrefix}${tokenGenerator()}`; +} + +function obfuscateToken(token: string) { + const withoutPrefix = token.replace(tokenPrefix, ""); + const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`; + return `${tokenPrefix}${obfuscated}`; +} + +function decryptOrganizationAccessToken(organizationAccessToken: OrganizationAccessToken) { + const encryptedData = EncryptedSecretValueSchema.safeParse( + organizationAccessToken.encryptedToken + ); + if (!encryptedData.success) { + throw new Error( + `Unable to parse encrypted OrganizationAccessToken with id: ${organizationAccessToken.id}: ${encryptedData.error.message}` + ); + } + + const decryptedToken = decryptToken( + encryptedData.data.nonce, + encryptedData.data.ciphertext, + encryptedData.data.tag + ); + return decryptedToken; +} diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index f48582ec9b..def7b4043e 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -1,10 +1,9 @@ -import { PersonalAccessToken } from "@trigger.dev/database"; +import { type PersonalAccessToken } from "@trigger.dev/database"; import { customAlphabet, nanoid } from "nanoid"; -import nodeCrypto from "node:crypto"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { env } from "~/env.server"; import { logger } from "./logger.server"; +import { decryptToken, encryptToken, hashToken } from "~/utils/tokens"; const tokenValueLength = 40; //lowercase only, removed 0 and l to avoid confusion @@ -303,22 +302,6 @@ function obfuscateToken(token: string) { return `${tokenPrefix}${obfuscated}`; } -function encryptToken(value: string) { - const nonce = nodeCrypto.randomBytes(12); - const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce); - - let encrypted = cipher.update(value, "utf8", "hex"); - encrypted += cipher.final("hex"); - - const tag = cipher.getAuthTag().toString("hex"); - - return { - nonce: nonce.toString("hex"), - ciphertext: encrypted, - tag, - }; -} - function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) { const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken); if (!encryptedData.success) { @@ -334,24 +317,3 @@ function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) { ); return decryptedToken; } - -function decryptToken(nonce: string, ciphertext: string, tag: string): string { - const decipher = nodeCrypto.createDecipheriv( - "aes-256-gcm", - env.ENCRYPTION_KEY, - Buffer.from(nonce, "hex") - ); - - decipher.setAuthTag(Buffer.from(tag, "hex")); - - let decrypted = decipher.update(ciphertext, "hex", "utf8"); - decrypted += decipher.final("utf8"); - - return decrypted; -} - -function hashToken(token: string): string { - const hash = nodeCrypto.createHash("sha256"); - hash.update(token); - return hash.digest("hex"); -} diff --git a/apps/webapp/app/utils/tokens.ts b/apps/webapp/app/utils/tokens.ts new file mode 100644 index 0000000000..7e8a85ea90 --- /dev/null +++ b/apps/webapp/app/utils/tokens.ts @@ -0,0 +1,39 @@ +import nodeCrypto from "node:crypto"; +import { env } from "~/env.server"; + +export function encryptToken(value: string) { + const nonce = nodeCrypto.randomBytes(12); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce); + + let encrypted = cipher.update(value, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const tag = cipher.getAuthTag().toString("hex"); + + return { + nonce: nonce.toString("hex"), + ciphertext: encrypted, + tag, + }; +} + +export function decryptToken(nonce: string, ciphertext: string, tag: string): string { + const decipher = nodeCrypto.createDecipheriv( + "aes-256-gcm", + env.ENCRYPTION_KEY, + Buffer.from(nonce, "hex") + ); + + decipher.setAuthTag(Buffer.from(tag, "hex")); + + let decrypted = decipher.update(ciphertext, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; +} + +export function hashToken(token: string): string { + const hash = nodeCrypto.createHash("sha256"); + hash.update(token); + return hash.digest("hex"); +} diff --git a/packages/cli-v3/src/utilities/isPersonalAccessToken.ts b/packages/cli-v3/src/utilities/isPersonalAccessToken.ts index 53d9f3959d..a80d78cb79 100644 --- a/packages/cli-v3/src/utilities/isPersonalAccessToken.ts +++ b/packages/cli-v3/src/utilities/isPersonalAccessToken.ts @@ -1,7 +1,26 @@ -const tokenPrefix = "tr_pat_"; +const personalTokenPrefix = "tr_pat_"; +const organizationTokenPrefix = "tr_oat_"; export function isPersonalAccessToken(token: string) { - return token.startsWith(tokenPrefix); + return token.startsWith(personalTokenPrefix); +} + +export function isOrganizationAccessToken(token: string) { + return token.startsWith(organizationTokenPrefix); +} + +export function validateAccessToken( + token: string +): { success: true; type: "personal" | "organization" } | { success: false } { + if (isPersonalAccessToken(token)) { + return { success: true, type: "personal" }; + } + + if (isOrganizationAccessToken(token)) { + return { success: true, type: "organization" }; + } + + return { success: false }; } export class NotPersonalAccessTokenError extends Error { @@ -10,3 +29,10 @@ export class NotPersonalAccessTokenError extends Error { this.name = "NotPersonalAccessTokenError"; } } + +export class NotAccessTokenError extends Error { + constructor(message: string) { + super(message); + this.name = "NotAccessTokenError"; + } +} From c6728d0616769ce231c2c4ba321c52a072b35718 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 11:16:27 +0200 Subject: [PATCH 03/21] Adapt the auth service to also accept OATs --- .../api.v1.projects.$projectRef.$env.ts | 176 ++--------------- ...ef.background-workers.$envSlug.$version.ts | 4 +- ...rojects.$projectRef.envvars.$slug.$name.ts | 6 +- ...ojects.$projectRef.envvars.$slug.import.ts | 4 +- ...i.v1.projects.$projectRef.envvars.$slug.ts | 6 +- apps/webapp/app/services/apiAuth.server.ts | 187 ++++++++++++++++-- 6 files changed, 197 insertions(+), 186 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index c45a3c55ed..53f2cad895 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -1,11 +1,11 @@ import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { type GetProjectEnvResponse } from "@trigger.dev/core/v3"; -import { type RuntimeEnvironment } from "@trigger.dev/database"; import { z } from "zod"; -import { prisma } from "~/db.server"; import { env as processEnv } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, +} from "~/services/apiAuth.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -15,14 +15,6 @@ const ParamsSchema = z.object({ type ParamsSchema = z.infer; export async function loader({ request, params }: LoaderFunctionArgs) { - logger.info("projects get env", { url: request.url }); - - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - const parsedParams = ParamsSchema.safeParse(params); if (!parsedParams.success) { @@ -31,162 +23,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef, env } = parsedParams.data; - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }, - }); - - if (!project) { - return json({ error: "Project not found" }, { status: 404 }); - } - - const envResult = await getEnvironmentFromEnv({ - projectId: project.id, - userId: authenticationResult.userId, - env, - }); + const authenticationResult = await authenticateRequest(request); - if (!envResult.success) { - return json({ error: envResult.error }, { status: 404 }); + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); } - const runtimeEnv = envResult.environment; + const environment = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, + env + ); const result: GetProjectEnvResponse = { - apiKey: runtimeEnv.apiKey, - name: project.name, + apiKey: environment.apiKey, + name: environment.project.name, apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN, - projectId: project.id, + projectId: environment.project.id, }; return json(result); } - -export async function getEnvironmentFromEnv({ - projectId, - userId, - env, - branch, -}: { - projectId: string; - userId: string; - env: ParamsSchema["env"]; - branch?: string; -}): Promise< - | { - success: true; - environment: RuntimeEnvironment; - } - | { - success: false; - error: string; - } -> { - if (env === "dev") { - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId, - orgMember: { - userId: userId, - }, - }, - }); - - if (!environment) { - return { - success: false, - error: "Dev environment not found", - }; - } - - return { - success: true, - environment, - }; - } - - let slug: "stg" | "prod" | "preview" = "prod"; - switch (env) { - case "staging": - slug = "stg"; - break; - case "prod": - slug = "prod"; - break; - case "preview": - slug = "preview"; - break; - default: - break; - } - - if (slug === "preview") { - const previewEnvironment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId, - slug: "preview", - }, - }); - - if (!previewEnvironment) { - return { - success: false, - error: "Preview environment not found", - }; - } - - // If no branch is provided, just return the parent preview environment - if (!branch) { - return { - success: true, - environment: previewEnvironment, - }; - } - - const branchEnvironment = await prisma.runtimeEnvironment.findFirst({ - where: { - parentEnvironmentId: previewEnvironment.id, - branchName: branch, - }, - }); - - if (!branchEnvironment) { - return { - success: false, - error: `Preview branch ${branch} not found`, - }; - } - - return { - success: true, - environment: branchEnvironment, - }; - } - - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId, - slug, - }, - }); - - if (!environment) { - return { - success: false, - error: `${env === "staging" ? "Staging" : "Production"} environment not found`, - }; - } - - return { - success: true, - environment, - }; -} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.$envSlug.$version.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.$envSlug.$version.ts index 299133cdf5..f9411c2d15 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.$envSlug.$version.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.background-workers.$envSlug.$version.ts @@ -2,7 +2,7 @@ import { LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { - authenticateProjectApiKeyOrPersonalAccessToken, + authenticateRequest, authenticatedEnvironmentForAuthentication, } from "~/services/apiAuth.server"; import zlib from "node:zlib"; @@ -20,7 +20,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 7682f6bbbe..13784b8110 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -3,7 +3,7 @@ import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { - authenticateProjectApiKeyOrPersonalAccessToken, + authenticateRequest, authenticatedEnvironmentForAuthentication, } from "~/services/apiAuth.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; @@ -21,7 +21,7 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -97,7 +97,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index 803d99f28e..ad2372a654 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -3,7 +3,7 @@ import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3"; import { parse } from "dotenv"; import { z } from "zod"; import { - authenticateProjectApiKeyOrPersonalAccessToken, + authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; @@ -21,7 +21,7 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index 6fb1cfba1d..fe2d77cbf8 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -2,7 +2,7 @@ import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server- import { CreateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { - authenticateProjectApiKeyOrPersonalAccessToken, + authenticateRequest, authenticatedEnvironmentForAuthentication, } from "~/services/apiAuth.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; @@ -19,7 +19,7 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -66,7 +66,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateProjectApiKeyOrPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 3d839ba440..4d69a35d56 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -2,6 +2,7 @@ import { json } from "@remix-run/server-runtime"; import { type Prettify } from "@trigger.dev/core"; import { SignJWT, errors, jwtVerify } from "jose"; import { z } from "zod"; + import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { findProjectByRef } from "~/models/project.server"; @@ -16,6 +17,11 @@ import { authenticateApiRequestWithPersonalAccessToken, isPersonalAccessToken, } from "./personalAccessToken.server"; +import { + type OrganizationAccessTokenAuthenticationResult, + authenticateApiRequestWithOrganizationAccessToken, + isOrganizationAccessToken, +} from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; import { sanitizeBranchName } from "~/v3/gitBranch"; @@ -309,25 +315,76 @@ function getApiKeyResult(apiKey: string): { return { apiKey, type }; } -export type DualAuthenticationResult = +export type AuthenticationResult = | { type: "personalAccessToken"; result: PersonalAccessTokenAuthenticationResult; } + | { + type: "organizationAccessToken"; + result: OrganizationAccessTokenAuthenticationResult; + } | { type: "apiKey"; result: ApiAuthenticationResult; }; -export async function authenticateProjectApiKeyOrPersonalAccessToken( - request: Request -): Promise { +type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | "apiKey"; + +type AllowedAuthenticationMethods = Record & + ({ personalAccessToken: true } | { organizationAccessToken: true } | { apiKey: true }); + +type FilteredAuthenticationResult< + T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods +> = + | (T["personalAccessToken"] extends true + ? Extract + : never) + | (T["organizationAccessToken"] extends true + ? Extract + : never) + | (T["apiKey"] extends true ? Extract : 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. + * The return type is conditionally filtered to only include authentication methods that are enabled. + * + * @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 = { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + } satisfies AllowedAuthenticationMethods as T +): Promise | undefined> { const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } - if (isPersonalAccessToken(apiKey)) { + if (allowedAuthenticationMethods.personalAccessToken && isPersonalAccessToken(apiKey)) { const result = await authenticateApiRequestWithPersonalAccessToken(request); if (!result) { @@ -337,23 +394,49 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken( return { type: "personalAccessToken", result, - }; + } satisfies Extract< + AuthenticationResult, + { type: "personalAccessToken" } + > as FilteredAuthenticationResult; } - const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); + if (allowedAuthenticationMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) { + const result = await authenticateApiRequestWithOrganizationAccessToken(request); - if (!result) { - return; + if (!result) { + return; + } + + return { + type: "organizationAccessToken", + result, + } satisfies Extract< + AuthenticationResult, + { type: "organizationAccessToken" } + > as FilteredAuthenticationResult; } - return { - type: "apiKey", - result, - }; + if (allowedAuthenticationMethods.apiKey) { + const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); + + if (!result) { + return; + } + + return { + type: "apiKey", + result, + } satisfies Extract< + AuthenticationResult, + { type: "apiKey" } + > as FilteredAuthenticationResult; + } + + return; } export async function authenticatedEnvironmentForAuthentication( - auth: DualAuthenticationResult, + auth: AuthenticationResult, projectRef: string, slug: string, branch?: string @@ -398,7 +481,7 @@ export async function authenticatedEnvironmentForAuthentication( }); if (!user) { - throw json({ error: "Invalid or Missing API key" }, { status: 401 }); + throw json({ error: "Invalid or missing personal access token" }, { status: 401 }); } const project = await findProjectByRef(projectRef, user.id); @@ -455,6 +538,80 @@ export async function authenticatedEnvironmentForAuthentication( project: environment.project, }; } + case "organizationAccessToken": { + const organization = await prisma.organization.findUnique({ + where: { + id: auth.result.organizationId, + }, + }); + + if (!organization) { + throw json({ error: "Invalid or missing organization access token" }, { status: 401 }); + } + + const project = await prisma.project.findFirst({ + where: { + organizationId: organization.id, + externalRef: projectRef, + }, + }); + + if (!project) { + throw json({ error: "Project not found" }, { status: 404 }); + } + + if (!branch) { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: slug, + }, + include: { + project: true, + organization: true, + }, + }); + + if (!environment) { + throw json({ error: "Environment not found" }, { status: 404 }); + } + + return environment; + } + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: slug, + branchName: sanitizeBranchName(branch), + archivedAt: null, + }, + include: { + project: true, + organization: true, + parentEnvironment: true, + }, + }); + + 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 { + ...environment, + apiKey: environment.parentEnvironment.apiKey, + organization: environment.organization, + project: environment.project, + }; + } + default: { + auth satisfies never; + throw json({ error: "Invalid authentication result" }, { status: 401 }); + } } } From 6d05b46495bd47f33d58e42646f53987338d12fe Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 11:18:09 +0200 Subject: [PATCH 04/21] Accept OATs in the whoami v2 endpoint --- apps/webapp/app/routes/api.v2.whoami.ts | 257 +++++++++++++++++------- 1 file changed, 180 insertions(+), 77 deletions(-) diff --git a/apps/webapp/app/routes/api.v2.whoami.ts b/apps/webapp/app/routes/api.v2.whoami.ts index 36e46221f5..bea88be78b 100644 --- a/apps/webapp/app/routes/api.v2.whoami.ts +++ b/apps/webapp/app/routes/api.v2.whoami.ts @@ -1,97 +1,200 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { json } from "@remix-run/server-runtime"; -import { WhoAmIResponse } from "@trigger.dev/core/v3"; +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type WhoAmIResponse } from "@trigger.dev/core/v3"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { v3ProjectPath } from "~/utils/pathBuilder"; +import { authenticateRequest } from "~/services/apiAuth.server"; export async function loader({ request }: LoaderFunctionArgs) { - logger.info("whoami v2", { url: request.url }); - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - if (!authenticationResult) { + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const url = new URL(request.url); + const projectRef = url.searchParams.get("projectRef") ?? undefined; + + switch (authenticationResult.type) { + case "personalAccessToken": { + const result = await getIdentityFromPAT(authenticationResult.result.userId, projectRef); + if (!result.success) { + if (result.error === "user_not_found") { + return json({ error: "User not found" }, { status: 404 }); + } + + return json({ error: result.error }, { status: 401 }); + } + return json(result.result); + } + case "organizationAccessToken": { + const result = await getIdentityFromOAT( + authenticationResult.result.organizationId, + projectRef + ); + return json(result.result); + } + default: { + authenticationResult satisfies never; return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); } + } +} + +async function getIdentityFromPAT( + userId: string, + projectRef: string | undefined +): Promise< + { success: true; result: WhoAmIResponse } | { success: false; error: "user_not_found" } +> { + const user = await prisma.user.findFirst({ + select: { + email: true, + }, + where: { + id: userId, + }, + }); + + if (!user) { + return { success: false, error: "user_not_found" }; + } + + const userDetails = { + userId, + email: user.email, + dashboardUrl: env.APP_ORIGIN, + } satisfies WhoAmIResponse; + + if (!projectRef) { + return { + success: true, + result: userDetails, + }; + } + + const orgs = await prisma.organization.findMany({ + select: { + id: true, + }, + where: { + members: { + some: { + userId, + }, + }, + }, + }); - const user = await prisma.user.findUnique({ - select: { - email: true, + if (orgs.length === 0) { + return { + success: true, + result: userDetails, + }; + } + + const project = await prisma.project.findFirst({ + select: { + externalRef: true, + name: true, + slug: true, + organization: { + select: { + slug: true, + title: true, + }, }, - where: { - id: authenticationResult.userId, + }, + where: { + externalRef: projectRef, + organizationId: { + in: orgs.map((org) => org.id), }, - }); + }, + }); - if (!user) { - return json({ error: "User not found" }, { status: 404 }); - } + if (!project) { + return { + success: true, + result: userDetails, + }; + } + + const projectPath = v3ProjectPath({ slug: project.organization.slug }, { slug: project.slug }); - const url = new URL(request.url); - const projectRef = url.searchParams.get("projectRef"); + return { + success: true, + result: { + ...userDetails, + project: { + url: new URL(projectPath, env.APP_ORIGIN).href, + name: project.name, + orgTitle: project.organization.title, + }, + }, + }; +} - let projectDetails: WhoAmIResponse["project"]; +async function getIdentityFromOAT( + organizationId: string, + projectRef: string | undefined +): Promise<{ success: true; result: WhoAmIResponse }> { + // Organization auth tokens are currently only used internally for the build server. + // We will eventually expose them in the application as well, as they are useful beyond the build server. + // At that point we will need a v3 whoami endpoint that properly handles org auth tokens. + // For now, we just return a dummy user id and email and keep using the existing v2 whoami endpoint. + const orgDetails = { + userId: `org_${organizationId}`, + email: "not_applicable@trigger.dev", + dashboardUrl: env.APP_ORIGIN, + } satisfies WhoAmIResponse; - if (projectRef) { - const orgs = await prisma.organization.findMany({ + if (!projectRef) { + return { + success: true, + result: orgDetails, + }; + } + + const project = await prisma.project.findFirst({ + select: { + externalRef: true, + name: true, + slug: true, + organization: { select: { - id: true, + slug: true, + title: true, }, - where: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }); - - if (orgs.length > 0) { - const project = await prisma.project.findFirst({ - select: { - externalRef: true, - name: true, - slug: true, - organization: { - select: { - slug: true, - title: true, - }, - }, - }, - where: { - externalRef: projectRef, - organizationId: { - in: orgs.map((org) => org.id), - }, - }, - }); - - if (project) { - const projectPath = v3ProjectPath( - { slug: project.organization.slug }, - { slug: project.slug } - ); - projectDetails = { - url: new URL(projectPath, env.APP_ORIGIN).href, - name: project.name, - orgTitle: project.organization.title, - }; - } - } - } + }, + }, + where: { + externalRef: projectRef, + organizationId, + }, + }); - const result: WhoAmIResponse = { - userId: authenticationResult.userId, - email: user.email, - dashboardUrl: env.APP_ORIGIN, - project: projectDetails, + if (!project) { + return { + success: true, + result: orgDetails, }; - return json(result); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Something went wrong"; - logger.error("Error in whoami v2", { error: errorMessage }); - return json({ error: errorMessage }, { status: 400 }); } + + const projectPath = v3ProjectPath({ slug: project.organization.slug }, { slug: project.slug }); + return { + success: true, + result: { + ...orgDetails, + project: { + url: new URL(projectPath, env.APP_ORIGIN).href, + name: project.name, + orgTitle: project.organization.title, + }, + }, + }; } From 388003b3f0ab45819973fa2476f069e8aff792c2 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 11:19:09 +0200 Subject: [PATCH 05/21] Enable deployments with the CLI using OATs --- packages/cli-v3/src/commands/deploy.ts | 2 +- packages/cli-v3/src/commands/login.ts | 20 ++++++++++++++++--- .../utilities/isOrganizationAccessToken.ts | 12 +++++++++++ packages/cli-v3/src/utilities/session.ts | 4 ++++ 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 packages/cli-v3/src/utilities/isOrganizationAccessToken.ts diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 87dbbc9787..75acc421cd 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -308,7 +308,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const deploymentResponse = await projectClient.client.initializeDeployment({ contentHash: buildManifest.contentHash, - userId: authorization.userId, + userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined, gitMeta, type: features.run_engine_v2 ? "MANAGED" : "V1", runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index da8b080580..6e0aa5e2f7 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -30,7 +30,10 @@ import { env, isCI } from "std-env"; import { CLOUD_API_URL } from "../consts.js"; import { isPersonalAccessToken, + isOrganizationAccessToken, + validateAccessToken, NotPersonalAccessTokenError, + NotAccessTokenError, } from "../utilities/isPersonalAccessToken.js"; import { links } from "@trigger.dev/core/v3"; @@ -94,8 +97,12 @@ export async function login(options?: LoginOptions): Promise { const accessTokenFromEnv = env.TRIGGER_ACCESS_TOKEN; if (accessTokenFromEnv) { - if (!isPersonalAccessToken(accessTokenFromEnv)) { - throw new NotPersonalAccessTokenError( + const validationResult = validateAccessToken(accessTokenFromEnv); + + if (!validationResult.success) { + // We deliberately don't surface the existence of organization access tokens to the user for now, as they're only used internally. + // Once we expose them in the application, we should also communicate that option here. + throw new NotAccessTokenError( "Your TRIGGER_ACCESS_TOKEN is not a Personal Access Token, they start with 'tr_pat_'. You can generate one here: https://cloud.trigger.dev/account/tokens" ); } @@ -119,6 +126,7 @@ export async function login(options?: LoginOptions): Promise { dashboardUrl: userData.data.dashboardUrl, auth: { accessToken: auth.accessToken, + tokenType: validationResult.type, apiUrl: auth.apiUrl, }, }; @@ -188,6 +196,7 @@ export async function login(options?: LoginOptions): Promise { auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } @@ -209,6 +218,7 @@ export async function login(options?: LoginOptions): Promise { auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } @@ -270,7 +280,10 @@ export async function login(options?: LoginOptions): Promise { getPersonalAccessTokenSpinner.stop(`Logged in with token ${indexResult.obfuscatedToken}`); writeAuthConfigProfile( - { accessToken: indexResult.token, apiUrl: opts.defaultApiUrl }, + { + accessToken: indexResult.token, + apiUrl: opts.defaultApiUrl, + }, options?.profile ); @@ -309,6 +322,7 @@ export async function login(options?: LoginOptions): Promise { auth: { accessToken: indexResult.token, apiUrl: authConfig?.apiUrl ?? opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } catch (e) { diff --git a/packages/cli-v3/src/utilities/isOrganizationAccessToken.ts b/packages/cli-v3/src/utilities/isOrganizationAccessToken.ts new file mode 100644 index 0000000000..d42c9c979c --- /dev/null +++ b/packages/cli-v3/src/utilities/isOrganizationAccessToken.ts @@ -0,0 +1,12 @@ +const tokenPrefix = "tr_oat_"; + +export function isOrganizationAccessToken(token: string) { + return token.startsWith(tokenPrefix); +} + +export class NotOrganizationAccessTokenError extends Error { + constructor(message: string) { + super(message); + this.name = "NotOrganizationAccessTokenError"; + } +} \ No newline at end of file diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts index 341947ff74..13e10549c2 100644 --- a/packages/cli-v3/src/utilities/session.ts +++ b/packages/cli-v3/src/utilities/session.ts @@ -13,6 +13,7 @@ export type LoginResultOk = { auth: { apiUrl: string; accessToken: string; + tokenType: "personal" | "organization"; }; }; @@ -24,6 +25,7 @@ export type LoginResult = auth?: { apiUrl: string; accessToken: string; + tokenType: "personal" | "organization"; }; }; @@ -45,6 +47,7 @@ export async function isLoggedIn(profile: string = "default"): Promise Date: Thu, 14 Aug 2025 11:39:11 +0200 Subject: [PATCH 06/21] Avoid reading env variables directly in the token utils --- .../app/services/organizationAccessToken.server.ts | 6 ++++-- .../app/services/personalAccessToken.server.ts | 6 ++++-- apps/webapp/app/utils/tokens.ts | 13 ++++--------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts index 4fea24bd1d..d2fbe4ad68 100644 --- a/apps/webapp/app/services/organizationAccessToken.server.ts +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; import { decryptToken, encryptToken, hashToken } from "~/utils/tokens"; +import { env } from "~/env.server"; const tokenValueLength = 40; //lowercase only, removed 0 and l to avoid confusion @@ -147,7 +148,7 @@ export async function createOrganizationAccessToken({ expiresAt, }: CreateOrganizationAccessTokenOptions) { const token = createToken(); - const encryptedToken = encryptToken(token); + const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY); const organizationAccessToken = await prisma.organizationAccessToken.create({ data: { @@ -199,7 +200,8 @@ function decryptOrganizationAccessToken(organizationAccessToken: OrganizationAcc const decryptedToken = decryptToken( encryptedData.data.nonce, encryptedData.data.ciphertext, - encryptedData.data.tag + encryptedData.data.tag, + env.ENCRYPTION_KEY ); return decryptedToken; } diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index def7b4043e..b0c860df4d 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; import { decryptToken, encryptToken, hashToken } from "~/utils/tokens"; +import { env } from "~/env.server"; const tokenValueLength = 40; //lowercase only, removed 0 and l to avoid confusion @@ -265,7 +266,7 @@ export async function createPersonalAccessToken({ userId, }: CreatePersonalAccessTokenOptions) { const token = createToken(); - const encryptedToken = encryptToken(token); + const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY); const personalAccessToken = await prisma.personalAccessToken.create({ data: { @@ -313,7 +314,8 @@ function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) { const decryptedToken = decryptToken( encryptedData.data.nonce, encryptedData.data.ciphertext, - encryptedData.data.tag + encryptedData.data.tag, + env.ENCRYPTION_KEY ); return decryptedToken; } diff --git a/apps/webapp/app/utils/tokens.ts b/apps/webapp/app/utils/tokens.ts index 7e8a85ea90..ae91018ba4 100644 --- a/apps/webapp/app/utils/tokens.ts +++ b/apps/webapp/app/utils/tokens.ts @@ -1,9 +1,8 @@ import nodeCrypto from "node:crypto"; -import { env } from "~/env.server"; -export function encryptToken(value: string) { +export function encryptToken(value: string, key: string) { const nonce = nodeCrypto.randomBytes(12); - const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, nonce); let encrypted = cipher.update(value, "utf8", "hex"); encrypted += cipher.final("hex"); @@ -17,12 +16,8 @@ export function encryptToken(value: string) { }; } -export function decryptToken(nonce: string, ciphertext: string, tag: string): string { - const decipher = nodeCrypto.createDecipheriv( - "aes-256-gcm", - env.ENCRYPTION_KEY, - Buffer.from(nonce, "hex") - ); +export function decryptToken(nonce: string, ciphertext: string, tag: string, key: string): string { + const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "hex")); decipher.setAuthTag(Buffer.from(tag, "hex")); From 690461a730bfbc2a79d8934e987d6ce682fe14bf Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 12:29:49 +0200 Subject: [PATCH 07/21] Remove duplicate cli token utils --- packages/cli-v3/src/commands/login.ts | 4 +--- .../{isPersonalAccessToken.ts => accessTokens.ts} | 4 ++-- .../src/utilities/isOrganizationAccessToken.ts | 12 ------------ 3 files changed, 3 insertions(+), 17 deletions(-) rename packages/cli-v3/src/utilities/{isPersonalAccessToken.ts => accessTokens.ts} (88%) delete mode 100644 packages/cli-v3/src/utilities/isOrganizationAccessToken.ts diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index 6e0aa5e2f7..bba0a68804 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -29,12 +29,10 @@ import { VERSION } from "../version.js"; import { env, isCI } from "std-env"; import { CLOUD_API_URL } from "../consts.js"; import { - isPersonalAccessToken, - isOrganizationAccessToken, validateAccessToken, NotPersonalAccessTokenError, NotAccessTokenError, -} from "../utilities/isPersonalAccessToken.js"; +} from "../utilities/accessTokens.js"; import { links } from "@trigger.dev/core/v3"; export const LoginCommandOptions = CommonCommandOptions.extend({ diff --git a/packages/cli-v3/src/utilities/isPersonalAccessToken.ts b/packages/cli-v3/src/utilities/accessTokens.ts similarity index 88% rename from packages/cli-v3/src/utilities/isPersonalAccessToken.ts rename to packages/cli-v3/src/utilities/accessTokens.ts index a80d78cb79..03eba12bfa 100644 --- a/packages/cli-v3/src/utilities/isPersonalAccessToken.ts +++ b/packages/cli-v3/src/utilities/accessTokens.ts @@ -1,11 +1,11 @@ const personalTokenPrefix = "tr_pat_"; const organizationTokenPrefix = "tr_oat_"; -export function isPersonalAccessToken(token: string) { +function isPersonalAccessToken(token: string) { return token.startsWith(personalTokenPrefix); } -export function isOrganizationAccessToken(token: string) { +function isOrganizationAccessToken(token: string) { return token.startsWith(organizationTokenPrefix); } diff --git a/packages/cli-v3/src/utilities/isOrganizationAccessToken.ts b/packages/cli-v3/src/utilities/isOrganizationAccessToken.ts deleted file mode 100644 index d42c9c979c..0000000000 --- a/packages/cli-v3/src/utilities/isOrganizationAccessToken.ts +++ /dev/null @@ -1,12 +0,0 @@ -const tokenPrefix = "tr_oat_"; - -export function isOrganizationAccessToken(token: string) { - return token.startsWith(tokenPrefix); -} - -export class NotOrganizationAccessTokenError extends Error { - constructor(message: string) { - super(message); - this.name = "NotOrganizationAccessTokenError"; - } -} \ No newline at end of file From 11ac4ab3926a3d7224897364362930de067287af Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 12:45:25 +0200 Subject: [PATCH 08/21] Validate ENCRYPTION_KEY length when parsing env vars --- apps/webapp/app/env.server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index fdd343c90b..cf53fb4176 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -23,7 +23,12 @@ const EnvironmentSchema = z.object({ DATABASE_READ_REPLICA_URL: z.string().optional(), SESSION_SECRET: z.string(), MAGIC_LINK_SECRET: z.string(), - ENCRYPTION_KEY: z.string(), + ENCRYPTION_KEY: z + .string() + .refine( + (val) => Buffer.from(val, "utf8").length === 32, + "ENCRYPTION_KEY must be exactly 32 bytes" + ), WHITELISTED_EMAILS: z .string() .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") From f4b88927ef47a99f76605150f1f73d27b30b7e60 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 12:47:30 +0200 Subject: [PATCH 09/21] Make token utils a server-only module --- apps/webapp/app/services/organizationAccessToken.server.ts | 2 +- apps/webapp/app/services/personalAccessToken.server.ts | 2 +- apps/webapp/app/utils/{tokens.ts => tokens.server.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/webapp/app/utils/{tokens.ts => tokens.server.ts} (100%) diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts index d2fbe4ad68..8e7c9422ad 100644 --- a/apps/webapp/app/services/organizationAccessToken.server.ts +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -3,7 +3,7 @@ import { customAlphabet } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; -import { decryptToken, encryptToken, hashToken } from "~/utils/tokens"; +import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server"; import { env } from "~/env.server"; const tokenValueLength = 40; diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index b0c860df4d..80a251f657 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -3,7 +3,7 @@ import { customAlphabet, nanoid } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; -import { decryptToken, encryptToken, hashToken } from "~/utils/tokens"; +import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server"; import { env } from "~/env.server"; const tokenValueLength = 40; diff --git a/apps/webapp/app/utils/tokens.ts b/apps/webapp/app/utils/tokens.server.ts similarity index 100% rename from apps/webapp/app/utils/tokens.ts rename to apps/webapp/app/utils/tokens.server.ts From 90f6858e99cdb1e906b01a584aa7c3bfc7398042 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 12:52:27 +0200 Subject: [PATCH 10/21] Disallow revoking already revoked OATs --- apps/webapp/app/services/organizationAccessToken.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts index 8e7c9422ad..99a1e8727a 100644 --- a/apps/webapp/app/services/organizationAccessToken.server.ts +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -51,6 +51,7 @@ export async function revokeOrganizationAccessToken(tokenId: string) { await prisma.organizationAccessToken.update({ where: { id: tokenId, + revokedAt: null, }, data: { revokedAt: new Date(), From 877560335635fc292dd33e12ed7a12ced326a584 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 13:09:22 +0200 Subject: [PATCH 11/21] Simplify generics in authenticateRequest --- apps/webapp/app/services/apiAuth.server.ts | 23 +++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 4d69a35d56..52a2e8c7ba 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -334,6 +334,12 @@ type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | type AllowedAuthenticationMethods = Record & ({ personalAccessToken: true } | { organizationAccessToken: true } | { apiKey: true }); +const defaultAllowedAuthenticationMethods: AllowedAuthenticationMethods = { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, +}; + type FilteredAuthenticationResult< T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods > = @@ -350,7 +356,8 @@ type FilteredAuthenticationResult< * * Supports personal access tokens, organization access tokens, and API keys. * Returns the appropriate authentication result based on the token type found. - * The return type is conditionally filtered to only include authentication methods that are enabled. + * + * 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 @@ -373,18 +380,16 @@ export async function authenticateRequest< T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods >( request: Request, - allowedAuthenticationMethods: T = { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - } satisfies AllowedAuthenticationMethods as T + allowedAuthenticationMethods?: T ): Promise | undefined> { + const allowedMethods = allowedAuthenticationMethods ?? defaultAllowedAuthenticationMethods; + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } - if (allowedAuthenticationMethods.personalAccessToken && isPersonalAccessToken(apiKey)) { + if (allowedMethods.personalAccessToken && isPersonalAccessToken(apiKey)) { const result = await authenticateApiRequestWithPersonalAccessToken(request); if (!result) { @@ -400,7 +405,7 @@ export async function authenticateRequest< > as FilteredAuthenticationResult; } - if (allowedAuthenticationMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) { + if (allowedMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) { const result = await authenticateApiRequestWithOrganizationAccessToken(request); if (!result) { @@ -416,7 +421,7 @@ export async function authenticateRequest< > as FilteredAuthenticationResult; } - if (allowedAuthenticationMethods.apiKey) { + if (allowedMethods.apiKey) { const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); if (!result) { From 7d092ee27082e71e0891850332edcabb86896c6d Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 13:50:51 +0200 Subject: [PATCH 12/21] Use 32 bytes mock encryption key in the test setup --- apps/webapp/test/registryConfig.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/test/registryConfig.test.ts b/apps/webapp/test/registryConfig.test.ts index 13d56bbb39..341761d011 100644 --- a/apps/webapp/test/registryConfig.test.ts +++ b/apps/webapp/test/registryConfig.test.ts @@ -8,7 +8,7 @@ describe("getRegistryConfig", () => { DIRECT_URL: "postgresql://test:test@localhost:5432/test", SESSION_SECRET: "test-session-secret", MAGIC_LINK_SECRET: "test-magic-link-secret", - ENCRYPTION_KEY: "test-encryption-key", + ENCRYPTION_KEY: "test-encryption-keeeeey-32-bytes", CLICKHOUSE_URL: "http://localhost:8123", }; From 27ba07a157d6d7a448c3f800e723e6d59748fca6 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 14:01:01 +0200 Subject: [PATCH 13/21] Update dummy encryption key values in tests and templates --- .github/workflows/unit-tests-webapp.yml | 2 +- docker/dev-compose.yml | 2 +- docker/services-compose.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml index f73eada842..ff00525825 100644 --- a/.github/workflows/unit-tests-webapp.yml +++ b/.github/workflows/unit-tests-webapp.yml @@ -85,7 +85,7 @@ jobs: DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres SESSION_SECRET: "secret" MAGIC_LINK_SECRET: "secret" - ENCRYPTION_KEY: "secret" + ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes" DEPLOY_REGISTRY_HOST: "docker.io" CLICKHOUSE_URL: "http://default:password@localhost:8123" diff --git a/docker/dev-compose.yml b/docker/dev-compose.yml index d3b02b3bf3..ff3a7d9b24 100644 --- a/docker/dev-compose.yml +++ b/docker/dev-compose.yml @@ -111,7 +111,7 @@ services: CLICKHOUSE_URL: http://default:password@clickhouse:8123 SESSION_SECRET: secret123 MAGIC_LINK_SECRET: secret123 - ENCRYPTION_KEY: secret123 + ENCRYPTION_KEY: dummy-encryption-keeeey-32-bytes REMIX_APP_PORT: 3030 PORT: 3030 networks: diff --git a/docker/services-compose.yml b/docker/services-compose.yml index 1d0509b217..32d7e8bdc1 100644 --- a/docker/services-compose.yml +++ b/docker/services-compose.yml @@ -37,7 +37,7 @@ services: DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public SESSION_SECRET: secret123 MAGIC_LINK_SECRET: secret123 - ENCRYPTION_KEY: secret123 + ENCRYPTION_KEY: dummy-encryption-keeeey-32-bytes REMIX_APP_PORT: 3030 PORT: 3030 WORKER_ENABLED: "false" @@ -56,7 +56,7 @@ services: DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public SESSION_SECRET: secret123 MAGIC_LINK_SECRET: secret123 - ENCRYPTION_KEY: secret123 + ENCRYPTION_KEY: dummy-encryption-keeeey-32-bytes REMIX_APP_PORT: 3030 PORT: 3030 HTTP_SERVER_DISABLED: "true" From f2cee5e720bea888a13cb9ba3174f639070b4f89 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 14:53:15 +0200 Subject: [PATCH 14/21] Add a column in the OATs table to differentiate between user and system generated --- .../migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 8 ++++++++ 2 files changed, 11 insertions(+) rename internal-packages/database/prisma/migrations/{20250813103207_add_organization_access_token => 20250814124843_add_organization_access_tokens}/migration.sql (85%) diff --git a/internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql b/internal-packages/database/prisma/migrations/20250814124843_add_organization_access_tokens/migration.sql similarity index 85% rename from internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql rename to internal-packages/database/prisma/migrations/20250814124843_add_organization_access_tokens/migration.sql index cd8a4e115c..19374b478e 100644 --- a/internal-packages/database/prisma/migrations/20250813103207_add_organization_access_token/migration.sql +++ b/internal-packages/database/prisma/migrations/20250814124843_add_organization_access_tokens/migration.sql @@ -1,6 +1,9 @@ +CREATE TYPE "OrganizationAccessTokenType" AS ENUM ('USER', 'SYSTEM'); + CREATE TABLE "OrganizationAccessToken" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, + "type" "OrganizationAccessTokenType" NOT NULL DEFAULT 'USER', "encryptedToken" JSONB NOT NULL, "obfuscatedToken" TEXT NOT NULL, "hashedToken" TEXT NOT NULL, diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 64c101da45..7ca8344443 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -133,12 +133,20 @@ model PersonalAccessToken { authorizationCodes AuthorizationCode[] } +enum OrganizationAccessTokenType { + USER + SYSTEM +} + model OrganizationAccessToken { id String @id @default(cuid()) /// User-provided name for the token name String + /// Used to differentiate between user-generated and system-generated tokens + type OrganizationAccessTokenType @default(USER) + /// This is the token encrypted using the ENCRYPTION_KEY encryptedToken Json From 54775472ed0cbd09b4b67a7b3db3c6dab9308571 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 14 Aug 2025 15:36:02 +0200 Subject: [PATCH 15/21] Simplify args for v3ProjectPath Co-authored-by: Matt Aitken --- apps/webapp/app/routes/api.v2.whoami.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v2.whoami.ts b/apps/webapp/app/routes/api.v2.whoami.ts index bea88be78b..16629db0ec 100644 --- a/apps/webapp/app/routes/api.v2.whoami.ts +++ b/apps/webapp/app/routes/api.v2.whoami.ts @@ -185,7 +185,7 @@ async function getIdentityFromOAT( }; } - const projectPath = v3ProjectPath({ slug: project.organization.slug }, { slug: project.slug }); + const projectPath = v3ProjectPath(project.organization, project); return { success: true, result: { From 908c66bd80be7ca702a41493d6ef4a5130e70b10 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 16:42:26 +0200 Subject: [PATCH 16/21] Add index on org id and createdAt --- .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 2 ++ 2 files changed, 4 insertions(+) rename internal-packages/database/prisma/migrations/{20250814124843_add_organization_access_tokens => 20250814144043_add_organization_access_tokens}/migration.sql (87%) diff --git a/internal-packages/database/prisma/migrations/20250814124843_add_organization_access_tokens/migration.sql b/internal-packages/database/prisma/migrations/20250814144043_add_organization_access_tokens/migration.sql similarity index 87% rename from internal-packages/database/prisma/migrations/20250814124843_add_organization_access_tokens/migration.sql rename to internal-packages/database/prisma/migrations/20250814144043_add_organization_access_tokens/migration.sql index 19374b478e..77f917a7cc 100644 --- a/internal-packages/database/prisma/migrations/20250814124843_add_organization_access_tokens/migration.sql +++ b/internal-packages/database/prisma/migrations/20250814144043_add_organization_access_tokens/migration.sql @@ -19,4 +19,6 @@ CREATE TABLE "OrganizationAccessToken" ( CREATE UNIQUE INDEX "OrganizationAccessToken_hashedToken_key" ON "OrganizationAccessToken"("hashedToken"); +CREATE INDEX "OrganizationAccessToken_organizationId_createdAt_idx" ON "OrganizationAccessToken"("organizationId", "createdAt" DESC); + ALTER TABLE "OrganizationAccessToken" ADD CONSTRAINT "OrganizationAccessToken_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7ca8344443..47a0d84563 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -166,6 +166,8 @@ model OrganizationAccessToken { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([organizationId, createdAt(sort: Desc)]) } model Organization { From 67f7e9abd5165fb21e9c4afc20c37c1cd9991ad3 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 17:01:52 +0200 Subject: [PATCH 17/21] Avoid storing the encrypted oat token and its obfuscated version in the DB at all It is a safer approach. Also we do not need to ever read the decrypted token value after creation. --- .../organizationAccessToken.server.ts | 50 +------------------ .../migration.sql | 2 - .../database/prisma/schema.prisma | 6 --- 3 files changed, 1 insertion(+), 57 deletions(-) rename internal-packages/database/prisma/migrations/{20250814144043_add_organization_access_tokens => 20250814144649_add_organization_access_tokens}/migration.sql (93%) diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts index 99a1e8727a..35cacf4605 100644 --- a/apps/webapp/app/services/organizationAccessToken.server.ts +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -1,10 +1,8 @@ -import { type OrganizationAccessToken } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; -import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server"; -import { env } from "~/env.server"; +import { hashToken } from "~/utils/tokens.server"; const tokenValueLength = 40; //lowercase only, removed 0 and l to avoid confusion @@ -21,7 +19,6 @@ export async function getValidOrganizationAccessTokens(organizationId: string) { select: { id: true, name: true, - obfuscatedToken: true, createdAt: true, lastAccessedAt: true, expiresAt: true, @@ -36,7 +33,6 @@ export async function getValidOrganizationAccessTokens(organizationId: string) { return organizationAccessTokens.map((oat) => ({ id: oat.id, name: oat.name, - obfuscatedToken: oat.obfuscatedToken, createdAt: oat.createdAt, lastAccessedAt: oat.lastAccessedAt, expiresAt: oat.expiresAt, @@ -63,12 +59,6 @@ export type OrganizationAccessTokenAuthenticationResult = { organizationId: string; }; -const EncryptedSecretValueSchema = z.object({ - nonce: z.string(), - ciphertext: z.string(), - tag: z.string(), -}); - const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/); export async function authenticateApiRequestWithOrganizationAccessToken( @@ -125,15 +115,6 @@ export async function authenticateOrganizationAccessToken( }, }); - const decryptedToken = decryptOrganizationAccessToken(organizationAccessToken); - - if (decryptedToken !== token) { - logger.error( - `OrganizationAccessToken with id: ${organizationAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.` - ); - return; - } - return { organizationId: organizationAccessToken.organizationId, }; @@ -149,14 +130,11 @@ export async function createOrganizationAccessToken({ expiresAt, }: CreateOrganizationAccessTokenOptions) { const token = createToken(); - const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY); const organizationAccessToken = await prisma.organizationAccessToken.create({ data: { name, organizationId, - encryptedToken, - obfuscatedToken: obfuscateToken(token), hashedToken: hashToken(token), expiresAt, }, @@ -167,7 +145,6 @@ export async function createOrganizationAccessToken({ name, organizationId, token, - obfuscatedToken: organizationAccessToken.obfuscatedToken, expiresAt: organizationAccessToken.expiresAt, }; } @@ -181,28 +158,3 @@ const tokenPrefix = "tr_oat_"; function createToken() { return `${tokenPrefix}${tokenGenerator()}`; } - -function obfuscateToken(token: string) { - const withoutPrefix = token.replace(tokenPrefix, ""); - const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`; - return `${tokenPrefix}${obfuscated}`; -} - -function decryptOrganizationAccessToken(organizationAccessToken: OrganizationAccessToken) { - const encryptedData = EncryptedSecretValueSchema.safeParse( - organizationAccessToken.encryptedToken - ); - if (!encryptedData.success) { - throw new Error( - `Unable to parse encrypted OrganizationAccessToken with id: ${organizationAccessToken.id}: ${encryptedData.error.message}` - ); - } - - const decryptedToken = decryptToken( - encryptedData.data.nonce, - encryptedData.data.ciphertext, - encryptedData.data.tag, - env.ENCRYPTION_KEY - ); - return decryptedToken; -} diff --git a/internal-packages/database/prisma/migrations/20250814144043_add_organization_access_tokens/migration.sql b/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql similarity index 93% rename from internal-packages/database/prisma/migrations/20250814144043_add_organization_access_tokens/migration.sql rename to internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql index 77f917a7cc..5dfb3e3ad9 100644 --- a/internal-packages/database/prisma/migrations/20250814144043_add_organization_access_tokens/migration.sql +++ b/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql @@ -4,8 +4,6 @@ CREATE TABLE "OrganizationAccessToken" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "type" "OrganizationAccessTokenType" NOT NULL DEFAULT 'USER', - "encryptedToken" JSONB NOT NULL, - "obfuscatedToken" TEXT NOT NULL, "hashedToken" TEXT NOT NULL, "organizationId" TEXT NOT NULL, "expiresAt" TIMESTAMP(3), diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 47a0d84563..72047ca2ae 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -147,12 +147,6 @@ model OrganizationAccessToken { /// Used to differentiate between user-generated and system-generated tokens type OrganizationAccessTokenType @default(USER) - /// This is the token encrypted using the ENCRYPTION_KEY - encryptedToken Json - - /// This is shown in the UI, with ******** - obfuscatedToken String - /// This is used to find the token in the database hashedToken String @unique From 1267d5532d5e64a5edd41cd2d363f4bda46405cc Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 14 Aug 2025 17:14:59 +0200 Subject: [PATCH 18/21] Fix prisma update condition --- apps/webapp/app/services/organizationAccessToken.server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts index 35cacf4605..ba11374c24 100644 --- a/apps/webapp/app/services/organizationAccessToken.server.ts +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -47,7 +47,6 @@ export async function revokeOrganizationAccessToken(tokenId: string) { await prisma.organizationAccessToken.update({ where: { id: tokenId, - revokedAt: null, }, data: { revokedAt: new Date(), From f25347099fdcd1e25daf7d389133de868e7ab49c Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 27 Aug 2025 10:17:12 +0200 Subject: [PATCH 19/21] Add token type to the OAT table index --- .../20250814144649_add_organization_access_tokens/migration.sql | 2 +- internal-packages/database/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql b/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql index 5dfb3e3ad9..d931607ec4 100644 --- a/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql +++ b/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql @@ -17,6 +17,6 @@ CREATE TABLE "OrganizationAccessToken" ( CREATE UNIQUE INDEX "OrganizationAccessToken_hashedToken_key" ON "OrganizationAccessToken"("hashedToken"); -CREATE INDEX "OrganizationAccessToken_organizationId_createdAt_idx" ON "OrganizationAccessToken"("organizationId", "createdAt" DESC); +CREATE INDEX "OrganizationAccessToken_organizationId_type_createdAt_idx" ON "OrganizationAccessToken"("organizationId", "type", "createdAt" DESC); ALTER TABLE "OrganizationAccessToken" ADD CONSTRAINT "OrganizationAccessToken_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 72047ca2ae..10df7a0398 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -161,7 +161,7 @@ model OrganizationAccessToken { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([organizationId, createdAt(sort: Desc)]) + @@index([organizationId, type, createdAt(sort: Desc)]) } model Organization { From f60d4b521bcb3a65e4c6ab9871842b9d02f7a77f Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 27 Aug 2025 10:51:16 +0200 Subject: [PATCH 20/21] Accept OATs in the mcp auth flow --- packages/cli-v3/src/mcp/auth.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli-v3/src/mcp/auth.ts b/packages/cli-v3/src/mcp/auth.ts index 5079fc8b66..a09543874d 100644 --- a/packages/cli-v3/src/mcp/auth.ts +++ b/packages/cli-v3/src/mcp/auth.ts @@ -3,10 +3,7 @@ import { env } from "std-env"; import { CliApiClient } from "../apiClient.js"; import { CLOUD_API_URL } from "../consts.js"; import { readAuthConfigProfile, writeAuthConfigProfile } from "../utilities/configFiles.js"; -import { - isPersonalAccessToken, - NotPersonalAccessTokenError, -} from "../utilities/isPersonalAccessToken.js"; +import { NotAccessTokenError, validateAccessToken } from "../utilities/accessTokens.js"; import { LoginResult, LoginResultOk } from "../utilities/session.js"; import { getPersonalAccessToken } from "../commands/login.js"; import open from "open"; @@ -30,8 +27,12 @@ export async function mcpAuth(options: McpAuthOptions): Promise { const accessTokenFromEnv = env.TRIGGER_ACCESS_TOKEN; if (accessTokenFromEnv) { - if (!isPersonalAccessToken(accessTokenFromEnv)) { - throw new NotPersonalAccessTokenError( + const validationResult = validateAccessToken(accessTokenFromEnv); + + if (!validationResult.success) { + // We deliberately don't surface the existence of organization access tokens to the user for now, as they're only used internally. + // Once we expose them in the application, we should also communicate that option here. + throw new NotAccessTokenError( "Your TRIGGER_ACCESS_TOKEN is not a Personal Access Token, they start with 'tr_pat_'. You can generate one here: https://cloud.trigger.dev/account/tokens" ); } @@ -57,6 +58,7 @@ export async function mcpAuth(options: McpAuthOptions): Promise { auth: { accessToken: auth.accessToken, apiUrl: auth.apiUrl, + tokenType: validationResult.type, }, }; } @@ -83,6 +85,7 @@ export async function mcpAuth(options: McpAuthOptions): Promise { auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } @@ -148,6 +151,7 @@ export async function mcpAuth(options: McpAuthOptions): Promise { auth: { accessToken: indexResult.token, apiUrl: opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } From d2b004526f8707cbba91ae2ed8d0dea493469eaa Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 27 Aug 2025 13:11:50 +0200 Subject: [PATCH 21/21] Simplify env auth flow around the /projects endpoints --- .../api.v1.projects.$projectRef.$env.jwt.ts | 71 +++++-------------- ...jects.$projectRef.$env.workers.$tagName.ts | 66 +++++------------ .../api.v1.projects.$projectRef.dev-status.ts | 46 ++++-------- 3 files changed, 50 insertions(+), 133 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts index 2db054d4d4..e4b48ece05 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts @@ -1,9 +1,10 @@ -import { ActionFunctionArgs, json } from "@remix-run/node"; +import { type ActionFunctionArgs, json } from "@remix-run/node"; import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, +} from "~/services/apiAuth.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -20,7 +21,11 @@ const RequestBodySchema = z.object({ }); export async function action({ request, params }: ActionFunctionArgs) { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); @@ -33,35 +38,14 @@ export async function action({ request, params }: ActionFunctionArgs) { } const { projectRef, env } = parsedParams.data; + const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined; - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }, - }); - - if (!project) { - return json({ error: "Project not found" }, { status: 404 }); - } - - const envResult = await getEnvironmentFromEnv({ - projectId: project.id, - userId: authenticationResult.userId, + const runtimeEnv = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, env, - }); - - if (!envResult.success) { - return json({ error: envResult.error }, { status: 404 }); - } - - const runtimeEnv = envResult.environment; + triggerBranch + ); const parsedBody = RequestBodySchema.safeParse(await request.json()); @@ -72,29 +56,8 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined; - - let previewBranchEnvironmentId: string | undefined; - - if (triggerBranch) { - const previewBranch = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId: project.id, - branchName: triggerBranch, - parentEnvironmentId: runtimeEnv.id, - archivedAt: null, - }, - }); - - if (previewBranch) { - previewBranchEnvironmentId = previewBranch.id; - } else { - return json({ error: `Preview branch ${triggerBranch} not found` }, { status: 404 }); - } - } - const claims = { - sub: previewBranchEnvironmentId ?? runtimeEnv.id, + sub: runtimeEnv.id, pub: true, ...parsedBody.data.claims, }; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts index b26923716d..ddb398b4c2 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts @@ -1,12 +1,14 @@ import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica, prisma } from "~/db.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; -import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env"; -import { GetWorkerByTagResponse } from "@trigger.dev/core/v3/schemas"; +import { type GetWorkerByTagResponse } from "@trigger.dev/core/v3/schemas"; import { env as $env } from "~/env.server"; import { v3RunsPath } from "~/utils/pathBuilder"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, +} from "~/services/apiAuth.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -21,7 +23,11 @@ const HeadersSchema = z.object({ type ParamsSchema = z.infer; export async function loader({ request, params }: LoaderFunctionArgs) { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); @@ -32,51 +38,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { if (!parsedParams.success) { return json({ error: "Invalid Params" }, { status: 400 }); } - - const parsedHeaders = HeadersSchema.safeParse(Object.fromEntries(request.headers)); - - const branch = parsedHeaders.success ? parsedHeaders.data["x-trigger-branch"] : undefined; - const { projectRef, env } = parsedParams.data; - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }, - select: { - id: true, - slug: true, - organization: { - select: { - slug: true, - }, - }, - }, - }); - - if (!project) { - return json({ error: "Project not found" }, { status: 404 }); - } + const parsedHeaders = HeadersSchema.safeParse(Object.fromEntries(request.headers)); + const triggerBranch = parsedHeaders.success ? parsedHeaders.data["x-trigger-branch"] : undefined; - const envResult = await getEnvironmentFromEnv({ - projectId: project.id, - userId: authenticationResult.userId, + const runtimeEnv = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, env, - branch, - }); - - if (!envResult.success) { - return json({ error: envResult.error }, { status: 404 }); - } - - const runtimeEnv = envResult.environment; + triggerBranch + ); const currentWorker = await findCurrentWorkerFromEnvironment( { @@ -110,8 +82,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const urls = { runs: `${$env.APP_ORIGIN}${v3RunsPath( - { slug: project.organization.slug }, - { slug: project.slug }, + { slug: runtimeEnv.organization.slug }, + { slug: runtimeEnv.project.slug }, { slug: runtimeEnv.slug }, { versions: [currentWorker.version] } )}`, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.dev-status.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.dev-status.ts index 58171cc5bb..f5f632a822 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.dev-status.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.dev-status.ts @@ -1,16 +1,21 @@ import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { z } from "zod"; -import { prisma } from "~/db.server"; import { devPresence } from "~/presenters/v3/DevPresence.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, +} from "~/services/apiAuth.server"; const ParamsSchema = z.object({ projectRef: z.string(), }); export async function loader({ request, params }: LoaderFunctionArgs) { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); @@ -24,34 +29,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef } = parsedParams.data; - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }, - }); - - if (!project) { - return json({ error: "Project not found" }, { status: 404 }); - } - - const envResult = await getEnvironmentFromEnv({ - projectId: project.id, - userId: authenticationResult.userId, - env: "dev", - }); - - if (!envResult.success) { - return json({ error: envResult.error }, { status: 404 }); - } - - const runtimeEnv = envResult.environment; + const runtimeEnv = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, + "dev" + ); const isConnected = await devPresence.isConnected(runtimeEnv.id);