mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #65] [MERGED] fix(auth): surface .authExpired when the token endpoint answers 400 invalid_grant #68
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#68
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/65
Author: @mattmillerai
Created: 9/4/2026
Status: ✅ Merged
Merged: 9/11/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-10358-auth-expired-invalid-grant📝 Commits (2)
be03e36fix(auth): surface .authExpired when the token endpoint answers 400 invalid_grant99eb122fix(auth): harden the 400 error path against echoed and forged endpoint strings📊 Changes
7 files changed (+586 additions, -22 deletions)
View changed files
📝
README.md(+1 -1)📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthExchanger.swift(+5 -3)📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthTokenEndpoint.swift(+201 -12)📝
Sources/ComfySwiftSDK/Internal/Auth/OAuthTokenRefreshExecutor.swift(+5 -3)📝
Sources/ComfySwiftSDK/Internal/ComfySDKInfo.swift(+1 -1)📝
Tests/ComfySwiftSDKTests/OAuthExchangeTests.swift(+20 -0)📝
Tests/ComfySwiftSDKTests/RefreshOn401Tests.swift(+353 -2)📄 Description
ELI-5
When your sign-in session dies for good, the server says so with a very specific
"400 invalid_grant" answer. The SDK didn't understand that answer, so it filed it
under "the network is flaky" — which told the user to check their Wi-Fi and never
offered them the sign-in button they actually needed. This teaches the SDK to read
that answer and say "your session ended" instead.
What changed
The token endpoint rejects a refresh token that is expired, revoked, reused, or
unknown with an RFC 6749 §5.2
400 {"error":"invalid_grant","error_description":…}.HTTP 400 has no branch in
Transport.checkStatus, so it fell to thedefault:armand reached the caller as
.network(URLError(.badServerResponse))— a permanentlydead session reported as a retryable connectivity problem, with the consuming app's
re-sign-in flow (which keys on
.authExpired) unreachable.OAuthTokenEndpoint.postnow classifies a 400 for the refresh grant beforeTransport.checkStatussees it:{"error":"invalid_grant"}.network.authExpired{"error":"invalid_request"}and the other RFC codes.network.unknown(OAuthTokenEndpointError).network.unknown(OAuthTokenEndpointError)Non-
invalid_grantcodes report a malformed request — a client implementation bug —so they deliberately do not become
.network, which would invite a retry thatcan never succeed. The existing 401/403 →
.authExpiredremap is kept as defence indepth for a proxy or CDN answering in front of the endpoint; its test comment now
says so, since 400 is what the production endpoint actually sends.
mapAuthInvalidToExpiredis renamedisRefreshGrant, because it now gates bothmappings. Both call sites are updated;
grepconfirms none were missed.Scope call: the authorization-code grant is deliberately untouched
OAuthTokenEndpoint.postis shared withOAuthExchanger.invalid_grantthere meansthe authorization code was rejected — a failed sign-in, with no session to expire —
so mapping it to
.authExpiredwould send the app straight back into the sign-in itjust failed. Exchange keeps its existing behaviour, and a new test pins that so the
refresh mapping can't leak into it later.
Credential hygiene
OAuthTokenEndpointErrorcarries only the endpoint's ownerror/error_descriptionfields, never the raw body. Both are scrubbed of the request's own secret values
(
refresh_token,code,code_verifier,client_secret) and then length-clamped —redaction runs before clamping, so a clamp can't split a secret and leave the prefix
behind. A test asserts a server echoing our refresh token back inside
error_descriptionreaches the caller as<redacted>.Release
ComfySDKInfo.versionis bumped to0.4.1and the README install snippet follows it.Two judgment calls there, both flagged rather than assumed:
0.3.0while0.4.0was already tagged and released, so thisalso closes that drift rather than bumping
0.3.0→0.3.1.from: "0.3.1". SemVer means that range already resolves0.4.1, so this is a docs-freshness change, not a functional one.How this was verified
swift buildandswift test --no-parallelfrom the package root: 252 tests in 30suites, 0 failures.
Red→green, so the new tests exercise the fix rather than restating their own premise:
with the new 400 arm short-circuited, exactly the six new refresh tests fail; restored,
all 252 pass.
The premise was falsified against the live production endpoint, not just against my
own mocks — two non-mutating probes with a token that belongs to no family:
That confirms both the status code and the exact snake_case body shape this change
decodes, including that
error_descriptionis optional (a test covers its absence).New tests, all in the existing mock-
URLProtocolpattern:400 invalid_granton the refresh POST →.authExpired, not.network400 invalid_grantwith noerror_description→ still.authExpired401 retry) hitting
400 invalid_grant→.authExpired, with no API call attempted400 invalid_request→.unknown, carryingcode == "invalid_request"400with an unparseable body →.unknown, carryingcode == nil400 error_descriptionechoing the refresh token → redacted before it reaches the caller400 invalid_grant→ not.authExpired(scoping guard)Residual
Not fixed here, and worth acting on separately:
A tag must point at a commit on the default branch, so cutting
0.4.1is apost-merge step — and the consuming app cannot pin the fix until it happens. Whoever
merges this needs to tag and release, or the version bump buys nothing.
.network. The scope callabove is about
invalid_grantspecifically, but the.network-for-a-400misclassification the
default:arm produces is the same defect on the exchange path— a failed sign-in still tells the user to check their connection. It needs its own
decision about which
ComfyErrorcase a rejected authorization code should carry, soit is not folded in here.
.unknownmay not fully deliver the "no useless retries" intent downstream. Theconsuming app's error presentation maps
.unknownto a generic "try again" with aretry action, so a non-
invalid_grant400 still offers the user a retry — justwithout the false "check your connection" diagnosis. Getting the rest of the way is
an app-side copy/affordance change, outside this repo.
Transport.checkStatus'sdefault:arm is unchanged. Every other unmodelledstatus still becomes
.network(URLError(.badServerResponse)). This change routesaround it for the refresh grant rather than fixing the general case, which has a much
wider blast radius across every endpoint.
is the root cause of, and the referenced consuming-app PR live in systems this run
cannot write to, so none of their state was updated.
OAuthTokenEndpointErrorisinternal, matching this repo's existing error-payload types — a consumer receivingit inside
.unknown(underlying:)can readlocalizedDescriptionbut cannot type-castit. That is deliberate for now; promoting it to public API is a separate call.
Provenance
swift build: clean;swift test --no-parallel: 252 tests in 30 suites, 0 failures; red→green check with the new 400 arm disabled: 6 new tests fail, rest pass; 2 live non-mutating probes ofhttps://cloud.comfy.org/oauth/tokenreturning400 invalid_grantand400 invalid_request🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.