Sign in through Fizzy's real magic-link contract in every SDK

Merged
JE
8d ago
+1353-551

Fizzy can't be signed into through the magic-link flow in five of the six SDKs, and nothing generated from the model can complete a sign-in either. This PR corrects the model to match Fizzy and moves every SDK's flow onto the generated routes and types, the way Rust's flow already worked.

This changes an auth flow in every SDK and breaks their public magic-link APIs, so it's held for @jeremy.

What Fizzy does

I read this from ~/Work/basecamp/fizzy at db36756, together with its controllers and tests. The SDK's api-provenance.json pins 08395fab, and since then the only auth change in Fizzy is unrelated (#3093).

  • POST /session(.json) takes email_address and answers 201 {pending_authentication_token}. It also sets that token as an HttpOnly pending_authentication_token cookie (via_magic_link.rb:32,43-50).
  • POST /session/magic_link(.json) takes params.expect(:code) (magic_links_controller.rb:32) and reads the pending sign-in only from the cookie (via_magic_link.rb:60-62). It answers 200 {session_token, requires_signup_completion}, or 401 when the cookie is missing or the code is wrong.
  • POST /signup/completion(.json) takes {full_name} and answers an empty 201.
  • DELETE /session answers 204.
  • There's no /api scope and no /sessions route, so the old SDK paths 404.

What was wrong

PathBodyAnswer
Modelrightredeem said token; Fizzy reads codeCreateSession modelled 200 (Fizzy sends 201), DestroySession 200 (sends 204); the cookie wasn't documented
Go/api/v1/sessions, /api/v1/sessions/redeem: 404{email}, {token}dropped the pending token; no cookie
TypeScript/sessions, /sessions/redeem: 404{email}, {token}discarded the create answer; no cookie
Ruby/sessions, /sessions/redeem: 404{email}, {token}no cookie
Swift/sessions.json, /sessions/redeem.json: 404; signup path right{email}, {token}, signup {name}the pending-token decode failed; signup expected a body
Kotlin/api/sessions…: 404redeem sent the pending token and no code; signup {name}redeem's argument was the wrong value
Rustright (generated routes)sent {code, token} to cover the model's tokenright, and it sent the cookie

What changes

  • Spec (spec/fizzy.smithy): RedeemMagicLinkInput.code replaces token. CreateSession is now code: 201 and DestroySession code: 204. The operation docs say which cookie each side carries.
  • Regeneration: every language regenerates. Rust's names.toml keeps the code redacted, as the token was.
  • TypeScript generator: a multi-line operation description now keeps the JSDoc * gutter on every line.
  • Conformance: paths.json now sends {code} and expects 201 and 204. The TypeScript, Swift and Kotlin runners send code.
  • Every flow now goes through the client's generated sessions operations, so paths, bodies, statuses, error mapping, HTTPS enforcement, body limits and hooks all come from the SDK's shared client.
    • Redeem adds an explicit, percent-encoded Cookie: pending_authentication_token=…, encoded the same way as Rust. None of the flows relies on a cookie jar.
    • Each flow keeps the pending token and can resume one that another process started.
    • Redeeming with no sign-in pending is a usage error and sends nothing.

Public API changes (breaking)

  • Go: CreateSession returns *generated.PendingAuthentication. RedeemMagicLink(ctx, code) returns *generated.SessionAuthorization. New: PendingToken() and ResumeMagicLinkFlow(...). Removed: CreateSessionRequest/Response, RedeemMagicLinkRequest/Response and UserID. Login returns (*SessionAuthorization, error).
  • TypeScript: requestLink(email) becomes createSession(emailAddress), and redeemToken(token) becomes redeem(code). New: resume, pendingToken and completeSignup(fullName).
  • Ruby: request_magic_link(email:) becomes create_session(email_address:), and redeem(token:) becomes redeem(code:). New: the pending_token: constructor argument and pending_token.
  • Swift: start(email:) becomes start(emailAddress:) -> PendingAuthentication. redeem(token:) becomes redeem(code:) -> SessionAuthorization. completeSignup(fullName:sessionToken:) now returns nothing. PendingSession, RedeemResult and SignupResult are removed. New: pendingToken and resume.
  • Kotlin: redeemMagicLink(code) takes the emailed code; before, it took the pending token. completeSignup(sessionToken, fullName) changes too. New: pendingToken and resume.
  • Rust: RedeemMagicLinkRequestContent.token is renamed code. cargo-semver-checks reports this as major, and that CI step is advisory.

