mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #67] [MERGED] fix(auth): classify the authorization-code grant's 400 as .unknown, not .network #67
Labels
No labels
enhancement
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/comfy-swift-sdk#67
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/comfy-swift-sdk/pull/67
Author: @mattmillerai
Created: 9/11/2026
Status: ✅ Merged
Merged: 9/11/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-13493-exchange-400-unknown📝 Commits (6)
be03e36fix(auth): surface .authExpired when the token endpoint answers 400 invalid_grant99eb122fix(auth): harden the 400 error path against echoed and forged endpoint stringsc343c96fix(auth): classify the authorization-code grant's 400 as .unknown, not .network85a07eemerge: integrate the squash-merged 400-classification foundation from maina5d2d15fix(auth): stop short secrets corrupting the 400 code, and close the sanitize redaction gap1d87360fix(auth): match redaction past spliced separators, and floor it only in the RFC code📊 Changes
4 files changed (+425 additions, -32 deletions)
View changed files
📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthExchanger.swift(+7 -4)📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthTokenEndpoint.swift(+134 -22)📝
Tests/ComfySwiftSDKTests/OAuthExchangeTests.swift(+249 -6)📝
Tests/ComfySwiftSDKTests/RefreshOn401Tests.swift(+35 -0)📄 Description
ELI-5
When you sign in, the app trades a short-lived code from the browser for real tokens. If that code has expired or was already used, the server says "no" with an HTTP 400. The SDK was filing that "no" under network problem — the same bucket as a dropped Wi-Fi connection — so anything that retries network errors would retry a code that can never work again. This makes the SDK call it what it is: a refusal, not a connection failure.
What changed
The HTTP 400 arm in
OAuthTokenEndpoint.postwas gated onisRefreshGrant, so the authorization-code grant never entered it and fell through toTransport.checkStatus, whosedefault:arm maps every unmodelled status to.network(URLError(.badServerResponse)). The gate is removed: both grants now enter the typedOAuthTokenEndpointErrorpath, and theinvalid_grant → .authExpiredshortcut moves inside the classifier behindisRefreshGrant.Result for the exchange grant: a 400 surfaces as
.unknown(OAuthTokenEndpointError)carrying the endpoint's own scrubbederror/error_description— never.authExpired(the user had no session to expire, and that case drives a "your session ended, sign in again" sheet that would misdescribe a failed first sign-in and send them back into the sign-in they just failed), and never.network.Refresh-grant behaviour is byte-identical: same classifier, same inputs,
isRefreshGrant: truepreserves every arm. Verified by the existing refresh-grant suite, which is unchanged and green.Two comments that had gone stale were corrected rather than left contradicting the code: the
postdoc comment (which documented the exchange exclusion) and the call-site comment inOAuthExchanger, which said the exchange grant "leaves a 400 toTransport.checkStatus".Chesterton's Fence
Removing the
isRefreshGrantgate broadens an existing guard, so the history was read before touching it. That gate is fresh — it landed onmainin #65, deliberately, to keep that PR to its stated scope, and itspostdoc comment said so explicitly. Two of #65's bot reviewers raised exactly this asymmetry on the same line and the reply on that thread deferred it as a follow-up rather than disputing it. So the broadening is the intended completion of that work, not a regression of it. The proof that old and new cannot disagree is structural: the only case that newly enters the arm isisRefreshGrant == false, and the only behaviour reachable there before wasTransport.checkStatus'sdefault:arm.Tests
exchange400InvalidGrantIsNotAuthExpiredcaughtis ComfyError {}, which confirmed the error was not.authExpiredbut pinned nothing about what it actually was — so the.networkclassification was untested in either direction. It now asserts the concrete case and the carried code and detail. Added alongside it: the never-.networkregression, a non-invalid_grantcode, an unparseable 400 body, and secret redaction over the exchange grant's owncode/code_verifier(which the refresh grant never sends, so that path had no coverage).Red→green was proven, not assumed: with
OAuthTokenEndpoint.swiftreverted to itsmainstate and the new tests in place, all five fail; with the change, all five pass.Provenance
swift build: clean;swift test --no-parallel: 261 tests in 30 suites, 0 failures (3 live-integration tests skipped as designed, gated on env vars). Falsification of the new tests: same suite with onlyOAuthTokenEndpoint.swiftreverted to itsmainstate → 5 failures, all of them the new/tightened assertions. On CI:Build & test,hygiene / public-repo-hygiene,Endpoint contract checkandAGENTS.md integrityall pass.## Residualfor what was deliberately left unfixed.Residual
Short-secret over-redaction in the shared
redacthelper — reachable on this grant now, not fixed here.redactis a plain substring replacement of the request's own secret values over the endpoint's error strings, so a short secret can strike characters out of the endpoint's ownerrorcode — a one-charactercode_verifierturnsinvalid_grantintoin<redacted>alid_grantin the carriedOAuthTokenEndpointError. This is pre-existing (it arrived with the helper in #65) and it is cosmetic rather than a leak — it errs toward redacting too much, and classification is unaffected because theinvalid_grantmatch runs on the raw decoded value before scrubbing. It was not reachable on the exchange grant before this change and now is, which is why it is named here. Unreachable in normal use: the SDK derives its verifier from 32 random bytes (43 base64url characters) and the server's authorization code is opaque and long. It is reachable by a third-party caller of the publicexchangeAuthorizationCode(_:codeVerifier:config:)entry point that supplies its own short verifier. Not fixed in this PR because the obvious fix — skipping secrets below some length — narrows a security control, which deserves its own change and its own review rather than riding along on an error-classification fix. This surfaced as a real red test here: the new fixtures use production-shaped 43-character values precisely so the suite pins real behaviour instead of an artifact of a one-letter fixture.Artifact naming discrepancy, no work left. The source report located
exchange400InvalidGrantIsNotAuthExpiredinTests/ComfySwiftSDKTests/RefreshOn401Tests.swift; the test actually lives inTests/ComfySwiftSDKTests/OAuthExchangeTests.swift. Both files were exercised (the whole suite runs), and the test named in the report is the one tightened here — recording it so a reader who greps the named path and finds nothing does not conclude the work was missed.hygiene / public-repo-hygiene— could not be run locally, confirmed on CI. That check loads its checker and allowlist from a pinned commit in a separate workflows repository by design, precisely so a PR cannot reach it from its own checkout, so nothing in a local worktree exercises it. It passed on this PR's first CI run, alongsideBuild & test,Endpoint contract checkandAGENTS.md integrity. Recorded here because the local verification genuinely did not cover it.Consumer-side copy is deliberately not touched. The downstream first-party consumer was checked out of band (private repo, so no detail is repeated here, as this repo is public): its sign-in path handles user-cancellation specially and folds every other error into a single message with no automatic retry, so nothing downstream branches on the case this change alters and there is no user-visible behaviour change to ship with it. Whether that copy should now distinguish a refused code is a separate product question, out of scope here.
No new public
ComfyErrorcase. A dedicated case for "the authorization code was rejected" would be a public-API addition and was deliberately not introduced;.unknowncarryingOAuthTokenEndpointErroris the landing spot. Worth revisiting if a consumer ever needs to branch on it, which none does today.Negative-claim falsification: not applicable. This diff denies no capability — it adds no dead-end, no "not supported" path, and no new failure. The authorization-code exchange already failed on a 400; only the
ComfyErrorcase a consumer sees changes, and it changes from a less accurate classification to a more accurate one.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.