Skip to content

Commit 6e9808b

Browse files
committed
Ask Claude to fix bug with token exchange callback when reusing refresh token.
As explained in the readme, when we rotate the refresh token, we still allow the previous refresh token to be used again, in case the new token is lost due to an error. However, in the previous commit, we hadn't properly updated the wrapped key to handle this case. Claude also found that the behavior when `grantProps` was returend without `tokenProps` was confusing -- you'd expect the access token to have the new props, not the old. I agreed and had Claude update it. Claude Code transcript: https://claude-workerd-transcript.pages.dev/oauth-provider-token-exchange-callback2
1 parent 89ad47e commit 6e9808b

File tree

2 files changed

+206
-14
lines changed

2 files changed

+206
-14
lines changed

__tests__/oauth-provider.test.ts

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,8 @@ describe('OAuthProvider', () => {
14191419
it('should handle callback that returns only tokenProps or only grantProps', async () => {
14201420
// Create a provider with a callback that returns only tokenProps for auth code
14211421
// and only grantProps for refresh token
1422+
// Note: With the enhanced implementation, when only grantProps is returned
1423+
// without tokenProps, the token props will inherit from grantProps
14221424
const tokenPropsOnlyCallback = async (options: any) => {
14231425
if (options.grantType === 'authorization_code') {
14241426
return {
@@ -1528,11 +1530,12 @@ describe('OAuthProvider', () => {
15281530
const api2Response = await specialProvider.fetch(api2Request, mockEnv, mockCtx);
15291531
const api2Data = await api2Response.json();
15301532

1531-
// The token should have the same props as the original token
1532-
// because we only updated grantProps, not tokenProps during the refresh
1533+
// With the enhanced implementation, the token props now inherit from grant props
1534+
// when only grantProps is returned but tokenProps is not specified
15331535
expect(api2Data.user).toEqual({
15341536
userId: "test-user-123",
1535-
username: "TestUser"
1537+
username: "TestUser",
1538+
grantOnly: true // This is now included in the token props
15361539
});
15371540
});
15381541

@@ -1615,6 +1618,169 @@ describe('OAuthProvider', () => {
16151618
// The props should be the original ones (no change)
16161619
expect(apiData.user).toEqual({ userId: "test-user-123", username: "TestUser" });
16171620
});
1621+
1622+
it('should correctly handle the previous refresh token when callback updates grant props', async () => {
1623+
// This test verifies fixes for two bugs:
1624+
// 1. previousRefreshTokenWrappedKey not being re-wrapped when grant props change
1625+
// 2. tokenProps not inheriting from grantProps when only grantProps is returned
1626+
let callCount = 0;
1627+
const propUpdatingCallback = async (options: any) => {
1628+
callCount++;
1629+
if (options.grantType === 'refresh_token') {
1630+
const updatedProps = {
1631+
...options.props,
1632+
updatedCount: (options.props.updatedCount || 0) + 1
1633+
};
1634+
1635+
// Only return grantProps to test that tokenProps will inherit from it
1636+
return {
1637+
// Return new grant props to trigger the re-encryption with a new key
1638+
grantProps: updatedProps
1639+
// Intentionally not setting tokenProps to verify inheritance works
1640+
};
1641+
}
1642+
return undefined;
1643+
};
1644+
1645+
const testProvider = new OAuthProvider({
1646+
apiRoute: ['/api/'],
1647+
apiHandler: TestApiHandler,
1648+
defaultHandler: testDefaultHandler,
1649+
authorizeEndpoint: '/authorize',
1650+
tokenEndpoint: '/oauth/token',
1651+
clientRegistrationEndpoint: '/oauth/register',
1652+
scopesSupported: ['read', 'write'],
1653+
tokenExchangeCallback: propUpdatingCallback
1654+
});
1655+
1656+
// Create a client
1657+
const clientData = {
1658+
redirect_uris: ['https://client.example.com/callback'],
1659+
client_name: 'Key-Rewrapping Test',
1660+
token_endpoint_auth_method: 'client_secret_basic'
1661+
};
1662+
1663+
const registerRequest = createMockRequest(
1664+
'https://example.com/oauth/register',
1665+
'POST',
1666+
{ 'Content-Type': 'application/json' },
1667+
JSON.stringify(clientData)
1668+
);
1669+
1670+
const registerResponse = await testProvider.fetch(registerRequest, mockEnv, mockCtx);
1671+
const client = await registerResponse.json();
1672+
const testClientId = client.client_id;
1673+
const testClientSecret = client.client_secret;
1674+
const testRedirectUri = 'https://client.example.com/callback';
1675+
1676+
// Get an auth code
1677+
const authRequest = createMockRequest(
1678+
`https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1679+
`&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1680+
`&scope=read%20write&state=xyz123`
1681+
);
1682+
1683+
const authResponse = await testProvider.fetch(authRequest, mockEnv, mockCtx);
1684+
const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1685+
1686+
// Exchange code for tokens
1687+
const params = new URLSearchParams();
1688+
params.append('grant_type', 'authorization_code');
1689+
params.append('code', code);
1690+
params.append('redirect_uri', testRedirectUri);
1691+
params.append('client_id', testClientId);
1692+
params.append('client_secret', testClientSecret);
1693+
1694+
const tokenRequest = createMockRequest(
1695+
'https://example.com/oauth/token',
1696+
'POST',
1697+
{ 'Content-Type': 'application/x-www-form-urlencoded' },
1698+
params.toString()
1699+
);
1700+
1701+
const tokenResponse = await testProvider.fetch(tokenRequest, mockEnv, mockCtx);
1702+
const tokens = await tokenResponse.json();
1703+
const refreshToken = tokens.refresh_token;
1704+
1705+
// Reset the callback invocations before refresh
1706+
callCount = 0;
1707+
1708+
// First refresh - this will update the grant props and re-encrypt them with a new key
1709+
const refreshParams = new URLSearchParams();
1710+
refreshParams.append('grant_type', 'refresh_token');
1711+
refreshParams.append('refresh_token', refreshToken);
1712+
refreshParams.append('client_id', testClientId);
1713+
refreshParams.append('client_secret', testClientSecret);
1714+
1715+
const refreshRequest = createMockRequest(
1716+
'https://example.com/oauth/token',
1717+
'POST',
1718+
{ 'Content-Type': 'application/x-www-form-urlencoded' },
1719+
refreshParams.toString()
1720+
);
1721+
1722+
const refreshResponse = await testProvider.fetch(refreshRequest, mockEnv, mockCtx);
1723+
expect(refreshResponse.status).toBe(200);
1724+
1725+
// The callback should have been called once for the refresh
1726+
expect(callCount).toBe(1);
1727+
1728+
// Get the new tokens from the first refresh
1729+
const newTokens = await refreshResponse.json();
1730+
1731+
// Get the refresh token's corresponding token data to verify it has the updated props
1732+
const apiRequest1 = createMockRequest(
1733+
'https://example.com/api/test',
1734+
'GET',
1735+
{ 'Authorization': `Bearer ${newTokens.access_token}` }
1736+
);
1737+
1738+
const apiResponse1 = await testProvider.fetch(apiRequest1, mockEnv, mockCtx);
1739+
const apiData1 = await apiResponse1.json();
1740+
1741+
// Print the actual API response to debug
1742+
console.log("First API response:", JSON.stringify(apiData1));
1743+
1744+
// Verify that the token has the updated props (updatedCount should be 1)
1745+
expect(apiData1.user.updatedCount).toBe(1);
1746+
1747+
// Reset callCount before the second refresh
1748+
callCount = 0;
1749+
1750+
// Now try to use the SAME refresh token again (which should work once due to token rotation)
1751+
// With the bug, this would fail because previousRefreshTokenWrappedKey wasn't re-wrapped with the new key
1752+
const secondRefreshRequest = createMockRequest(
1753+
'https://example.com/oauth/token',
1754+
'POST',
1755+
{ 'Content-Type': 'application/x-www-form-urlencoded' },
1756+
refreshParams.toString() // Using same params with the same refresh token
1757+
);
1758+
1759+
const secondRefreshResponse = await testProvider.fetch(secondRefreshRequest, mockEnv, mockCtx);
1760+
1761+
// With the bug, this would fail with an error.
1762+
// When fixed, it should succeed because the previous refresh token is still valid once.
1763+
expect(secondRefreshResponse.status).toBe(200);
1764+
1765+
const secondTokens = await secondRefreshResponse.json();
1766+
expect(secondTokens.access_token).toBeDefined();
1767+
1768+
// The callback should have been called again
1769+
expect(callCount).toBe(1);
1770+
1771+
// Use the token to access API and verify it has the updated props
1772+
const apiRequest2 = createMockRequest(
1773+
'https://example.com/api/test',
1774+
'GET',
1775+
{ 'Authorization': `Bearer ${secondTokens.access_token}` }
1776+
);
1777+
1778+
const apiResponse2 = await testProvider.fetch(apiRequest2, mockEnv, mockCtx);
1779+
const apiData2 = await apiResponse2.json();
1780+
1781+
// The updatedCount should be 2 now (incremented again during the second refresh)
1782+
expect(apiData2.user.updatedCount).toBe(2);
1783+
});
16181784
});
16191785

16201786
describe('OAuthHelpers', () => {

src/oauth-provider.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,12 +1255,19 @@ class OAuthProviderImpl {
12551255

12561256
if (callbackResult) {
12571257
// Use the returned props if provided, otherwise keep the original props
1258-
if (callbackResult.tokenProps) {
1259-
accessTokenProps = callbackResult.tokenProps;
1260-
}
1261-
12621258
if (callbackResult.grantProps) {
12631259
grantProps = callbackResult.grantProps;
1260+
1261+
// If tokenProps wasn't explicitly specified, use the updated grantProps for the token too
1262+
// This ensures token props are updated when only grant props are specified
1263+
if (!callbackResult.tokenProps) {
1264+
accessTokenProps = callbackResult.grantProps;
1265+
}
1266+
}
1267+
1268+
// If tokenProps was explicitly specified, use those
1269+
if (callbackResult.tokenProps) {
1270+
accessTokenProps = callbackResult.tokenProps;
12641271
}
12651272
}
12661273

@@ -1451,22 +1458,41 @@ class OAuthProviderImpl {
14511458

14521459
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
14531460

1461+
let grantPropsChanged = false;
14541462
if (callbackResult) {
14551463
// Use the returned props if provided, otherwise keep the original props
1464+
if (callbackResult.grantProps) {
1465+
grantProps = callbackResult.grantProps;
1466+
grantPropsChanged = true;
1467+
1468+
// If tokenProps wasn't explicitly specified, use the updated grantProps for the token too
1469+
// This ensures token props are updated when only grant props are specified
1470+
if (!callbackResult.tokenProps) {
1471+
accessTokenProps = callbackResult.grantProps;
1472+
}
1473+
}
1474+
1475+
// If tokenProps was explicitly specified, use those
14561476
if (callbackResult.tokenProps) {
14571477
accessTokenProps = callbackResult.tokenProps;
14581478
}
1479+
}
14591480

1460-
if (callbackResult.grantProps) {
1461-
grantProps = callbackResult.grantProps;
1481+
// Only re-encrypt the grant props if they've changed
1482+
if (grantPropsChanged) {
1483+
// Re-encrypt the updated grant props
1484+
const grantResult = await encryptProps(grantProps);
1485+
grantData.encryptedProps = grantResult.encryptedData;
1486+
1487+
// If the encryption key changed, we need to re-wrap the previous token key
1488+
if (grantResult.key !== encryptionKey) {
1489+
grantEncryptionKey = grantResult.key;
1490+
wrappedKeyToUse = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
1491+
} else {
1492+
grantEncryptionKey = grantResult.key;
14621493
}
14631494
}
14641495

1465-
// Re-encrypt the potentially updated grant props
1466-
const grantResult = await encryptProps(grantProps);
1467-
grantData.encryptedProps = grantResult.encryptedData;
1468-
grantEncryptionKey = grantResult.key;
1469-
14701496
// Re-encrypt the access token props if they're different from grant props
14711497
if (accessTokenProps !== grantProps) {
14721498
const tokenResult = await encryptProps(accessTokenProps);

0 commit comments

Comments
 (0)