None of these SDKs has a CHANGELOG or MIGRATING file, so this list is the migration note. The old flows couldn't sign in, so a caller has nothing working to migrate from.

fizzy-cli isn't affected. It signs in with its own net/http client and a cookie jar, against exactly the contract described above (internal/commands/signup.go), and it doesn't use any SDK MagicLinkFlow. Once this lands it could drop its copy.

Tests

None of the five rewritten flows had tests before. Each now has one that walks the whole exchange against a fake Fizzy: the paths, the exact bodies, no cookie on create, the encoded cookie on redeem, 201 handling, resume, a 401 mapped to an auth error and sent once, and a usage error when nothing is pending. Swift and Kotlin also cover an empty-201 signup.

  • Each leg checked that its tests fail against the old flow.
  • Ruby swapped the old flow back in: 6 of its 7 tests fail. The seventh, the plain-HTTP refusal, passes either way.
  • TypeScript swapped the old flow back in: all 7 fail.
  • Kotlin swapped it back in: the 3 tests that compile against the old API fail, and the other 2 don't compile.
  • Go and Swift didn't run the old code. Their tests don't compile against the old API, and the leg reasoned that the old paths and bodies would fail them.
  • Rust's test now matches the body exactly as {"code":"ABC123"}.

Verified locally: make -k check passes everything except audit-check, which is the stale rubric-audit.json date that #214 refreshes. That includes all six conformance runners at 134/134. Nothing was sent to a live Fizzy. I didn't probe staging, because CreateSession sends a real email.

Still open

  • Swift's and Go's flows now build the SDK client, which refuses a non-HTTPS base URL outside localhost. Swift does so with a preconditionFailure. The old flows didn't check.
  • The public CookieAuth in Kotlin and Swift still writes session_token unencoded. I left it as it was.
  • The generated RedeemMagicLink alone still can't complete a sign-in, because the model can't express the cookie. The hand-written flows supply it.

Summary by cubic

Fizzy couldn't be signed into through magic links in five of the six SDKs, and nothing generated from the Smithy model could complete a sign-in either. This PR corrects the model to Fizzy's real contract and moves every SDK's flow onto the generated sessions operations, the way Rust already worked.

What changed

  • RedeemMagicLinkInput.code replaces token; CreateSession and DestroySession now model their true statuses, 201 and 204.
  • The operation docs now describe the pending_authentication_token cookie; each flow sends it explicitly, percent-encoded, and can resume a sign-in another process started.
  • Each rewritten flow has a test covering the full exchange against a fake Fizzy.
  • Swift builds its sessions service directly per request, so a finished flow's client and the cookie it carried are released.
  • A redeem now spends only the sign-in it consumed: the pending token clears after a successful redeem, so a refused code can be retried and a second redeem fails locally instead of hitting Fizzy. Kotlin and Ruby bind each redeem to the token it read and guard the pending state, so a hook that starts another sign-in mid-redeem can't pair the code with a different cookie.
  • The TypeScript flow no longer keeps a session; completeSignup takes the token redeem answered, so an older redeem can't sign one person's sign-up under another's session.

Breaking API changes

  • Go: CreateSession returns *generated.PendingAuthentication; RedeemMagicLink(ctx, code) returns *generated.SessionAuthorization; the hand-written request/response types and UserID are gone.
  • TypeScript: requestLink(email) → createSession(emailAddress), redeemToken(token) → redeem(code); new resume, pendingToken, completeSignup(fullName).
  • Ruby: request_magic_link(email:) → create_session(email_address:), redeem(token:) → redeem(code:); new pending_token: constructor argument and reader.
  • Swift: start(email:) → start(emailAddress:) -> PendingAuthentication, redeem(token:) → redeem(code:) -> SessionAuthorization; old result types removed.
  • Kotlin: redeemMagicLink(code) takes the emailed code, not the pending token; completeSignup takes fullName; new pendingToken and resume.
  • Rust: RedeemMagicLinkRequestContent.token → code.