Skip to content

feat(auth): emit a PKCE S256 code challenge on the OAuth2 authorization URI - #820

Open
AmaadMartin wants to merge 3 commits into
google:mainfrom
AmaadMartin:feat/auth-handler-pkce-s256-code-challenge
Open

feat(auth): emit a PKCE S256 code challenge on the OAuth2 authorization URI#820
AmaadMartin wants to merge 3 commits into
google:mainfrom
AmaadMartin:feat/auth-handler-pkce-s256-code-challenge

Conversation

@AmaadMartin

Copy link
Copy Markdown
Collaborator

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

N/A

2. Or, if no issue exists, describe the change:

Problem:

adk-js implements only half of PKCE (RFC 7636). The token exchange already
sends code_verifier (core/src/auth/oauth2/oauth2_utils.ts), but
AuthHandler.generateAuthUri() puts no code_challenge on the authorization
URI. A provider therefore has nothing to bind the verifier to, and the
code_verifier sent at exchange time is a value the provider cannot check.
The credential's audience and nonce are dropped from the URI as well.
adk-python's AuthHandler.generate_auth_uri emits all three.

Solution:

generateAuthUri() now derives the S256 challenge from the credential's code
verifier, generates a verifier with node:crypto's CSPRNG when the caller
supplies none, and returns that verifier on the exchanged credential so the
later token exchange can send it. It rejects any code challenge method other
than S256, and forwards audience and nonce when set. The two PKCE helpers
live in auth/oauth2/oauth2_utils.ts, next to the code_verifier half of the
flow.

Behaviour is unchanged unless the credential sets codeChallengeMethod: a
credential that requests no method produces the same authorization URI as
before. Only the challenge and the method reach the URI; the verifier itself is
never placed on it and is never logged.

Notes for the reviewer:

  • The hash is synchronous on purpose. generateAuthUri() is reached from
    the synchronous public CallbackContext.requestCredential(), so
    crypto.subtle.digest is unusable. It uses node:crypto, which the web
    bundle aliases to core/src/utils/crypto_shim.ts. The two new shim stubs
    throw, following the file's existing randomUUID contract, so PKCE is
    unavailable in the bundled web build. That is a documented limitation rather
    than a regression — PKCE works in no build today — and the alternative is an
    async public API. The shim throws rather than emit an authorization request
    with a missing or forged challenge. npm run build:bundle succeeds and the
    bundle contains no node:crypto reference.
  • Parity vs. local convention. Wire-visible strings match Python exactly
    (code_challenge, code_challenge_method, S256, audience, nonce). The
    error message uses the JS field name codeChallengeMethod and throws
    Error, like every other guard in the file.
  • A verifier without a method emits no challenge. This reproduces authlib's
    behaviour, which Python relies on.
  • The generated verifier is 36 CSPRNG bytes encoded as base64url: 48
    unreserved characters, inside the 43–128 range RFC 7636 requires, with no
    modulo bias.
  • No suppressions, no any, no new dependency.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.
npx vitest run --project unit:core core/test/auth/   # 14 files, 217 tests, all pass
npm run build && npm run ts:check && npm run ts:check:samples
npm run lint && npm run format:check                 # all clean

17 new cases in core/test/auth/auth_handler_test.ts, 7 in
core/test/auth/oauth2/oauth2_utils_test.ts, and a no-mocks round trip in
core/test/auth/pkce_round_trip_test.ts. The four existing generateAuthUri
tests are unmodified; no existing test was weakened or removed.

New source lines are at 100% line and branch coverage.

Proof the tests can fail. Each mutation was applied to the source and
reverted:

Mutation Result
Delete searchParams.set('code_challenge', ...) 3 failed — expected null to be 'HocX9xaevoNbL5le-QirVOjee…'
Send the raw verifier as the challenge (plain, not S256) 3 failed — expected '6w2bQx644JZ1dFssOF_3J3Wu0Uor…' to match /^[A-Za-z0-9_-]{43}$/
Delete the nonce forwarding 1 failed — expected null to be 'n-0S6'
Delete the unsupported-method guard 2 failed — expected [Function] to throw an error
Delete the audience forwarding 1 failed — expected null to be 'https://api.example.com'
digest('base64') instead of digest('base64url') 6 failed — expected '…OD9+DWVAoUkuWkK0CITuX…' to match /^[A-Za-z0-9_-]{43}$/
Delete code_challenge (round trip only) 1 failed — expected null to be 'HgM2b6EgtYh0tVg2vOZhBrKbi…'
Return the raw verifier field instead of the generated one 1 failed — expected an auth URI and a code verifier

Manual End-to-End (E2E) Tests:

A live provider is not needed. core/test/auth/pkce_round_trip_test.ts runs the
real flow with no mocks: it builds the authorization URI through
AuthHandler.generateAuthRequest(), feeds the returned verifier into
createOAuth2TokenRequestBody(), and performs the check the provider performs —
that the URI's code_challenge is the SHA-256 of the body's code_verifier.

To verify by hand: build an OAuth2 credential with codeChallengeMethod: 'S256',
call generateAuthUri(), and confirm against any RFC 7636 checker that
base64url(sha256(codeVerifier)) equals the URI's code_challenge.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

PKCE stays opt-in: it activates only for credentials that set
codeChallengeMethod: 'S256'. Callers that want the protection have to request
it; nothing in this change turns it on for an existing credential.

Amaad Martin added 3 commits August 25, 2026 12:00
adk-js already sent code_verifier at token exchange, but the authorization
request carried no code_challenge, so the provider had nothing to bind the
verifier to. generateAuthUri now derives the S256 challenge and forwards
audience and nonce, matching adk-python's AuthHandler.generate_auth_uri.

The SHA-256 must be synchronous because generateAuthUri is reached from the
synchronous public requestCredential API, so it uses node:crypto rather than
the async crypto.subtle.digest. The browser shim throws instead of degrading.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant