Skip to content

Commit a397902

Browse files
committed
Stop using Response errors when validating API Keys, instead introduce a new "Result" type that has success and failure conditions. Adding in a way to progressively adopt because this touches everything.
1 parent 3792394 commit a397902

File tree

5 files changed

+203
-36
lines changed

5 files changed

+203
-36
lines changed

apps/webapp/app/services/apiAuth.server.ts

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,30 +29,67 @@ export type AuthenticatedEnvironment = Optional<
2929
"orgMember"
3030
>;
3131

32-
export type ApiAuthenticationResult = {
32+
export type ApiAuthenticationResult =
33+
| ApiAuthenticationResultSuccess
34+
| ApiAuthenticationResultFailure;
35+
36+
export type ApiAuthenticationResultSuccess = {
37+
ok: true;
3338
apiKey: string;
3439
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
3540
environment: AuthenticatedEnvironment;
3641
scopes?: string[];
3742
};
3843

44+
export type ApiAuthenticationResultFailure = {
45+
ok: false;
46+
error: string;
47+
};
48+
49+
/**
50+
* @deprecated Use `authenticateApiRequestWithFailure` instead.
51+
*/
3952
export async function authenticateApiRequest(
4053
request: Request,
4154
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
55+
): Promise<ApiAuthenticationResultSuccess | undefined> {
56+
const apiKey = getApiKeyFromRequest(request);
57+
58+
if (!apiKey) {
59+
return;
60+
}
61+
62+
const authentication = await authenticateApiKey(apiKey, options);
63+
64+
return authentication;
65+
}
66+
67+
/**
68+
* This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined.
69+
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
70+
*/
71+
export async function authenticateApiRequestWithFailure(
72+
request: Request,
73+
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
4274
): Promise<ApiAuthenticationResult | undefined> {
4375
const apiKey = getApiKeyFromRequest(request);
4476

4577
if (!apiKey) {
4678
return;
4779
}
4880

49-
return authenticateApiKey(apiKey, options);
81+
const authentication = await authenticateApiKeyWithFailure(apiKey, options);
82+
83+
return authentication;
5084
}
5185

86+
/**
87+
* @deprecated Use `authenticateApiKeyWithFailure` instead.
88+
*/
5289
export async function authenticateApiKey(
5390
apiKey: string,
5491
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
55-
): Promise<ApiAuthenticationResult | undefined> {
92+
): Promise<ApiAuthenticationResultSuccess | undefined> {
5693
const result = getApiKeyResult(apiKey);
5794

5895
if (!result) {
@@ -70,30 +107,120 @@ export async function authenticateApiKey(
70107
switch (result.type) {
71108
case "PUBLIC": {
72109
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
73-
if (!environment) return;
110+
if (!environment) {
111+
return;
112+
}
113+
74114
return {
115+
ok: true,
75116
...result,
76117
environment,
77118
};
78119
}
79120
case "PRIVATE": {
80121
const environment = await findEnvironmentByApiKey(result.apiKey);
81-
if (!environment) return;
122+
if (!environment) {
123+
return;
124+
}
125+
82126
return {
127+
ok: true,
83128
...result,
84129
environment,
85130
};
86131
}
87132
case "PUBLIC_JWT": {
88133
const validationResults = await validatePublicJwtKey(result.apiKey);
89134

90-
if (!validationResults) {
135+
if (!validationResults.ok) {
91136
return;
92137
}
93138

94139
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
95140

96141
return {
142+
ok: true,
143+
...result,
144+
environment: validationResults.environment,
145+
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
146+
};
147+
}
148+
}
149+
}
150+
151+
/**
152+
* This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
153+
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
154+
*/
155+
export async function authenticateApiKeyWithFailure(
156+
apiKey: string,
157+
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
158+
): Promise<ApiAuthenticationResult> {
159+
const result = getApiKeyResult(apiKey);
160+
161+
if (!result) {
162+
return {
163+
ok: false,
164+
error: "Invalid API Key",
165+
};
166+
}
167+
168+
if (!options.allowPublicKey && result.type === "PUBLIC") {
169+
return {
170+
ok: false,
171+
error: "Public API keys are not allowed for this request",
172+
};
173+
}
174+
175+
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
176+
return {
177+
ok: false,
178+
error: "Public JWT API keys are not allowed for this request",
179+
};
180+
}
181+
182+
switch (result.type) {
183+
case "PUBLIC": {
184+
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
185+
if (!environment) {
186+
return {
187+
ok: false,
188+
error: "Invalid API Key",
189+
};
190+
}
191+
192+
return {
193+
ok: true,
194+
...result,
195+
environment,
196+
};
197+
}
198+
case "PRIVATE": {
199+
const environment = await findEnvironmentByApiKey(result.apiKey);
200+
if (!environment) {
201+
return {
202+
ok: false,
203+
error: "Invalid API Key",
204+
};
205+
}
206+
207+
return {
208+
ok: true,
209+
...result,
210+
environment,
211+
};
212+
}
213+
case "PUBLIC_JWT": {
214+
const validationResults = await validatePublicJwtKey(result.apiKey);
215+
216+
if (!validationResults.ok) {
217+
return validationResults;
218+
}
219+
220+
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
221+
222+
return {
223+
ok: true,
97224
...result,
98225
environment: validationResults.environment,
99226
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
@@ -207,6 +334,10 @@ export async function authenticatedEnvironmentForAuthentication(
207334

208335
switch (auth.type) {
209336
case "apiKey": {
337+
if (!auth.result.ok) {
338+
throw json({ error: auth.result.error }, { status: 401 });
339+
}
340+
210341
if (auth.result.environment.project.externalRef !== projectRef) {
211342
throw json(
212343
{
@@ -337,6 +468,14 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
337468
return;
338469
}
339470

471+
if (!authenticatedEnv.ok) {
472+
logger.error("Failed to renew JWT token, invalid API key", {
473+
error: error.message,
474+
});
475+
476+
return;
477+
}
478+
340479
const payload = payloadSchema.safeParse(error.payload);
341480

342481
if (!payload.success) {

apps/webapp/app/services/apiRateLimit.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
2929
allowJWT: true,
3030
});
3131

32-
if (!authenticatedEnv) {
32+
if (!authenticatedEnv || !authenticatedEnv.ok) {
3333
return;
3434
}
3535

apps/webapp/app/services/realtime/jwtAuth.server.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,67 @@
11
import { json } from "@remix-run/server-runtime";
22
import { validateJWT } from "@trigger.dev/core/v3/jwt";
33
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
4+
import { AuthenticatedEnvironment } from "../apiAuth.server";
45

5-
export async function validatePublicJwtKey(token: string) {
6+
export type ValidatePublicJwtKeySuccess = {
7+
ok: true;
8+
environment: AuthenticatedEnvironment;
9+
claims: Record<string, unknown>;
10+
};
11+
12+
export type ValidatePublicJwtKeyError = {
13+
ok: false;
14+
error: string;
15+
};
16+
17+
export type ValidatePublicJwtKeyResult = ValidatePublicJwtKeySuccess | ValidatePublicJwtKeyError;
18+
19+
export async function validatePublicJwtKey(token: string): Promise<ValidatePublicJwtKeyResult> {
620
// Get the sub claim from the token
721
// Use the sub claim to find the environment
822
// Validate the token against the environment.apiKey
923
// Once that's done, return the environment and the claims
1024
const sub = extractJWTSub(token);
1125

1226
if (!sub) {
13-
throw json({ error: "Invalid Public Access Token, missing subject." }, { status: 401 });
27+
return { ok: false, error: "Invalid Public Access Token, missing subject." };
1428
}
1529

1630
const environment = await findEnvironmentById(sub);
1731

1832
if (!environment) {
19-
throw json({ error: "Invalid Public Access Token, environment not found." }, { status: 401 });
33+
return { ok: false, error: "Invalid Public Access Token, environment not found." };
2034
}
2135

2236
const result = await validateJWT(token, environment.apiKey);
2337

2438
if (!result.ok) {
2539
switch (result.code) {
2640
case "ERR_JWT_EXPIRED": {
27-
throw json(
28-
{
29-
error:
30-
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
31-
},
32-
{ status: 401 }
33-
);
41+
return {
42+
ok: false,
43+
error:
44+
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
45+
};
3446
}
3547
case "ERR_JWT_CLAIM_INVALID": {
36-
throw json(
37-
{
38-
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
39-
},
40-
{ status: 401 }
41-
);
48+
return {
49+
ok: false,
50+
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
51+
};
4252
}
4353
default: {
44-
throw json(
45-
{
46-
error:
47-
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
48-
},
49-
{ status: 401 }
50-
);
54+
return {
55+
ok: false,
56+
error:
57+
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
58+
};
5159
}
5260
}
5361
}
5462

5563
return {
64+
ok: true,
5665
environment,
5766
claims: result.payload,
5867
};

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { z } from "zod";
2-
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
2+
import {
3+
ApiAuthenticationResultSuccess,
4+
authenticateApiRequestWithFailure,
5+
} from "../apiAuth.server";
36
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
47
import { fromZodError } from "zod-validation-error";
58
import { apiCors } from "~/utils/apiCors";
@@ -48,7 +51,7 @@ type ApiKeyHandlerFunction<
4851
? z.infer<TSearchParamsSchema>
4952
: undefined;
5053
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
51-
authentication: ApiAuthenticationResult;
54+
authentication: ApiAuthenticationResultSuccess;
5255
request: Request;
5356
}) => Promise<Response>;
5457

@@ -75,7 +78,7 @@ export function createLoaderApiRoute<
7578
}
7679

7780
try {
78-
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
81+
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
7982

8083
if (!authenticationResult) {
8184
return await wrapResponse(
@@ -85,6 +88,14 @@ export function createLoaderApiRoute<
8588
);
8689
}
8790

91+
if (!authenticationResult.ok) {
92+
return await wrapResponse(
93+
request,
94+
json({ error: authenticationResult.error }, { status: 401 }),
95+
corsStrategy !== "none"
96+
);
97+
}
98+
8899
let parsedParams: any = undefined;
89100
if (paramsSchema) {
90101
const parsed = paramsSchema.safeParse(params);
@@ -352,7 +363,7 @@ type ApiKeyActionHandlerFunction<
352363
: undefined;
353364
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
354365
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined;
355-
authentication: ApiAuthenticationResult;
366+
authentication: ApiAuthenticationResultSuccess;
356367
request: Request;
357368
}) => Promise<Response>;
358369

@@ -396,7 +407,7 @@ export function createActionApiRoute<
396407

397408
async function action({ request, params }: ActionFunctionArgs) {
398409
try {
399-
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
410+
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
400411

401412
if (!authenticationResult) {
402413
return await wrapResponse(
@@ -406,6 +417,14 @@ export function createActionApiRoute<
406417
);
407418
}
408419

420+
if (!authenticationResult.ok) {
421+
return await wrapResponse(
422+
request,
423+
json({ error: authenticationResult.error }, { status: 401 }),
424+
corsStrategy !== "none"
425+
);
426+
}
427+
409428
if (maxContentLength) {
410429
const contentLength = request.headers.get("content-length");
411430

0 commit comments

Comments
 (0)