Skip to content

Commit 9fea0af

Browse files
committed
Correctly provide client ID (+ secret) to every auth step
1 parent 47fd6df commit 9fea0af

File tree

2 files changed

+95
-43
lines changed

2 files changed

+95
-43
lines changed

src/client/auth.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,18 @@ describe("OAuth Authorization", () => {
9696
code_challenge_methods_supported: ["S256"],
9797
};
9898

99+
const validClientInfo = {
100+
client_id: "client123",
101+
client_secret: "secret123",
102+
redirect_uris: ["http://localhost:3000/callback"],
103+
client_name: "Test Client",
104+
};
105+
99106
it("generates authorization URL with PKCE challenge", async () => {
100107
const { authorizationUrl, codeVerifier } = await startAuthorization(
101108
"https://auth.example.com",
102109
{
110+
clientInformation: validClientInfo,
103111
redirectUrl: "http://localhost:3000/callback",
104112
}
105113
);
@@ -123,6 +131,7 @@ describe("OAuth Authorization", () => {
123131
"https://auth.example.com",
124132
{
125133
metadata: validMetadata,
134+
clientInformation: validClientInfo,
126135
redirectUrl: "http://localhost:3000/callback",
127136
}
128137
);
@@ -141,6 +150,7 @@ describe("OAuth Authorization", () => {
141150
await expect(
142151
startAuthorization("https://auth.example.com", {
143152
metadata,
153+
clientInformation: validClientInfo,
144154
redirectUrl: "http://localhost:3000/callback",
145155
})
146156
).rejects.toThrow(/does not support response type/);
@@ -156,6 +166,7 @@ describe("OAuth Authorization", () => {
156166
await expect(
157167
startAuthorization("https://auth.example.com", {
158168
metadata,
169+
clientInformation: validClientInfo,
159170
redirectUrl: "http://localhost:3000/callback",
160171
})
161172
).rejects.toThrow(/does not support code challenge method/);
@@ -170,6 +181,13 @@ describe("OAuth Authorization", () => {
170181
refresh_token: "refresh123",
171182
};
172183

184+
const validClientInfo = {
185+
client_id: "client123",
186+
client_secret: "secret123",
187+
redirect_uris: ["http://localhost:3000/callback"],
188+
client_name: "Test Client",
189+
};
190+
173191
it("exchanges code for tokens", async () => {
174192
mockFetch.mockResolvedValueOnce({
175193
ok: true,
@@ -178,6 +196,7 @@ describe("OAuth Authorization", () => {
178196
});
179197

180198
const tokens = await exchangeAuthorization("https://auth.example.com", {
199+
clientInformation: validClientInfo,
181200
authorizationCode: "code123",
182201
codeVerifier: "verifier123",
183202
});
@@ -199,6 +218,8 @@ describe("OAuth Authorization", () => {
199218
expect(body.get("grant_type")).toBe("authorization_code");
200219
expect(body.get("code")).toBe("code123");
201220
expect(body.get("code_verifier")).toBe("verifier123");
221+
expect(body.get("client_id")).toBe("client123");
222+
expect(body.get("client_secret")).toBe("secret123");
202223
});
203224

204225
it("validates token response schema", async () => {
@@ -213,6 +234,7 @@ describe("OAuth Authorization", () => {
213234

214235
await expect(
215236
exchangeAuthorization("https://auth.example.com", {
237+
clientInformation: validClientInfo,
216238
authorizationCode: "code123",
217239
codeVerifier: "verifier123",
218240
})
@@ -227,6 +249,7 @@ describe("OAuth Authorization", () => {
227249

228250
await expect(
229251
exchangeAuthorization("https://auth.example.com", {
252+
clientInformation: validClientInfo,
230253
authorizationCode: "code123",
231254
codeVerifier: "verifier123",
232255
})
@@ -242,6 +265,13 @@ describe("OAuth Authorization", () => {
242265
refresh_token: "newrefresh123",
243266
};
244267

268+
const validClientInfo = {
269+
client_id: "client123",
270+
client_secret: "secret123",
271+
redirect_uris: ["http://localhost:3000/callback"],
272+
client_name: "Test Client",
273+
};
274+
245275
it("exchanges refresh token for new tokens", async () => {
246276
mockFetch.mockResolvedValueOnce({
247277
ok: true,
@@ -250,6 +280,7 @@ describe("OAuth Authorization", () => {
250280
});
251281

252282
const tokens = await refreshAuthorization("https://auth.example.com", {
283+
clientInformation: validClientInfo,
253284
refreshToken: "refresh123",
254285
});
255286

@@ -269,6 +300,8 @@ describe("OAuth Authorization", () => {
269300
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
270301
expect(body.get("grant_type")).toBe("refresh_token");
271302
expect(body.get("refresh_token")).toBe("refresh123");
303+
expect(body.get("client_id")).toBe("client123");
304+
expect(body.get("client_secret")).toBe("secret123");
272305
});
273306

274307
it("validates token response schema", async () => {
@@ -283,6 +316,7 @@ describe("OAuth Authorization", () => {
283316

284317
await expect(
285318
refreshAuthorization("https://auth.example.com", {
319+
clientInformation: validClientInfo,
286320
refreshToken: "refresh123",
287321
})
288322
).rejects.toThrow();
@@ -296,9 +330,10 @@ describe("OAuth Authorization", () => {
296330

297331
await expect(
298332
refreshAuthorization("https://auth.example.com", {
333+
clientInformation: validClientInfo,
299334
refreshToken: "refresh123",
300335
})
301-
).rejects.toThrow("Token exchange failed");
336+
).rejects.toThrow("Token refresh failed");
302337
});
303338
});
304339

src/client/auth.ts

Lines changed: 59 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,6 @@ export type OAuthClientInformation = z.infer<typeof OAuthClientInformationSchema
8989
export interface OAuthClientProvider {
9090
/**
9191
* The URL to redirect the user agent to after authorization.
92-
*
93-
* If the client is not redirecting to localhost, `clientInformation` must be
94-
* implemented.
9592
*/
9693
get redirectUrl(): string | URL;
9794

@@ -104,19 +101,16 @@ export interface OAuthClientProvider {
104101
* Loads information about this OAuth client, as registered already with the
105102
* server, or returns `undefined` if the client is not registered with the
106103
* server.
107-
*
108-
* This method must be implemented _unless_ redirecting to `localhost`.
109104
*/
110-
clientInformation?(): OAuthClientInformation | undefined | Promise<OAuthClientInformation | undefined>;
105+
clientInformation(): OAuthClientInformation | undefined | Promise<OAuthClientInformation | undefined>;
111106

112107
/**
113108
* If implemented, this permits the OAuth client to dynamically register with
114109
* the server. Client information saved this way should later be read via
115110
* `clientInformation()`.
116111
*
117-
* This method is not required to be implemented if redirecting to
118-
* `localhost`, or if client information is statically known (e.g.,
119-
* pre-registered).
112+
* This method is not required to be implemented if client information is
113+
* statically known (e.g., pre-registered).
120114
*/
121115
saveClientInformation?(clientInformation: OAuthClientInformation): void | Promise<void>;
122116

@@ -164,38 +158,30 @@ export async function auth(
164158
const metadata = await discoverOAuthMetadata(serverUrl);
165159

166160
// Handle client registration if needed
167-
const hostname = new URL(provider.redirectUrl).hostname;
168-
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
169-
if (!provider.clientInformation) {
170-
throw new Error("OAuth client information is required when not redirecting to localhost")
161+
let clientInformation = await Promise.resolve(provider.clientInformation());
162+
if (!clientInformation) {
163+
if (authorizationCode !== undefined) {
164+
throw new Error("Existing OAuth client information is required when exchanging an authorization code");
171165
}
172166

173-
let clientInformation = await Promise.resolve(provider.clientInformation());
174-
if (!clientInformation) {
175-
if (authorizationCode !== undefined) {
176-
throw new Error("Existing OAuth client information is required when exchanging an authorization code");
177-
}
178-
179-
if (!provider.saveClientInformation) {
180-
throw new Error("OAuth client information must be saveable when not provided and not redirecting to localhost");
181-
}
182-
183-
clientInformation = await registerClient(serverUrl, {
184-
metadata,
185-
clientMetadata: provider.clientMetadata,
186-
});
187-
188-
await provider.saveClientInformation(clientInformation);
167+
if (!provider.saveClientInformation) {
168+
throw new Error("OAuth client information must be saveable for dynamic registration");
189169
}
190170

191-
// TODO: Send clientInformation into auth flow
171+
clientInformation = await registerClient(serverUrl, {
172+
metadata,
173+
clientMetadata: provider.clientMetadata,
174+
});
175+
176+
await provider.saveClientInformation(clientInformation);
192177
}
193178

194179
// Exchange authorization code for tokens
195180
if (authorizationCode !== undefined) {
196181
const codeVerifier = await provider.codeVerifier();
197182
const tokens = await exchangeAuthorization(serverUrl, {
198183
metadata,
184+
clientInformation,
199185
authorizationCode,
200186
codeVerifier,
201187
});
@@ -212,6 +198,7 @@ export async function auth(
212198
// Attempt to refresh the token
213199
const newTokens = await refreshAuthorization(serverUrl, {
214200
metadata,
201+
clientInformation,
215202
refreshToken: tokens.refresh_token,
216203
});
217204

@@ -223,7 +210,12 @@ export async function auth(
223210
}
224211

225212
// Start new authorization flow
226-
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { metadata, redirectUrl: provider.redirectUrl });
213+
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, {
214+
metadata,
215+
clientInformation,
216+
redirectUrl: provider.redirectUrl
217+
});
218+
227219
await provider.saveCodeVerifier(codeVerifier);
228220
await provider.redirectToAuthorization(authorizationUrl);
229221
return "REDIRECT";
@@ -260,8 +252,13 @@ export async function startAuthorization(
260252
serverUrl: string | URL,
261253
{
262254
metadata,
255+
clientInformation,
263256
redirectUrl,
264-
}: { metadata?: OAuthMetadata; redirectUrl: string | URL },
257+
}: {
258+
metadata?: OAuthMetadata;
259+
clientInformation: OAuthClientInformation;
260+
redirectUrl: string | URL;
261+
},
265262
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
266263
const responseType = "code";
267264
const codeChallengeMethod = "S256";
@@ -294,6 +291,7 @@ export async function startAuthorization(
294291
const codeChallenge = challenge.code_challenge;
295292

296293
authorizationUrl.searchParams.set("response_type", responseType);
294+
authorizationUrl.searchParams.set("client_id", clientInformation.client_id);
297295
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
298296
authorizationUrl.searchParams.set(
299297
"code_challenge_method",
@@ -311,10 +309,12 @@ export async function exchangeAuthorization(
311309
serverUrl: string | URL,
312310
{
313311
metadata,
312+
clientInformation,
314313
authorizationCode,
315314
codeVerifier,
316315
}: {
317316
metadata?: OAuthMetadata;
317+
clientInformation: OAuthClientInformation;
318318
authorizationCode: string;
319319
codeVerifier: string;
320320
},
@@ -338,16 +338,23 @@ export async function exchangeAuthorization(
338338
}
339339

340340
// Exchange code for tokens
341+
const params = new URLSearchParams({
342+
grant_type: grantType,
343+
client_id: clientInformation.client_id,
344+
code: authorizationCode,
345+
code_verifier: codeVerifier,
346+
});
347+
348+
if (clientInformation.client_secret) {
349+
params.set("client_secret", clientInformation.client_secret);
350+
}
351+
341352
const response = await fetch(tokenUrl, {
342353
method: "POST",
343354
headers: {
344355
"Content-Type": "application/x-www-form-urlencoded",
345356
},
346-
body: new URLSearchParams({
347-
grant_type: grantType,
348-
code: authorizationCode,
349-
code_verifier: codeVerifier,
350-
}),
357+
body: params,
351358
});
352359

353360
if (!response.ok) {
@@ -364,9 +371,11 @@ export async function refreshAuthorization(
364371
serverUrl: string | URL,
365372
{
366373
metadata,
374+
clientInformation,
367375
refreshToken,
368376
}: {
369377
metadata?: OAuthMetadata;
378+
clientInformation: OAuthClientInformation;
370379
refreshToken: string;
371380
},
372381
): Promise<OAuthTokens> {
@@ -388,19 +397,27 @@ export async function refreshAuthorization(
388397
tokenUrl = new URL("/token", serverUrl);
389398
}
390399

400+
// Exchange refresh token
401+
const params = new URLSearchParams({
402+
grant_type: grantType,
403+
client_id: clientInformation.client_id,
404+
refresh_token: refreshToken,
405+
});
406+
407+
if (clientInformation.client_secret) {
408+
params.set("client_secret", clientInformation.client_secret);
409+
}
410+
391411
const response = await fetch(tokenUrl, {
392412
method: "POST",
393413
headers: {
394414
"Content-Type": "application/x-www-form-urlencoded",
395415
},
396-
body: new URLSearchParams({
397-
grant_type: grantType,
398-
refresh_token: refreshToken,
399-
}),
416+
body: params,
400417
});
401418

402419
if (!response.ok) {
403-
throw new Error(`Token exchange failed: HTTP ${response.status}`);
420+
throw new Error(`Token refresh failed: HTTP ${response.status}`);
404421
}
405422

406423
return OAuthTokensSchema.parse(await response.json());

0 commit comments

Comments
 (0)