Skip to content

Commit e87cd73

Browse files
committed
fix: address PR #2448 review feedback
- force fresh authorization when a 401 widens the granted scope - cover refresh-token scope widening with a regression test
1 parent 110dfb4 commit e87cd73

2 files changed

Lines changed: 23 additions & 4 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,13 @@ export async function handleOAuthUnauthorized(
185185
): Promise<void> {
186186
const challenge = extractWWWAuthenticateParams(ctx.response);
187187
const tokens = await provider.tokens();
188+
const unionScope = computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope);
189+
const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope);
188190
const result = await auth(provider, {
189191
serverUrl: ctx.serverUrl,
190192
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
191-
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
193+
scope: unionScope,
194+
...(forceReauthorization ? { forceReauthorization } : {}),
192195
fetchFn: ctx.fetchFn,
193196
...extraAuthOptions
194197
});

packages/client/test/client/auth.test.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,9 @@ describe('OAuth Authorization', () => {
217217
});
218218

219219
describe('handleOAuthUnauthorized', () => {
220-
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
220+
it('forces reauthorization with the full scope union instead of refreshing a narrower token', async () => {
221221
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
222+
const tokenEndpoint = 'https://auth.example.com/token';
222223
const provider: OAuthClientProvider = {
223224
get redirectUrl() {
224225
return 'http://localhost:3000/callback';
@@ -230,7 +231,12 @@ describe('OAuth Authorization', () => {
230231
};
231232
},
232233
clientInformation: vi.fn().mockResolvedValue({ client_id: 'test-client' }),
233-
tokens: vi.fn().mockResolvedValue({ access_token: 'old-token', token_type: 'Bearer', scope: 'openid read' }),
234+
tokens: vi.fn().mockResolvedValue({
235+
access_token: 'old-token',
236+
token_type: 'Bearer',
237+
refresh_token: 'refresh-token',
238+
scope: 'openid read'
239+
}),
234240
saveTokens: vi.fn(),
235241
saveCodeVerifier: vi.fn(),
236242
codeVerifier: vi.fn(),
@@ -256,12 +262,21 @@ describe('OAuth Authorization', () => {
256262
Response.json({
257263
issuer: 'https://auth.example.com',
258264
authorization_endpoint: 'https://auth.example.com/authorize',
259-
token_endpoint: 'https://auth.example.com/token',
265+
token_endpoint: tokenEndpoint,
260266
response_types_supported: ['code'],
261267
code_challenge_methods_supported: ['S256']
262268
})
263269
);
264270
}
271+
if (urlString === tokenEndpoint) {
272+
return Promise.resolve(
273+
Response.json({
274+
access_token: 'refreshed-token',
275+
token_type: 'Bearer',
276+
scope: 'openid read'
277+
})
278+
);
279+
}
265280
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
266281
});
267282

@@ -276,6 +291,7 @@ describe('OAuth Authorization', () => {
276291
).rejects.toBeInstanceOf(UnauthorizedError);
277292

278293
expect(mockFetch.mock.calls[0]?.[0].toString()).toBe(resourceMetadataUrl.toString());
294+
expect(mockFetch.mock.calls.some(([url]) => url.toString() === tokenEndpoint)).toBe(false);
279295
const authorizationUrl = (provider.redirectToAuthorization as Mock).mock.calls[0]?.[0] as URL;
280296
expect(authorizationUrl.searchParams.get('scope')).toBe('openid read write');
281297
});

0 commit comments

Comments
 (0)