Skip to content

Commit 4227451

Browse files
thomasballingerConvex, Inc.
authored andcommitted
Allow missing 'aud' when none specified (#39283)
GitOrigin-RevId: 999194d88f7444d9fbbe4bf731ca3a9083437fde
1 parent 39b52c5 commit 4227451

File tree

3 files changed

+128
-77
lines changed

3 files changed

+128
-77
lines changed

crates/authentication/src/lib.rs

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,14 @@ where
196196
that matches one of your configured auth providers."
197197
));
198198
};
199-
let Some(audiences) = payload.registered.audience else {
200-
anyhow::bail!(ErrorMetadata::unauthenticated(
201-
"InvalidAuthHeader",
202-
"Missing audience claim ('aud') in JWT payload. The JWT must include an 'aud' \
203-
claim that matches your configured application ID."
204-
));
205-
};
206-
let audiences = match audiences {
207-
biscuit::SingleOrMultiple::Single(audience) => vec![audience],
208-
biscuit::SingleOrMultiple::Multiple(audiences) => audiences,
199+
let audiences = match payload.registered.audience {
200+
Some(biscuit::SingleOrMultiple::Single(audience)) => vec![audience],
201+
Some(biscuit::SingleOrMultiple::Multiple(audiences)) => audiences,
202+
None => vec![],
209203
};
210-
// Find the provider matching this token
204+
// Find the first provider matching this token.
205+
// `iss` claim must match the provider's issuer but 'aud' claim is only
206+
// required to match if the provider has an applicationId specified.
211207
let auth_info = auth_infos
212208
.iter()
213209
.find(|info| info.matches_token(&audiences, &issuer))
@@ -320,13 +316,12 @@ where
320316

321317
let detailed_msg = if let Some(kid) = kid {
322318
format!(
323-
"Could not decode token. The JWT's 'kid' (key ID) header is '{}' but \
324-
this doesn't match any key in the provider's JWKS.",
319+
"Could not decode token. The JWT's 'kid' (key ID) header is '{}', \
320+
does this key match any key in the provider's JWKS?",
325321
kid
326322
)
327323
} else {
328-
"Could not decode token. The JWT is missing a 'kid' (key ID) header, or \
329-
the JWT signature is invalid."
324+
"Could not decode token. JWT may be missing a 'kid' (key ID) header."
330325
.to_string()
331326
};
332327

@@ -361,14 +356,14 @@ where
361356
format!("Invalid issuer: {} != {}", token_issuer, issuer)
362357
));
363358
}
364-
let Some(ref token_audience) = payload.registered.audience else {
365-
anyhow::bail!(ErrorMetadata::unauthenticated(
366-
"InvalidAuthHeader",
367-
"Missing audience claim ('aud') in JWT payload. The JWT must include an 'aud' \
368-
claim that matches your configured application ID."
369-
));
370-
};
371359
if let Some(application_id) = application_id {
360+
let Some(ref token_audience) = payload.registered.audience else {
361+
anyhow::bail!(ErrorMetadata::unauthenticated(
362+
"InvalidAuthHeader",
363+
"Missing audience claim ('aud') in JWT payload. The JWT must include an \
364+
'aud' claim that matches your configured application ID."
365+
));
366+
};
372367
if !token_audience
373368
.iter()
374369
.any(|audience| audience == &application_id)
@@ -392,12 +387,13 @@ where
392387
};
393388
decoded_token
394389
.validate(validation_options)
395-
.with_context(|| {
390+
.map_err(|original_error| {
391+
eprintln!("Original validation error: {:?}", original_error);
392+
let msg = original_error.to_string();
393+
396394
ErrorMetadata::unauthenticated(
397395
"InvalidAuthHeader",
398-
"Could not validate token. This often happens due to timing issues: check \
399-
that your JWT's 'iat' (issued at) and 'exp' (expires) claims are valid \
400-
for the current time.",
396+
format!("Could not validate token: {}", msg),
401397
)
402398
})?;
403399
UserIdentity::from_custom_jwt(decoded_token, token_str.0).context(

npm-packages/js-integration-tests/auth.test.ts

Lines changed: 102 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { privateKeyPEM, kid as correctKid } from "./authCredentials";
77
async function createSignedJWT(
88
payload: any,
99
options: {
10-
issuer?: string;
11-
audience?: string;
10+
issuer?: string | null;
11+
audience?: string | null;
1212
expiresIn?: string;
1313
subject?: string;
14-
issuedAt?: string;
14+
issuedAt?: string | null;
1515
alg?: "RS256" | "ES256" | (string & { ignore_me?: never });
1616
useKid?: "wrong kid" | "missing kid" | "correct kid";
1717
} = {},
@@ -34,18 +34,20 @@ async function createSignedJWT(
3434
? "key-2 (oops, this is the wrong kid!)"
3535
: undefined;
3636

37-
let jwtBuilder = new SignJWT(payload)
38-
.setProtectedHeader({
39-
kid,
40-
alg,
41-
})
42-
.setIssuedAt(issuedAt);
37+
let jwtBuilder = new SignJWT(payload).setProtectedHeader({
38+
kid,
39+
alg,
40+
});
41+
42+
if (issuedAt !== null) {
43+
jwtBuilder = jwtBuilder.setIssuedAt(issuedAt);
44+
}
4345

44-
if (issuer !== undefined) {
46+
if (issuer !== null) {
4547
jwtBuilder = jwtBuilder.setIssuer(issuer);
4648
}
4749

48-
if (audience !== undefined) {
50+
if (audience !== null) {
4951
jwtBuilder = jwtBuilder.setAudience(audience);
5052
}
5153

@@ -119,16 +121,6 @@ describe("auth debugging", () => {
119121
expect(logger.logs).toEqual([]);
120122
});
121123

122-
test("missing kid", async () => {
123-
const error = await getErrorFromJwt(
124-
await createSignedJWT({ name: "Presley" }, { useKid: "missing kid" }),
125-
);
126-
// The exact error message may vary, but should be enhanced
127-
expect(error.code).toEqual("InvalidAuthHeader");
128-
expect(error.message).toContain("JWT");
129-
expect(logger.logs).toEqual([]);
130-
});
131-
132124
test("no auth provider found - enhanced error message", async () => {
133125
const error = await getErrorFromJwt(
134126
await createSignedJWT(
@@ -149,7 +141,7 @@ describe("auth debugging", () => {
149141
"CustomJWT(issuer=https://issuer.example.com/1, app_id=App 1)",
150142
);
151143
expect(error.message).toContain(
152-
"CustomJWT(issuer=https://issuer.example.com/2, app_id=none)",
144+
"CustomJWT(issuer=https://issuer.example.com/no-aud-specified, app_id=none)",
153145
);
154146
expect(error.message).toContain(
155147
"CustomJWT(issuer=https://issuer.example.com/3, app_id=App 3)",
@@ -158,35 +150,73 @@ describe("auth debugging", () => {
158150
});
159151

160152
test("missing issuer claim", async () => {
161-
// Create a JWT without an issuer claim
162-
try {
163-
const jwt = await createSignedJWT({ name: "Presley" }, {
164-
issuer: undefined,
165-
} as any);
166-
const error = await getErrorFromJwt(jwt);
167-
expect(error.code).toEqual("InvalidAuthHeader");
168-
expect(error.message).toContain("issuer");
169-
expect(error.message).toContain("iss");
170-
} catch (e) {
171-
// JWT creation might fail, which is also valid behavior
172-
console.log("JWT creation failed for missing issuer");
173-
}
153+
const jwt = await createSignedJWT(
154+
{ name: "Presley" },
155+
{
156+
issuer: null,
157+
},
158+
);
159+
const error = await getErrorFromJwt(jwt);
160+
expect(error.code).toEqual("InvalidAuthHeader");
161+
expect(error.message).toContain("issuer");
162+
expect(error.message).toContain("iss");
174163
});
175164

176165
test("missing audience claim", async () => {
177-
// Create a JWT without an audience claim
178-
try {
179-
const jwt = await createSignedJWT({ name: "Presley" }, {
180-
audience: undefined,
181-
} as any);
182-
const error = await getErrorFromJwt(jwt);
183-
expect(error.code).toEqual("InvalidAuthHeader");
184-
expect(error.message).toContain("audience");
185-
expect(error.message).toContain("aud");
186-
} catch (e) {
187-
// JWT creation might fail, which is also valid behavior
188-
console.log("JWT creation failed for missing audience");
189-
}
166+
const jwt = await createSignedJWT(
167+
{ name: "Presley" },
168+
{
169+
audience: null,
170+
},
171+
);
172+
const error = await getErrorFromJwt(jwt);
173+
expect(error.code).toEqual("NoAuthProvider");
174+
});
175+
176+
test("missing kid", async () => {
177+
const error = await getErrorFromJwt(
178+
await createSignedJWT({ name: "Presley" }, { useKid: "missing kid" }),
179+
);
180+
expect(error.code).toEqual("InvalidAuthHeader");
181+
expect(error.message).toContain("missing a 'kid'");
182+
expect(logger.logs).toEqual([]);
183+
});
184+
185+
test("wrong audience claim", async () => {
186+
const jwt = await createSignedJWT(
187+
{ name: "Presley" },
188+
{
189+
audience: "asdf",
190+
},
191+
);
192+
const error = await getErrorFromJwt(jwt);
193+
expect(error.code).toEqual("NoAuthProvider");
194+
});
195+
196+
test("audience claim allowed when none required", async () => {
197+
const jwt = await createSignedJWT(
198+
{ name: "Presley" },
199+
{
200+
issuer: "https://issuer.example.com/no-aud-specified",
201+
audience: "asdf",
202+
},
203+
);
204+
httpClient.setAuth(jwt);
205+
const result = await httpClient.query(api.auth.q);
206+
expect(result?.name).toEqual("Presley");
207+
});
208+
209+
test("missing audience claim allowed when none required", async () => {
210+
const jwt = await createSignedJWT(
211+
{ name: "Presley" },
212+
{
213+
issuer: "https://issuer.example.com/no-aud-specified",
214+
audience: null,
215+
},
216+
);
217+
httpClient.setAuth(jwt);
218+
const result = await httpClient.query(api.auth.q);
219+
expect(result?.name).toEqual("Presley");
190220
});
191221

192222
test("wrong kid", async () => {
@@ -209,6 +239,7 @@ describe("auth debugging", () => {
209239
expect(error.message).toContain("three base64-encoded parts");
210240
});
211241

242+
// Integration tests that hit real APIs are a bummer, TODO hit something else
212243
// eslint-disable-next-line jest/no-disabled-tests
213244
test.skip("unreachable JWKS URL", async () => {
214245
// Use App 3 which has a non-existent JWKS URL
@@ -244,6 +275,18 @@ describe("auth debugging", () => {
244275
expect(error.message).toContain("not valid JSON");
245276
});
246277

278+
test("token expired 10 seconds ago", async () => {
279+
const error = await getErrorFromJwt(
280+
await createSignedJWT(
281+
{ name: "Presley" },
282+
{ issuedAt: "20 sec ago", expiresIn: "10 sec ago" },
283+
),
284+
);
285+
expect(error.code).toEqual("InvalidAuthHeader");
286+
expect(error.message).toContain("Token expired");
287+
expect(error.message).toContain("seconds ago");
288+
});
289+
247290
test("token issued 3 seconds in future", async () => {
248291
// Should succeed with 5-second tolerance
249292
httpClient.setAuth(
@@ -265,8 +308,17 @@ describe("auth debugging", () => {
265308
),
266309
);
267310
expect(error.code).toEqual("InvalidAuthHeader");
268-
expect(error.message).toContain("timing issues");
269-
expect(error.message).toContain("iat");
311+
expect(error.message).toContain("will be valid in");
312+
});
313+
314+
// Not recommended (some client logic may expect an iat) but not required.
315+
test("missing iat", async () => {
316+
httpClient.setAuth(
317+
await createSignedJWT({ name: "Presley" }, { issuedAt: null }),
318+
);
319+
const result = await httpClient.query(api.auth.q);
320+
expect(result?.subject).toEqual("The Subject");
321+
expect(result?.name).toEqual("Presley");
270322
});
271323
});
272324
});

npm-packages/js-integration-tests/convex/auth.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { jwksDataUri } from "../authCredentials.js";
22

3+
// This deploy needs to succeed for these tests to work so these providers
4+
// all need to be valid. Invalid providers and the error messages they cause
5+
// are tested in analyze tests.
36
export default {
47
providers: [
58
{
@@ -13,7 +16,7 @@ export default {
1316
type: "customJwt",
1417
// application ID (aud) is not required
1518
//applicationID: "App 2",
16-
issuer: "https://issuer.example.com/2",
19+
issuer: "https://issuer.example.com/no-aud-specified",
1720
jwks: jwksDataUri,
1821
algorithm: "RS256",
1922
},

0 commit comments

Comments
 (0)