diff --git a/.changeset/oauth-device-refresh-resources.md b/.changeset/oauth-device-refresh-resources.md new file mode 100644 index 0000000000..c919f00f4b --- /dev/null +++ b/.changeset/oauth-device-refresh-resources.md @@ -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. diff --git a/packages/oauth-provider/src/device-code.test.ts b/packages/oauth-provider/src/device-code.test.ts index 34a13dc9b6..7e439bcfde 100644 --- a/packages/oauth-provider/src/device-code.test.ts +++ b/packages/oauth-provider/src/device-code.test.ts @@ -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); diff --git a/packages/oauth-provider/src/device-code.ts b/packages/oauth-provider/src/device-code.ts index 529e16a331..9bb2d8e9b4 100644 --- a/packages/oauth-provider/src/device-code.ts +++ b/packages/oauth-provider/src/device-code.ts @@ -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, });