mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #70] [MERGED] feat(auth)!: add ComfyError.authCodeRejected for a refused authorization-code exchange #73
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#73
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/70
Author: @mattmillerai
Created: 9/11/2026
Status: ✅ Merged
Merged: 9/11/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-13548-auth-code-rejected📝 Commits (9)
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 gapd28081dfeat(auth)!: surface a rejected authorization code as ComfyError.authCodeRejected6ee634bmerge: integrate the squash-merged 400-classification foundation from main6f18d8efix(auth): close the escape-case redaction gap and the normalize asymmetry5656dbbdocs(auth): widen the non-invalid_grant recovery note beyond config mismatch📊 Changes
11 files changed (+467 additions, -109 deletions)
View changed files
📝
README.md(+1 -1)📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthExchanger.swift(+11 -7)📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthTokenEndpoint.swift(+145 -42)📝
Sources/ComfySwiftSDK/Internal/ComfySDKInfo.swift(+1 -1)📝
Sources/ComfySwiftSDK/Internal/PollingFallback.swift(+2 -1)📝
Sources/ComfySwiftSDK/Internal/SDKLog.swift(+1 -0)📝
Sources/ComfySwiftSDK/Public/ComfyAuthSignIn.swift(+7 -1)📝
Sources/ComfySwiftSDK/Public/ComfyCloudClient.swift(+9 -1)📝
Sources/ComfySwiftSDK/Public/ComfyError.swift(+34 -0)📝
Tests/ComfySwiftSDKTests/OAuthExchangeTests.swift(+222 -55)📝
Tests/ComfySwiftSDKTests/RefreshOn401Tests.swift(+34 -0)📄 Description
ELI-5
When you sign in, the app trades a short-lived authorization code for real tokens. If the server says "no" to that trade, the SDK used to report it as
.unknown(underlying:)wrapping an internal type — so an app could see only "something unknown went wrong" for the single auth failure it most needs to react to, with no way to read the reason. This PR gives that refusal its own public case,ComfyError.authCodeRejected(code:detail:), so an app can branch on it with the compiler's help and send the user back to sign in for a fresh code.What changed
ComfyErrorgainscase authCodeRejected(code: String?, detail: String?). Every HTTP 400 on theauthorization_codegrant now throws it — parseable body or not. The 400 on this grant is the refusal signal, and there is no consumer-meaningful difference between "refused with a body we could not parse" and "refused", so the unparseable case carries(nil, nil)rather than landing somewhere else. It is deliberately not.authExpired(a failed sign-in has no session to expire, and that case drives a "your session ended" sheet that misdescribes what happened) and deliberately not.network(which would invite retrying a code no retry can redeem).codeis normalized — trimmed and lowercased — so a consumer can compare it with==instead of re-implementing the case/whitespace tolerance the SDK already applies to the refresh grant's owninvalid_grantmatch. The same normalization now runs onOAuthTokenEndpointError.code, through one shared private helper used by the refresh-grant match and both payloads, so what the SDK branches on and what it hands the consumer agree.The normalization runs after the existing scrub (redact → sanitize → clamp), not before.
redactmatches the request's own secrets, so lowercasing first would stop it matching a mixed-case secret the server echoed back inerror— and that credential would land in the publiccodeof an error consumers log. A new test pins the ordering; see the falsification note below.The refresh grant's classification is untouched:
invalid_grantis still.authExpired, every other refresh 400 is still.unknown(OAuthTokenEndpointError), and that struct is now reached only by those. Its doc comment says so now.Also: the two internal exhaustive switches over
ComfyErrorgain an arm (PollingFallback.isTransient→false, listed with the other auth cases, because a refused grant is never transient;SDKLog.comfyErrorCaseName→"ComfyError.authCodeRejected"), the public- Throws:docs onComfyCloudClient.exchangeAuthorizationCodeandComfyAuth.signInname the new case, andComfySDKInfo.versiongoes0.4.1→0.5.0.Source-breaking, and the tag is a manual post-merge step
(1) This is a source-breaking addition for consumers with exhaustive
switchstatements overComfyError— a new enum case makes those switches non-exhaustive. Hence the minor bump to0.5.0rather than a patch: the consuming app pinsupToNextMinorVersionfrom0.4.0.(2)
mainalso carries an unreleased.routercase (from the merged router error-taxonomy PR #66), which requires the same minor bump. So0.5.0covers both, and the tag must be cut manually after this merges — it is not created by merging. The follow-up work in the consuming iOS app cannot start until that tag exists. This mirrors the same residual left by PR #65.A note on the history: this PR was cut while #67 was still open
This branch started stacked on #67's head. #67 squash-merged mid-flight, so its commits here had no ancestor on
mainand the three-dot diff double-counted the foundation. The merge commit resolves that without a force-push — and it turned out to matter for more than diff hygiene: #67 gained substantial review-driven work after the commit this branch was cut from (redaction that ignores separators spliced through an echoed credential, and a length floor narrowed to the RFCerrorcode alone rather than applying toerror_descriptiontoo). Those are taken frommainverbatim; this change is re-applied on top of them, socodeis normalized after scrubbing the floor-filtered secret set, and the two tests #67 added for that behaviour are converted to the new case with their assertions intact rather than dropped. The net diff againstmainis now 10 files — this change only.Tests
Existing exchange-path assertions were converted from
#require(underlying as? OAuthTokenEndpointError)to matching.authCodeRejected— every redaction / sanitize / clamp assertion is preserved byte-for-byte, including the two that arrived with #67's later review round; only the case being matched changed. The two scoping guards ("…not.authExpired", "…never.network") are retargeted to the new case and still pin both directions. Refresh-grant tests are untouched and green.Three new tests: a re-cased, padded code (
" INVALID_GRANT \n") normalizes toinvalid_grantwith the description intact; an unparseable 400 body yields.authCodeRejected(nil, nil); and a secret whose casing differs from the code text is still redacted, which guards the scrub-then-normalize order.That ordering test was falsified, not just written. I flipped the production order to
scrub(normalizeCode(dto.error), …)and re-ran it — twice, before and after the merge, because the redaction internals changed underneath it. Both times it fails, and the failure message shows the authorization code leaking verbatim into the publiccode("test-code-not-a-real-authorization-codeaaaa"). The original order was restored each time and the tree verified clean. So the test proves the ordering rather than asserting its own premise.One thing worth knowing for anyone running the suite locally against this branch: inserting an enum case mid-
ComfyErrorshifts the case tags, and an incremental build can leave theComfyAuthKittarget compiled against the old ones — which shows up as an unrelated-looking failure wherecatch ComfyError.authCancelleddoes not match an error that prints as.authCancelled.rm -rf .buildclears it, and it passes on a clean build, which is what CI does. It is a build-cache artifact, not a behaviour change.Negative-claim falsification
The capability-denial trigger does not fire here, and the reasoning is not "it looked fine": this diff adds no "not supported" / "unavailable" / "STOP" string, no new throw or deny dead-end, and no test flipped to assert a dead-end. The exchange 400 path already threw before this change — it threw
.unknown. The user-facing outcome is strictly more actionable, not less: the same failure now carries a case a consumer can branch on and a documented recovery (restart sign-in for a fresh code). Nothing that previously succeeded now fails.Judgment calls
from: "0.5.0"alongsideComfySDKInfo.version. That matches how the0.4.1bump was done — both in one PR, before the tag existed. The consequence is named under Residual..unknownpayload gains the normalization too (itscodeis now trimmed/lowercased). This is a deliberate, requested unification so both paths agree, and it is the one refresh-side observable change in the diff: the case mapping is identical, and only a proxy that re-cased the value (e.g.INVALID_REQUEST) would see a differentcodestring in a diagnostic. No refresh test changes behaviour; the existing ones assert already-lowercase codes.Residual
0.5.0git tag is not created by this PR and must be cut manually after merge. Nothing in the repo automates it. Until it exists: (a) the README install snippet this PR updates —from: "0.5.0"— resolves to no published version, and (b) the follow-up work in the consuming iOS app, which needs the new case, is blocked. This is the same unfinished tag step left behind by PR #65 and is believed to be tracked already; it is restated here because a reader of this PR alone would not know it is outstanding.URLProtocolstubs. The repo's one live check (OAuthExchangeIntegrationTests) is gated behindRUN_OAUTH_INTEGRATION=1and a seeded OAuth client this environment does not have, so it stayed skipped. The claim that the production endpoint answers a refused code with an RFC 6749 §5.2400is inherited from #65/#67, not re-measured here..routercase is not exercised by this change beyond compilation. Both new switch arms sit alongside it and the full suite is green, but nothing here tests router behaviour; that remains PR #66's coverage.Provenance
swift build— clean;swift test --no-parallelafterrm -rf .build— 292 tests in 31 suites, 0 failures (post-merge with the currentmain); pre-merge run on the stacked branch — 265 tests, 0 failures; scrub-then-normalize ordering falsified twice by flipping the production order and observing the credential leak, restored and the tree re-verified clean each time.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.