Repository navigation
feat(core): let swift apps renew a refused sign-in - #2261
Conversation
aicayzer
left a comment
There was a problem hiding this comment.
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
Foldersopens 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 perRenewalaround the call; a refusal of a token already replaced gets the new one without asking. Two tests, both seen failing. register-native-private-formhad 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.
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-suppliedCredentialRenewerwhen 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 ascom.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): theCredentialRenewercallback interface (renew(refused:) throws -> String) and theRenewalErrorthe app throws:Ended: the token endpoint answeredinvalid_grantorinvalid_client. The call throwsMarfaError.SignedOut, and every later refusal throws it without asking the renewer again.Unavailable: no network, or no answer. The call throwsMarfaError.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 throwsMarfaError.Invalid.Invalid.Core.open(path:url:key:renewer:)andFolders(url:key:renewer:)take an optional renewer that defaults tonil, so existing callers are unchanged. A renewer without a URL and key is refused asInvalid.Foldersshare one renewal across every folder they open, including the folders a watch opens again. The renewal holds a lock around the app'srenew, 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.mdand theCredentialRenewerdoc describe the renewer:renewis called once at a time within oneCoreor oneFolders, 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 whenrefuseddiffers, throwEndedand delete the refresh token oninvalid_grantorinvalid_client, time-limit the request and throwUnavailable, never block the main thread, never log the refused token).conformance/spec/keys-and-oauth.mdhas four new statements:keys-and-oauth/register-native-private-use,keys-and-oauth/register-native-private-form,keys-and-oauth/register-web-private-useandkeys-and-oauth/authorize-private-use. New fixture citations were added tokeys-and-oauth/authorize-redirect-unregisteredandkeys-and-oauth/code-exchange.oauth.test.tsandoauth-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
cargo nextest runincore/bindings/swift): 59 passed, 7 skipped (the live tests).folders_refused_at_once_ask_the_renewer_once(two threads refused together through one renewal) anda_folder_holding_the_old_token_gets_the_renewed_one_without_askingfailed 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 asBlocked(CredentialRefused), and the server catalog read answeredUnauthorized. The latch test was also run with the latch disabled to show that the renewer would otherwise be asked again.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 warningsandcargo fmt --checkare clean for the core workspace and the Swift binding.bindings/swift/build.shgenerated the Swift package. The generated API isCore.open(path:url:key:renewer: CredentialRenewer? = nil),Folders(url:key:renewer: CredentialRenewer? = nil),protocol CredentialRenewer: AnyObject, Sendable { func renew(refused: String) throws -> String }andenum RenewalError { case Ended(message:), Unavailable(message:), Failed(message:) }.compliance/oauth.test.ts(13 passed) against a server booted withpnpm marfa:up, andcompliance/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-citationsandspec-statementspassed.pnpm typecheck,pnpm lint,pnpm format:checkandpnpm test:changed(1096 and 238 tests) passed.marfa-swiftwrappers 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.
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.Endedmaps toSignedOut, notUnauthorized. This matches the command line's renewal.Unauthorizedwould let the 401 stand and park the queue asCredentialRefused.SignedOutends the drain and leaves the queue intact for the app to send after it signs in again.renew: without it, two watched folders refused together exchanged one refresh token twice, whichrefresh-replay-chaintakes as a replay that ends the sign-in. The lock order is the store connection, then the transport'srenewinglock, then the renewal's lock, thenlatest. Nothing taken under the renewal's lock reaches back: the renewer must not call the copy or the folders, and theon_renewedrecord runs after the lock is released and only tries the connection. SeparateCoreandFoldersobjects 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.validateClientRedirectUri): a web client's redirect must behttpsoff the loopback.marfa-swiftWorkingCopyandFolderswrappers and their reference docs, the Swift app sign-in tutorial, and the docs site's page on signing in and token renewal.core/bindings/swift/src/lib.rsandconformance/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