Fix: discriminate auth schemes by type in determineGrantType - #773
Open
AmaadMartin wants to merge 4 commits into
Open
Fix: discriminate auth schemes by type in determineGrantType#773AmaadMartin wants to merge 4 commits into
AmaadMartin wants to merge 4 commits into
Conversation
added 4 commits
August 7, 2026 05:51
Eight fixtures in the OAuth2 credential exchanger test used `as AuthScheme`
to compile objects that no member of the AuthScheme union accepts: they
carried a `flows` or `grantTypesSupported` field and no `type` discriminant.
Retyping them to `const authScheme: AuthScheme = {...}` removes the
assertion and drops the compiler's approval back in.
The suite stays green against the unchanged source, which shows the fixtures
were only mistyped.
The `{} as AuthScheme` fixtures in the exchangeClientCredentials and
exchangeAuthorizationCode describes are left alone. Those functions never
call determineGrantType and getTokenEndpoint is mocked for them.
determineGrantType() decided which OAuth2 grant type a scheme describes by looking for a `flows` or a `grantTypesSupported` property, then asserting the value to OpenIdConnectWithConfig. An `http` or `apiKey` scheme that carries either property name was classified as OAuth2 or OpenID Connect, and the exchanger ran a real token exchange against it. The function now branches on the OpenAPI `type` discriminant through two new type guards, isOAuth2Scheme and isOpenIdConnectWithConfigScheme. This matches adk-python, which branches on isinstance. Both unchecked assertions are gone. Every scheme that satisfies the AuthScheme union keeps its old result, so no correctly-typed caller changes behaviour. A scheme with a foreign `type` now returns undefined, and the exchanger logs the existing warning. The guards are internal. They are not added to common.ts or index.ts.
Three changes to the guard surface added by the previous commit. isOAuth2Scheme is gone. SecuritySchemeObject is a union discriminated on a literal `type`, so `authScheme.type === 'oauth2'` already narrows to OAuth2SecurityScheme with no predicate. getTokenEndpoint in the same feature has always relied on that. The helper bought nothing. The OIDC predicate was unsound. OpenIdConnectWithConfig declares authorizationEndpoint and tokenEndpoint as required, but the check is on `type` alone, so the predicate handed callers a `tokenEndpoint: string` that can be absent at runtime, and a JSDoc paragraph asked them to remember. The narrowed type is now `OpenIdSecurityScheme & Partial<OpenIdConnectWithConfig>`, which is what the check actually proves, so the compiler enforces the caveat and the prose is deleted. Reading `scheme.tokenEndpoint.length` after the guard is now TS18048 instead of a runtime TypeError. The name loses `WithConfig` because the guard no longer asserts one. getTokenEndpoint now uses the guard. Its two `(authScheme as OpenIdConnectWithConfig)` casts read a field the type never verified, which is the defect this branch exists to remove, and they sat one function away from it. Removing them also gives the exported guard a second caller.
Retyping the fixtures replaced the `{} as AuthScheme` case with a valid
apiKey scheme, which left the untyped-object path untested. That path is
exactly what the `type` discriminant changed: before the fix an object with
no `type` but a `flows` or `grantTypesSupported` field was classified as
OAuth2 or OpenID Connect.
Three cases cover it now, alongside the apiKey scheme rather than instead of
it. They use `as AuthScheme` on purpose, because the input under test is data
that does not satisfy the union.
This was referenced Aug 9, 2026
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
None.
Problem:
determineGrantType()decided which OAuth2 grant type a schemedescribes by looking for a
flowsor agrantTypesSupportedproperty, thenasserting the value to
OpenIdConnectWithConfig. AnhttporapiKeyschemethat carries either property name was classified as OAuth2 or OpenID Connect,
and
OAuth2CredentialExchanger.exchange()then ran a real token exchangeagainst it.
getTokenEndpoint()in the same feature readstokenEndpointthrough the same kind of cast. The assertions are what hid all of this from the
compiler.
Solution:
determineGrantType()now branches on the OpenAPItypediscriminant. The OAuth2 arm needs no helper, because
SecuritySchemeObjectisa discriminated union and
authScheme.type === 'oauth2'narrows on its own.The OIDC arm does need one, since plain narrowing does not reach the
configuration fields, so this adds
isOpenIdConnectSchemenarrowing toOpenIdSecurityScheme & Partial<OpenIdConnectWithConfig>— what the checkactually proves.
getTokenEndpoint()uses the same guard, which removes thelast two casts.
Why the guard is
PartialOpenIdConnectWithConfigdeclaresauthorizationEndpointandtokenEndpointas required, but a
type === 'openIdConnect'check proves neither. A predicateof
scheme is OpenIdConnectWithConfigwould therefore be anasin disguise:it hands the caller a
tokenEndpoint: stringthat is absent at runtime. ThePartialintersection keepstypeandopenIdConnectUrlrequired and makesthe configuration fields optional, so the compiler enforces the caveat instead
of a doc comment asking the reader to remember it.
Verified, with the guard applied and the field read unchecked:
That line compiled clean under a
scheme is OpenIdConnectWithConfigpredicateand threw at runtime.
Behaviour change
Every value that satisfies the
AuthSchemeunion keeps its old result. Nounion member can reach a changed row without an
asassertion, so nocorrectly-typed caller in the repository can regress.
getTokenEndpoint()isunchanged in behaviour; only its casts are gone.
{type: 'oauth2', flows: {clientCredentials: …}}CLIENT_CREDENTIALS{type: 'oauth2', flows: {authorizationCode: …}}AUTHORIZATION_CODE{type: 'oauth2', flows: {implicit: …}}IMPLICIT{type: 'oauth2', flows: {password: …}}PASSWORD{type: 'oauth2', flows: {}}undefined{type: 'openIdConnect', …, grantTypesSupported: ['client_credentials']}CLIENT_CREDENTIALS{type: 'openIdConnect', …, grantTypesSupported: ['authorization_code']}AUTHORIZATION_CODE{type: 'openIdConnect', …}with nograntTypesSupportedundefined{type: 'apiKey' | 'http' | …}with no stray fieldsundefined{type: 'http' | 'apiKey' | …}carryingflowsflowsundefined{type: 'http' | 'apiKey' | …}carryinggrantTypesSupportedundefinedtype, carrying either fieldundefinedThe last three rows are the fix. A caller that passes an untyped scheme object
today now gets
undefinedand the existingUnsupported OAuth2 grant typewarning from
logger.warn. That is deliberate.Scope
&& authScheme.flowsand&& authScheme.grantTypesSupportedare both kept, sothe classifier moves one thing only: the discriminant. The resulting divergence
from
adk-python, which defaults an OIDC scheme with no advertised grant typesto
AUTHORIZATION_CODE, is left alone. A new test pins the currentundefinedso a later change to it has to be deliberate.
getTokenEndpoint()was going to be a follow-up. I folded it in because it istwo lines, it carries the same defect, and it is what gives the exported guard
a second caller.
auth_handler.tsalso discriminates structurally and isuntouched; PR #691 is already in that file.
The guard is internal. It is not added to
common.tsorindex.ts.Collision check
I listed every open PR on this fork and read the changed-file list of all of
them. None touches
auth_schemes.ts,oauth2_credential_exchanger.tsoroauth2_utils.ts. The nearest neighbours are #741 (types incore/test/auth/exchanger/credential_exchanger_test.ts), #772(
tool_auth_handler.ts) and #691 (auth_handler.ts).Testing Plan
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Why existing fixtures were edited
The repository convention is to add a test rather than rewrite one. Eight
fixtures in
oauth2_credential_exchanger_test.tsare the documented exception,so I am naming them rather than slipping them in. They used
as AuthSchemetocompile objects that no member of the
AuthSchemeunion accepts: aflowsorgrantTypesSupportedfield and notype. They encoded the bug as thecontract.
No assertion was touched. Only the fixture literals changed, and four of the
eight cannot pass at all once the discriminant is checked. The commit split is
the proof:
b8c06286retypes the fixtures and touches no source file, and thesuite stays green against the unfixed
determineGrantType;223f26a2makesthe behaviour change. No test was deleted, skipped or weakened.
6903ae99puts back the one case the retyping did drop. The old{} as AuthSchemefixture became a validapiKeyscheme, which left theuntyped-object path — precisely the input whose behaviour changed — uncovered.
Three cases now cover it, alongside the
apiKeyscheme rather than instead ofit. They use
as AuthSchemedeliberately, because the input under test is datathat does not satisfy the union.
The
{} as AuthSchemefixtures in theexchangeClientCredentialsandexchangeAuthorizationCodedescribes are untouched. Those functions never calldetermineGrantTypeandgetTokenEndpointis mocked for them.Proof that the new tests can fail
Mutation 1 — restore the original structural body of
determineGrantType:The other 28 pass under the mutation, which is the same evidence from the other
side: the change is behaviour-preserving for every well-typed scheme.
Mutation 2 — make the guard structural again
(
return 'grantTypesSupported' in scheme):Mutation 3 — make
getTokenEndpointreturn the wrong OIDC field(
return authScheme.openIdConnectUrl):Coverage
Measured on the three changed source files:
Both gaps are pre-existing and outside this change. Lines 169 and 177 are in
exchangeAuthorizationCode. Lines 26-27 are theflows.password || flows.implicitfallbacks ingetTokenEndpoint; onmainthe same filemeasures 97.89% statements and the same 93.1% branch with the same two lines
uncovered, so this change is coverage-neutral there.
One branch in the new code is not reachable from a valid input: the false arm
of
&& authScheme.flows, becauseflowsis statically required onOAuth2SecurityScheme. The guard is deliberate. It protectsgetOAuthGrantTypeFromFlowfrom a deserialized{"type": "oauth2"}arrivingfrom an OpenAPI document or a YAML agent config, and the three untyped-input
tests exercise the same defensive posture one level up. I did not write a
cast-based fixture to reach it and I did not add a coverage suppression.
Commands
Run on the pushed commit:
auth_handler_test.tsis a regression check on the otherauth/consumer ofAuthScheme. It needed no edit.One CI note
run-testsis green on ubuntu, macOS and Windows. Getting there took twore-runs, both lost to
tests/integration/app_loader/app_loader_test.ts > should discover apps vs agents across directories and standalone files, which timed out at 40000ms.That test is unrelated to this change and it is already borderline: on the
Windows leg of #772, where it passed, it took 37358ms of its 40000ms budget.
PRs #610, #652 and #664 are fixing it. I did not touch it.
Manual End-to-End (E2E) Tests:
Not applicable.
determineGrantTypeis a pure classifier with no CLI surface,no server route, and no user-visible change for a correctly-typed scheme. To
reproduce the bug on
maininstead: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.