Skip to content

Commit e76f1e4

Browse files
authored
fix(oauth): fix client auth for public clients, use createLocalJWKSet (#132)
Two changes: 1. Clients that omit `token_endpoint_auth_method` in their metadata get the Zod schema default of `client_secret_basic`, which isn't supported by AT Protocol OAuth. The type assertion silently passed this through, causing `invalid_client` at the token endpoint. Now explicitly maps: only `private_key_jwt` is preserved, everything else becomes `none`. 2. Replace hand-rolled JWKS key selection with jose's `createLocalJWKSet` which handles key matching by kid, alg, use, and key_ops.
1 parent 2e3ad52 commit e76f1e4

4 files changed

Lines changed: 67 additions & 28 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@getcirrus/oauth-provider": patch
3+
---
4+
5+
Fix OAuth client authentication failures for public clients and mixed JWKS
6+
7+
- Fix `invalid_client` error for clients that omit `token_endpoint_auth_method` in their metadata (Zod default of `client_secret_basic` was passed through unsupported)
8+
- Fix `invalid usage "encrypt"` error when client JWKS contains both signing and encryption keys by using jose's `createLocalJWKSet` for proper key selection

packages/oauth-provider/src/client-auth.ts

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55

66
import {
77
jwtVerify,
8+
createLocalJWKSet,
89
createRemoteJWKSet,
9-
importJWK,
1010
errors,
1111
customFetch,
1212
} from "jose";
1313
import type { JWTPayload } from "jose";
14-
import type { ClientMetadata, JWK } from "./storage.js";
14+
import type { ClientMetadata } from "./storage.js";
1515

1616
const { JOSEError } = errors;
1717

@@ -93,30 +93,9 @@ export async function verifyClientAssertion(
9393
let keyResolver: Parameters<typeof jwtVerify>[1];
9494

9595
if (client.jwks && client.jwks.keys.length > 0) {
96-
// For inline JWKS, we need to find the right key based on the JWT header
97-
keyResolver = async (header) => {
98-
const keys = client.jwks!.keys;
99-
// Find key by kid if present, otherwise use first key with matching alg
100-
let key: JWK | undefined;
101-
if (header.kid) {
102-
key = keys.find((k) => k.kid === header.kid);
103-
}
104-
if (!key) {
105-
key = keys.find((k) => !k.alg || k.alg === header.alg);
106-
}
107-
if (!key) {
108-
key = keys[0];
109-
}
110-
if (!key) {
111-
throw new ClientAuthError(
112-
"No suitable key found in client JWKS",
113-
"invalid_client",
114-
);
115-
}
116-
// Pass the algorithm from the header when the JWK doesn't have one
117-
const alg = key.alg ?? header.alg;
118-
return importJWK(key as Parameters<typeof importJWK>[0], alg);
119-
};
96+
// Use jose's createLocalJWKSet which handles key selection by
97+
// kid, alg, use, and key_ops
98+
keyResolver = createLocalJWKSet(client.jwks);
12099
} else if (client.jwksUri) {
121100
// Use remote JWKS
122101
keyResolver = createRemoteJWKSet(new URL(client.jwksUri), {

packages/oauth-provider/src/client-resolver.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,9 @@ export class ClientResolver {
232232
logoUri: doc.logo_uri,
233233
clientUri: doc.client_uri,
234234
tokenEndpointAuthMethod:
235-
(doc.token_endpoint_auth_method as "none" | "private_key_jwt") ??
236-
"none",
235+
doc.token_endpoint_auth_method === "private_key_jwt"
236+
? "private_key_jwt"
237+
: "none",
237238
jwks: doc.jwks as { keys: JWK[] } | undefined,
238239
jwksUri: doc.jwks_uri,
239240
cachedAt: Date.now(),

packages/oauth-provider/test/client-resolver.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,57 @@ describe("ClientResolver", () => {
126126
});
127127
});
128128

129+
describe("token_endpoint_auth_method mapping", () => {
130+
it("maps unrecognized auth methods to none (public client)", async () => {
131+
const clientId = "https://app.example.com/client-metadata.json";
132+
133+
// Metadata without token_endpoint_auth_method — Zod schema
134+
// defaults it to "client_secret_basic" which we don't support
135+
const metadata = {
136+
client_id: clientId,
137+
client_name: "App",
138+
redirect_uris: ["https://app.example.com/callback"],
139+
// token_endpoint_auth_method omitted (defaults to client_secret_basic)
140+
};
141+
142+
const mockFetch = vi.fn().mockResolvedValue({
143+
ok: true,
144+
json: () => Promise.resolve(metadata),
145+
});
146+
147+
const resolver = new ClientResolver({
148+
fetch: mockFetch as unknown as typeof fetch,
149+
});
150+
151+
const result = await resolver.resolveClient(clientId);
152+
expect(result.tokenEndpointAuthMethod).toBe("none");
153+
});
154+
155+
it("preserves private_key_jwt auth method", async () => {
156+
const clientId = "https://app.example.com/client-metadata.json";
157+
158+
const metadata = {
159+
client_id: clientId,
160+
client_name: "Confidential App",
161+
redirect_uris: ["https://app.example.com/callback"],
162+
token_endpoint_auth_method: "private_key_jwt",
163+
jwks_uri: "https://app.example.com/jwks",
164+
};
165+
166+
const mockFetch = vi.fn().mockResolvedValue({
167+
ok: true,
168+
json: () => Promise.resolve(metadata),
169+
});
170+
171+
const resolver = new ClientResolver({
172+
fetch: mockFetch as unknown as typeof fetch,
173+
});
174+
175+
const result = await resolver.resolveClient(clientId);
176+
expect(result.tokenEndpointAuthMethod).toBe("private_key_jwt");
177+
});
178+
});
179+
129180
describe("cache invalidation", () => {
130181
it("re-fetches cached client without tokenEndpointAuthMethod", async () => {
131182
// This test ensures we don't use stale cache entries from before

0 commit comments

Comments
 (0)