Skip to content

feat(oauth2): PKCE for device-code + configurable opt-out + refresh-backed bearer propagation#24

Open
stephane-segning wants to merge 2 commits into
mainfrom
claude/quizzical-shaw-d229f0
Open

feat(oauth2): PKCE for device-code + configurable opt-out + refresh-backed bearer propagation#24
stephane-segning wants to merge 2 commits into
mainfrom
claude/quizzical-shaw-d229f0

Conversation

@stephane-segning
Copy link
Copy Markdown
Contributor

Why

Two bugs surfaced bringing up a Keycloak device_code provider distributed via .well-known/opencode:

  1. Device-code login 400s under PKCE enforcement. The device-code flow never sent PKCE, so a Keycloak client with Proof Key for Code Exchange Code Challenge Method set rejected the device-authorization request with invalid_request: Missing parameter: code_challenge_method. (authorization_code already did PKCE; device_code was the gap.)

  2. @vymalo/opencode-models-info 401s fetching meta.modelsInfoUrl. Config-time bearer propagation read the raw cached token behind a fixed 30s expiry skew. On a first interactive login against a short-lived realm, the just-minted token already fell inside that window, so options.headers.Authorization was never stamped and models-info fetched the OAuth2-protected metadata endpoint with no token → models_info_fetch_failed_no_cache (HTTP 401).

What changed

PKCE

  • Both interactive flows (authorization_code + device_code) send PKCE by default and replay the code_verifier on the token exchange/poll.
  • New pkce?: boolean server option (default true), parsed from both config shapes, as an opt-out for non-compliant IdPs that reject the extra parameters. Compliant servers ignore PKCE, so it stays on unless explicitly disabled.

Bearer propagation

  • propagateCachedBearer now goes through a refresh-only ensure (ensureAccessToken(id, { interactive: false })): valid tokens pass through, near-expiry ones are transparently refreshed, and anything that would need a fresh browser/device-code prompt is skipped (oauth2_bearer_propagation_skipped_no_token) rather than blocking startup. Removed the dead skew constant/helper.
  • ensureAccessToken gained an interactive param so config-time propagation never triggers a second login.

Docs

  • New docs/well-known.md — how .well-known/opencode distributes a provider + plugin setup: the auth/config blocks, the echo plugin-managed placeholder-key pattern, where config and tokens actually live (re-fetched from the server each launch; only a pointer in auth.json; the OAuth token in the plugin cache), the CLI, gotchas, and how to serve your own.
  • architecture.md (config step 6 rewrite + PKCE section + new log event), models-info.md (TTL/no-cache/seeding answer — cache is already 1-day TTL by default), troubleshooting.md (two symptom-keyed entries), oauth2 README (pkce row).

Tests

+4 (159 total passing): device-code PKCE opt-out; config pkce default/false/reject; and a regression test proving a near-expiry token is refreshed during propagation so the header carries the fresh token.

Build, typecheck, lint, and format all clean.

🤖 Generated with Claude Code

…ked bearer propagation

Two fixes surfaced bringing up a Keycloak device-code provider distributed
via .well-known/opencode:

- The device-code flow never sent PKCE, so Keycloak clients with PKCE
  enforced rejected the device-authorization request with
  `Missing parameter: code_challenge_method`. Both interactive flows
  (authorization_code + device_code) now send PKCE by default and replay the
  code_verifier on the token exchange/poll. Add a `pkce` server option
  (default true) as an opt-out for non-compliant IdPs.

- Config-time bearer propagation read the raw cached token behind a fixed 30s
  expiry skew. On a first interactive login against a short-lived realm the
  just-minted token fell inside that window, so the Authorization header was
  never stamped and @vymalo/opencode-models-info fetched an OAuth2-protected
  meta.modelsInfoUrl with no token (HTTP 401). Propagation now goes through a
  refresh-only ensure (ensureAccessToken with interactive:false): valid tokens
  pass through, near-expiry ones are refreshed, and anything needing a fresh
  prompt is skipped rather than blocking startup.

Docs: new docs/well-known.md explaining the .well-known/opencode distribution
mechanism (auth/config blocks, the placeholder-key pattern, where config and
tokens actually live); architecture/models-info/troubleshooting updates for
PKCE, the propagation change, and the models-info cache TTL / no-cache 401.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces PKCE support by default for interactive OAuth2 flows (authorization_code and device_code) with an opt-out configuration, and updates bearer token propagation to use a refresh-only token retrieval mechanism instead of a raw cache read, preventing downstream 401 errors. It also adds extensive documentation on configuration distribution and troubleshooting. Feedback suggests adding a debug log when a retrieved token is empty to improve observability, and executing bearer propagation in parallel using Promise.all to prevent sequential network requests from delaying startup.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/opencode-oauth2/src/opencode.ts
Comment thread packages/opencode-oauth2/src/opencode.ts Outdated
…g-time propagation

Address PR review feedback:
- Emit `oauth2_bearer_propagation_skipped_empty_token` when ensureAccessToken
  resolves without a usable token, so a downstream 401 isn't a silent mystery.
- Fan out per-provider bearer propagation with Promise.all instead of a
  sequential await loop — refresh round trips (up to httpTimeoutMs) no longer
  serialize startup behind a slow IdP. Providers are independent and
  propagateCachedBearer swallows its own errors, so this never rejects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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