Skip to content

Commit 1cc6223

Browse files
myftijamatt-aitken
andauthored
feat: introduce organization access tokens (#2391)
* Create schema and migration for organization access tokens * Add helpers for creating and authenticating OATs * Adapt the auth service to also accept OATs * Accept OATs in the whoami v2 endpoint * Enable deployments with the CLI using OATs * Avoid reading env variables directly in the token utils * Remove duplicate cli token utils * Validate ENCRYPTION_KEY length when parsing env vars * Make token utils a server-only module * Disallow revoking already revoked OATs * Simplify generics in authenticateRequest * Use 32 bytes mock encryption key in the test setup * Update dummy encryption key values in tests and templates * Add a column in the OATs table to differentiate between user and system generated * Simplify args for v3ProjectPath Co-authored-by: Matt Aitken <[email protected]> * Add index on org id and createdAt * 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. * Fix prisma update condition * Add token type to the OAT table index * Accept OATs in the mcp auth flow * Simplify env auth flow around the /projects endpoints --------- Co-authored-by: Matt Aitken <[email protected]>
1 parent 96243ef commit 1cc6223

26 files changed

+766
-468
lines changed

.github/workflows/unit-tests-webapp.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ jobs:
8585
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
8686
SESSION_SECRET: "secret"
8787
MAGIC_LINK_SECRET: "secret"
88-
ENCRYPTION_KEY: "secret"
88+
ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes"
8989
DEPLOY_REGISTRY_HOST: "docker.io"
9090
CLICKHOUSE_URL: "http://default:password@localhost:8123"
9191

apps/webapp/app/env.server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ const EnvironmentSchema = z.object({
2323
DATABASE_READ_REPLICA_URL: z.string().optional(),
2424
SESSION_SECRET: z.string(),
2525
MAGIC_LINK_SECRET: z.string(),
26-
ENCRYPTION_KEY: z.string(),
26+
ENCRYPTION_KEY: z
27+
.string()
28+
.refine(
29+
(val) => Buffer.from(val, "utf8").length === 32,
30+
"ENCRYPTION_KEY must be exactly 32 bytes"
31+
),
2732
WHITELISTED_EMAILS: z
2833
.string()
2934
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")

apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts

Lines changed: 17 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { ActionFunctionArgs, json } from "@remix-run/node";
1+
import { type ActionFunctionArgs, json } from "@remix-run/node";
22
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
33
import { z } from "zod";
4-
import { prisma } from "~/db.server";
5-
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
6-
import { getEnvironmentFromEnv } from "./api.v1.projects.$projectRef.$env";
4+
import {
5+
authenticatedEnvironmentForAuthentication,
6+
authenticateRequest,
7+
} from "~/services/apiAuth.server";
78

89
const ParamsSchema = z.object({
910
projectRef: z.string(),
@@ -20,7 +21,11 @@ const RequestBodySchema = z.object({
2021
});
2122

2223
export async function action({ request, params }: ActionFunctionArgs) {
23-
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
24+
const authenticationResult = await authenticateRequest(request, {
25+
personalAccessToken: true,
26+
organizationAccessToken: true,
27+
apiKey: false,
28+
});
2429

2530
if (!authenticationResult) {
2631
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -33,35 +38,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
3338
}
3439

3540
const { projectRef, env } = parsedParams.data;
41+
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
3642

37-
const project = await prisma.project.findFirst({
38-
where: {
39-
externalRef: projectRef,
40-
organization: {
41-
members: {
42-
some: {
43-
userId: authenticationResult.userId,
44-
},
45-
},
46-
},
47-
},
48-
});
49-
50-
if (!project) {
51-
return json({ error: "Project not found" }, { status: 404 });
52-
}
53-
54-
const envResult = await getEnvironmentFromEnv({
55-
projectId: project.id,
56-
userId: authenticationResult.userId,
43+
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
44+
authenticationResult,
45+
projectRef,
5746
env,
58-
});
59-
60-
if (!envResult.success) {
61-
return json({ error: envResult.error }, { status: 404 });
62-
}
63-
64-
const runtimeEnv = envResult.environment;
47+
triggerBranch
48+
);
6549

6650
const parsedBody = RequestBodySchema.safeParse(await request.json());
6751

@@ -72,29 +56,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
7256
);
7357
}
7458

75-
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
76-
77-
let previewBranchEnvironmentId: string | undefined;
78-
79-
if (triggerBranch) {
80-
const previewBranch = await prisma.runtimeEnvironment.findFirst({
81-
where: {
82-
projectId: project.id,
83-
branchName: triggerBranch,
84-
parentEnvironmentId: runtimeEnv.id,
85-
archivedAt: null,
86-
},
87-
});
88-
89-
if (previewBranch) {
90-
previewBranchEnvironmentId = previewBranch.id;
91-
} else {
92-
return json({ error: `Preview branch ${triggerBranch} not found` }, { status: 404 });
93-
}
94-
}
95-
9659
const claims = {
97-
sub: previewBranchEnvironmentId ?? runtimeEnv.id,
60+
sub: runtimeEnv.id,
9861
pub: true,
9962
...parsedBody.data.claims,
10063
};
Lines changed: 15 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { type GetProjectEnvResponse } from "@trigger.dev/core/v3";
3-
import { type RuntimeEnvironment } from "@trigger.dev/database";
43
import { z } from "zod";
5-
import { prisma } from "~/db.server";
64
import { env as processEnv } from "~/env.server";
7-
import { logger } from "~/services/logger.server";
8-
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
5+
import {
6+
authenticatedEnvironmentForAuthentication,
7+
authenticateRequest,
8+
} from "~/services/apiAuth.server";
99

1010
const ParamsSchema = z.object({
1111
projectRef: z.string(),
@@ -15,14 +15,6 @@ const ParamsSchema = z.object({
1515
type ParamsSchema = z.infer<typeof ParamsSchema>;
1616

1717
export async function loader({ request, params }: LoaderFunctionArgs) {
18-
logger.info("projects get env", { url: request.url });
19-
20-
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
21-
22-
if (!authenticationResult) {
23-
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
24-
}
25-
2618
const parsedParams = ParamsSchema.safeParse(params);
2719

2820
if (!parsedParams.success) {
@@ -31,162 +23,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3123

3224
const { projectRef, env } = parsedParams.data;
3325

34-
const project = await prisma.project.findFirst({
35-
where: {
36-
externalRef: projectRef,
37-
organization: {
38-
members: {
39-
some: {
40-
userId: authenticationResult.userId,
41-
},
42-
},
43-
},
44-
},
45-
});
46-
47-
if (!project) {
48-
return json({ error: "Project not found" }, { status: 404 });
49-
}
50-
51-
const envResult = await getEnvironmentFromEnv({
52-
projectId: project.id,
53-
userId: authenticationResult.userId,
54-
env,
55-
});
26+
const authenticationResult = await authenticateRequest(request);
5627

57-
if (!envResult.success) {
58-
return json({ error: envResult.error }, { status: 404 });
28+
if (!authenticationResult) {
29+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
5930
}
6031

61-
const runtimeEnv = envResult.environment;
32+
const environment = await authenticatedEnvironmentForAuthentication(
33+
authenticationResult,
34+
projectRef,
35+
env
36+
);
6237

6338
const result: GetProjectEnvResponse = {
64-
apiKey: runtimeEnv.apiKey,
65-
name: project.name,
39+
apiKey: environment.apiKey,
40+
name: environment.project.name,
6641
apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN,
67-
projectId: project.id,
42+
projectId: environment.project.id,
6843
};
6944

7045
return json(result);
7146
}
72-
73-
export async function getEnvironmentFromEnv({
74-
projectId,
75-
userId,
76-
env,
77-
branch,
78-
}: {
79-
projectId: string;
80-
userId: string;
81-
env: ParamsSchema["env"];
82-
branch?: string;
83-
}): Promise<
84-
| {
85-
success: true;
86-
environment: RuntimeEnvironment;
87-
}
88-
| {
89-
success: false;
90-
error: string;
91-
}
92-
> {
93-
if (env === "dev") {
94-
const environment = await prisma.runtimeEnvironment.findFirst({
95-
where: {
96-
projectId,
97-
orgMember: {
98-
userId: userId,
99-
},
100-
},
101-
});
102-
103-
if (!environment) {
104-
return {
105-
success: false,
106-
error: "Dev environment not found",
107-
};
108-
}
109-
110-
return {
111-
success: true,
112-
environment,
113-
};
114-
}
115-
116-
let slug: "stg" | "prod" | "preview" = "prod";
117-
switch (env) {
118-
case "staging":
119-
slug = "stg";
120-
break;
121-
case "prod":
122-
slug = "prod";
123-
break;
124-
case "preview":
125-
slug = "preview";
126-
break;
127-
default:
128-
break;
129-
}
130-
131-
if (slug === "preview") {
132-
const previewEnvironment = await prisma.runtimeEnvironment.findFirst({
133-
where: {
134-
projectId,
135-
slug: "preview",
136-
},
137-
});
138-
139-
if (!previewEnvironment) {
140-
return {
141-
success: false,
142-
error: "Preview environment not found",
143-
};
144-
}
145-
146-
// If no branch is provided, just return the parent preview environment
147-
if (!branch) {
148-
return {
149-
success: true,
150-
environment: previewEnvironment,
151-
};
152-
}
153-
154-
const branchEnvironment = await prisma.runtimeEnvironment.findFirst({
155-
where: {
156-
parentEnvironmentId: previewEnvironment.id,
157-
branchName: branch,
158-
},
159-
});
160-
161-
if (!branchEnvironment) {
162-
return {
163-
success: false,
164-
error: `Preview branch ${branch} not found`,
165-
};
166-
}
167-
168-
return {
169-
success: true,
170-
environment: branchEnvironment,
171-
};
172-
}
173-
174-
const environment = await prisma.runtimeEnvironment.findFirst({
175-
where: {
176-
projectId,
177-
slug,
178-
},
179-
});
180-
181-
if (!environment) {
182-
return {
183-
success: false,
184-
error: `${env === "staging" ? "Staging" : "Production"} environment not found`,
185-
};
186-
}
187-
188-
return {
189-
success: true,
190-
environment,
191-
};
192-
}

0 commit comments

Comments
 (0)