Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ const provider = createPkceProvider<Account>({
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),
})

Expand All @@ -212,7 +220,9 @@ attachLoginCommand<Account>(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<string>` — 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<string>` — 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)

Expand Down
28 changes: 28 additions & 0 deletions src/auth/providers/pkce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Account>({
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<Account>({
authorizeUrl: 'unused',
Expand Down
16 changes: 16 additions & 0 deletions src/auth/providers/pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export type PkceProviderOptions<TAccount extends AuthAccount = AuthAccount> = {
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. */
Comment thread
odsamuels marked this conversation as resolved.
tokenRequestParams?: (ctx: {
handshake: Record<string, unknown>
flags: Record<string, unknown>
}) =>
| Record<string, string | number | undefined>
| Promise<Record<string, string | number | undefined>>
/** How to join scopes in the authorize URL. Default `' '` (RFC 6749). Pass `','` for Todoist. */
scopeSeparator?: string
verifierAlphabet?: string
Expand Down Expand Up @@ -130,12 +137,21 @@ export function createPkceProvider<TAccount extends AuthAccount>(
const flags = (input.handshake.flags as Record<string, unknown> | 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({
Expand Down
Loading