Skip to content

feat(core): let swift apps renew a refused sign-in - #2261

Merged
aicayzer merged 5 commits into
mainfrom
feat/099-swift-renewal
Oct 21, 2026
Merged

aicayzer merged 5 commits into
mainfrom
feat/099-swift-renewal

Conversation

@aicayzer

@aicayzer aicayzer commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

An app signed in with OAuth holds a refresh token that the core never sees, so until now a Swift app's working copy had no way to renew an access token the server refused: its drain parked the queue as CredentialRefused. The Swift binding now takes an app-supplied CredentialRenewer when a copy or a set of folders opens. The core calls the renewer when the server refuses the token, sends the request again with the token it answers, and tells apart a sign-in that ended from a renewal that the network interrupted. The contract also now states how a native app's private-use redirect URI (such as com.example.app:/callback) is registered, authorized and exchanged, which is how an app on a phone receives its sign-in.

Part of withmarfa/marfa-swift#338.

Changes

  • core/bindings/swift/src/renewal.rs (new): the CredentialRenewer callback interface (renew(refused:) throws -> String) and the RenewalError the app throws:
    • Ended: the token endpoint answered invalid_grant or invalid_client. The call throws MarfaError.SignedOut, and every later refusal throws it without asking the renewer again.
    • Unavailable: no network, or no answer. The call throws MarfaError.Network, which a drain takes as environmental: it reports the pass as unavailable and the writes stay queued.
    • Failed, or any other error the renewer throws: the call throws MarfaError.Invalid.
    • Queued writes stay queued in every case. An empty token is refused as Invalid.
  • Core.open(path:url:key:renewer:) and Folders(url:key:renewer:) take an optional renewer that defaults to nil, so existing callers are unchanged. A renewer without a URL and key is refused as Invalid.
  • Folders share one renewal across every folder they open, including the folders a watch opens again. The renewal holds a lock around the app's renew, so folders refused together ask the app once, and a folder still sending the token renewed away gets the new one without a call. A folder opened after a renewal starts with the renewed token, not the refused one.
  • core/README.md and the CredentialRenewer doc describe the renewer: renew is called once at a time within one Core or one Folders, separate objects may call it concurrently so the app serializes across them, and a list of what a renewer must do (keep the new pair before returning, return the token it holds when refused differs, throw Ended and delete the refresh token on invalid_grant or invalid_client, time-limit the request and throw Unavailable, never block the main thread, never log the refused token).
  • conformance/spec/keys-and-oauth.md has four new statements: keys-and-oauth/register-native-private-use, keys-and-oauth/register-native-private-form, keys-and-oauth/register-web-private-use and keys-and-oauth/authorize-private-use. New fixture citations were added to keys-and-oauth/authorize-redirect-unregistered and keys-and-oauth/code-exchange.
  • Four new compliance tests in oauth.test.ts and oauth-authorize.test.ts. They cover registering a reverse-domain private-use URI and refusing other forms, refusing one for a web client, authorizing and exchanging a code at a private-use URI, and sending an unregistered private-use URI (another path, a trailing slash or another scheme) to the error page.

Testing

  • Binding unit tests (cargo nextest run in core/bindings/swift): 59 passed, 7 skipped (the live tests). folders_refused_at_once_ask_the_renewer_once (two threads refused together through one renewal) and a_folder_holding_the_old_token_gets_the_renewed_one_without_asking failed before the lock: the renewer was called twice with the same refused token, and asked again for a token already renewed. The second test's witness is a refusal of the renewed token itself, which still asks the renewer. The new tests failed before the wiring: the drain parked the queue as Blocked(CredentialRefused), and the server catalog read answered Unauthorized. The latch test was also run with the latch disabled to show that the renewer would otherwise be asked again.
  • Live binding tests against a server booted with core/scripts/server-up.sh (--ignored): all 7 passed. They include a copy that renews a refused token through the renewer and drains under the new one, and folders that add, confirm, sync and watch with a refused token and ask the renewer exactly once.
  • cargo test -p marfa-core: 859 passed, 10 ignored. cargo clippy --all-targets -D warnings and cargo fmt --check are clean for the core workspace and the Swift binding.
  • bindings/swift/build.sh generated the Swift package. The generated API is Core.open(path:url:key:renewer: CredentialRenewer? = nil), Folders(url:key:renewer: CredentialRenewer? = nil), protocol CredentialRenewer: AnyObject, Sendable { func renew(refused: String) throws -> String } and enum RenewalError { case Ended(message:), Unavailable(message:), Failed(message:) }.
  • compliance/oauth.test.ts (13 passed) against a server booted with pnpm marfa:up, and compliance/oauth-authorize.test.ts (19 passed) on its own server. These statements describe what the server already does, so the new fixtures passed on their first run. Each carries its own witness: the accepted native registration for the refusals, and the registered URI reaching the consent screen for the unregistered ones.
  • spec-form, spec-citations and spec-statements passed. pnpm typecheck, pnpm lint, pnpm format:check and pnpm test:changed (1096 and 238 tests) passed.
  • Not tested: no Swift code calls the generated glue yet. The marfa-swift wrappers and a Swift-side test come in that repository. The Node module takes no renewer (see notes).

