-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): add createDcrProvider for RFC 7591 dynamic client registration #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
scottlovegrove
wants to merge
3
commits into
main
Choose a base branch
from
scottl/dcr-provider
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
09cf471
feat(auth): add createDcrProvider for RFC 7591 dynamic client registr…
scottlovegrove 445613a
test(auth): trim redundant coverage in DCR + _oauth tests
scottlovegrove c1d9c85
refactor(auth): address PR review — honour server auth method, URL-en…
scottlovegrove File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { postTokenEndpoint } from './_oauth.js' | ||
|
|
||
| const TOKEN_URL = 'https://example.com/oauth/token' | ||
|
|
||
| describe('postTokenEndpoint', () => { | ||
| it('POSTs the form body, returns access_token + refresh_token + expiresAt, no Authorization header without basicAuth', async () => { | ||
| let captured: { url: string; init: RequestInit } | undefined | ||
| const result = await postTokenEndpoint({ | ||
| url: TOKEN_URL, | ||
| body: new URLSearchParams({ grant_type: 'authorization_code', code: 'c' }), | ||
| fetchImpl: ((url, init = {}) => { | ||
| captured = { url: String(url), init } | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ | ||
| access_token: 'tok', | ||
| refresh_token: 'rtok', | ||
| expires_in: 60, | ||
| }), | ||
| { status: 200 }, | ||
| ), | ||
| ) | ||
| }) as typeof fetch, | ||
| }) | ||
| expect(result).toMatchObject({ accessToken: 'tok', refreshToken: 'rtok' }) | ||
| expect(result.expiresAt).toBeGreaterThan(Date.now()) | ||
| expect(captured?.url).toBe(TOKEN_URL) | ||
| const headers = captured?.init.headers as Record<string, string> | ||
| expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded') | ||
| expect(headers.Authorization).toBeUndefined() | ||
| }) | ||
|
|
||
| it('adds Authorization: Basic when basicAuth is supplied', async () => { | ||
| let headers: Record<string, string> | undefined | ||
| await postTokenEndpoint({ | ||
| url: TOKEN_URL, | ||
| body: new URLSearchParams({ grant_type: 'authorization_code' }), | ||
| basicAuth: { clientId: 'cid', clientSecret: 'sec' }, | ||
| fetchImpl: ((_url, init = {}) => { | ||
| headers = init.headers as Record<string, string> | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify({ access_token: 'x' }), { status: 200 }), | ||
| ) | ||
| }) as typeof fetch, | ||
| }) | ||
| expect(headers?.Authorization).toBe( | ||
| `Basic ${Buffer.from('cid:sec', 'utf8').toString('base64')}`, | ||
| ) | ||
| }) | ||
|
|
||
| it('non-2xx wraps as AUTH_TOKEN_EXCHANGE_FAILED with user errorHints first and body text second', async () => { | ||
| await expect( | ||
| postTokenEndpoint({ | ||
| url: TOKEN_URL, | ||
| body: new URLSearchParams(), | ||
| errorHints: ['Re-run login'], | ||
| fetchImpl: (() => | ||
| Promise.resolve( | ||
| new Response('invalid_grant', { status: 400 }), | ||
| )) as typeof fetch, | ||
| }), | ||
| ).rejects.toMatchObject({ | ||
| code: 'AUTH_TOKEN_EXCHANGE_FAILED', | ||
| hints: ['Re-run login', 'invalid_grant'], | ||
| }) | ||
| }) | ||
|
|
||
| it('network errors, non-JSON bodies, and responses missing access_token all become AUTH_TOKEN_EXCHANGE_FAILED', async () => { | ||
| const cases: Array<() => Promise<Response>> = [ | ||
| () => Promise.reject(new Error('econnrefused')), | ||
| () => | ||
| Promise.resolve( | ||
| new Response('<html>oops</html>', { | ||
| status: 200, | ||
| headers: { 'Content-Type': 'text/html' }, | ||
| }), | ||
| ), | ||
| () => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify({ refresh_token: 'r' }), { status: 200 }), | ||
| ), | ||
| ] | ||
| for (const fetchImpl of cases) { | ||
| await expect( | ||
| postTokenEndpoint({ | ||
| url: TOKEN_URL, | ||
| body: new URLSearchParams(), | ||
| fetchImpl: fetchImpl as typeof fetch, | ||
| }), | ||
| ).rejects.toMatchObject({ code: 'AUTH_TOKEN_EXCHANGE_FAILED' }) | ||
| } | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| import { CliError, getErrorMessage } from '../../errors.js' | ||
| import type { AuthErrorCode } from '../errors.js' | ||
| import type { PkceLazyString } from './pkce.js' | ||
|
|
||
| /** | ||
| * Build a `CliError` with user-supplied `errorHints` prepended and an optional | ||
| * server-derived `extra` detail appended. Centralises the "user-actionable | ||
| * first, diagnostic second" ordering used everywhere in this directory. | ||
| */ | ||
| export function buildAuthError( | ||
| code: AuthErrorCode, | ||
| message: string, | ||
| userHints: string[] | undefined, | ||
| extra?: string, | ||
| ): CliError { | ||
| const hints = [...(userHints ?? []), ...(extra ? [extra] : [])] | ||
| return new CliError(code, message, hints.length > 0 ? { hints } : {}) | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a literal-or-function endpoint/clientId against the current handshake | ||
| * and runtime flags. Used by every provider in this directory. | ||
| */ | ||
| export function resolve( | ||
| resolver: PkceLazyString, | ||
| handshake: Record<string, unknown>, | ||
| flags: Record<string, unknown>, | ||
| ): string { | ||
| return typeof resolver === 'function' ? resolver({ handshake, flags }) : resolver | ||
| } | ||
|
|
||
| /** Read a response body without letting a stream error escape — used for hints. */ | ||
| export async function safeReadText(response: Response): Promise<string | undefined> { | ||
| try { | ||
| const text = (await response.text()).trim() | ||
| return text.length > 0 ? text : undefined | ||
| } catch { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| export type BuildPkceAuthorizeUrlInput = { | ||
| authorizeUrl: string | ||
| clientId: string | ||
| redirectUri: string | ||
| state: string | ||
| scopes: string[] | ||
| scopeSeparator: string | ||
| codeChallenge: string | ||
| } | ||
|
|
||
| /** Construct the standard PKCE S256 authorize URL. */ | ||
| export function buildPkceAuthorizeUrl(input: BuildPkceAuthorizeUrlInput): string { | ||
| const url = new URL(input.authorizeUrl) | ||
| url.searchParams.set('response_type', 'code') | ||
| url.searchParams.set('client_id', input.clientId) | ||
| url.searchParams.set('redirect_uri', input.redirectUri) | ||
| url.searchParams.set('state', input.state) | ||
| url.searchParams.set('code_challenge', input.codeChallenge) | ||
| url.searchParams.set('code_challenge_method', 'S256') | ||
| if (input.scopes.length > 0) { | ||
| url.searchParams.set('scope', input.scopes.join(input.scopeSeparator)) | ||
| } | ||
| return url.toString() | ||
| } | ||
|
|
||
| /** | ||
| * Per RFC 6749 §2.3.1, the `client_id` and `client_secret` MUST be | ||
| * `application/x-www-form-urlencoded`-encoded before being concatenated with | ||
| * a colon for HTTP Basic Authentication. A literal colon (or any reserved | ||
| * character) in either value would otherwise corrupt the credential. | ||
| */ | ||
| export function encodeBasicAuth(clientId: string, clientSecret: string): string { | ||
| return Buffer.from( | ||
| `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`, | ||
| 'utf8', | ||
| ).toString('base64') | ||
| } | ||
|
|
||
| export type PostAndParseJsonInput = { | ||
| url: string | ||
| headers: Record<string, string> | ||
| /** Pre-encoded request body. */ | ||
| body: string | ||
| /** Error code wrapped around every failure mode. */ | ||
| errorCode: AuthErrorCode | ||
| /** Prefix for error messages, e.g. `'Token endpoint'` or `'Registration endpoint'`. */ | ||
| errorLabel: string | ||
| errorHints?: string[] | ||
| fetchImpl: typeof fetch | ||
| } | ||
|
|
||
| /** | ||
| * POST a request, parse a JSON response, and wrap every failure mode as a | ||
| * typed `CliError`. Common backbone for the OAuth token endpoint and the | ||
| * RFC 7591 dynamic-client-registration endpoint — both POST a body, both | ||
| * expect a JSON reply, both want uniform error handling. | ||
| * | ||
| * Throws `errorCode` with the configured hints on: | ||
| * - network failure (fetch rejection) | ||
| * - non-2xx response (body text appended as a hint after `errorHints`) | ||
| * - non-JSON 2xx body (a misconfigured proxy returning HTML, etc.) | ||
| * | ||
| * Success-shape validation (e.g. `access_token` present) is the caller's | ||
| * job, because it differs per endpoint. | ||
| */ | ||
| export async function postAndParseJson<T>(input: PostAndParseJsonInput): Promise<T> { | ||
| const fail = (message: string, extra?: string): CliError => | ||
| buildAuthError(input.errorCode, message, input.errorHints, extra) | ||
|
|
||
| let response: Response | ||
| try { | ||
| response = await input.fetchImpl(input.url, { | ||
| method: 'POST', | ||
| headers: input.headers, | ||
| body: input.body, | ||
| }) | ||
| } catch (error) { | ||
| throw fail(`${input.errorLabel} request failed: ${getErrorMessage(error)}`) | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| const detail = await safeReadText(response) | ||
| throw fail(`${input.errorLabel} returned HTTP ${response.status}.`, detail) | ||
| } | ||
|
|
||
| // Parse defensively — a misconfigured proxy can return a 2xx HTML error | ||
| // page that would otherwise blow up with a raw SyntaxError. | ||
| try { | ||
| return (await response.json()) as T | ||
| } catch (error) { | ||
| throw fail(`${input.errorLabel} returned non-JSON response: ${getErrorMessage(error)}`) | ||
| } | ||
| } | ||
|
|
||
| export type PostTokenEndpointInput = { | ||
| url: string | ||
| /** Form-encoded body. Caller owns grant_type + grant-specific params. */ | ||
| body: URLSearchParams | ||
| /** When present, sent as `Authorization: Basic base64(clientId:clientSecret)`. */ | ||
| basicAuth?: { clientId: string; clientSecret: string } | ||
| /** | ||
| * User-facing remediation hints attached to every `CliError` this helper | ||
| * throws (network failure, non-2xx, parse failure, missing access_token). | ||
| * The server-returned response body (for non-2xx) is appended after these | ||
| * so user hints stay at the top. | ||
| */ | ||
| errorHints?: string[] | ||
| fetchImpl: typeof fetch | ||
| } | ||
|
|
||
| export type PostTokenEndpointResult = { | ||
| accessToken: string | ||
| refreshToken?: string | ||
| /** Unix-epoch ms. Computed from `expires_in` when the server returns it. */ | ||
| expiresAt?: number | ||
| } | ||
|
|
||
| /** | ||
| * POST to an OAuth 2.0 token endpoint and parse the standard JSON response. | ||
| * The same shape covers `authorization_code` (PKCE / DCR exchange) and | ||
| * `refresh_token` grants — the caller picks the grant by populating `body`. | ||
| * | ||
| * Failures uniformly throw `CliError('AUTH_TOKEN_EXCHANGE_FAILED', …)`: | ||
| * network errors, non-2xx responses (with body text as a hint), non-JSON | ||
| * bodies, and responses missing `access_token`. | ||
| */ | ||
| export async function postTokenEndpoint( | ||
| input: PostTokenEndpointInput, | ||
| ): Promise<PostTokenEndpointResult> { | ||
| const headers: Record<string, string> = { | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| Accept: 'application/json', | ||
| } | ||
| if (input.basicAuth) { | ||
| headers.Authorization = `Basic ${encodeBasicAuth(input.basicAuth.clientId, input.basicAuth.clientSecret)}` | ||
| } | ||
|
|
||
| const payload = await postAndParseJson<{ | ||
| access_token?: string | ||
| refresh_token?: string | ||
| expires_in?: number | ||
| }>({ | ||
| url: input.url, | ||
| headers, | ||
| body: input.body.toString(), | ||
| errorCode: 'AUTH_TOKEN_EXCHANGE_FAILED', | ||
| errorLabel: 'Token endpoint', | ||
| errorHints: input.errorHints, | ||
| fetchImpl: input.fetchImpl, | ||
| }) | ||
| if (!payload.access_token) { | ||
| throw buildAuthError( | ||
| 'AUTH_TOKEN_EXCHANGE_FAILED', | ||
| 'Token endpoint response missing access_token.', | ||
| input.errorHints, | ||
| ) | ||
| } | ||
| return { | ||
| accessToken: payload.access_token, | ||
| refreshToken: payload.refresh_token, | ||
| expiresAt: | ||
| typeof payload.expires_in === 'number' | ||
| ? Date.now() + payload.expires_in * 1000 | ||
| : undefined, | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P3] The generic
resolvehelper and the newDcrProviderOptionsboth rely onPkceLazyString. Consider renaming this type to something grant-agnostic (e.g.OAuthLazyStringorLazyEndpoint) and moving it to_oauth.tsortypes.tsso the shared OAuth utilities and DCR provider don't depend on a PKCE-specific name.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
OAuthLazyStringas a grant-agnostic alias ofPkceLazyStringin c1d9c85, exported from theauthsubpath alongside the original. New code can use the grant-agnostic name; existingPkceLazyStringconsumers stay green. Kept the alias rather than a rename because the original is already part of the published surface (cli-core 0.16.x).