Skip to content

feat(gatekeeper-kit): oauth 2.0 token client - #555

Merged
ndisidore merged 2 commits into
mainfrom
nathan/gatekeeper-kit-oauth-refresh
Sep 23, 2026
Merged

ndisidore merged 2 commits into
mainfrom
nathan/gatekeeper-kit-oauth-refresh

Conversation

@ndisidore

Copy link
Copy Markdown
Member

Adds @gadgets/gatekeeper-kit/oauth-client, a provider-independent OAuth 2.0 token-endpoint client. It includes:

  • PKCE;
  • a hardened code exchange, refresh and revoke;
  • OAuthResponseError, with isInvalidGrant as the default proof of grant death;
  • oauthRefresh and mergeOAuthTokens, which adapt it onto CredentialCoordinator.

gatekeeper-cloudflare pilots it, and only its token code changes.

The PR has two commits so that each can be reviewed alone:

  1. the kit leaf, its tests, README, USAGE and plans/gatekeeper-kit.md;
  2. the Cloudflare pilot.

Nothing touches workshop-backend or workshop-shared. Discovery and DCR are deferred.

@github-actions github-actions Bot added the gatekeeper Changes to a gatekeeper integration label Sep 23, 2026
@ndisidore ndisidore changed the title Nathan/gatekeeper kit oauth refresh feat(gatekeeper-kit): oauth 2.0 token client Sep 23, 2026
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 23, 2026

Copy link
Copy Markdown

Submitted one review with 2 actionable inline findings: a refresh race across Cloudflare reconnects and an OAuth Basic-auth encoding issue.

github run

@github-actions

Copy link
Copy Markdown

Preview: pr555-nathan-gateke-867afd73

https://pr555-nathan-gateke-867afd73-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-oauth-refresh branch from 609347f to efd1f74 Compare September 23, 2026 14:20
@ndisidore
ndisidore marked this pull request as ready for review September 23, 2026 14:20
@ask-bonk

This comment was marked as outdated.

devin-ai-integration[bot]

This comment was marked as resolved.

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-oauth-refresh branch from efd1f74 to 13a6432 Compare September 23, 2026 14:32
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-oauth-refresh branch from 13a6432 to 2527251 Compare September 23, 2026 14:44
@ask-bonk

ask-bonk Bot commented Sep 23, 2026

Copy link
Copy Markdown

No new actionable findings on the current head. The unresolved Cloudflare refresh-failure classification concern is already covered by an existing inline review comment.

github run

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-oauth-refresh branch from 2527251 to 3bed2a2 Compare September 23, 2026 15:33
@ask-bonk

ask-bonk Bot commented Sep 23, 2026

Copy link
Copy Markdown

No new actionable findings. The existing inline review comments cover the remaining concerns on this PR.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

Every public gatekeeper hand-rolls its token exchange, refresh and
revoke. None of them follows redirects manually, caps the response
body or bounds the request with a timeout, and each classifies failures
its own way. `./oauth-client` is the provider-independent protocol
client from the internal Access hardening, ported without its session
layer, which duplicates leaves the kit already ships.

- `OAuthClient`: authorize URL, code exchange, refresh, RFC 7009
  revoke, and an unreserved `request()` escape hatch. Every request uses
  `redirect: "manual"` (following a 307/308 would re-POST the secret and
  code), a timeout combined with the caller's signal, and
  `readTextCapped`. Client auth is `none`, `basic` or `post`. `basic`
  sends raw `base64(id:secret)` by default, as the existing gatekeepers
  and providers' docs do; `encoding: "form"` sends RFC 6749 §2.3.1's
  form-encoded pair to a server that decodes it, such as Ory Hydra. A
  raw id containing `:` is rejected (RFC 7617).
- `OAuthResponseError` carries `httpStatus` and `oauthError`, and
  deliberately not `status` or `code`: `isNoAccessError` duck-types
  `status` and credential marks match `code`, so a token-endpoint 401
  would otherwise read as "user lacks access" and a provider body could
  spoof `CredentialsExpiredError`. Neither its message nor its
  enumerable props carry provider text beyond a validated `error` code.
  A 2xx body carrying an `error` and no non-empty `access_token` is a
  rejection.
- `isInvalidGrant` is the default proof of grant death: `invalid_grant`
  returned to a refresh, below HTTP 500, other than 429. `invalid_client`
  and the other codes are operator or provider faults under static
  client credentials, so they are rethrown unless a gatekeeper opts in.
- `oauthRefresh` adapts a client onto `CredentialCoordinator` as a
  `RefreshCredentials<Creds>`, and `mergeOAuthTokens` is the canonical
  merge: it keeps an unrotated refresh token and unreported scopes, and
  replaces `expiresAt` rather than carrying a stale one forward. A
  refresh that requests scopes reports them when the response omits
  `scope` (RFC 6749 §5.1), so a narrowing is never recorded as the
  wider grant it gave up.
- PKCE S256 (`createPkce`, `pkceChallenge`), plus `parseTokenResponse`
  for nested or renamed payloads.

A workerd test drives it end to end through the connect handshake and
the coordinator's fence, adjudication and revoke-on-discard paths.

README, USAGE and the plan now state one grant-death doctrine keyed on
the error code. Discovery and dynamic client registration stay
deferred.
@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-oauth-refresh branch from 3bed2a2 to 6573d18 Compare September 23, 2026 15:42
@ask-bonk

ask-bonk Bot commented Sep 23, 2026

Copy link
Copy Markdown

LGTM!

github run

devin-ai-integration[bot]

This comment was marked as resolved.

Behavior change: a refresh that fails for a reason that says nothing
about the grant -- a 5xx, a 429, a 4xx without an OAuth error such as a
WAF challenge, a network error, a timeout, a redirect or a malformed
response -- no longer marks the account expired. Until now any failed
refresh set the sticky `credentialsExpired` flag, which hid the account
from binding pickers and asked for a reconnect that fixed nothing. Nor
does `invalid_client`, which is the deployment's own client failing
authentication: a bad `CLIENT_SECRET` expired every account that
refreshed, and no reconnect could fix it. Every other OAuth error in a
4xx still expires the account, as before: how Cloudflare rejects a
revoked grant is unverified, so `isGrantDeath` widens the kit's
`isInvalidGrant` to those until a live test justifies narrowing it.

A transient failure serves the cached token while it is unexpired, and
otherwise throws the real error. `describe()` and
`getUsableAccessToken()` still degrade to null; the other callers see
the error rather than a synthetic expiry.

`getAccessToken` now coalesces concurrent refreshes of a grant, so a
rotating refresh token is never redeemed twice (the loser's
`invalid_grant` would be a false death). A refresh that a reconnect or
revoke overtook is discarded, even when the reconnect reuses the
refresh token, and a read after the reconnect starts its own refresh
rather than joining it, as the kit's `CredentialCoordinator` does. Both
failure paths are logged, as `credentials.refresh.expired` and
`credentials.refresh.failed`.

The stored token shape is unchanged.
@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-oauth-refresh branch from 6573d18 to a33480c Compare September 23, 2026 15:46
@ask-bonk

ask-bonk Bot commented Sep 23, 2026

Copy link
Copy Markdown

LGTM!

github run

@ndisidore
ndisidore merged commit 5e8f42a into main Sep 23, 2026
14 checks passed
@ndisidore
ndisidore deleted the nathan/gatekeeper-kit-oauth-refresh branch September 23, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants