Fix: degrade instead of throwing when an OAuth2 credential exchange fails - #954
Open
AmaadMartin wants to merge 8 commits into
Open
Fix: degrade instead of throwing when an OAuth2 credential exchange fails#954AmaadMartin wants to merge 8 commits into
AmaadMartin wants to merge 8 commits into
Conversation
added 8 commits
August 11, 2026 12:12
The OAuth2 exchanger rejected on every failure of the remote token endpoint,
so a 503 aborted the whole invocation. adk-python returns the original
credential with was_exchanged=False and lets the run continue.
Each remote or runtime failure now logs and returns
{credential, wasExchanged: false}. A missing authScheme and a detected state
mismatch still throw CredentialExchangeError. The state comparison moved out
of the try block so a mismatch is no longer rewrapped as a parse failure.
…ract Nine cases asserted the rejection that the previous commit deliberately replaced. Each now asserts the degrade contract: wasExchanged is false, the result carries the same credential object, and it holds no access token. A guard case also asserts that no token request went out. The two cases that must keep rejecting -- a missing authScheme and a state mismatch -- are unchanged.
…rade Adds three cases: an unparseable authResponseUri degrades before the mismatch comparison runs, a state mismatch carries the mismatch message and not the parse-failure message, and exchange() still degrades through its delegation to the authorization code path.
With the exchanger degrading, a failed exchange returned a credential with no access token. ToolAuthHandler cached it, and the read path returns a cached credential verbatim as 'done', so one transient token endpoint error stranded the tool for the rest of the session. The store is now gated on externalExchangeRequired(), ported from adk-python. The predicate is false for apiKey, http and serviceAccount credentials, so they are still cached as before.
AuthHandler.parseAndStoreAuthResponse() now resolves and stores the unexchanged credential for both an oauth2 and an openIdConnect scheme, with only fetchOAuth2Tokens stubbed so the real exchanger runs. RestApiTool.runAsync() gets the end-to-end case: the token endpoint answers 503, the API answers 200, and the call resolves with the API response instead of rejecting.
Closes the last uncovered branch in the state read: a response that carries no state parameter must not be treated as matching.
The degrade swallowed the SSRF guard as well as remote failures, so a tokenUrl pointing at a link-local address looked exactly like a 503 and the tool went on unauthenticated. A disallowed endpoint is a configuration or tampering signal, so it belongs with the state mismatch on the throwing side. fetchOAuth2Tokens now throws OAuth2EndpointNotAllowedError, and both catches rethrow it. Its type guard matches on the error name rather than instanceof, so it still holds when a runtime loads two copies of the package. Both catches also stop logging: fetchOAuth2Tokens already logs the failure with its endpoint context, so every network error produced two lines for one event.
tool_auth_handler_test.ts already owns the AutoAuthCredentialExchanger mock, so a second file re-established it for no reason. The three cases and the real Context helper move across unchanged; the file's mock now shares one hoisted exchange function, which lets a case set its own result without a cast.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A
Problem: The OAuth2 credential exchanger rejected on every failure, so a 503 from the token endpoint aborted the whole invocation. None of its three callers catches. adk-python returns the original credential with
was_exchanged=Falseand the run continues, and the siblingOAuth2CredentialRefresherin this repo already degrades the same way.Solution: Each failure that depends on the remote server or on runtime credential data now logs and returns
{credential, wasExchanged: false}. Two conditions still throwCredentialExchangeError: a missingauthScheme, which is a programmer error, and a detectedstatemismatch, which is a tampering signal.ToolAuthHandlerno longer caches a credential that still needs a token, otherwise one transient failure would strand a tool on a token-less credential for the whole session.Notes for the reviewer:
exchange()in atry/catchto detect a failed exchange should checkwasExchangedinstead. No signature, type or export changed.fetchOAuth2Tokens()now throwsOAuth2EndpointNotAllowedErrorwhen the SSRF guard rejects an endpoint, and both catches rethrow it. AtokenUrlpointing at a link-local or loopback address is a configuration or tampering signal, so it must not look like a 503 and let the tool proceed unauthenticated. This is a deliberate deviation from the spec, which asked for a log and a degrade there. Its type guard matches on the errornamerather thaninstanceof, so it still holds when a runtime has loaded two copies of the package.34b99476). They asserted the rejection that this change deliberately replaces. The two cases that must keep rejecting,throws CredentialExchangeError if authScheme is missingandthrows CredentialExchangeError if state in authResponseUri does not match expected state, are untouched.statecomparison moved out of thetryblock. It previously sat inside, so its owncatchrewrapped a detected CSRF attempt asFailed to parse authResponseUri for state validation.authResponseUri. Only the error'smessageis logged, and Node's URL error message is the constantInvalid URL.gh pr list --repo AmaadMartin/adk-js --state open --limit 300plusgh pr diff --name-onlyon every adjacent PR. No open PR implements this change. Fix: discriminate auth schemes by type in determineGrantType #773 edits the same file but onlydetermineGrantType(), which this change does not touch, so this branch starts frommain.oauth2branch inapplyCredential(), andToolAuthHandlernot catchingServiceAccountCredentialExchangerthrows.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
npx vitest run --project unit:core core/test/auth core/test/tools/openapi_tool— 21 files, 296 tests passed. This includes the untouchedoauth2_credential_refresher_test.tsandauth_preprocessor_test.ts.npm run lint,npm run format:check— clean.npm run ts:checkreports 47 files with errors, the same 47 as onmain; none of them is a file in this diff.Coverage of the three changed source files, measured with
--coverage.includeon those files:oauth2_credential_exchanger.tsandtool_auth_handler.tsare at 100% of statements, lines and functions, with 98.18% and 95.65% of branches;oauth2_utils.tsis at 98.13%, with the new error class and its guard fully covered. Three branches fall short. One is the non-Errorarm of thenew URL()catch, which cannot run becauseURLonly throwsTypeError. The other two are pre-existing lines ingetCredentialKey()andgetTokenEndpoint()that this change does not touch.Proof that the new tests fail against the unfixed code. Four mutations, each reverted afterwards:
throw new CredentialExchangeErrorin thefetchOAuth2Tokenscatch ofexchangeAuthorizationCode(). Six tests fail, including the end-to-end one:RestApiTool > resolves with the API response when the OAuth2 token exchange failsfails withError: Failed to exchange tokens: Token request failed with status 503, and bothAuthHandlercases fail withpromise rejected "Error: Failed to exchange tokens: Token r…" instead of resolving.statecomparison back inside thetry. Two tests fail withAssertionError: promise resolved "{ …(2) }" instead of rejecting.externalExchangeRequired()gate from the cache write.does not cache an OAuth2 credential whose exchange failedfails onexpect(context.state.get('oauth2_existing_exchanged_credential')).toBeUndefined(). The other two cases still pass, which shows the gate does not widen into the paths it must not touch.OAuth2EndpointNotAllowedErrorrethrow from both catches. Bothrethrows when the SSRF guard rejects the token endpointcases fail withAssertionError: promise resolved "{ …(2) }" instead of rejecting.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
core/test/tools/openapi_tool/rest_api_tool_test.tscovers this without mocks of ADK code: aRestApiToolwith anoauth2scheme drives the realToolAuthHandlerand the real exchanger,globalThis.fetchanswers 503 at the token endpoint and 200 at the API, andrunAsync()resolves with the API response. The outgoing API request carries noAuthorizationheader, which matches adk-python today.To reproduce by hand, point an
oauth2tool'sauthorizationCode.tokenUrlat an unreachable host and run the tool. Before this change the call rejected. Now it logsFailed to fetch OAuth2 tokens: <message>once and returns the API's own response.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.