Skip to content

Commit 7e209e0

Browse files
authored
Stop using Response errors when validating API Keys (#1498)
* 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. * Make sure authenticateApiRequestWithFailure always returns a result
1 parent 3792394 commit 7e209e0

File tree

5 files changed

+207
-37
lines changed

5 files changed

+207
-37
lines changed

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

Lines changed: 149 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,30 +29,70 @@ 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 } = {}
42-
): Promise<ApiAuthenticationResult | undefined> {
55+
): Promise<ApiAuthenticationResultSuccess | undefined> {
4356
const apiKey = getApiKeyFromRequest(request);
4457

4558
if (!apiKey) {
4659
return;
4760
}
4861

49-
return authenticateApiKey(apiKey, options);
62+
const authentication = await authenticateApiKey(apiKey, options);
63+
64+
return authentication;
5065
}
5166

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 } = {}
74+
): Promise<ApiAuthenticationResult> {
75+
const apiKey = getApiKeyFromRequest(request);
76+
77+
if (!apiKey) {
78+
return {
79+
ok: false,
80+
error: "Invalid API Key",
81+
};
82+
}
83+
84+
const authentication = await authenticateApiKeyWithFailure(apiKey, options);
85+
86+
return authentication;
87+
}
88+
89+
/**
90+
* @deprecated Use `authenticateApiKeyWithFailure` instead.
91+
*/
5292
export async function authenticateApiKey(
5393
apiKey: string,
5494
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
55-
): Promise<ApiAuthenticationResult | undefined> {
95+
): Promise<ApiAuthenticationResultSuccess | undefined> {
5696
const result = getApiKeyResult(apiKey);
5797

5898
if (!result) {
@@ -70,30 +110,120 @@ export async function authenticateApiKey(
70110
switch (result.type) {
71111
case "PUBLIC": {
72112
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
73-
if (!environment) return;
113+
if (!environment) {
114+
return;
115+
}
116+
74117
return {
118+
ok: true,
75119
...result,
76120
environment,
77121
};
78122
}
79123
case "PRIVATE": {
80124
const environment = await findEnvironmentByApiKey(result.apiKey);
81-
if (!environment) return;
125+
if (!environment) {
126+
return;
127+
}
128+
82129
return {
130+
ok: true,
83131
...result,
84132
environment,
85133
};
86134
}
87135
case "PUBLIC_JWT": {
88136
const validationResults = await validatePublicJwtKey(result.apiKey);
89137

90-
if (!validationResults) {
138+
if (!validationResults.ok) {
91139
return;
92140
}
93141

94142
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
95143

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

208338
switch (auth.type) {
209339
case "apiKey": {
340+
if (!auth.result.ok) {
341+
throw json({ error: auth.result.error }, { status: 401 });
342+
}
343+
210344
if (auth.result.environment.project.externalRef !== projectRef) {
211345
throw json(
212346
{
@@ -337,6 +471,14 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
337471
return;
338472
}
339473

474+
if (!authenticatedEnv.ok) {
475+
logger.error("Failed to renew JWT token, invalid API key", {
476+
error: error.message,
477+
});
478+
479+
return;
480+
}
481+
340482
const payload = payloadSchema.safeParse(error.payload);
341483

342484
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
};

0 commit comments

Comments
 (0)