Skip to content

Test OAuth renewal and revocation through a credential adapter - #2244

Open
GeiserX wants to merge 14 commits into
UsefulSoftwareCo:v2from
GeiserX:v2-port/credentials-renew-e2e
Open

GeiserX wants to merge 14 commits into
UsefulSoftwareCo:v2from
GeiserX:v2-port/credentials-renew-e2e

Conversation

@GeiserX

@GeiserX GeiserX commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Tests for #2243. This branch carries that PR's commits because a fork PR cannot target a branch in this repo. Merge that one first and the diff shrinks to the e2e files.

A fake adapter in e2e/support/credential-adapter.ts seals grants with placeholders for the refresh token and client secret. Three self-host scenarios check that renewal through it keeps the rotated refresh token across a host restart and an interrupted save, including a numeric string expires_in and a raw token body with a null member and a scope array; that its refusals and outages are classified like the service's own answers, with only invalid_grant or a missing refresh token reconnecting; and that deleting the account revokes the token the adapter holds. Each assertion went red first against a one-line mutation (the renew branch removed, the revoke branch removed, the refusal status dropped, the save point removed, the renewable guard removed, the string expires_in decode reverted, the token normalisation removed) and green with the fix.

Not covered: destination_blocked on the store path, the revoke outcomes unsupported, no_token and failed, subject_changed and challenge answers, the adapter's 30 second timeout, client_credentials grants under a store, and the cloud target.

Claude Code agents wrote and iterated the spec against the v2 tree under my direction. On a Mac mini the 20 scenarios in the files below passed together with bun run e2e:self-host --test-name, the renewal scenario taking about 38 s against its 60 s deadline, the local scenario passed with bun run e2e:local --test-name, and bun run check was green.

oauth-credential-adapter.spec.ts
oauth-refresh-resilience.spec.ts
oauth-renewal-interruption.spec.ts
oauth-revocation.spec.ts
oauth-scheduled-renewal.spec.ts
oauth-client-metadata.spec.ts
local-oauth-renewal-interruption.spec.ts

GeiserX added 14 commits October 9, 2026 19:01
…gh a failed save

A grant with no refresh token now reconnects before the host asks the store, as it does on the
AES path and as usable reports. The store's renewed seal is written under the claim before the
host re-seals it, so an adapter failure in that window leaves a claim that resumes from the
rotated token instead of the replaced one. The adapter URL may not carry a query, the URL error
moves to contracts, and the docs say the encryption key is still read.
… grants skip the store

The renewal scenario now fails the host's re-seal once after the issuer rotated the token and
checks that the next call resumes from the adapter's new seal. The refusals scenario connects an
account without a refresh token and checks that it reconnects without a request to the adapter.
The fake counts an outage as no renewal and recognises a grant by all four of its keys.
… migration

The adapter paragraph had split the accounts and deployments paragraph in two. It
now follows that paragraph, wrapped like its neighbours, and says that turning
the setting on or off after accounts exist leaves their records unreadable. The
renew docs now say the store's encrypt must keep its own placeholders, and the
comments state that a seal the host cannot open needs a new sign-in and that an
interrupted store renewal can wait for four store calls.
The title claimed the host's responses and traces never carry the refresh
token, which the AES path also satisfies. It now names what the scenario
shows: the adapter makes every renewal and keeps the rotated token, a restarted
host renews from the adapter's seal, and an interrupted renewal resumes from
the adapter's new seal.
… refusal rule exactly

The host's own refresh accepts "expires_in":"3600" through oauth4webapi, so the store
path now does too; before, the renewal failed as incompatible after the service had
already rotated the refresh token. The CredentialsRenewalRefused doc now says what the
classifier does: invalid_grant ends the grant only with the service's status.
The fake adapter can now pass the service's expires_in on as a string. The renewal
scenario checks that such a renewal succeeds and keeps the rotated refresh token and the
lifetime. The 500 after a failed reseal is asserted by status only, and the scenario
titles are shorter.
A credentials store that passed the service's body as it came, with
expires_in: null or a scope array, failed renewal before the resealed
grant was saved, so a rotated refresh token was lost. The store path now
runs the tokens through the same normalizedTokens the host applies to its
own token response, and refuses a negative expires_in as invalid_response
as oauth4webapi does on the host path instead of reading it as no lifetime.
…a loose token body

The previous commit dropped the CredentialsError assertion from the reseal
outage step without cause: the failed decrypt of the store's new seal still
reaches the caller as a CredentialsError, a declared 500 on tool calls, so
the step again fails on any other 500.

A new last step has the service answer the refresh with expires_in: null
and a scope array, which the adapter passes on as it came. The renewal must
still succeed with the token the rotated refresh token bought.

This branch has not been deployed

No deployments
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