Notes for reviewers

Review depth: full, it is sign-in and credential renewal.

  • One method, not two. The renewer has only renew. The app must save the new access token and refresh token before it returns, because a refresh token is spent once the server answers. A separate "renewed" callback would be a second channel for the same fact.
  • Ended maps to SignedOut, not Unauthorized. This matches the command line's renewal. Unauthorized would let the 401 stand and park the queue as CredentialRefused. SignedOut ends the drain and leaves the queue intact for the app to send after it signs in again.
  • Concurrency. Calls that one copy has refused at the same time renew once, under the core's renewal lock on that copy's transport. Each folder has its own transport, so the binding's renewal holds a second lock around the app's renew: without it, two watched folders refused together exchanged one refresh token twice, which refresh-replay-chain takes as a replay that ends the sign-in. The lock order is the store connection, then the transport's renewing lock, then the renewal's lock, then latest. Nothing taken under the renewal's lock reaches back: the renewer must not call the copy or the folders, and the on_renewed record runs after the lock is released and only tries the connection. Separate Core and Folders objects still renew independently, so the docs tell the app to serialize across them. The renewer's request needs a time limit, because stopping a watch waits for a renewal in progress.
  • Web clients cannot register a private-use scheme. This follows the server (Better Auth's validateClientRedirectUri): a web client's redirect must be https off the loopback.
  • Node module. It has no renewer. A synchronous JavaScript callback from a core thread needs a thread-safe function and a blocking round trip that could deadlock if the call ran on the JavaScript thread, so it is not the same small change. It is left for when a Node app needs it.
  • Docs for the release pass: the marfa-swift WorkingCopy and Folders wrappers and their reference docs, the Swift app sign-in tutorial, and the docs site's page on signing in and token renewal.
  • This touches core/bindings/swift/src/lib.rs and conformance/spec/keys-and-oauth.md, as do fix(cli): say the right thing for webhooks, setup codes, unreachable servers and keychains #2258 and fix(server): make the owner-facing words true and consistent #2257. Both merge cleanly with this branch.

Checklist

  • The title is a scoped Conventional Commit.
  • New behavior has a test that was seen failing first, or no test was needed and the reason is above.
  • Docs, comments and the contract still say what is true after this change.

@aicayzer aicayzer left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: ready to merge

Summary. A Swift app can now hand the core a renewer that answers a refused sign-in with a new token, so a copy and its folders go on through an expired access token, and a native app's private-use redirect is in the contract; safe to merge.

First draft: 3/5. 1 blocking issue found, fixed in 22f10139c and 0c79cdfd4; prose fixed in 3ad256ce2.

Found.

  • Blocking: the folders one Folders opens each renew on their own, so two refused together called the app's renewer twice with the same token (reproduced: 2 calls). With refresh-token rotation the second exchange is a replay and ends the whole chain. Fixed: one lock per Renewal around the call; a refusal of a token already replaced gets the new one without asking. Two tests, both seen failing.
  • register-native-private-form had no Reason. Added, citing RFC 8252 section 7.1.

Checked. A resend after renewal keeps its Idempotency-Key; Ended and Unavailable leave the queue unchanged; Ended sticks for the object's life and stops further asking; no token reaches telemetry or an error message; the lock order is store connection, transport renewal lock, Renewal lock, latest, and nothing held under the new lock reaches back up it; the private-use statements match the server's sign-in library.

Left for the package (marfa-swift). Separate Core and Folders objects can still renew at once, so the package's renewer serialises across them, saves the new pair before returning, deletes the refresh token on invalid_grant, limits the token request's time and never waits on the main actor. The doc says so.

Not checked. Two folders refused together against a real server; the unit test with two threads covers it.

@aicayzer
aicayzer marked this pull request as ready for review October 21, 2026 10:44
@aicayzer
aicayzer enabled auto-merge (squash) October 21, 2026 10:44
@aicayzer
aicayzer merged commit 59bf0af into main Oct 21, 2026
53 checks passed
@aicayzer
aicayzer deleted the feat/099-swift-renewal branch October 21, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant