fix(oauth-provider): keep approved resources refreshable after a narrowed device poll (#11815)

This commit is contained in:
Gustavo Valverde
2026-10-15 12:48:52 +01:00
committed by GitHub
parent fedd05586c
commit 44f14bbf82
3 changed files with 44 additions and 1 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/oauth-provider": patch
---
Device-code clients that request `offline_access` and poll the token endpoint for only some of the RFC 8707 resources the user approved can now refresh for any approved resource. Previously, a refresh for an approved resource left out of the poll failed with `invalid_target`. Device codes polled without `resource`, or for every approved resource, are unaffected.
@@ -280,6 +280,7 @@ describe("oauth-provider device-code grant", async () => {
/** Registers a public OAuth client able to use the device-code grant. */
async function createDeviceClient(
grantTypes: string[] = [DEVICE_CODE_GRANT_TYPE],
scope = "openid profile email",
) {
const { headers } = await signInWithTestUser();
const created = await auth.api.adminCreateOAuthClient({
@@ -287,7 +288,7 @@ describe("oauth-provider device-code grant", async () => {
body: {
token_endpoint_auth_method: "none",
grant_types: grantTypes,
scope: "openid profile email",
scope,
application_type: "native",
},
});
@@ -1256,6 +1257,40 @@ describe("oauth-provider device-code grant", async () => {
expect(stored?.deviceCode).toBe(deviceCode);
});
it("keeps every approved resource refreshable after a narrowed exchange", async () => {
const scope = "openid offline_access";
const clientId = await createDeviceClient(
[DEVICE_CODE_GRANT_TYPE, "refresh_token"],
scope,
);
const deviceCode = await approvedDeviceCode(clientId, scope, [
resource,
secondResource,
]);
const narrowed = await pollToken({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code: deviceCode,
client_id: clientId,
resource,
});
expect(narrowed.error).toBeNull();
expect(decodeJwt(narrowed.data!.access_token as string).aud).not.toContain(
secondResource,
);
const refreshed = await pollToken({
grant_type: "refresh_token",
refresh_token: narrowed.data!.refresh_token as string,
client_id: clientId,
resource: secondResource,
});
expect(refreshed.error).toBeNull();
expect(decodeJwt(refreshed.data!.access_token as string).aud).toContain(
secondResource,
);
});
it("single-uses the device code (second exchange fails)", async () => {
const clientId = await createDeviceClient();
const deviceCode = await approvedDeviceCode(clientId);
@@ -119,6 +119,7 @@ async function exchangeOAuthDeviceCode(
> & { scopes: string[] };
const {
authorizationContext: { client, confirmation, scopes },
claimedDeviceCode,
redemptionContext: resources,
user,
} = await redeemDeviceCode<
@@ -187,6 +188,8 @@ async function exchangeOAuthDeviceCode(
scopes,
user,
resources,
// Seed refresh tokens with every approved resource, not the narrowed poll.
originalResources: claimedDeviceCode.resources ?? undefined,
// Forward a sender-constraint a confidential client-auth strategy proved.
confirmation,
});