Skip to content

Commit 8b9631d

Browse files
committed
Ask Claude to extend tokenExchangeCallback to allow customizing the access token TTL.
Transcript (same as previous commit): https://claude-workerd-transcript.pages.dev/oauth-provider-token-exchange-callback2
1 parent 135a7e5 commit 8b9631d

File tree

3 files changed

+147
-9
lines changed

3 files changed

+147
-9
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ new OAuthProvider({
242242
newProps: {
243243
...options.props,
244244
upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken
245-
}
245+
},
246+
// Optionally override the default access token TTL to match the upstream token
247+
accessTokenTTL: upstreamTokens.expires_in
246248
};
247249
}
248250
}
@@ -253,8 +255,11 @@ The callback can:
253255
- Return both `accessTokenProps` and `newProps` to update both
254256
- Return only `accessTokenProps` to update just the current access token
255257
- Return only `newProps` to update both the grant and access token (the access token inherits these props)
258+
- Return `accessTokenTTL` to override the default TTL for this specific access token
256259
- Return nothing to keep the original props unchanged
257260

261+
The `accessTokenTTL` override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired.
262+
258263
The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
259264

260265
## Written by Claude

__tests__/oauth-provider.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,6 +1539,111 @@ describe('OAuthProvider', () => {
15391539
});
15401540
});
15411541

