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/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.") 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.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.$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.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.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); 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/routes/api.v2.whoami.ts b/apps/webapp/app/routes/api.v2.whoami.ts index 36e46221f5..16629db0ec 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(project.organization, project); + return { + success: true, + result: { + ...orgDetails, + project: { + url: new URL(projectPath, env.APP_ORIGIN).href, + name: project.name, + orgTitle: project.organization.title, + }, + }, + }; } diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 3d839ba440..52a2e8c7ba 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,81 @@ 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 }); + +const defaultAllowedAuthenticationMethods: AllowedAuthenticationMethods = { + 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. + * + * 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 | undefined> { + const allowedMethods = allowedAuthenticationMethods ?? defaultAllowedAuthenticationMethods; + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } - if (isPersonalAccessToken(apiKey)) { + if (allowedMethods.personalAccessToken && isPersonalAccessToken(apiKey)) { const result = await authenticateApiRequestWithPersonalAccessToken(request); if (!result) { @@ -337,23 +399,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 (allowedMethods.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 (allowedMethods.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 +486,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 +543,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 }); + } } } diff --git a/apps/webapp/app/services/organizationAccessToken.server.ts b/apps/webapp/app/services/organizationAccessToken.server.ts new file mode 100644 index 0000000000..ba11374c24 --- /dev/null +++ b/apps/webapp/app/services/organizationAccessToken.server.ts @@ -0,0 +1,159 @@ +import { customAlphabet } from "nanoid"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { logger } from "./logger.server"; +import { hashToken } from "~/utils/tokens.server"; + +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, + 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, + 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 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(), + }, + }); + + 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 organizationAccessToken = await prisma.organizationAccessToken.create({ + data: { + name, + organizationId, + hashedToken: hashToken(token), + expiresAt, + }, + }); + + return { + id: organizationAccessToken.id, + name, + organizationId, + token, + expiresAt: organizationAccessToken.expiresAt, + }; +} + +export type CreatedOrganizationAccessToken = Awaited< + ReturnType +>; + +const tokenPrefix = "tr_oat_"; + +function createToken() { + return `${tokenPrefix}${tokenGenerator()}`; +} diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index f48582ec9b..80a251f657 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -1,10 +1,10 @@ -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.server"; +import { env } from "~/env.server"; const tokenValueLength = 40; //lowercase only, removed 0 and l to avoid confusion @@ -266,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: { @@ -303,22 +303,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) { @@ -330,28 +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; } - -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.server.ts b/apps/webapp/app/utils/tokens.server.ts new file mode 100644 index 0000000000..ae91018ba4 --- /dev/null +++ b/apps/webapp/app/utils/tokens.server.ts @@ -0,0 +1,34 @@ +import nodeCrypto from "node:crypto"; + +export function encryptToken(value: string, key: string) { + const nonce = nodeCrypto.randomBytes(12); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", 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, key: string): string { + const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", 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/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", }; 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" 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 new file mode 100644 index 0000000000..d931607ec4 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250814144649_add_organization_access_tokens/migration.sql @@ -0,0 +1,22 @@ +CREATE TYPE "OrganizationAccessTokenType" AS ENUM ('USER', 'SYSTEM'); + +CREATE TABLE "OrganizationAccessToken" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "OrganizationAccessTokenType" NOT NULL DEFAULT 'USER', + "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"); + +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 1f0397904d..10df7a0398 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -133,6 +133,37 @@ 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 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 + + @@index([organizationId, type, createdAt(sort: Desc)]) +} + model Organization { id String @id @default(cuid()) slug String @unique @@ -174,6 +205,7 @@ model Organization { members OrgMember[] invites OrgMemberInvite[] organizationIntegrations OrganizationIntegration[] + organizationAccessTokens OrganizationAccessToken[] workerGroups WorkerInstanceGroup[] workerInstances WorkerInstance[] executionSnapshots TaskRunExecutionSnapshot[] 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..bba0a68804 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -29,9 +29,10 @@ import { VERSION } from "../version.js"; import { env, isCI } from "std-env"; import { CLOUD_API_URL } from "../consts.js"; import { - isPersonalAccessToken, + validateAccessToken, NotPersonalAccessTokenError, -} from "../utilities/isPersonalAccessToken.js"; + NotAccessTokenError, +} from "../utilities/accessTokens.js"; import { links } from "@trigger.dev/core/v3"; export const LoginCommandOptions = CommonCommandOptions.extend({ @@ -94,8 +95,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 +124,7 @@ export async function login(options?: LoginOptions): Promise { dashboardUrl: userData.data.dashboardUrl, auth: { accessToken: auth.accessToken, + tokenType: validationResult.type, apiUrl: auth.apiUrl, }, }; @@ -188,6 +194,7 @@ export async function login(options?: LoginOptions): Promise { auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } @@ -209,6 +216,7 @@ export async function login(options?: LoginOptions): Promise { auth: { accessToken: authConfig.accessToken, apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl, + tokenType: "personal" as const, }, }; } @@ -270,7 +278,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 +320,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/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, }, }; } diff --git a/packages/cli-v3/src/utilities/accessTokens.ts b/packages/cli-v3/src/utilities/accessTokens.ts new file mode 100644 index 0000000000..03eba12bfa --- /dev/null +++ b/packages/cli-v3/src/utilities/accessTokens.ts @@ -0,0 +1,38 @@ +const personalTokenPrefix = "tr_pat_"; +const organizationTokenPrefix = "tr_oat_"; + +function isPersonalAccessToken(token: string) { + return token.startsWith(personalTokenPrefix); +} + +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 { + constructor(message: string) { + super(message); + this.name = "NotPersonalAccessTokenError"; + } +} + +export class NotAccessTokenError extends Error { + constructor(message: string) { + super(message); + this.name = "NotAccessTokenError"; + } +} diff --git a/packages/cli-v3/src/utilities/isPersonalAccessToken.ts b/packages/cli-v3/src/utilities/isPersonalAccessToken.ts deleted file mode 100644 index 53d9f3959d..0000000000 --- a/packages/cli-v3/src/utilities/isPersonalAccessToken.ts +++ /dev/null @@ -1,12 +0,0 @@ -const tokenPrefix = "tr_pat_"; - -export function isPersonalAccessToken(token: string) { - return token.startsWith(tokenPrefix); -} - -export class NotPersonalAccessTokenError extends Error { - constructor(message: string) { - super(message); - this.name = "NotPersonalAccessTokenError"; - } -} 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