From 6c029c7565f0e25644608ec8a26a23354c9d5548 Mon Sep 17 00:00:00 2001 From: Omar Samuels <2964789+odsamuels@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:50:57 -0400 Subject: [PATCH 1/3] feat: add flexibility for token request parameters in PKCE provider --- package-lock.json | 79 ++++----------------------------- src/auth/providers/pkce.test.ts | 28 ++++++++++++ src/auth/providers/pkce.ts | 16 +++++++ 3 files changed, 52 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1ab119..984b73a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,29 +162,6 @@ "node": ">=0.1.90" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -918,6 +895,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1196,9 +1174,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1216,9 +1191,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1236,9 +1208,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1256,9 +1225,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1276,9 +1242,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1296,9 +1259,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1316,9 +1276,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1336,9 +1293,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1543,9 +1497,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1563,9 +1514,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1583,9 +1531,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1603,9 +1548,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1623,9 +1565,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1643,9 +1582,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1663,9 +1599,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1683,9 +1616,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2693,6 +2623,7 @@ "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -7874,6 +7805,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8874,6 +8806,7 @@ "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -8996,6 +8929,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9775,6 +9709,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9884,6 +9819,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10036,6 +9972,7 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", 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({ From a82516e8d63f818737ee4f5b7ed9d489e3b22f8f Mon Sep 17 00:00:00 2001 From: Omar Samuels <2964789+odsamuels@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:08:44 -0400 Subject: [PATCH 2/3] chore: restore package-lock.json to match main The lock file had drifted out of sync with package.json (missing @emnapi optional entries, stale libc metadata), causing npm ci to fail in CI. No dependency changes were needed for this feature. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 79 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 984b73a..b1ab119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,6 +162,29 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -895,7 +918,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1174,6 +1196,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1191,6 +1216,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1208,6 +1236,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1225,6 +1256,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1242,6 +1276,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1259,6 +1296,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1276,6 +1316,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1293,6 +1336,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1497,6 +1543,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1514,6 +1563,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1531,6 +1583,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1548,6 +1603,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1565,6 +1623,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1582,6 +1643,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1599,6 +1663,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1616,6 +1683,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2623,7 +2693,6 @@ "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -7805,7 +7874,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8806,7 +8874,6 @@ "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -8929,7 +8996,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9709,7 +9775,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9819,7 +9884,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9972,7 +10036,6 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", From aa8e1053739eb71025a7794b227b04e67b01ad15 Mon Sep 17 00:00:00 2001 From: Omar Samuels <2964789+odsamuels@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:21:41 -0400 Subject: [PATCH 3/3] feat: add tokenRequestParams for flexible authorization-code token requests --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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)