fix(auth): keep stack writable on errors built from OAuth error responses - #9195
Open
Om-singhaI wants to merge 2 commits into
Open
fix(auth): keep stack writable on errors built from OAuth error responses#9195Om-singhaI wants to merge 2 commits into
Om-singhaI wants to merge 2 commits into
Conversation
…nses getErrorFromOAuthErrorResponse copies the own properties of the original error onto the new Error with Object.defineProperty using writable false and enumerable true, and it adds stack to the list of copied keys. Every error raised from an STS or OAuth error response by external account credentials therefore carries a read only, enumerable stack, unlike a regular Error. Consumers commonly append causal context to error.stack. Compiled TypeScript runs in strict mode, so that assignment throws TypeError: Cannot assign to read only property 'stack', which replaces the real authentication failure. In Firestore the append happens inside a stream error handler, so the TypeError escapes as an uncaughtException instead of a rejected promise, and the underlying invalid_grant message is lost. Keep the copied stack writable and non enumerable, matching the shape of a normal Error, while leaving the other copied properties as they were. Add a unit test that checks the property descriptor and that appending to the stack in strict mode does not throw. Fixes googleapis#9155
Contributor
There was a problem hiding this comment.
Code Review
This pull request ensures that when copying properties to a new error object in 'getErrorFromOAuthErrorResponse', the 'stack' property remains writable and non-enumerable, and adds a corresponding unit test. The review feedback recommends explicitly setting 'configurable: true' on the property descriptor to fully align with standard 'Error' behavior and prevent potential strict mode errors, as well as adding a corresponding assertion in the unit test.
Review follow up for the OAuth error stack change. The defineProperty call in getErrorFromOAuthErrorResponse now sets configurable to true for stack, alongside the existing writable and enumerable attributes, instead of relying on the omitted attribute being kept from the stack that a fresh Error already owns. Other copied keys keep configurable false, which is what the omitted attribute already meant for them, so their descriptors are unchanged. The unit test now also asserts that the stack descriptor reports configurable true.
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.
fix(auth): keep stack writable on errors built from OAuth error responses
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
Fixes #9155 🦕
Problem
getErrorFromOAuthErrorResponse()incore/packages/google-auth-library-nodejs/src/auth/oauth2common.tsbuilds a newErrorfrom an OAuth or STS error response and copies the original error's own properties onto it withObject.defineProperty(..., {writable: false, enumerable: true}). It also pushesstackonto the list of keys to copy, so the resulting error ends up with a read only, enumerablestack. A regularErrorhas a writable, non enumerablestack.Both call sites (
StsCredentials.exchangeToken()instscredentials.tsandExternalAccountAuthorizedUserHandlerinexternalAccountAuthorizedUserClient.ts) pass the originalGaxiosError, so every token exchange failure surfaced byexternal_accountcredentials (Workload Identity Federation) has this shape.Consumers that append causal context to
error.stackhit it. Compiled TypeScript modules run under"use strict", where the assignment throwsTypeError: Cannot assign to read only property 'stack'instead of being silently dropped.@google-cloud/firestoredoes exactly this inwrapError()(handwritten/firestore/dev/src/util.ts,err.stack += '\nCaused by: ' + stack;) from stream error handlers, so a rejected federated credential turns into anuncaughtExceptionthat names Firestore and hides the realinvalid_grantmessage. The consumer side is tracked in #9154; this PR fixes the root cause in the auth library so the errors it produces behave like ordinary errors.Reproduction on
main(48e0941), using the compiledbuild/src/auth/oauth2common.jsin a strict mode script:Change
In the property copy loop,
stackis now defined withwritable: true,enumerable: falseandconfigurable: true, which is the shape it has on a freshly constructedError. All other copied properties (code,name,response, and so on) keep the existingwritable: false, enumerable: truedefinition withconfigurableleft unset (so false, as before), andmessageis still never overwritten. The value ofstackis still copied from the original error, so the existing "should preserve the original error properties" test is unchanged and still passes.Same script after the change:
A new unit test in the
getErrorFromOAuthErrorResponseblock oftest/test.oauth2common.tsasserts the property descriptor (writable === true,enumerable === false,configurable === true), thatactualError.stack += ...does not throw (the compiled test module is strict mode), and that the appended text is present afterwards.Why
configurableis set explicitly (review follow up)Review feedback asked for
configurableto be spelled out forstackrather than relying on the descriptor default, and for the test to assert it. The attribute is nowconfigurable: key === 'stack'next to the existingwritableandenumerablelines.For
stackthis makes no runtime difference:new Error()already owns a configurablestack, andObject.definePropertyon an existing property keeps the attributes that the descriptor omits, soconfigurablewas already true. The descriptor printed by the reproduction script is identical before and after this follow up:Spelling it out means the behaviour no longer depends on that defineProperty rule. For every other copied key the expression evaluates to false, which is exactly what the omitted attribute meant for those new properties, so their descriptors are byte for byte the same as before (for example
codeis still{"writable":false,"enumerable":true,"configurable":false}). The test assertion is meaningful: withconfigurable: falseforced forstackin the compiled output, the new test fails withAssertionError [ERR_ASSERTION]: Expected values to be strictly equal: false !== true.Verification
All runs used
tsc -p . --sourceMapfollowed bymochaon the compiled output insidecore/packages/google-auth-library-nodejs, with Node 25.6.1 (the outcome does not depend on the Node version: the attributes are set explicitly on the property descriptor, so 22, 24 and 26 in the CI matrix behave the same).main:mocha build/test/test.oauth2common.jsreports 25 passing.main(test file only changed): 25 passing, 1 failing. The failure is the new test,should keep the copied stack writable, configurable and non-enumerable, withAssertionError [ERR_ASSERTION]: Expected values to be strictly equal: false !== trueon thewritableassertion. The full unit suite in that state reports 936 passing, 1 failing.mocha build/test/test.oauth2common.jsreports 26 passing; the full unit suite (mocha build/test) reports 937 passing, 0 failing.configurable):mocha build/test/test.oauth2common.jsagain reports 26 passing andmocha build/testagain reports 937 passing, 0 failing.Coverage: no executable lines change (the diff adds two comment lines inside the descriptor), only the attribute values inside the existing
definePropertycall change, and the new test exercises that call, so coverage does not decrease.Lint
node ./bin/linter.mjsfrom the repository root (the same scriptpresubmitruns; it lints the changed.tsfiles against the root ESLint config and runstsc --noEmitfor the package) exits 0 with no findings, both for the original change and after the review follow up.npx gts check --no-inline-config src/auth/oauth2common.ts test/test.oauth2common.tsinside the package exits 0.npx prettier --checkon both files passes.Alternatives considered
The issue also suggests not copying
stackat all, or attaching the original error ascause. Both would change what callers currently see inerror.stack(today it is the original request error's stack, and the existing test asserts that), so this PR keeps the copied value and only fixes its attributes. Happy to switch tocausein a follow up if maintainers prefer that direction.One observable change worth stating: the copied
stackis now non enumerable as well as writable, exactly like thestackof a plainError, soObject.keys(err),for...inandJSON.stringify(err)no longer include it. Anything that serialized these errors and relied onstackappearing as an own enumerable key would see that key disappear; a plainErrornever exposed it that way, which is why the change is framed as restoring normalErrorsemantics.