1542+
it('should allow customizing access token TTL via callback', async () => {
1543+
// Create a provider with a callback that customizes TTL
1544+
const customTtlCallback = async (options: any) => {
1545+
if (options.grantType === 'refresh_token') {
1546+
// Return custom TTL for the access token
1547+
return {
1548+
accessTokenProps: { ...options.props, customTtl: true },
1549+
accessTokenTTL: 7200 // 2 hours instead of default
1550+
};
1551+
}
1552+
return undefined;
1553+
};
1554+
1555+
const customTtlProvider = new OAuthProvider({
1556+
apiRoute: ['/api/'],
1557+
apiHandler: TestApiHandler,
1558+
defaultHandler: testDefaultHandler,
1559+
authorizeEndpoint: '/authorize',
1560+
tokenEndpoint: '/oauth/token',
1561+
clientRegistrationEndpoint: '/oauth/register',
1562+
scopesSupported: ['read', 'write'],
1563+
accessTokenTTL: 3600, // Default 1 hour
1564+
tokenExchangeCallback: customTtlCallback
1565+
});
1566+
1567+
// Create a client
1568+
const clientData = {
1569+
redirect_uris: ['https://client.example.com/callback'],
1570+
client_name: 'Custom TTL Test',
1571+
token_endpoint_auth_method: 'client_secret_basic'
1572+
};
1573+
1574+
const registerRequest = createMockRequest(
1575+
'https://example.com/oauth/register',
1576+
'POST',
1577+
{ 'Content-Type': 'application/json' },
1578+
JSON.stringify(clientData)
1579+
);
1580+
1581+
const registerResponse = await customTtlProvider.fetch(registerRequest, mockEnv, mockCtx);
1582+
const client = await registerResponse.json();
1583+
const testClientId = client.client_id;
1584+
const testClientSecret = client.client_secret;
1585+
const testRedirectUri = 'https://client.example.com/callback';
1586+
1587+
// Get an auth code
1588+
const authRequest = createMockRequest(
1589+
`https://example.com/authorize?response_type=code&client_id=${testClientId}` +
1590+
`&redirect_uri=${encodeURIComponent(testRedirectUri)}` +
1591+
`&scope=read%20write&state=xyz123`
1592+
);
1593+
1594+
const authResponse = await customTtlProvider.fetch(authRequest, mockEnv, mockCtx);
1595+
const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!;
1596+
1597+
// Exchange code for tokens
1598+
const params = new URLSearchParams();
1599+
params.append('grant_type', 'authorization_code');
1600+
params.append('code', code);
1601+
params.append('redirect_uri', testRedirectUri);
1602+
params.append('client_id', testClientId);
1603+
params.append('client_secret', testClientSecret);
1604+
1605+
const tokenRequest = createMockRequest(
1606+
'https://example.com/oauth/token',
1607+
'POST',
1608+
{ 'Content-Type': 'application/x-www-form-urlencoded' },
1609+
params.toString()
1610+
);
1611+
1612+
const tokenResponse = await customTtlProvider.fetch(tokenRequest, mockEnv, mockCtx);
1613+
const tokens = await tokenResponse.json();
1614+
1615+
// Now do a refresh
1616+
const refreshParams = new URLSearchParams();
1617+
refreshParams.append('grant_type', 'refresh_token');
1618+
refreshParams.append('refresh_token', tokens.refresh_token);
1619+
refreshParams.append('client_id', testClientId);
1620+
refreshParams.append('client_secret', testClientSecret);
1621+
1622+
const refreshRequest = createMockRequest(
1623+
'https://example.com/oauth/token',
1624+
'POST',
1625+
{ 'Content-Type': 'application/x-www-form-urlencoded' },
1626+
refreshParams.toString()
1627+
);
1628+
1629+
const refreshResponse = await customTtlProvider.fetch(refreshRequest, mockEnv, mockCtx);
1630+
const newTokens = await refreshResponse.json();
1631+
1632+
// Verify that the TTL is from the callback, not the default
1633+
expect(newTokens.expires_in).toBe(7200);
1634+
1635+
// Verify the token contains our custom property
1636+
const apiRequest = createMockRequest(
1637+
'https://example.com/api/test',
1638+
'GET',
1639+
{ 'Authorization': `Bearer ${newTokens.access_token}` }
1640+
);
1641+
1642+
const apiResponse = await customTtlProvider.fetch(apiRequest, mockEnv, mockCtx);
1643+
const apiData = await apiResponse.json();
1644+
expect(apiData.user.customTtl).toBe(true);
1645+
});
1646+
15421647
it('should handle callback that returns undefined (keeping original props)', async () => {
15431648
// Create a provider with a callback that returns undefined
15441649
const noopCallback = async (options: any) => {

src/oauth-provider.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export interface TokenExchangeCallbackResult {
5050
* If not provided, the original props will be used.
5151
*/
5252
newProps?: any;
53+
54+
/**
55+
* Override the default access token TTL (time-to-live) for this specific token.
56+
* This is especially useful when the application is also an OAuth client to another service
57+
* and wants to match its access token TTL to the upstream access token TTL.
58+
* Value should be in seconds.
59+
*/
60+
accessTokenTTL?: number;
5361
}
5462

5563
/**
@@ -1227,7 +1235,9 @@ class OAuthProviderImpl {
12271235
const refreshTokenId = await generateTokenId(refreshToken);
12281236

12291237
const now = Math.floor(Date.now() / 1000);
1230-
const accessTokenExpiresAt = now + this.options.accessTokenTTL!;
1238+
1239+
// Define the access token TTL, may be updated by callback if provided
1240+
let accessTokenTTL = this.options.accessTokenTTL!;
12311241

12321242
// Get the encryption key for props by unwrapping it using the auth code
12331243
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey!);
@@ -1272,6 +1282,11 @@ class OAuthProviderImpl {
12721282
if (callbackResult.accessTokenProps) {
12731283
accessTokenProps = callbackResult.accessTokenProps;
12741284
}
1285+
1286+
// If accessTokenTTL was specified, use that for this token
1287+
if (callbackResult.accessTokenTTL !== undefined) {
1288+
accessTokenTTL = callbackResult.accessTokenTTL;
1289+
}
12751290
}
12761291

12771292
// Re-encrypt the potentially updated grant props
@@ -1291,6 +1306,9 @@ class OAuthProviderImpl {
12911306
}
12921307
}
12931308

1309+
// Calculate the access token expiration time (after callback might have updated TTL)
1310+
const accessTokenExpiresAt = now + accessTokenTTL;
1311+
12941312
// Wrap the keys for the new tokens
12951313
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
12961314
const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
@@ -1328,18 +1346,18 @@ class OAuthProviderImpl {
13281346
}
13291347
};
13301348

1331-
// Save access token with TTL
1349+
// Save access token with TTL (using the potentially callback-provided TTL)
13321350
await env.OAUTH_KV.put(
13331351
`token:${userId}:${grantId}:${accessTokenId}`,
13341352
JSON.stringify(accessTokenData),
1335-
{ expirationTtl: this.options.accessTokenTTL }
1353+
{ expirationTtl: accessTokenTTL }
13361354
);
13371355

13381356
// Return the tokens
13391357
return new Response(JSON.stringify({
13401358
access_token: accessToken,
13411359
token_type: 'bearer',
1342-
expires_in: this.options.accessTokenTTL,
1360+
expires_in: accessTokenTTL,
13431361
refresh_token: refreshToken,
13441362
scope: grantData.scope.join(' ')
13451363
}), {
@@ -1424,7 +1442,9 @@ class OAuthProviderImpl {
14241442
const newRefreshTokenId = await generateTokenId(newRefreshToken);
14251443

14261444
const now = Math.floor(Date.now() / 1000);
1427-
const accessTokenExpiresAt = now + this.options.accessTokenTTL!;
1445+
1446+
// Define the access token TTL, may be updated by callback if provided
1447+
let accessTokenTTL = this.options.accessTokenTTL!;
14281448

14291449
// Determine which wrapped key to use for unwrapping
14301450
let wrappedKeyToUse: string;
@@ -1479,6 +1499,11 @@ class OAuthProviderImpl {
14791499
if (callbackResult.accessTokenProps) {
14801500
accessTokenProps = callbackResult.accessTokenProps;
14811501
}
1502+
1503+
// If accessTokenTTL was specified, use that for this token
1504+
if (callbackResult.accessTokenTTL !== undefined) {
1505+
accessTokenTTL = callbackResult.accessTokenTTL;
1506+
}
14821507
}
14831508

14841509
// Only re-encrypt the grant props if they've changed
@@ -1508,6 +1533,9 @@ class OAuthProviderImpl {
15081533
}
15091534
}
15101535

1536+
// Calculate the access token expiration time (after callback might have updated TTL)
1537+
const accessTokenExpiresAt = now + accessTokenTTL;
1538+
15111539
// Wrap the key for both the new access token and refresh token
15121540
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
15131541
const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
@@ -1549,18 +1577,18 @@ class OAuthProviderImpl {
15491577
}
15501578
};
15511579

1552-
// Save access token with TTL
1580+
// Save access token with TTL (using the potentially callback-provided TTL)
15531581
await env.OAUTH_KV.put(
15541582
`token:${userId}:${grantId}:${accessTokenId}`,
15551583
JSON.stringify(accessTokenData),
1556-
{ expirationTtl: this.options.accessTokenTTL }
1584+
{ expirationTtl: accessTokenTTL }
15571585
);
15581586

15591587
// Return the new access token and refresh token
15601588
return new Response(JSON.stringify({
15611589
access_token: newAccessToken,
15621590
token_type: 'bearer',
1563-
expires_in: this.options.accessTokenTTL,
1591+
expires_in: accessTokenTTL,
15641592
refresh_token: newRefreshToken,
15651593
scope: grantData.scope.join(' ')
15661594
}), {

0 commit comments

Comments
 (0)