diff --git a/README.md b/README.md index 1e49760..89121b7 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,14 @@ const provider = createPkceProvider({ authorizeUrl: ({ handshake }) => `${handshake.baseUrl as string}/oauth/authorize`, tokenUrl: ({ handshake }) => `${handshake.baseUrl as string}/oauth/token`, clientId: ({ flags }) => flags.clientId as string, + // Optional: add extra form parameters to the authorization-code token + // request (e.g. Zendesk's `expires_in` / `refresh_token_expires_in`). + tokenRequestParams: async ({ handshake, flags }) => ({ + // `handshake` carries the authorize-time state; `flags` carries any + // runtime CLI flags that shaped the flow. + expires_in: 172800, + refresh_token_expires_in: 7776000, + }), validate: async ({ token, handshake }) => probeUser(token, handshake.baseUrl as string), }) @@ -212,7 +220,9 @@ attachLoginCommand(auth, { `attachLoginCommand` returns the new `Command` so you can chain `.description(...)` / `.option(...)` / `.addHelpText(...)`. Any consumer-attached options land in the `flags` object passed to `resolveScopes`, `onSuccess`, and the provider hooks. -The `authorizeUrl` / `tokenUrl` / `clientId` resolvers may return `string` **or** `Promise` — so a consumer can resolve the base URL or client id asynchronously (reading config, prompting the user) without abandoning `createPkceProvider`. An injected `fetchImpl` is used for the token exchange **and** the refresh grant (threaded into `oauth4webapi` via its `customFetch`), so a custom transport — proxy dispatcher, decompression — applies on every OAuth call rather than being bypassed by the library's global `fetch`. +The `authorizeUrl` / `tokenUrl` / `clientId` resolvers may return `string` **or** `Promise` — so a consumer can resolve the base URL or client id asynchronously (reading config, prompting the user) without abandoning `createPkceProvider`. `tokenRequestParams` is the equivalent escape hatch for authorization-code token requests: it is optional, receives the same `handshake` + `flags` context as the other provider hooks, and may return either a plain object or a `Promise` when the extra parameters need to be resolved asynchronously. This is useful for providers that require non-standard form fields such as Zendesk's `expires_in` or `refresh_token_expires_in`. + +An injected `fetchImpl` is used for the token exchange **and** the refresh grant (threaded into `oauth4webapi` via its `customFetch`), so a custom transport — proxy dispatcher, decompression — applies on every OAuth call rather than being bypassed by the library's global `fetch`. #### Quick start (Dynamic Client Registration) diff --git a/src/auth/providers/pkce.test.ts b/src/auth/providers/pkce.test.ts index 6afc3fa..bd7bd5a 100644 --- a/src/auth/providers/pkce.test.ts +++ b/src/auth/providers/pkce.test.ts @@ -81,6 +81,34 @@ describe('createPkceProvider', () => { expect(url.searchParams.get('client_id')).toBe('async-client') }) + it('adds provider-defined token endpoint parameters (e.g. Zendesk max expiry values)', async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init: RequestInit = {}) => { + const body = new URLSearchParams(init.body as string) + expect(body.get('expires_in')).toBe('172800') + expect(body.get('refresh_token_expires_in')).toBe('7776000') + return respond({ access_token: 'tok-1', expires_in: 3600 }) + }) as unknown as typeof fetch + + const provider = createPkceProvider({ + authorizeUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + clientId: 'client-xyz', + tokenRequestParams: () => ({ + expires_in: 172800, + refresh_token_expires_in: 7776000, + }), + validate, + fetchImpl, + }) + + await provider.exchangeCode({ + code: 'the-code', + state: 's', + redirectUri: 'http://localhost/callback', + handshake: { codeVerifier: 'the-verifier', clientId: 'client-xyz' }, + }) + }) + it('exchangeCode POSTs without client_secret and surfaces token endpoint failures as AUTH_TOKEN_EXCHANGE_FAILED', async () => { const ok = createPkceProvider({ authorizeUrl: 'unused', diff --git a/src/auth/providers/pkce.ts b/src/auth/providers/pkce.ts index 33d7f55..6441316 100644 --- a/src/auth/providers/pkce.ts +++ b/src/auth/providers/pkce.ts @@ -49,6 +49,13 @@ export type PkceProviderOptions = { tokenUrl: PkceLazyString /** Pre-registered client_id, or a function that derives one from `input.flags`. */ clientId: PkceLazyString + /** Additional form-encoded parameters to include in the token request body. */ + tokenRequestParams?: (ctx: { + handshake: Record + flags: Record + }) => + | Record + | Promise> /** How to join scopes in the authorize URL. Default `' '` (RFC 6749). Pass `','` for Todoist. */ scopeSeparator?: string verifierAlphabet?: string @@ -130,12 +137,21 @@ export function createPkceProvider( const flags = (input.handshake.flags as Record | undefined) ?? {} const tokenUrl = await resolve(options.tokenUrl, input.handshake, flags) + const extraTokenParams = await (options.tokenRequestParams?.({ + handshake: input.handshake, + flags, + }) ?? {}) const body = new URLSearchParams({ grant_type: 'authorization_code', code: input.code, redirect_uri: input.redirectUri, client_id: clientId, code_verifier: verifier, + ...Object.fromEntries( + Object.entries(extraTokenParams) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]), + ), }) const result = await postTokenEndpoint({