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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ const provider = createDcrProvider<Account>({
})
```

The DCR-issued `client_id` (and `client_secret`, if returned) are stashed in the handshake and threaded through the rest of the flow. The server-returned `token_endpoint_auth_method` is authoritative (RFC 7591 §3.2.1) and overrides the configured one. By default the token exchange uses `Authorization: Basic` with form-url-encoded credentials (RFC 6749 §2.3.1); pass `tokenEndpointAuthMethod: 'client_secret_post'` to send credentials in the body instead, or `'none'` for a public-client registration. When the registration response carries no `client_secret`, the exchange falls back to a public-client POST regardless of the requested method. Any extra registration metadata (e.g. `software_statement`) goes in `clientMetadata.extra`. cli-core does **not** cache the registered client — each login mints a fresh one.
The DCR-issued `client_id` (and `client_secret`, if returned) are stashed in the handshake and threaded through the rest of the flow. The server-returned `token_endpoint_auth_method` is authoritative (RFC 7591 §3.2.1) and overrides the configured one. By default the token exchange uses `Authorization: Basic` with each credential component percent-encoded via `encodeURIComponent` (RFC 3986) rather than oauth4webapi's stricter RFC 6749 §2.3.1 form-url-encoding. Both escape genuinely reserved chars (`:` / `%` / `+` / `/`) so a conformant server reconstructs them, but §2.3.1 _also_ escapes the unreserved `-` `_` `.` `~` — which breaks servers that don't url-decode the Basic credential (a DCR-issued `twd_…` would arrive as `twd%5F…` and miss the lookup). Pass `tokenEndpointAuthMethod: 'client_secret_post'` to send credentials in the body instead, or `'none'` for a public-client registration. When the registration response carries no `client_secret`, the exchange falls back to a public-client POST regardless of the requested method. Any extra registration metadata (e.g. `software_statement`) goes in `clientMetadata.extra`. cli-core does **not** cache the registered client — each login mints a fresh one.

Both `createPkceProvider` and `createDcrProvider` accept an optional `errorHints: string[]` that is prepended to every `CliError` they throw. Use it for CLI-specific remediation that should accompany every auth failure (e.g. `['Try again: tw auth login', 'Or set TWIST_API_TOKEN environment variable']`). Server-returned response bodies (for non-2xx replies) are appended after the user hints so the actionable hint stays at the top.

Expand Down
17 changes: 10 additions & 7 deletions src/auth/providers/dcr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('createDcrProvider', () => {
it('prepare POSTs RFC 7591 metadata, authorize uses the issued client_id, exchangeCode sends Basic auth', async () => {
const { calls, fetchImpl } = makeFetchRecorder((u) =>
u === REGISTRATION_URL
? registration({ client_id: 'issuedid', client_secret: 'issuedsecret' })
? registration({ client_id: 'twd_id', client_secret: 'se+cr/et' })
: token({ access_token: 'tok-1', expires_in: 3600 }),
)
const provider = createDcrProvider<Account>({
Expand All @@ -65,7 +65,7 @@ describe('createDcrProvider', () => {
})

const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} })
expect(prepared.handshake).toEqual({ clientId: 'issuedid', clientSecret: 'issuedsecret' })
expect(prepared.handshake).toEqual({ clientId: 'twd_id', clientSecret: 'se+cr/et' })

const regBody = JSON.parse(calls[0].init.body as string) as Record<string, unknown>
expect(regBody).toMatchObject({
Expand All @@ -88,14 +88,14 @@ describe('createDcrProvider', () => {
handshake: prepared.handshake,
})
const url = new URL(authorize.authorizeUrl)
expect(url.searchParams.get('client_id')).toBe('issuedid')
expect(url.searchParams.get('client_id')).toBe('twd_id')
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI)
expect(url.searchParams.get('state')).toBe('state-123')
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/)
expect(url.searchParams.get('scope')).toBe('user:read threads:read')
expect(typeof authorize.handshake.codeVerifier).toBe('string')
expect(authorize.handshake.clientSecret).toBe('issuedsecret')
expect(authorize.handshake.clientSecret).toBe('se+cr/et')

const result = await provider.exchangeCode({
code: 'auth-code',
Expand All @@ -107,10 +107,13 @@ describe('createDcrProvider', () => {
expect(result.expiresAt).toBeGreaterThan(Date.now())

const tokenCall = calls.find((c) => c.url === TOKEN_URL)!
// oauth4webapi form-url-encodes the credentials per RFC 6749 §2.3.1
// before base64; alphanumeric id/secret round-trip unchanged.
// RFC 3986 per-component encoding: the unreserved `_` is preserved (so
// servers that don't url-decode the Basic credential still match — the
// bug oauth4webapi's §2.3.1 `%5F` escaping caused), while reserved chars
// (`+` → `%2B`, `/` → `%2F`) are escaped so a conformant server
// reconstructs them.
expect(headersOf(tokenCall).get('authorization')).toBe(
`Basic ${Buffer.from('issuedid:issuedsecret', 'utf8').toString('base64')}`,
`Basic ${Buffer.from('twd_id:se%2Bcr%2Fet', 'utf8').toString('base64')}`,
)
const tokenBody = bodyOf(tokenCall)
expect(tokenBody.get('grant_type')).toBe('authorization_code')
Expand Down
27 changes: 24 additions & 3 deletions src/auth/providers/dcr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ const VALID_AUTH_METHODS: ReadonlySet<DcrTokenEndpointAuthMethod> = new Set([
* - `authorize`: standard PKCE S256 with `client_id` read from the handshake.
* - `exchangeCode`: `authorizationCodeGrantRequest` authenticated per the
* handshake's server-returned auth method (falling back to the configured
* one) — `ClientSecretBasic` / `ClientSecretPost` / `None` (the last also
* when the registration response carried no `client_secret`).
* one) — HTTP Basic (RFC 3986-encoded, see `clientSecretBasicRfc3986`),
* client-secret POST, or public-client `None` (the last also when the
* registration response carried no `client_secret`).
* - `validateToken`: caller-supplied.
*/
export function createDcrProvider<TAccount extends AuthAccount>(
Expand Down Expand Up @@ -242,7 +243,7 @@ export function createDcrProvider<TAccount extends AuthAccount>(
} else if (effectiveAuthMethod === 'client_secret_post') {
clientAuth = oauth.ClientSecretPost(clientSecret)
} else {
clientAuth = oauth.ClientSecretBasic(clientSecret)
clientAuth = clientSecretBasicRfc3986(clientSecret)
}

try {
Expand Down Expand Up @@ -284,6 +285,26 @@ export function createDcrProvider<TAccount extends AuthAccount>(
}
}

/**
* HTTP Basic client auth that percent-encodes each credential component with
* `encodeURIComponent` (RFC 3986) rather than oauth4webapi's stricter RFC 6749
* §2.3.1 `application/x-www-form-urlencoded` form. Both escape the genuinely
* reserved chars (`:` `%` `+` `/` …) so a conformant server reconstructs them —
* but §2.3.1 *also* escapes the unreserved `-` `_` `.` `~`, which breaks servers
* that don't url-decode the Basic credential (a DCR-issued `twd_…` would arrive
* as `twd%5F…` and miss the lookup). Leaving those unreserved chars intact keeps
* such servers working while still transmitting reserved chars safely.
*/
function clientSecretBasicRfc3986(clientSecret: string): ClientAuth {
return (_as, client, _body, headers) => {
const credentials = Buffer.from(
`${encodeURIComponent(client.client_id)}:${encodeURIComponent(clientSecret)}`,
'utf8',
).toString('base64')
headers.set('authorization', `Basic ${credentials}`)
}
}

/** Thread an injected `fetchImpl` into oauth4webapi via its `customFetch` symbol. */
function customFetchOptions(
oauth: typeof import('oauth4webapi'),
Expand Down
Loading