From 44b74d5aafe023f1d4183347ad2f9f060edb6acc Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:46:45 -0700 Subject: [PATCH 01/12] Model RedeemMagicLink's code, and the statuses Fizzy answers for sessions Sessions::MagicLinksController reads params[:code], not token, and reads the pending sign-in from the pending_authentication_token cookie CreateSession sets; CreateSession answers 201 and DestroySession 204. The model said token, 200 and 200, so nothing generated from it could complete a sign-in. RedeemMagicLinkInput now carries code, the two statuses are modelled, and the operations' docs say which cookie each side of the exchange carries. Every language regenerates; the conformance fixtures and runners send code and expect 201 and 204, and Rust's names.toml keeps the code redacted as the token was. --- .../Sources/ConformanceRunner/Dispatch.swift | 2 +- conformance/runner/typescript/runner.test.ts | 2 +- conformance/tests/paths.json | 6 ++--- go/pkg/generated/types.gen.go | 3 ++- .../com/basecamp/fizzy/conformance/Main.kt | 2 +- .../fizzy/generated/services/Types.kt | 2 +- .../fizzy/generated/services/sessions.kt | 6 ++--- openapi.json | 17 +++++++------ ruby/lib/fizzy/generated/metadata.json | 2 +- .../generated/services/sessions_service.rb | 10 ++++---- ruby/lib/fizzy/generated/types.rb | 4 +-- rust/fizzy-sdk/src/generated/redaction.rs | 2 +- rust/fizzy-sdk/src/generated/types.rs | 4 +-- rust/generator/names.toml | 2 +- spec/fizzy.smithy | 17 ++++++++++--- .../Models/RedeemMagicLinkRequest.swift | 6 ++--- typescript/src/generated/metadata.json | 2 +- .../src/generated/openapi-stripped.json | 17 +++++++------ typescript/src/generated/schema.d.ts | 25 +++++++++++++++---- typescript/src/generated/services/sessions.ts | 17 ++++++++++--- 20 files changed, 95 insertions(+), 53 deletions(-) diff --git a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift index cf9eeffa..40442fe9 100644 --- a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift +++ b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift @@ -414,7 +414,7 @@ func dispatchOperation(_ tc: TestCase, _ client: FizzyClient) async throws -> Di case "DestroySession": try await client.sessions.destroy() case "RedeemMagicLink": - _ = try await client.sessions.redeemMagicLink(req: RedeemMagicLinkRequest(token: rb.stringParam("token"))) + _ = try await client.sessions.redeemMagicLink(req: RedeemMagicLinkRequest(code: rb.stringParam("code"))) case "CompleteSignup": try await client.sessions.completeSignup(req: CompleteSignupRequest(fullName: rb.stringParam("full_name"))) case "CompleteJoin": diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index a09ff0ff..d8d11a73 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -356,7 +356,7 @@ async function dispatch( data = await (client as any).sessions.destroy(); break; case "RedeemMagicLink": - data = await (client as any).sessions.redeemMagicLink({ token: body.token }); + data = await (client as any).sessions.redeemMagicLink({ code: body.code }); break; case "CompleteSignup": data = await (client as any).sessions.completeSignup({ full_name: body.full_name }); diff --git a/conformance/tests/paths.json b/conformance/tests/paths.json index dad27fd9..9653884c 100644 --- a/conformance/tests/paths.json +++ b/conformance/tests/paths.json @@ -123,7 +123,7 @@ "requestBody": { "email_address": "test@example.com" }, "mockResponses": [ { - "status": 200, + "status": 201, "headers": { "Content-Type": "application/json" }, "body": { "pending_authentication_token": "pat_12345" } } @@ -293,7 +293,7 @@ "operation": "RedeemMagicLink", "method": "POST", "path": "/session/magic_link.json", - "requestBody": { "token": "ml_token_123" }, + "requestBody": { "code": "ABC123" }, "mockResponses": [ { "status": 200, @@ -314,7 +314,7 @@ "path": "/session.json", "mockResponses": [ { - "status": 200, + "status": 204, "headers": { "Content-Type": "application/json" }, "body": null } diff --git a/go/pkg/generated/types.gen.go b/go/pkg/generated/types.gen.go index 7b880765..b89ffacc 100644 --- a/go/pkg/generated/types.gen.go +++ b/go/pkg/generated/types.gen.go @@ -568,7 +568,8 @@ type Reaction struct { // RedeemMagicLinkRequestContent defines model for RedeemMagicLinkRequestContent. type RedeemMagicLinkRequestContent struct { - Token string `json:"token"` + // Code The code from the magic-link email. + Code string `json:"code"` } // RedeemMagicLinkResponseContent defines model for RedeemMagicLinkResponseContent. diff --git a/kotlin/conformance/src/main/kotlin/com/basecamp/fizzy/conformance/Main.kt b/kotlin/conformance/src/main/kotlin/com/basecamp/fizzy/conformance/Main.kt index 239039f7..96838909 100644 --- a/kotlin/conformance/src/main/kotlin/com/basecamp/fizzy/conformance/Main.kt +++ b/kotlin/conformance/src/main/kotlin/com/basecamp/fizzy/conformance/Main.kt @@ -506,7 +506,7 @@ suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Any? { ) "DestroySession" -> account.sessions.destroy() "RedeemMagicLink" -> account.sessions.redeemMagicLink( - RedeemMagicLinkBody(token = body?.str("token") ?: ""), + RedeemMagicLinkBody(code = body?.str("code") ?: ""), ) "CompleteSignup" -> account.sessions.completeSignup( CompleteSignupBody(fullName = body?.str("full_name") ?: ""), diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/Types.kt index aa3b363b..a95c7190 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/Types.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/Types.kt @@ -211,7 +211,7 @@ data class CreateSessionBody( /** Request body for RedeemMagicLink. */ data class RedeemMagicLinkBody( - val token: String + val code: String ) /** Request body for CompleteSignup. */ diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/sessions.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/sessions.kt index 4f14a69d..81b5a182 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/sessions.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/services/sessions.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.JsonElement class SessionsService(client: AccountClient) : BaseService(client) { /** - * create operation + * Starts a magic-link sign-in and answers the pending authentication token. * @param body Request body */ suspend fun create(body: CreateSessionBody): PendingAuthentication { @@ -52,7 +52,7 @@ class SessionsService(client: AccountClient) : BaseService(client) { } /** - * redeemMagicLink operation + * Redeems the code from a magic-link email for a session token. * @param body Request body */ suspend fun redeemMagicLink(body: RedeemMagicLinkBody): SessionAuthorization { @@ -66,7 +66,7 @@ class SessionsService(client: AccountClient) : BaseService(client) { ) return request(info, { httpPostRoot("/session/magic_link.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { - put("token", kotlinx.serialization.json.JsonPrimitive(body.token)) + put("code", kotlinx.serialization.json.JsonPrimitive(body.code)) }), operationName = info.operation) }) { body -> json.decodeFromString(body) diff --git a/openapi.json b/openapi.json index 6e88eb26..753b2c52 100644 --- a/openapi.json +++ b/openapi.json @@ -413,8 +413,8 @@ "delete": { "operationId": "DestroySession", "responses": { - "200": { - "description": "DestroySession 200 response" + "204": { + "description": "DestroySession 204 response" }, "400": { "description": "BadRequestError 400 response", @@ -492,6 +492,7 @@ } }, "post": { + "description": "Starts a magic-link sign-in and answers the pending authentication token.\n\nFizzy answers 201 and also sets the token as the pending_authentication_token cookie, which\nRedeemMagicLink reads.", "operationId": "CreateSession", "requestBody": { "content": { @@ -504,8 +505,8 @@ "required": true }, "responses": { - "200": { - "description": "CreateSession 200 response", + "201": { + "description": "CreateSession 201 response", "content": { "application/json": { "schema": { @@ -592,6 +593,7 @@ }, "/session/magic_link.json": { "post": { + "description": "Redeems the code from a magic-link email for a session token.\n\nFizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession\nset, not from the body, so the request must carry it as\n`Cookie: pending_authentication_token=`. The session_token it answers is sent back as\n`Cookie: session_token=`.", "operationId": "RedeemMagicLink", "requestBody": { "content": { @@ -14255,12 +14257,13 @@ "RedeemMagicLinkRequestContent": { "type": "object", "properties": { - "token": { - "type": "string" + "code": { + "type": "string", + "description": "The code from the magic-link email." } }, "required": [ - "token" + "code" ] }, "RedeemMagicLinkResponseContent": { diff --git a/ruby/lib/fizzy/generated/metadata.json b/ruby/lib/fizzy/generated/metadata.json index bc7869a3..b6f07e91 100644 --- a/ruby/lib/fizzy/generated/metadata.json +++ b/ruby/lib/fizzy/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://fizzy.do/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-10-17T09:24:49Z", + "generated": "2026-10-17T23:45:49Z", "operations": { "ListAccessTokens": { "retry": { diff --git a/ruby/lib/fizzy/generated/services/sessions_service.rb b/ruby/lib/fizzy/generated/services/sessions_service.rb index 75e37785..73aa00c0 100644 --- a/ruby/lib/fizzy/generated/services/sessions_service.rb +++ b/ruby/lib/fizzy/generated/services/sessions_service.rb @@ -7,7 +7,7 @@ module Services # @generated from OpenAPI spec class SessionsService < BaseService - # create operation + # Starts a magic-link sign-in and answers the pending authentication token. # @param email_address [String] email address # @return [Hash] response data def create(email_address:) @@ -25,12 +25,12 @@ def destroy() end end - # redeem_magic_link operation - # @param token [String] token + # Redeems the code from a magic-link email for a session token. + # @param code [String] The code from the magic-link email. # @return [Hash] response data - def redeem_magic_link(token:) + def redeem_magic_link(code:) with_operation(service: "sessions", operation: "RedeemMagicLink", is_mutation: true) do - http_post("/session/magic_link.json", body: compact_params(token: token)).json + http_post("/session/magic_link.json", body: compact_params(code: code)).json end end diff --git a/ruby/lib/fizzy/generated/types.rb b/ruby/lib/fizzy/generated/types.rb index 1e7afc95..799a6e56 100644 --- a/ruby/lib/fizzy/generated/types.rb +++ b/ruby/lib/fizzy/generated/types.rb @@ -801,11 +801,11 @@ def self.from_json(data) end # @generated - RedeemMagicLinkRequestContent = Data.define(:token) do + RedeemMagicLinkRequestContent = Data.define(:code) do # @param data [Hash] raw JSON response def self.from_json(data) new( - token: data["token"] + code: data["code"] ) end end diff --git a/rust/fizzy-sdk/src/generated/redaction.rs b/rust/fizzy-sdk/src/generated/redaction.rs index 67496f89..4f0695e3 100644 --- a/rust/fizzy-sdk/src/generated/redaction.rs +++ b/rust/fizzy-sdk/src/generated/redaction.rs @@ -11,7 +11,7 @@ pub static PATHS: &[(&str, &[&str])] = &[ ("CreateSessionRequestContent", &["$.email_address"]), ("Identity", &["$.name", "$.email_address"]), ("PendingAuthentication", &["$.pending_authentication_token"]), - ("RedeemMagicLinkRequestContent", &["$.token"]), + ("RedeemMagicLinkRequestContent", &["$.code"]), ( "RequestEmailAddressChangeRequestContent", &["$.email_address"], diff --git a/rust/fizzy-sdk/src/generated/types.rs b/rust/fizzy-sdk/src/generated/types.rs index 0f483943..add9d11b 100644 --- a/rust/fizzy-sdk/src/generated/types.rs +++ b/rust/fizzy-sdk/src/generated/types.rs @@ -1394,12 +1394,12 @@ pub struct Reaction { /// `RedeemMagicLinkRequestContent`. Built by hand, so it derives `Default` and takes `..Default::default()`. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RedeemMagicLinkRequestContent { - /// `token`. + /// `code`. #[serde( default, deserialize_with = "crate::types::null_as_default::deserialize" )] - pub token: SensitiveString, + pub code: SensitiveString, } /// `RedeemMagicLinkResponseContent`, as the model names it. diff --git a/rust/generator/names.toml b/rust/generator/names.toml index 72239626..67d774a5 100644 --- a/rust/generator/names.toml +++ b/rust/generator/names.toml @@ -14,7 +14,7 @@ sensitive_fields = [ "AccessToken.token", "PendingAuthentication.pending_authentication_token", - "RedeemMagicLinkRequestContent.token", + "RedeemMagicLinkRequestContent.code", "SessionAuthorization.session_token", "Webhook.signing_secret", ] diff --git a/spec/fizzy.smithy b/spec/fizzy.smithy index fb6ee9b1..fd550d11 100644 --- a/spec/fizzy.smithy +++ b/spec/fizzy.smithy @@ -3282,7 +3282,11 @@ structure ListWebhookDeliveriesOutput { // Sessions (no account prefix — server-root-scoped) // ═══════════════════════════════════════════════════════════════════════════ -@http(method: "POST", uri: "/session.json") +/// Starts a magic-link sign-in and answers the pending authentication token. +/// +/// Fizzy answers 201 and also sets the token as the pending_authentication_token cookie, which +/// RedeemMagicLink reads. +@http(method: "POST", uri: "/session.json", code: 201) @tags(["Sessions"]) @fizzyRetry(maxAttempts: 1) operation CreateSession { @@ -3302,6 +3306,12 @@ structure CreateSessionOutput { pending_authentication: PendingAuthentication } +/// Redeems the code from a magic-link email for a session token. +/// +/// Fizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession +/// set, not from the body, so the request must carry it as +/// `Cookie: pending_authentication_token=`. The session_token it answers is sent back as +/// `Cookie: session_token=`. @http(method: "POST", uri: "/session/magic_link.json") @tags(["Sessions"]) @fizzyRetry(maxAttempts: 1) @@ -3312,8 +3322,9 @@ operation RedeemMagicLink { } structure RedeemMagicLinkInput { + /// The code from the magic-link email. @required - token: String + code: String } structure RedeemMagicLinkOutput { @@ -3322,7 +3333,7 @@ structure RedeemMagicLinkOutput { } @idempotent -@http(method: "DELETE", uri: "/session.json") +@http(method: "DELETE", uri: "/session.json", code: 204) @tags(["Sessions"]) @fizzyRetry(maxAttempts: 1) operation DestroySession { diff --git a/swift/Sources/Fizzy/Generated/Models/RedeemMagicLinkRequest.swift b/swift/Sources/Fizzy/Generated/Models/RedeemMagicLinkRequest.swift index 7758719d..1d853dd8 100644 --- a/swift/Sources/Fizzy/Generated/Models/RedeemMagicLinkRequest.swift +++ b/swift/Sources/Fizzy/Generated/Models/RedeemMagicLinkRequest.swift @@ -2,9 +2,9 @@ import Foundation public struct RedeemMagicLinkRequest: Codable, Sendable { - public let token: String + public let code: String - public init(token: String) { - self.token = token + public init(code: String) { + self.code = code } } diff --git a/typescript/src/generated/metadata.json b/typescript/src/generated/metadata.json index f31a20c7..ea6bd7ee 100644 --- a/typescript/src/generated/metadata.json +++ b/typescript/src/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://fizzy.do/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-10-17T09:24:48.318Z", + "generated": "2026-10-17T23:45:47.795Z", "operations": { "ListAccessTokens": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 6adaf3a2..36f58c65 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -413,8 +413,8 @@ "delete": { "operationId": "DestroySession", "responses": { - "200": { - "description": "DestroySession 200 response" + "204": { + "description": "DestroySession 204 response" }, "400": { "description": "BadRequestError 400 response", @@ -492,6 +492,7 @@ } }, "post": { + "description": "Starts a magic-link sign-in and answers the pending authentication token.\n\nFizzy answers 201 and also sets the token as the pending_authentication_token cookie, which\nRedeemMagicLink reads.", "operationId": "CreateSession", "requestBody": { "content": { @@ -504,8 +505,8 @@ "required": true }, "responses": { - "200": { - "description": "CreateSession 200 response", + "201": { + "description": "CreateSession 201 response", "content": { "application/json": { "schema": { @@ -592,6 +593,7 @@ }, "/session/magic_link.json": { "post": { + "description": "Redeems the code from a magic-link email for a session token.\n\nFizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession\nset, not from the body, so the request must carry it as\n`Cookie: pending_authentication_token=`. The session_token it answers is sent back as\n`Cookie: session_token=`.", "operationId": "RedeemMagicLink", "requestBody": { "content": { @@ -13411,12 +13413,13 @@ "RedeemMagicLinkRequestContent": { "type": "object", "properties": { - "token": { - "type": "string" + "code": { + "type": "string", + "description": "The code from the magic-link email." } }, "required": [ - "token" + "code" ] }, "RedeemMagicLinkResponseContent": { diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 3deab8aa..a68302a9 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -61,6 +61,12 @@ export interface paths { }; get?: never; put?: never; + /** + * @description Starts a magic-link sign-in and answers the pending authentication token. + * + * Fizzy answers 201 and also sets the token as the pending_authentication_token cookie, which + * RedeemMagicLink reads. + */ post: operations["CreateSession"]; delete: operations["DestroySession"]; options?: never; @@ -77,6 +83,14 @@ export interface paths { }; get?: never; put?: never; + /** + * @description Redeems the code from a magic-link email for a session token. + * + * Fizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession + * set, not from the body, so the request must carry it as + * `Cookie: pending_authentication_token=`. The session_token it answers is sent back as + * `Cookie: session_token=`. + */ post: operations["RedeemMagicLink"]; delete?: never; options?: never; @@ -1635,7 +1649,8 @@ export interface components { url: string; }; RedeemMagicLinkRequestContent: { - token: string; + /** @description The code from the magic-link email. */ + code: string; }; RedeemMagicLinkResponseContent: components["schemas"]["SessionAuthorization"]; RegisterDeviceRequestContent: { @@ -2191,8 +2206,8 @@ export interface operations { }; }; responses: { - /** @description CreateSession 200 response */ - 200: { + /** @description CreateSession 201 response */ + 201: { headers: { [name: string]: unknown; }; @@ -2274,8 +2289,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description DestroySession 200 response */ - 200: { + /** @description DestroySession 204 response */ + 204: { headers: { [name: string]: unknown; }; diff --git a/typescript/src/generated/services/sessions.ts b/typescript/src/generated/services/sessions.ts index 17380fc2..88217983 100644 --- a/typescript/src/generated/services/sessions.ts +++ b/typescript/src/generated/services/sessions.ts @@ -13,7 +13,8 @@ export interface CreateSessionRequest { } export interface RedeemMagicLinkRequest { - token: string; + /** The code from the magic-link email. */ + code: string; } export interface CompleteSignupRequest { @@ -44,7 +45,10 @@ export class SessionsService extends BaseService { } /** - * CreateSession + * Starts a magic-link sign-in and answers the pending authentication token. + +Fizzy answers 201 and also sets the token as the pending_authentication_token cookie, which +RedeemMagicLink reads. */ async create(body: CreateSessionRequest): Promise { return this.request( @@ -61,7 +65,12 @@ export class SessionsService extends BaseService { } /** - * RedeemMagicLink + * Redeems the code from a magic-link email for a session token. + +Fizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession +set, not from the body, so the request must carry it as +`Cookie: pending_authentication_token=`. The session_token it answers is sent back as +`Cookie: session_token=`. */ async redeemMagicLink(body: RedeemMagicLinkRequest): Promise { return this.request( @@ -72,7 +81,7 @@ export class SessionsService extends BaseService { isMutation: true, }, () => this.client.POST("/session/magic_link.json" as never, { - body: { token: body.token } as never, + body: { code: body.code } as never, } as never), ); } From 01ccf9d8a357810570649abdae10e3719f6852df Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:50:50 -0700 Subject: [PATCH 02/12] Keep the JSDoc gutter on every line of a generated operation's description --- typescript/scripts/generate-services.ts | 5 ++++- typescript/src/generated/services/sessions.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 718185d7..48cfa498 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -743,7 +743,10 @@ function generateServiceFile(service: ServiceDefinition): string { for (const op of service.operations) { lines.push(""); lines.push(" /**"); - lines.push(` * ${op.description}`); + // A description of several lines keeps the comment's gutter on every one of them. + for (const line of op.description.split("\n")) { + lines.push(line ? ` * ${line}` : " *"); + } lines.push(" */"); const methodSig = generateMethodSignature(op, service.types); diff --git a/typescript/src/generated/services/sessions.ts b/typescript/src/generated/services/sessions.ts index 88217983..b1d97500 100644 --- a/typescript/src/generated/services/sessions.ts +++ b/typescript/src/generated/services/sessions.ts @@ -46,9 +46,9 @@ export class SessionsService extends BaseService { /** * Starts a magic-link sign-in and answers the pending authentication token. - -Fizzy answers 201 and also sets the token as the pending_authentication_token cookie, which -RedeemMagicLink reads. + * + * Fizzy answers 201 and also sets the token as the pending_authentication_token cookie, which + * RedeemMagicLink reads. */ async create(body: CreateSessionRequest): Promise { return this.request( @@ -66,11 +66,11 @@ RedeemMagicLink reads. /** * Redeems the code from a magic-link email for a session token. - -Fizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession -set, not from the body, so the request must carry it as -`Cookie: pending_authentication_token=`. The session_token it answers is sent back as -`Cookie: session_token=`. + * + * Fizzy reads the pending sign-in from the pending_authentication_token cookie CreateSession + * set, not from the body, so the request must carry it as + * `Cookie: pending_authentication_token=`. The session_token it answers is sent back as + * `Cookie: session_token=`. */ async redeemMagicLink(body: RedeemMagicLinkRequest): Promise { return this.request( From d4be9e2afcaa715b61d1dafccca1cef1a597a275 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:49:55 -0700 Subject: [PATCH 03/12] Sign Go's magic-link flow in through Fizzy's session routes MagicLinkFlow posted {email} to /api/v1/sessions and {token} to /api/v1/sessions/redeem, neither of which Fizzy routes, and dropped the pending token the answer carries, so it could not sign anyone in. It now sends through the generated SessionsService: CreateSession posts {email_address} to /session.json and keeps the pending token from the 201; RedeemMagicLink posts {code} to /session/magic_link.json with the token as the pending_authentication_token cookie, escaped as Rack unescapes it, and answers the generated SessionAuthorization. PendingToken and ResumeMagicLinkFlow let a sign-in finish in another process, as Rust's flow does. Like NewClient, the flow now requires HTTPS outside localhost. This breaks the flow's API: CreateSession answers *generated.PendingAuthentication, RedeemMagicLink takes the emailed code and answers *generated.SessionAuthorization, Login answers that session too, and the hand-written CreateSessionRequest/Response and RedeemMagicLinkRequest/Response types, with their user_id, are gone. fizzy-cli does not use this flow. --- go/pkg/fizzy/magic_link.go | 205 +++++++++++++++----------------- go/pkg/fizzy/magic_link_test.go | 163 +++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 108 deletions(-) create mode 100644 go/pkg/fizzy/magic_link_test.go diff --git a/go/pkg/fizzy/magic_link.go b/go/pkg/fizzy/magic_link.go index 2d7e522e..06d7725e 100644 --- a/go/pkg/fizzy/magic_link.go +++ b/go/pkg/fizzy/magic_link.go @@ -2,149 +2,138 @@ package fizzy import ( "context" - "encoding/json" "fmt" "net/http" + "net/url" "strings" + "sync" + + "github.com/basecamp/fizzy-sdk/go/pkg/generated" ) -// MagicLinkFlow orchestrates passwordless login via magic links. -// The flow is: -// 1. CreateSession — sends a magic link to the user's email -// 2. User clicks the magic link in their email -// 3. RedeemMagicLink — exchanges the magic link token for a session token +// PendingAuthenticationCookie is the cookie a pending sign-in is carried in between +// creating the session and redeeming the code. +const PendingAuthenticationCookie = "pending_authentication_token" + +// MagicLinkFlow signs in without a password. Fizzy emails a code to an address and, +// once the person types it back, answers a session token for CookieAuth. +// +// The two steps are one exchange as far as Fizzy is concerned: CreateSession answers a +// pending authentication token, and RedeemMagicLink has to send it back as the +// pending_authentication_token cookie or the code is refused. The flow keeps the token +// between the calls, and hands it out (PendingToken) for a caller that finishes in +// another process — a CLI that prompts for the code on its next run — which picks it +// back up with ResumeMagicLinkFlow. +// +// Both steps go out through the generated SessionsService routes (/session.json and +// /session/magic_link.json) and their generated request and response types. type MagicLinkFlow struct { - baseURL string - httpClient *http.Client + client *Client + + mu sync.Mutex + pending string } -// NewMagicLinkFlow creates a new magic link flow. +// NewMagicLinkFlow creates a flow against baseURL. A non-nil httpClient lends its +// Transport and Timeout; the flow sends no credentials, since there are none yet. Like +// NewClient, it panics on a base URL that is not HTTPS (localhost aside). func NewMagicLinkFlow(baseURL string, httpClient *http.Client) *MagicLinkFlow { - if httpClient == nil { - httpClient = &http.Client{} + var opts []ClientOption + if httpClient != nil { + if httpClient.Transport != nil { + opts = append(opts, WithTransport(httpClient.Transport)) + } + if httpClient.Timeout > 0 { + opts = append(opts, WithTimeout(httpClient.Timeout)) + } } + opts = append(opts, WithAuthStrategy(pendingCookieAuth{})) return &MagicLinkFlow{ - baseURL: NormalizeBaseURL(baseURL), - httpClient: httpClient, + client: NewClient(&Config{BaseURL: NormalizeBaseURL(baseURL)}, nil, opts...), } } -// CreateSessionRequest is the request body for creating a session. -type CreateSessionRequest struct { - Email string `json:"email"` -} - -// CreateSessionResponse is the response from creating a session. -type CreateSessionResponse struct { - Message string `json:"message"` +// ResumeMagicLinkFlow creates a flow that picks up where another left off, with the +// pending token that one handed out through PendingToken. +func ResumeMagicLinkFlow(baseURL string, httpClient *http.Client, pendingToken string) *MagicLinkFlow { + flow := NewMagicLinkFlow(baseURL, httpClient) + flow.pending = pendingToken + return flow } -// CreateSession initiates the magic link flow by sending a magic link email. -func (f *MagicLinkFlow) CreateSession(ctx context.Context, email string) (*CreateSessionResponse, error) { - body, err := json.Marshal(CreateSessionRequest{Email: email}) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - url := f.baseURL + "/api/v1/sessions" - req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body))) +// CreateSession asks Fizzy to email a sign-in code to emailAddress, and keeps the +// pending token the answer carries for RedeemMagicLink. +func (f *MagicLinkFlow) CreateSession(ctx context.Context, emailAddress string) (*generated.PendingAuthentication, error) { + pending, _, err := f.client.Sessions().Create(ctx, &generated.CreateSessionRequest{EmailAddress: emailAddress}) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := f.httpClient.Do(req) - if err != nil { - return nil, networkError(err, f.baseURL) - } - defer func() { _ = resp.Body.Close() }() - - respBody, err := limitedReadAll(resp.Body, MaxResponseBodyBytes) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return nil, ErrAPI(resp.StatusCode, fmt.Sprintf("create session failed: HTTP %d", resp.StatusCode)) - } - - var result CreateSessionResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &result, nil + f.mu.Lock() + f.pending = pending.PendingAuthenticationToken + f.mu.Unlock() + return pending, nil } -// RedeemMagicLinkRequest is the request body for redeeming a magic link. -type RedeemMagicLinkRequest struct { - Token string `json:"token"` +// PendingToken is the token a pending sign-in is waiting on, or "" when none is, for a +// caller that finishes the flow in another process. +func (f *MagicLinkFlow) PendingToken() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.pending } -// RedeemMagicLinkResponse is the response from redeeming a magic link. -type RedeemMagicLinkResponse struct { - SessionToken string `json:"session_token"` - UserID string `json:"user_id"` -} - -// RedeemMagicLink exchanges a magic link token for a session token. -func (f *MagicLinkFlow) RedeemMagicLink(ctx context.Context, token string) (*RedeemMagicLinkResponse, error) { - body, err := json.Marshal(RedeemMagicLinkRequest{Token: token}) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) +// RedeemMagicLink sends the code from the email back with the pending token, and +// answers the session Fizzy signed in. Without a pending token — no CreateSession +// before it, and none given to ResumeMagicLinkFlow — there is nothing to redeem against. +func (f *MagicLinkFlow) RedeemMagicLink(ctx context.Context, code string) (*generated.SessionAuthorization, error) { + pending := f.PendingToken() + if pending == "" { + return nil, &Error{ + Code: CodeUsage, + Message: "no sign-in is pending", + Hint: "call CreateSession first, or resume with the pending token", + } } - - url := f.baseURL + "/api/v1/sessions/redeem" - req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body))) + ctx = context.WithValue(ctx, pendingTokenKey{}, pending) + session, _, err := f.client.Sessions().RedeemMagicLink(ctx, &generated.RedeemMagicLinkRequest{Code: code}) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := f.httpClient.Do(req) - if err != nil { - return nil, networkError(err, f.baseURL) - } - defer func() { _ = resp.Body.Close() }() + f.mu.Lock() + f.pending = "" + f.mu.Unlock() + return session, nil +} - respBody, err := limitedReadAll(resp.Body, MaxResponseBodyBytes) +// Login redeems the code and stores the session token Fizzy answers. When the answer +// says RequiresSignupCompletion, the caller completes the signup next +// (SessionsService.CompleteSignup) with that session. +func (f *MagicLinkFlow) Login(ctx context.Context, authManager *AuthManager, code string) (*generated.SessionAuthorization, error) { + session, err := f.RedeemMagicLink(ctx, code) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, ErrAPI(resp.StatusCode, fmt.Sprintf("redeem magic link failed: HTTP %d", resp.StatusCode)) + return nil, err } - - var result RedeemMagicLinkResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + if err := authManager.SaveSessionToken(session.SessionToken); err != nil { + return nil, fmt.Errorf("failed to save session token: %w", err) } - - return &result, nil + return session, nil } -// Login performs the full magic link login flow and stores the session token. -// After calling CreateSession, the caller must obtain the magic link token -// (e.g., by prompting the user to check their email), then call RedeemMagicLink. -// This method handles the final step of storing the token. -func (f *MagicLinkFlow) Login(ctx context.Context, authManager *AuthManager, magicLinkToken string) error { - result, err := f.RedeemMagicLink(ctx, magicLinkToken) - if err != nil { - return err - } +type pendingTokenKey struct{} - if err := authManager.SaveSessionToken(result.SessionToken); err != nil { - return fmt.Errorf("failed to save session token: %w", err) - } +// pendingCookieAuth sends no credentials, except the pending token as the +// pending_authentication_token cookie on the redeem it is handed to. +type pendingCookieAuth struct{} - if result.UserID != "" { - if err := authManager.SetUserID(result.UserID); err != nil { - return fmt.Errorf("failed to save user ID: %w", err) - } +func (pendingCookieAuth) Authenticate(ctx context.Context, req *http.Request) error { + if token, ok := ctx.Value(pendingTokenKey{}).(string); ok && token != "" { + req.Header.Add("Cookie", PendingAuthenticationCookie+"="+escapeCookieValue(token)) } - return nil } + +// escapeCookieValue percent-encodes everything but ASCII letters, digits and -_.~, as +// Rack unescapes a cookie value; a raw token can carry characters a cookie cannot. +func escapeCookieValue(value string) string { + return strings.ReplaceAll(url.QueryEscape(value), "+", "%20") +} diff --git a/go/pkg/fizzy/magic_link_test.go b/go/pkg/fizzy/magic_link_test.go new file mode 100644 index 00000000..c050ee1c --- /dev/null +++ b/go/pkg/fizzy/magic_link_test.go @@ -0,0 +1,163 @@ +package fizzy + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// fizzySessions answers the two halves of a magic-link sign-in as Fizzy does: 201 with +// the pending token for {email_address} at /session.json, and the session for {code} at +// /session/magic_link.json only when the pending token comes back as the cookie. +func fizzySessions(t *testing.T, pending string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var params map[string]any + _ = json.Unmarshal(body, ¶ms) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session.json": + if params["email_address"] != "jane@example.com" || len(params) != 1 { + t.Errorf("create session body = %s, want only email_address", body) + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"pending_authentication_token":"`+pending+`"}`) + case r.Method == http.MethodPost && r.URL.Path == "/session/magic_link.json": + if params["code"] != "ABC123" || len(params) != 1 { + t.Errorf("redeem body = %s, want only code", body) + } + cookie, err := r.Cookie(PendingAuthenticationCookie) + if err != nil || cookie.Value == "" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"message":"Enter your email address to sign in."}`) + return + } + if r.Header.Get("Authorization") != "" { + t.Errorf("redeem sent an Authorization header") + } + w.Header().Set("X-Pending-Seen", cookie.Value) + _, _ = io.WriteString(w, `{"session_token":"st-1","requires_signup_completion":false}`) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} + +func TestMagicLinkFlowSignsInThroughFizzysSessionRoutes(t *testing.T) { + server := fizzySessions(t, "pend-1") + flow := NewMagicLinkFlow(server.URL, nil) + + pending, err := flow.CreateSession(context.Background(), "jane@example.com") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if pending.PendingAuthenticationToken != "pend-1" || flow.PendingToken() != "pend-1" { + t.Fatalf("pending = %+v, flow keeps %q; want pend-1", pending, flow.PendingToken()) + } + + session, err := flow.RedeemMagicLink(context.Background(), "ABC123") + if err != nil { + t.Fatalf("RedeemMagicLink: %v", err) + } + if session.SessionToken != "st-1" || session.RequiresSignupCompletion { + t.Errorf("session = %+v, want st-1 with no signup to complete", session) + } + if flow.PendingToken() != "" { + t.Errorf("the pending token outlived its redeem") + } +} + +// A token Fizzy hands out can carry characters a cookie cannot; it goes out escaped, as +// Rack unescapes it. +func TestMagicLinkFlowEscapesThePendingCookie(t *testing.T) { + var seen string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Cookie") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"session_token":"st-1","requires_signup_completion":true}`) + })) + t.Cleanup(server.Close) + + flow := ResumeMagicLinkFlow(server.URL, nil, "eyJ a/b+c==--d") + session, err := flow.RedeemMagicLink(context.Background(), "ABC123") + if err != nil { + t.Fatalf("RedeemMagicLink: %v", err) + } + if want := "pending_authentication_token=eyJ%20a%2Fb%2Bc%3D%3D--d"; seen != want { + t.Errorf("Cookie = %q, want %q", seen, want) + } + if !session.RequiresSignupCompletion { + t.Errorf("requires_signup_completion was lost") + } +} + +func TestMagicLinkFlowResumesWithAPendingTokenFromAnotherProcess(t *testing.T) { + server := fizzySessions(t, "unused") + flow := ResumeMagicLinkFlow(server.URL, nil, "pend-9") + + session, err := flow.RedeemMagicLink(context.Background(), "ABC123") + if err != nil { + t.Fatalf("RedeemMagicLink: %v", err) + } + if session.SessionToken != "st-1" { + t.Errorf("session = %+v", session) + } +} + +func TestMagicLinkFlowRefusesARedeemWithNothingPending(t *testing.T) { + server := fizzySessions(t, "unused") + flow := NewMagicLinkFlow(server.URL, nil) + + _, err := flow.RedeemMagicLink(context.Background(), "ABC123") + if e := AsError(err); e == nil || e.Code != CodeUsage { + t.Fatalf("err = %v, want a usage error", err) + } +} + +// Fizzy answers 401 for a wrong code or a missing cookie, and that is an auth error, not +// a retry. +func TestMagicLinkFlowReportsARefusedCodeAsAnAuthError(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"message":"Try another code."}`) + })) + t.Cleanup(server.Close) + flow := ResumeMagicLinkFlow(server.URL, nil, "pend-1") + + _, err := flow.RedeemMagicLink(context.Background(), "WRONG1") + if e := AsError(err); e == nil || e.Code != CodeAuth || e.HTTPStatus != http.StatusUnauthorized { + t.Fatalf("err = %v, want an auth error", err) + } + if requests != 1 { + t.Errorf("requests = %d, want 1", requests) + } + if flow.PendingToken() != "pend-1" { + t.Errorf("a refused code dropped the pending token; the person can still try again") + } +} + +func TestMagicLinkFlowLoginSavesTheSessionToken(t *testing.T) { + t.Setenv("FIZZY_NO_KEYRING", "1") + server := fizzySessions(t, "pend-1") + flow := ResumeMagicLinkFlow(server.URL, nil, "pend-1") + store := NewCredentialStore(t.TempDir()) + manager := NewAuthManagerWithStore(&Config{BaseURL: server.URL}, store) + + session, err := flow.Login(context.Background(), manager, "ABC123") + if err != nil { + t.Fatalf("Login: %v", err) + } + if session.SessionToken != "st-1" { + t.Errorf("session = %+v", session) + } +} From d0c4b79482cd20e59d96682c95e48185e88e2f32 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:50:00 -0700 Subject: [PATCH 04/12] Sign in to Fizzy the way it signs in, in TypeScript MagicLinkFlow posted {email} to /sessions and {token} to /sessions/redeem, which Fizzy has never routed, and discarded the pending token CreateSession answers. It now goes through the generated sessions operations on a client from createFizzyClient: CreateSession with {email_address}, keeping the 201's pending token; RedeemMagicLink with {code} and the pending token as a percent-encoded pending_authentication_token cookie, which is where Fizzy reads it; and CompleteSignup with {full_name} under the session cookie. This changes the API: requestLink(email) is createSession(emailAddress) and answers the pending token; redeemToken(token) is redeem(code) and answers requiresSignupCompletion beside the session token and auth; resume(pendingToken) and pendingToken let a sign-in finish in another process; completeSignup(fullName) is new. The options take the client options that apply before there is a credential (baseUrl, userAgent, requestTimeoutMs, hooks). --- typescript/src/auth/magic-link.ts | 194 ++++++++++++++++------------ typescript/tests/magic-link.test.ts | 135 +++++++++++++++++++ 2 files changed, 249 insertions(+), 80 deletions(-) create mode 100644 typescript/tests/magic-link.test.ts diff --git a/typescript/src/auth/magic-link.ts b/typescript/src/auth/magic-link.ts index c44043a8..6fb044ef 100644 --- a/typescript/src/auth/magic-link.ts +++ b/typescript/src/auth/magic-link.ts @@ -1,128 +1,162 @@ /** - * Magic-link passwordless login flow for the Fizzy SDK. + * Magic-link passwordless sign-in for the Fizzy SDK. * - * Two-step orchestrator: - * 1. CreateSession — sends a magic link to the user's email - * 2. RedeemMagicLink — exchanges the magic link token for a session + * Fizzy's sign-in is three requests, each a generated operation: + * 1. CreateSession (`POST /session.json`, `{email_address}`) emails a code and answers 201 + * with the pending authentication token. + * 2. RedeemMagicLink (`POST /session/magic_link.json`, `{code}`) exchanges the code for a + * session token. Fizzy reads the pending sign-in from the `pending_authentication_token` + * cookie, not the body, so the flow sends that cookie itself. + * 3. CompleteSignup (`POST /signup/completion.json`, `{full_name}`) finishes a new account, + * when the redemption says one needs it. + * + * Every request goes through a client from {@link createFizzyClient}, so HTTPS enforcement, + * body limits, error mapping and hooks are the client's own. */ import type { AuthStrategy } from "../auth-strategy.js"; +import { createFizzyClient, type FizzyClient, type FizzyClientOptions } from "../client.js"; import { FizzyError } from "../errors.js"; +import { CookieAuth } from "./cookie-auth.js"; + +/** The cookie a pending sign-in is carried in between creating the session and redeeming the code. */ +const PENDING_COOKIE = "pending_authentication_token"; +/** The cookie a session token is sent back in. */ +const SESSION_COOKIE = "session_token"; /** - * Result of the magic link request step. + * A `Cookie` header value carrying one cookie, its value percent-encoded as Rack decodes it. */ +export function cookieHeader(name: string, value: string): string { + return `${name}=${encodeURIComponent(value)}`; +} + +/** What creating a session answers. */ export interface MagicLinkRequestResult { - /** Whether the magic link email was sent successfully */ - sent: boolean; - /** User-facing message (e.g., "Check your email") */ - message: string; + /** + * The token the pending sign-in waits on. The flow keeps it for {@link MagicLinkFlow.redeem}; + * a caller that finishes the sign-in in another process hands it to + * {@link MagicLinkFlow.resume} there. + */ + pendingAuthenticationToken: string; } -/** - * Result of the magic link redemption step. - */ +/** What redeeming a code answers. */ export interface MagicLinkRedeemResult { - /** The session token to use for subsequent requests */ + /** The session token, sent back as the `session_token` cookie. */ sessionToken: string; - /** The authentication strategy configured with the session */ + /** Whether the account is new and needs {@link MagicLinkFlow.completeSignup} before use. */ + requiresSignupCompletion: boolean; + /** An authentication strategy that sends the session cookie, for {@link createFizzyClient}. */ auth: AuthStrategy; } -/** - * Options for the magic link flow. - */ -export interface MagicLinkFlowOptions { - /** Base URL of the Fizzy API (defaults to https://fizzy.do) */ - baseUrl?: string; - /** User-Agent header for requests */ - userAgent?: string; -} +/** Options for the magic link flow: the client options that apply before there is a credential. */ +export type MagicLinkFlowOptions = Pick< + FizzyClientOptions, + "baseUrl" | "userAgent" | "requestTimeoutMs" | "hooks" +>; + +/** Sends no credential: there is none yet when a sign-in starts. */ +const noCredentials: AuthStrategy = { authenticate: async () => {} }; /** - * Orchestrates the magic-link passwordless login flow. + * Orchestrates Fizzy's magic-link sign-in. * * @example * ```ts * const flow = new MagicLinkFlow(); * - * // Step 1: Request the magic link - * const { sent } = await flow.requestLink("user@example.com"); + * // Step 1: Fizzy emails a code + * await flow.createSession("user@example.com"); * - * // Step 2: User clicks the link, you capture the token - * const { auth } = await flow.redeemToken(token); + * // Step 2: the user types the code in + * const { auth, requiresSignupCompletion } = await flow.redeem(code); + * if (requiresSignupCompletion) await flow.completeSignup("Ada Lovelace"); * - * // Step 3: Use the auth strategy with the client + * // Step 3: use the session * const client = createFizzyClient({ auth }); * ``` */ export class MagicLinkFlow { - private readonly baseUrl: string; - private readonly userAgent: string; + private readonly options: MagicLinkFlowOptions; + private pending: string | undefined; + private session: string | undefined; constructor(options?: MagicLinkFlowOptions) { - this.baseUrl = options?.baseUrl ?? "https://fizzy.do"; - this.userAgent = options?.userAgent ?? "fizzy-sdk-ts/0.1.0"; + this.options = options ?? {}; } /** - * Step 1: Request a magic link be sent to the user's email. + * A flow that picks up where another left off, with the pending token that one handed out + * through {@link MagicLinkFlow.pendingToken}. */ - async requestLink(email: string): Promise { - const response = await fetch(`${this.baseUrl}/sessions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": this.userAgent, - Accept: "application/json", - }, - body: JSON.stringify({ email }), - }); - - if (!response.ok) { - throw new FizzyError( - "api_error", - `Magic link request failed: ${response.status} ${response.statusText}`, - { httpStatus: response.status }, - ); - } + static resume(pendingToken: string, options?: MagicLinkFlowOptions): MagicLinkFlow { + const flow = new MagicLinkFlow(options); + flow.pending = pendingToken; + return flow; + } - return { sent: true, message: "Check your email for the magic link" }; + /** The token the pending sign-in waits on, for a caller that finishes the flow elsewhere. */ + get pendingToken(): string | undefined { + return this.pending; } /** - * Step 2: Redeem a magic link token for a session. + * Asks Fizzy to email a sign-in code to the address, and keeps the pending token its 201 + * answer carries for {@link MagicLinkFlow.redeem}. */ - async redeemToken(token: string): Promise { - const response = await fetch(`${this.baseUrl}/sessions/redeem`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": this.userAgent, - Accept: "application/json", - }, - body: JSON.stringify({ token }), - }); - - if (!response.ok) { - throw new FizzyError( - "auth_required", - `Magic link redemption failed: ${response.status} ${response.statusText}`, - { httpStatus: response.status, hint: "The magic link may have expired. Request a new one." }, - ); + async createSession(emailAddress: string): Promise { + const answer = await this.client(noCredentials).sessions.create({ emailAddress }); + const token = answer?.pending_authentication_token; + if (typeof token !== "string" || token === "") { + throw new FizzyError("api_error", "No pending authentication token in the session response"); } + this.pending = token; + return { pendingAuthenticationToken: token }; + } - const body = await response.json() as { session_token?: string; token?: string }; - const sessionToken = body.session_token ?? body.token; - - if (!sessionToken || typeof sessionToken !== "string") { - throw new FizzyError("api_error", "No session token in magic link response"); + /** + * Sends the code back with the pending token as its cookie, and answers the session Fizzy + * signed in. Without a pending token, from {@link MagicLinkFlow.createSession} or + * {@link MagicLinkFlow.resume}, there is nothing to redeem against. + */ + async redeem(code: string): Promise { + if (!this.pending) { + throw new FizzyError("usage", "No sign-in is pending", { + hint: "Call createSession first, or resume with the pending token", + }); } + const pendingCookie = new CookieAuth(cookieHeader(PENDING_COOKIE, this.pending)); + const answer = await this.client(pendingCookie).sessions.redeemMagicLink({ code }); + const sessionToken = answer?.session_token; + if (typeof sessionToken !== "string" || sessionToken === "") { + throw new FizzyError("api_error", "No session token in the magic link response"); + } + this.session = sessionToken; + this.pending = undefined; + return { + sessionToken, + requiresSignupCompletion: answer.requires_signup_completion === true, + auth: new CookieAuth(cookieHeader(SESSION_COOKIE, sessionToken)), + }; + } - // Import CookieAuth dynamically to avoid circular dependency at module level - const { CookieAuth } = await import("./cookie-auth.js"); - const auth = new CookieAuth(sessionToken); + /** + * Finishes a new account's sign-up under the session {@link MagicLinkFlow.redeem} answered, + * or the session token given. Fizzy answers 201 with no body. + */ + async completeSignup(fullName: string, sessionToken?: string): Promise { + const token = sessionToken ?? this.session; + if (!token) { + throw new FizzyError("usage", "No session to complete the sign-up under", { + hint: "Redeem the code first, or pass the session token", + }); + } + await this.client(new CookieAuth(cookieHeader(SESSION_COOKIE, token))).sessions.completeSignup({ fullName }); + } - return { sessionToken, auth }; + private client(auth: AuthStrategy): FizzyClient { + return createFizzyClient({ ...this.options, auth }); } } diff --git a/typescript/tests/magic-link.test.ts b/typescript/tests/magic-link.test.ts new file mode 100644 index 00000000..f3ffc112 --- /dev/null +++ b/typescript/tests/magic-link.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; +import { MagicLinkFlow, isFizzyError } from "../src/index.js"; + +// Fizzy's contract (SessionsController, Sessions::MagicLinksController, Signups::CompletionsController): +// POST /session.json {email_address} → 201 {pending_authentication_token}; +// POST /session/magic_link.json {code} with Cookie: pending_authentication_token → 200 +// {session_token, requires_signup_completion}; POST /signup/completion.json {full_name} +// under the session cookie → 201, no body. + +const BASE_URL = "http://localhost:9878"; +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +interface Seen { + path: string; + body: unknown; + cookie: string | null; +} + +function fizzy(seen: Seen[], redeem: { status: number; body: unknown } = { + status: 200, + body: { session_token: "st/1=", requires_signup_completion: false }, +}) { + const record = async (request: Request) => { + const text = await request.text(); + seen.push({ + path: new URL(request.url).pathname, + body: text ? JSON.parse(text) : undefined, + cookie: request.headers.get("Cookie"), + }); + }; + server.use( + http.post(`${BASE_URL}/session.json`, async ({ request }) => { + await record(request); + return HttpResponse.json({ pending_authentication_token: "pend+1/=" }, { status: 201 }); + }), + http.post(`${BASE_URL}/session/magic_link.json`, async ({ request }) => { + await record(request); + return HttpResponse.json(redeem.body, { status: redeem.status }); + }), + http.post(`${BASE_URL}/signup/completion.json`, async ({ request }) => { + await record(request); + return new HttpResponse(null, { status: 201 }); + }), + ); +} + +describe("MagicLinkFlow", () => { + it("creates the session, keeps the pending token, and redeems the code with it as a cookie", async () => { + const seen: Seen[] = []; + fizzy(seen); + const flow = new MagicLinkFlow({ baseUrl: BASE_URL }); + + const created = await flow.createSession("ada@example.com"); + expect(created.pendingAuthenticationToken).toBe("pend+1/="); + expect(flow.pendingToken).toBe("pend+1/="); + + const redeemed = await flow.redeem("ABC123"); + expect(redeemed.sessionToken).toBe("st/1="); + expect(redeemed.requiresSignupCompletion).toBe(false); + + expect(seen).toEqual([ + { path: "/session.json", body: { email_address: "ada@example.com" }, cookie: null }, + { + path: "/session/magic_link.json", + body: { code: "ABC123" }, + cookie: "pending_authentication_token=pend%2B1%2F%3D", + }, + ]); + }); + + it("answers an auth strategy that sends the session cookie", async () => { + fizzy([]); + const flow = MagicLinkFlow.resume("pend", { baseUrl: BASE_URL }); + const { auth } = await flow.redeem("ABC123"); + + const headers = new Headers(); + await auth.authenticate(headers); + expect(headers.get("Cookie")).toBe("session_token=st%2F1%3D"); + }); + + it("resumes a sign-in another process started", async () => { + const seen: Seen[] = []; + fizzy(seen); + const flow = MagicLinkFlow.resume("pend-elsewhere", { baseUrl: BASE_URL }); + + await flow.redeem("ABC123"); + expect(seen).toEqual([ + { path: "/session/magic_link.json", body: { code: "ABC123" }, cookie: "pending_authentication_token=pend-elsewhere" }, + ]); + }); + + it("refuses to redeem with no sign-in pending, sending nothing", async () => { + const seen: Seen[] = []; + fizzy(seen); + const flow = new MagicLinkFlow({ baseUrl: BASE_URL }); + + await expect(flow.redeem("ABC123")).rejects.toMatchObject({ code: "usage" }); + expect(seen).toEqual([]); + }); + + it("maps a refused code to an auth error", async () => { + fizzy([], { status: 401, body: { message: "Try another code." } }); + const flow = MagicLinkFlow.resume("pend", { baseUrl: BASE_URL }); + + const error = await flow.redeem("WRONG").catch((e: unknown) => e); + expect(isFizzyError(error)).toBe(true); + expect(error).toMatchObject({ code: "auth_required", httpStatus: 401 }); + }); + + it("completes a new account's sign-up under the session cookie", async () => { + const seen: Seen[] = []; + fizzy(seen, { status: 200, body: { session_token: "st-new", requires_signup_completion: true } }); + const flow = MagicLinkFlow.resume("pend", { baseUrl: BASE_URL }); + + const { requiresSignupCompletion } = await flow.redeem("ABC123"); + expect(requiresSignupCompletion).toBe(true); + await flow.completeSignup("Ada Lovelace"); + + expect(seen[1]).toEqual({ + path: "/signup/completion.json", + body: { full_name: "Ada Lovelace" }, + cookie: "session_token=st-new", + }); + }); + + it("refuses a plain-HTTP origin other than localhost", async () => { + const flow = new MagicLinkFlow({ baseUrl: "http://fizzy.example.com" }); + await expect(flow.createSession("ada@example.com")).rejects.toMatchObject({ code: "usage" }); + }); +}); From 5a50bf6619f5f59df03e67fa996c802560f330d7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:49:20 -0700 Subject: [PATCH 05/12] Sign Ruby's magic-link flow in the way Fizzy answers MagicLinkFlow posted {email} to /sessions and {token} to /sessions/redeem, neither of which Fizzy routes, and never sent back the pending_authentication_token cookie Fizzy reads the sign-in from, so no sign-in could complete. It now goes through the generated SessionsService: create_session(email_address:) posts /session.json, expects the 201's pending token and keeps it; redeem(code:) posts /session/magic_link.json with {code} and the token as a percent-encoded Cookie, and answers session_token and requires_signup_completion. pending_token hands the token out and pending_token: resumes a sign-in another process started. request_magic_link(email:) and redeem(token:) are gone; they could not have worked. --- ruby/lib/fizzy/magic_link_flow.rb | 109 +++++++++++++++++++++-------- ruby/test/magic_link_flow_test.rb | 110 ++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 30 deletions(-) create mode 100644 ruby/test/magic_link_flow_test.rb diff --git a/ruby/lib/fizzy/magic_link_flow.rb b/ruby/lib/fizzy/magic_link_flow.rb index 899140e2..8d51975f 100644 --- a/ruby/lib/fizzy/magic_link_flow.rb +++ b/ruby/lib/fizzy/magic_link_flow.rb @@ -1,56 +1,105 @@ # frozen_string_literal: true module Fizzy - # Orchestrates the passwordless magic link authentication flow. + # Signs in without a password. Fizzy emails a code to an address and, once the person types + # it back, hands out a session token for {CookieAuth}. # - # The flow works in two steps: - # 1. Call CreateSession with an email address - this sends a magic link email - # 2. Call RedeemMagicLink with the token from the magic link URL + # The two steps are one exchange as far as Fizzy is concerned: creating the session sets a + # +pending_authentication_token+ cookie naming the address the code went to, and redeeming + # the code has to send that cookie back or the code is refused. The flow keeps the token + # between the calls, and hands it out for a caller that finishes in another process (a CLI + # that prompts for the code on its next run), which picks it up again with +pending_token:+. # - # After redemption, the response contains a session token that can be used - # with CookieAuth or BearerAuth for subsequent requests. + # Both requests go through the generated {Services::SessionsService}, so their paths and + # bodies are the model's: +POST /session.json+ with +email_address+, then + # +POST /session/magic_link.json+ with +code+. # # @example - # flow = Fizzy::MagicLinkFlow.new(base_url: "https://fizzy.do") - # flow.request_magic_link(email: "user@example.com") - # # User clicks magic link in email, your app extracts the token - # session = flow.redeem(token: "magic-link-token-from-url") - # # Use session token for authenticated requests + # flow = Fizzy::MagicLinkFlow.new + # flow.create_session(email_address: "jane@example.com") + # # ... the person reads the code out of their email ... + # session = flow.redeem(code: "ABC123") # client = Fizzy.client(auth: Fizzy::CookieAuth.new(session["session_token"])) + # + # @example Finishing in another process + # pending = Fizzy::MagicLinkFlow.new.tap { _1.create_session(email_address: email) }.pending_token + # # ... later ... + # Fizzy::MagicLinkFlow.new(pending_token: pending).redeem(code: code) class MagicLinkFlow + # The cookie a pending sign-in is carried in between creating the session and redeeming + # the code. + PENDING_COOKIE = "pending_authentication_token" + + # The token a pending sign-in is waiting on, for a caller that finishes the flow in + # another process. + # @return [String, nil] + attr_reader :pending_token + # @param base_url [String] Fizzy API base URL # @param hooks [Hooks, nil] observability hooks - def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil) - @config = Config.new(base_url: base_url) - @hooks = hooks || NoopHooks.new - @http = Http.new(config: @config, auth_strategy: NullAuth.new, hooks: @hooks) + # @param pending_token [String, nil] the pending token of a sign-in another flow started + def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil, pending_token: nil) + @pending_token = pending_token + @client = Client.new( + config: Config.new(base_url: base_url), + auth_strategy: PendingCookieAuth.new(self), + hooks: hooks + ) end - # Step 1: Request a magic link email. + # Asks Fizzy to email a sign-in code to the address, and keeps the pending token the + # answer carries for {#redeem}. # - # @param email [String] the user's email address - # @return [Hash] response from the API - def request_magic_link(email:) - response = @http.post("/sessions", body: { email: email }) - response.json + # @param email_address [String] the address to send the code to + # @return [String] the pending authentication token + # @raise [APIError] when Fizzy answers without a pending token + def create_session(email_address:) + answer = @client.sessions.create(email_address: email_address) + token = answer.is_a?(Hash) ? answer["pending_authentication_token"] : nil + raise APIError.new("Fizzy answered no pending authentication token") \ + unless token.is_a?(String) && !token.empty? + + @pending_token = token end - # Step 2: Redeem a magic link token to get a session. + # Redeems the code from the email, sending back the pending token as the cookie Fizzy + # reads it from. # - # @param token [String] the magic link token from the URL - # @return [Hash] response containing session_token and user info - def redeem(token:) - response = @http.post("/sessions/redeem", body: { token: token }) - response.json + # @param code [String] the code from the magic-link email + # @return [Hash] +session_token+ and +requires_signup_completion+ + # @raise [UsageError] when no sign-in is pending + # @raise [AuthError] when Fizzy refuses the code or the pending token + def redeem(code:) + raise UsageError.new("no sign-in is pending; call create_session first, or pass pending_token:") \ + if @pending_token.nil? || @pending_token.empty? + + answer = @client.sessions.redeem_magic_link(code: code) + raise APIError.new("Fizzy answered no session token") \ + unless answer.is_a?(Hash) && answer["session_token"].is_a?(String) + + answer.slice("session_token", "requires_signup_completion") end - # Null authentication strategy for unauthenticated requests. + # A +Cookie+ header value carrying one cookie, its value percent-encoded as Rack decodes it. # @api private - class NullAuth + def self.cookie(name, value) + encoded = value.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/n) { |byte| format("%%%02X", byte.ord) } + "#{name}=#{encoded}" + end + + # Sends the flow's pending token, once there is one, as the cookie Fizzy reads the + # pending sign-in from, and no other credentials: there are none yet. + # @api private + class PendingCookieAuth include AuthStrategy + def initialize(flow) + @flow = flow + end + def authenticate(headers) - # No authentication needed for magic link flow initiation + token = @flow.pending_token + headers["Cookie"] = MagicLinkFlow.cookie(PENDING_COOKIE, token) unless token.nil? || token.empty? end end end diff --git a/ruby/test/magic_link_flow_test.rb b/ruby/test/magic_link_flow_test.rb new file mode 100644 index 00000000..951f06e9 --- /dev/null +++ b/ruby/test/magic_link_flow_test.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +# Fizzy's magic-link sign-in: POST /session.json with the address answers 201 and the pending +# token, and POST /session/magic_link.json redeems the code only with that token sent back as +# the pending_authentication_token cookie. +class FizzyMagicLinkFlowTest < Minitest::Test + def setup + @requests = [] + end + + def test_create_session_posts_the_address_and_keeps_the_pending_token + flow = flow_with do |stubs| + stubs.post("/session.json") { |env| record(env, 201, { pending_authentication_token: "pend-1" }) } + end + + assert_equal "pend-1", flow.create_session(email_address: "jane@example.com") + assert_equal "pend-1", flow.pending_token + assert_equal 1, @requests.size + request = @requests.first + assert_equal "/session.json", request[:path] + assert_equal({ "email_address" => "jane@example.com" }, request[:body]) + assert_nil request[:headers]["Cookie"], "no pending token yet, so no cookie" + assert_nil request[:headers]["Authorization"], "a sign-in has no credentials to send" + end + + def test_redeem_sends_the_code_with_the_pending_token_as_a_cookie + flow = flow_with do |stubs| + stubs.post("/session.json") { |env| record(env, 201, { pending_authentication_token: "pend/1+=" }) } + stubs.post("/session/magic_link.json") do |env| + record(env, 200, { session_token: "sess-1", requires_signup_completion: false }) + end + end + + flow.create_session(email_address: "jane@example.com") + session = flow.redeem(code: "ABC123") + + assert_equal({ "session_token" => "sess-1", "requires_signup_completion" => false }, session) + redeem = @requests.last + assert_equal "/session/magic_link.json", redeem[:path] + assert_equal({ "code" => "ABC123" }, redeem[:body]) + assert_equal "pending_authentication_token=pend%2F1%2B%3D", redeem[:headers]["Cookie"] + end + + def test_redeem_resumes_a_sign_in_another_flow_started + flow = flow_with(pending_token: "pend-2") do |stubs| + stubs.post("/session/magic_link.json") do |env| + record(env, 200, { session_token: "sess-2", requires_signup_completion: true }) + end + end + + assert_equal true, flow.redeem(code: "XYZ789")["requires_signup_completion"] + assert_equal 1, @requests.size + assert_equal "pending_authentication_token=pend-2", @requests.first[:headers]["Cookie"] + end + + def test_redeem_without_a_pending_sign_in_is_a_usage_error + flow = flow_with { |_stubs| } + + assert_raises(Fizzy::UsageError) { flow.redeem(code: "ABC123") } + assert_empty @requests + end + + def test_a_refused_code_is_an_auth_error_and_is_not_resent + flow = flow_with(pending_token: "pend-3") do |stubs| + stubs.post("/session/magic_link.json") { |env| record(env, 401, { message: "Try another code." }) } + end + + assert_raises(Fizzy::AuthError) { flow.redeem(code: "WRONG1") } + assert_equal 1, @requests.size + end + + def test_an_answer_without_a_pending_token_is_an_api_error + flow = flow_with do |stubs| + stubs.post("/session.json") { |env| record(env, 201, {}) } + end + + assert_raises(Fizzy::APIError) { flow.create_session(email_address: "jane@example.com") } + assert_nil flow.pending_token + end + + def test_the_flow_refuses_a_plain_http_origin + assert_raises(Fizzy::UsageError, ArgumentError) { Fizzy::MagicLinkFlow.new(base_url: "http://fizzy.example.com") } + end + + private + + def record(env, status, body) + @requests << { + path: env.url.path, + body: env.body.nil? || env.body.empty? ? nil : JSON.parse(env.body), + headers: env.request_headers.to_h + } + [ status, { "Content-Type" => "application/json" }, JSON.generate(body) ] + end + + def flow_with(pending_token: nil) + stubs = Faraday::Adapter::Test::Stubs.new + yield stubs + flow = Fizzy::MagicLinkFlow.new(base_url: "https://fizzy.do", pending_token: pending_token) + http = flow.instance_variable_get(:@client).http + http.instance_variable_set(:@faraday, Faraday.new(url: "https://fizzy.do") { |f| + f.request :json + f.response :raise_error + f.adapter :test, stubs + }) + flow + end +end From a0c5885e7610161c72ea2659721d5d7950cebb14 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:52:04 -0700 Subject: [PATCH 06/12] Sign the Swift magic-link flow in through the routes Fizzy serves MagicLinkFlow posted {email} to /sessions.json and {token} to /sessions/redeem.json, which Fizzy does not route, decoded a pendingToken Fizzy never sends, and completed signup with {name} into a body Fizzy does not answer. It now goes through the generated SessionsService: start(emailAddress:) posts {email_address} to /session.json and keeps the 201's pending token, redeem(code:) posts {code} to /session/magic_link.json with the token as an explicit, percent-encoded pending_authentication_token cookie and no cookie store in play, and completeSignup(fullName:sessionToken:) posts {full_name} and takes the empty 201. pendingToken and resume(pendingToken:) let a flow finish in another process, as Rust's does. This changes the flow's API: start(email:) is start(emailAddress:) and answers PendingAuthentication, redeem(token:) is redeem(code:) and answers SessionAuthorization, and completeSignup takes fullName and answers nothing; PendingSession, RedeemResult and SignupResult are gone. --- swift/Sources/Fizzy/Auth/MagicLinkFlow.swift | 242 ++++++++---------- .../Tests/FizzyTests/MagicLinkFlowTests.swift | 131 ++++++++++ 2 files changed, 236 insertions(+), 137 deletions(-) create mode 100644 swift/Tests/FizzyTests/MagicLinkFlowTests.swift diff --git a/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift index 81a8708a..e6ec7b46 100644 --- a/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift +++ b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift @@ -3,55 +3,52 @@ import Foundation import FoundationNetworking #endif -/// Orchestrates passwordless login via magic link. +/// Signs in without a password: Fizzy emails a code, and the person types it back. /// -/// The flow: -/// 1. Call `start(email:)` to create a session -- the server sends a magic link email. -/// 2. The user clicks the link, which contains a token. -/// 3. Call `redeem(token:)` with the magic link token to get a session token. +/// The two steps are one exchange as far as Fizzy is concerned. Creating the session answers a +/// pending authentication token and sets it as the `pending_authentication_token` cookie; +/// redeeming the code has to send that cookie back or the code is refused. The flow keeps the +/// token between the calls, sends it as an explicit `Cookie` header rather than leaving it to a +/// cookie store, and hands it out for a caller that finishes in another process — a CLI that +/// prompts for the code on its next run — through ``pendingToken`` and +/// ``resume(pendingToken:baseURL:userAgent:transport:)``. /// /// ```swift /// let flow = MagicLinkFlow(userAgent: "my-app/1.0") /// -/// // Step 1: Request magic link -/// let pendingToken = try await flow.start(email: "user@example.com") +/// // Step 1: Fizzy emails a sign-in code. +/// try await flow.start(emailAddress: "user@example.com") /// -/// // Step 2: User clicks the magic link (out of band) +/// // Step 2: the person reads the code out of their email. /// -/// // Step 3: Redeem the magic link token -/// let session = try await flow.redeem(token: magicLinkToken) -/// // session.sessionToken is now available for CookieAuth +/// // Step 3: redeem it for a session token. +/// let session = try await flow.redeem(code: code) +/// let client = FizzyClient(auth: CookieAuth(sessionToken: session.sessionToken), userAgent: "my-app/1.0") +/// +/// // A new account finishes signing up with a name. +/// if session.requiresSignupCompletion { +/// try await flow.completeSignup(fullName: "Jane Doe", sessionToken: session.sessionToken) +/// } /// ``` -public final class MagicLinkFlow: Sendable { +/// +/// Every request goes through the generated `SessionsService`, so its paths, bodies and answers +/// are the model's: `POST /session.json`, `POST /session/magic_link.json` and +/// `POST /signup/completion.json`. +public final class MagicLinkFlow: @unchecked Sendable { + /// The cookie a pending sign-in is carried in between creating the session and redeeming the code. + public static let pendingCookie = "pending_authentication_token" + private let baseURL: String private let userAgent: String private let transport: any Transport - - /// The response from creating a session (requesting a magic link). - public struct PendingSession: Codable, Sendable { - /// The pending token returned by CreateSession. - public let pendingToken: String - } - - /// The response from redeeming a magic link. - public struct RedeemResult: Codable, Sendable { - /// The session token to use with CookieAuth. - public let sessionToken: String - - /// Whether the user needs to complete signup (new account). - public let requiresSignupCompletion: Bool - } - - /// The response from completing signup. - public struct SignupResult: Codable, Sendable { - /// The user's name after signup completion. - public let name: String - } + private let lock = NSLock() + private var pending: String? /// Creates a MagicLinkFlow. /// /// - Parameters: - /// - baseURL: The Fizzy API base URL (default: `https://fizzy.do`). + /// - baseURL: The Fizzy base URL (default: `https://fizzy.do`). It must use HTTPS, except on + /// localhost. /// - userAgent: Required User-Agent identifying your app. /// - transport: Optional custom transport (for testing). public init( @@ -64,125 +61,96 @@ public final class MagicLinkFlow: Sendable { self.transport = transport ?? URLSessionTransport() } - /// Step 1: Creates a session by requesting a magic link email. - /// - /// - Parameter email: The user's email address. - /// - Returns: A `PendingSession` with the pending token. - public func start(email: String) async throws -> PendingSession { - let url = "\(baseURL)/sessions.json" - - guard let requestURL = URL(string: url) else { - throw FizzyError.usage(message: "Invalid URL: \(url)", hint: nil) - } - - var request = URLRequest(url: requestURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - - let body = ["email": email] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await transport.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw FizzyError.network(message: "Invalid response type", cause: nil) - } - - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw FizzyError.fromHTTPResponse( - status: httpResponse.statusCode, data: data, - headers: httpResponse.allHeaderFields as? [String: String] ?? [:], - requestId: httpResponse.value(forHTTPHeaderField: "X-Request-Id") - ) - } + /// A flow that picks up where another left off, with the pending token that one handed out + /// through ``pendingToken``. + public static func resume( + pendingToken: String, + baseURL: String = FizzyConfig.defaultBaseURL, + userAgent: String, + transport: (any Transport)? = nil + ) -> MagicLinkFlow { + let flow = MagicLinkFlow(baseURL: baseURL, userAgent: userAgent, transport: transport) + flow.setPending(pendingToken) + return flow + } - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(PendingSession.self, from: data) + /// The token a pending sign-in is waiting on, for a caller that finishes the flow in another + /// process; nil before ``start(emailAddress:)``. + public var pendingToken: String? { + lock.withLock { pending } } - /// Step 3: Redeems a magic link token for a session token. + /// Step 1: asks Fizzy to email a sign-in code to the address, and keeps the pending token the + /// answer carries for ``redeem(code:)``. /// - /// - Parameter token: The magic link token from the email. - /// - Returns: A `RedeemResult` containing the session token. - public func redeem(token: String) async throws -> RedeemResult { - let url = "\(baseURL)/sessions/redeem.json" - - guard let requestURL = URL(string: url) else { - throw FizzyError.usage(message: "Invalid URL: \(url)", hint: nil) - } - - var request = URLRequest(url: requestURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - - let body = ["token": token] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await transport.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw FizzyError.network(message: "Invalid response type", cause: nil) - } + /// - Parameter emailAddress: The address the code goes to. + /// - Returns: The pending authentication Fizzy answered. + @discardableResult + public func start(emailAddress: String) async throws -> PendingAuthentication { + let pendingAuthentication = try await client(auth: CookieHeaderAuth(cookie: nil)) + .sessions.create(req: CreateSessionRequest(emailAddress: emailAddress)) + setPending(pendingAuthentication.pendingAuthenticationToken) + return pendingAuthentication + } - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw FizzyError.fromHTTPResponse( - status: httpResponse.statusCode, data: data, - headers: httpResponse.allHeaderFields as? [String: String] ?? [:], - requestId: httpResponse.value(forHTTPHeaderField: "X-Request-Id") - ) + /// Step 3: sends the code back with the pending token, and answers the session Fizzy signed in. + /// + /// Without a pending token — no ``start(emailAddress:)`` before it, and none given to + /// ``resume(pendingToken:baseURL:userAgent:transport:)`` — there is nothing to redeem against, + /// and it throws a usage error without sending anything. + /// + /// - Parameter code: The code from the email. + /// - Returns: The session token, for ``CookieAuth``, and whether signup is still to complete. + public func redeem(code: String) async throws -> SessionAuthorization { + guard let pending = pendingToken else { + throw FizzyError.usage( + message: "No sign-in is pending", + hint: "Call start(emailAddress:) first, or resume with the pending token") } - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(RedeemResult.self, from: data) + return try await client(auth: CookieHeaderAuth(cookie: (Self.pendingCookie, pending))) + .sessions.redeemMagicLink(req: RedeemMagicLinkRequest(code: code)) } - /// Completes signup for a new user after magic link redemption. - /// - /// Only needed when `RedeemResult.requiresSignupCompletion` is `true`. + /// Completes signup for a new person after the code is redeemed. Only needed when + /// ``SessionAuthorization/requiresSignupCompletion`` is true. Fizzy answers 201 with no body. /// /// - Parameters: - /// - name: The user's display name. - /// - sessionToken: The session token from `redeem(token:)`. - /// - Returns: A `SignupResult` confirming the signup. - public func completeSignup(name: String, sessionToken: String) async throws -> SignupResult { - let url = "\(baseURL)/signup/completion.json" - - guard let requestURL = URL(string: url) else { - throw FizzyError.usage(message: "Invalid URL: \(url)", hint: nil) - } + /// - fullName: The person's full name. + /// - sessionToken: The session token ``redeem(code:)`` answered. + public func completeSignup(fullName: String, sessionToken: String) async throws { + try await client(auth: CookieHeaderAuth(cookie: ("session_token", sessionToken))) + .sessions.completeSignup(req: CompleteSignupRequest(fullName: fullName)) + } - var request = URLRequest(url: requestURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - request.setValue("session_token=\(sessionToken)", forHTTPHeaderField: "Cookie") + // MARK: - Private - let body = ["name": name] - request.httpBody = try JSONSerialization.data(withJSONObject: body) + private func setPending(_ token: String) { + lock.withLock { pending = token } + } - let (data, response) = try await transport.data(for: request) + private func client(auth: CookieHeaderAuth) -> FizzyClient { + FizzyClient(auth: auth, userAgent: userAgent, config: FizzyConfig(baseURL: baseURL), transport: transport) + } +} - guard let httpResponse = response as? HTTPURLResponse else { - throw FizzyError.network(message: "Invalid response type", cause: nil) - } +/// Sends one cookie, or none, as an explicit `Cookie` header, and keeps any cookie store out of +/// the request: what goes out is exactly what the flow decided, whatever a shared +/// `URLSession` has stored for the origin. +struct CookieHeaderAuth: AuthStrategy { + let cookie: (name: String, value: String)? - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw FizzyError.fromHTTPResponse( - status: httpResponse.statusCode, data: data, - headers: httpResponse.allHeaderFields as? [String: String] ?? [:], - requestId: httpResponse.value(forHTTPHeaderField: "X-Request-Id") - ) + func authenticate(_ request: inout URLRequest) async throws { + request.httpShouldHandleCookies = false + if let cookie { + request.setValue("\(cookie.name)=\(Self.encode(cookie.value))", forHTTPHeaderField: "Cookie") } + } - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(SignupResult.self, from: data) + /// A cookie value escaped as Rack unescapes it: everything but ASCII letters, digits and + /// `-._~` is percent-encoded, so a `;` or `,` in a token cannot end the cookie early. + static func encode(_ value: String) -> String { + var allowed = CharacterSet() + allowed.insert(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value } } diff --git a/swift/Tests/FizzyTests/MagicLinkFlowTests.swift b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift new file mode 100644 index 00000000..6d39f17e --- /dev/null +++ b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +@testable import Fizzy +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Answers each request with the next scripted reply, and records what was sent. +final class ScriptedMagicLinkTransport: Transport, @unchecked Sendable { + struct Reply { + let status: Int + let body: String + } + + private let lock = NSLock() + private var replies: [Reply] + private(set) var requests: [URLRequest] = [] + + init(_ replies: Reply...) { + self.replies = replies + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let reply: Reply = lock.withLock { + requests.append(request) + return replies.isEmpty ? Reply(status: 500, body: "") : replies.removeFirst() + } + let response = HTTPURLResponse( + url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(reply.body.utf8), response) + } + + func json(_ index: Int) throws -> [String: Any] { + let body = lock.withLock { requests[index].httpBody } ?? Data() + return try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + } + + func request(_ index: Int) -> URLRequest { + lock.withLock { requests[index] } + } +} + +@Suite("MagicLinkFlow") +struct MagicLinkFlowTests { + private let created = ScriptedMagicLinkTransport.Reply(status: 201, body: #"{"pending_authentication_token":"pend;1"}"#) + private let signedIn = ScriptedMagicLinkTransport.Reply( + status: 200, body: #"{"session_token":"sess-1","requires_signup_completion":true}"#) + + @Test("Creating the session posts the email address to /session.json and keeps the 201's pending token") + func createsTheSession() async throws { + let transport = ScriptedMagicLinkTransport(created) + let flow = MagicLinkFlow(userAgent: "test/1.0", transport: transport) + + let pending = try await flow.start(emailAddress: "jane@example.com") + + let request = transport.request(0) + #expect(request.httpMethod == "POST") + #expect(request.url?.absoluteString == "https://fizzy.do/session.json") + #expect(try transport.json(0) as NSDictionary == ["email_address": "jane@example.com"]) + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + #expect(pending.pendingAuthenticationToken == "pend;1") + #expect(flow.pendingToken == "pend;1") + } + + @Test("Redeeming posts the code to /session/magic_link.json with the pending token as an encoded cookie") + func redeemsTheCode() async throws { + let transport = ScriptedMagicLinkTransport(created, signedIn) + let flow = MagicLinkFlow(userAgent: "test/1.0", transport: transport) + try await flow.start(emailAddress: "jane@example.com") + + let session = try await flow.redeem(code: "ABC123") + + let request = transport.request(1) + #expect(request.httpMethod == "POST") + #expect(request.url?.absoluteString == "https://fizzy.do/session/magic_link.json") + #expect(try transport.json(1) as NSDictionary == ["code": "ABC123"]) + #expect(request.value(forHTTPHeaderField: "Cookie") == "pending_authentication_token=pend%3B1") + #expect(request.httpShouldHandleCookies == false) + #expect(session.sessionToken == "sess-1") + #expect(session.requiresSignupCompletion) + } + + @Test("A resumed flow redeems against the pending token it was given") + func resumes() async throws { + let transport = ScriptedMagicLinkTransport(signedIn) + let flow = MagicLinkFlow.resume(pendingToken: "pend-2", userAgent: "test/1.0", transport: transport) + + _ = try await flow.redeem(code: "ABC123") + + #expect(transport.request(0).value(forHTTPHeaderField: "Cookie") == "pending_authentication_token=pend-2") + } + + @Test("Redeeming with nothing pending is a usage error and sends nothing") + func redeemsNothingWithoutAPendingToken() async throws { + let transport = ScriptedMagicLinkTransport() + let flow = MagicLinkFlow(userAgent: "test/1.0", transport: transport) + + await #expect(throws: FizzyError.self) { try await flow.redeem(code: "ABC123") } + #expect(transport.requests.isEmpty) + } + + @Test("A refused code is an auth error") + func refusedCode() async throws { + let transport = ScriptedMagicLinkTransport( + ScriptedMagicLinkTransport.Reply(status: 401, body: #"{"message":"Try another code."}"#)) + let flow = MagicLinkFlow.resume(pendingToken: "pend-2", userAgent: "test/1.0", transport: transport) + + do { + _ = try await flow.redeem(code: "WRONG1") + Issue.record("a 401 must throw") + } catch let error as FizzyError { + #expect(error.httpStatusCode == 401) + if case .auth = error {} else { Issue.record("a 401 is an auth error, got \(error)") } + #expect(transport.requests.count == 1) + } + } + + @Test("Completing signup posts the full name with an encoded session cookie and takes the empty 201") + func completesSignup() async throws { + let transport = ScriptedMagicLinkTransport(ScriptedMagicLinkTransport.Reply(status: 201, body: "")) + let flow = MagicLinkFlow(userAgent: "test/1.0", transport: transport) + + try await flow.completeSignup(fullName: "Jane Doe", sessionToken: "sess=1") + + let request = transport.request(0) + #expect(request.url?.absoluteString == "https://fizzy.do/signup/completion.json") + #expect(try transport.json(0) as NSDictionary == ["full_name": "Jane Doe"]) + #expect(request.value(forHTTPHeaderField: "Cookie") == "session_token=sess%3D1") + } +} From a775a05d9d3338fe2bfb30cda0ff0a06b7e36ccf Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:51:30 -0700 Subject: [PATCH 07/12] Sign Kotlin's magic-link flow in on Fizzy's sessions contract MagicLinkFlow posted to /api/sessions, /api/sessions/redeem and /api/sessions/signup, which Fizzy does not route, redeemed with the pending token in the body where Fizzy wants the emailed code and the token as a cookie, and named a new user with name rather than full_name. No sign-in could complete. The flow now runs on the generated Sessions operations through a FizzyClient, so the paths, bodies, statuses, error mapping, HTTPS check and body limits are the SDK's own: requestMagicLink posts /session.json with email_address and keeps the pending token from the 201; redeemMagicLink takes the code and sends it to /session/magic_link.json with the pending token as a percent-encoded pending_authentication_token cookie; completeSignup posts full_name to /signup/completion.json on the session cookie and takes the empty 201. A sign-in finishing in another process resumes from pendingToken, as a constructor argument or through resume; redeeming with none pending is a usage error. Breaking: redeemMagicLink takes the emailed code, not the pending token, and completeSignup's second argument is the full name. --- .../com/basecamp/fizzy/auth/MagicLinkFlow.kt | 205 +++++++----------- .../com/basecamp/fizzy/MagicLinkFlowTest.kt | 109 ++++++++++ 2 files changed, 191 insertions(+), 123 deletions(-) create mode 100644 kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt index e09ec4ce..810f636a 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt @@ -1,78 +1,74 @@ package com.basecamp.fizzy.auth -import com.basecamp.fizzy.* -import com.basecamp.fizzy.http.FizzyHttpClient -import io.ktor.client.* +import com.basecamp.fizzy.AuthStrategy +import com.basecamp.fizzy.FizzyClient +import com.basecamp.fizzy.FizzyConfig +import com.basecamp.fizzy.FizzyException +import com.basecamp.fizzy.generated.services.CompleteSignupBody +import com.basecamp.fizzy.generated.services.CreateSessionBody +import com.basecamp.fizzy.generated.services.RedeemMagicLinkBody +import com.basecamp.fizzy.generated.sessions import io.ktor.client.engine.* -import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.* -import io.ktor.client.statement.* import io.ktor.http.* -import kotlinx.serialization.json.* /** - * Passwordless authentication via magic links. + * Passwordless sign-in with a magic link, on the generated Sessions operations. * - * This flow uses an unauthenticated client to: - * 1. Request a magic link email via [requestMagicLink] - * 2. Redeem the token from the link via [redeemMagicLink] - * 3. Optionally complete signup for new users via [completeSignup] + * 1. [requestMagicLink] starts a sign-in (`POST /session.json`). Fizzy emails a code and + * answers a pending authentication token, which this flow keeps. + * 2. [redeemMagicLink] trades the code from the email for a session token + * (`POST /session/magic_link.json`). Fizzy reads the pending sign-in from the + * `pending_authentication_token` cookie, so the flow sends it as one. + * 3. [completeSignup] names a new user when the session says signup is incomplete. * - * The resulting session token can be used with [CookieAuth]. + * A sign-in that finishes in another process resumes from [pendingToken], passed back as + * `pendingToken` when building the flow or with [resume]. * * ```kotlin - * val flow = MagicLinkFlow(baseUrl = "https://fizzy.do") - * - * // Step 1: Request magic link + * val flow = MagicLinkFlow(baseUrl = "https://app.fizzy.do") * flow.requestMagicLink("user@example.com") - * - * // Step 2: User clicks email link, extract token - * val session = flow.redeemMagicLink(pendingAuthToken) - * - * // Step 3: Create authenticated client - * val client = FizzyClient { - * auth(CookieAuth(session.sessionToken)) - * } + * val session = flow.redeemMagicLink(codeFromEmail) + * if (session.requiresSignupCompletion) flow.completeSignup(session.sessionToken, "Ada Lovelace") + * val client = FizzyClient { auth(CookieAuth(session.sessionToken)) } * ``` */ class MagicLinkFlow( private val baseUrl: String = FizzyConfig.DEFAULT_BASE_URL, - engine: HttpClientEngine? = null, + private val engine: HttpClientEngine? = null, + pendingToken: String? = null, ) { - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } + /** The pending authentication token of the sign-in under way, or null before one starts. */ + var pendingToken: String? = pendingToken?.takeIf { it.isNotBlank() } + private set - private val httpClient: HttpClient = if (engine != null) { - HttpClient(engine) { configureClient() } - } else { - HttpClient { configureClient() } + /** Carries the pending sign-in's cookie on the requests that need it, and nothing otherwise. */ + private val pendingAuth = AuthStrategy { request -> + this.pendingToken?.let { request.header(HttpHeaders.Cookie, cookie(PENDING_COOKIE, it)) } } - private fun HttpClientConfig<*>.configureClient() { - expectSuccess = false - install(HttpTimeout) { - requestTimeoutMillis = 30_000 - connectTimeoutMillis = 30_000 - socketTimeoutMillis = 30_000 - } + private val client: FizzyClient = client(pendingAuth) + + private fun client(auth: AuthStrategy): FizzyClient = FizzyClient { + baseUrl = this@MagicLinkFlow.baseUrl + engine = this@MagicLinkFlow.engine + auth(auth) } /** - * Result of a magic link request. + * Result of starting a sign-in. * - * @property pendingAuthenticationToken Token to use when redeeming the magic link. + * @property pendingAuthenticationToken The token [redeemMagicLink] sends back as a cookie. */ data class MagicLinkRequest( val pendingAuthenticationToken: String, ) /** - * Result of redeeming a magic link. + * Result of redeeming a magic-link code. * - * @property sessionToken Session token for authenticated requests. - * @property requiresSignupCompletion Whether the user needs to complete signup. + * @property sessionToken Session token for [CookieAuth]. + * @property requiresSignupCompletion Whether the user must still [completeSignup]. */ data class MagicLinkSession( val sessionToken: String, @@ -80,108 +76,71 @@ class MagicLinkFlow( ) /** - * Request a magic link email be sent to the given address. - * - * @param emailAddress The email to send the magic link to. - * @return A [MagicLinkRequest] with the pending authentication token. + * Starts a sign-in: Fizzy emails [emailAddress] a code and answers the pending token, + * which this flow keeps for [redeemMagicLink]. Fizzy answers the same for an address it + * does not know, so the answer says nothing about whether the account exists. */ suspend fun requestMagicLink(emailAddress: String): MagicLinkRequest { - val response = httpClient.request("$baseUrl/api/sessions") { - method = HttpMethod.Post - header(HttpHeaders.ContentType, "application/json") - header(HttpHeaders.Accept, "application/json") - setBody(buildJsonObject { put("email_address", emailAddress) }.toString()) - } - - if (!response.status.isSuccess()) { - throw errorFromResponse(response) - } - - val body = json.parseToJsonElement(response.bodyAsText()).jsonObject - val token = body["pending_authentication_token"]?.jsonPrimitive?.content - ?: throw FizzyException.Api("Missing pending_authentication_token in response", 0) + val pending = client.forAccount(ROOT).sessions.create(CreateSessionBody(emailAddress = emailAddress)) + pendingToken = pending.pendingAuthenticationToken + return MagicLinkRequest(pendingAuthenticationToken = pending.pendingAuthenticationToken) + } - return MagicLinkRequest(pendingAuthenticationToken = token) + /** Resumes a sign-in another process started, from its [pendingToken]. */ + fun resume(pendingToken: String) { + require(pendingToken.isNotBlank()) { "Pending token must not be blank" } + this.pendingToken = pendingToken } /** - * Redeem a magic link token to obtain a session. + * Redeems the [code] from the magic-link email for a session. * - * @param pendingAuthenticationToken The token from the magic link email. - * @return A [MagicLinkSession] with the session token. + * @throws FizzyException.Usage when no sign-in is pending: call [requestMagicLink] or [resume] first. + * @throws FizzyException.Auth when Fizzy refuses the code or the pending sign-in. */ - suspend fun redeemMagicLink(pendingAuthenticationToken: String): MagicLinkSession { - val response = httpClient.request("$baseUrl/api/sessions/redeem") { - method = HttpMethod.Post - header(HttpHeaders.ContentType, "application/json") - header(HttpHeaders.Accept, "application/json") - setBody(buildJsonObject { put("pending_authentication_token", pendingAuthenticationToken) }.toString()) - } - - if (!response.status.isSuccess()) { - throw errorFromResponse(response) + suspend fun redeemMagicLink(code: String): MagicLinkSession { + if (pendingToken == null) { + throw FizzyException.Usage( + "No sign-in is pending", + hint = "Call requestMagicLink first, or resume with the pending token", + ) } - - val body = json.parseToJsonElement(response.bodyAsText()).jsonObject - val sessionToken = body["session_token"]?.jsonPrimitive?.content - ?: throw FizzyException.Api("Missing session_token in response", 0) - val requiresSignup = body["requires_signup_completion"]?.jsonPrimitive?.content?.toBoolean() ?: false - + val session = client.forAccount(ROOT).sessions.redeemMagicLink(RedeemMagicLinkBody(code = code)) return MagicLinkSession( - sessionToken = sessionToken, - requiresSignupCompletion = requiresSignup, + sessionToken = session.sessionToken, + requiresSignupCompletion = session.requiresSignupCompletion, ) } /** - * Complete signup for a new user after redeeming a magic link. + * Completes signup for a new user, signed in with the [sessionToken] [redeemMagicLink] answered. * - * @param sessionToken The session token from [redeemMagicLink]. - * @param name The user's display name. + * @param fullName The user's full name. */ - suspend fun completeSignup(sessionToken: String, name: String) { - val response = httpClient.request("$baseUrl/api/sessions/signup") { - method = HttpMethod.Post - header(HttpHeaders.ContentType, "application/json") - header(HttpHeaders.Accept, "application/json") - header("Cookie", "session_token=$sessionToken") - setBody(buildJsonObject { put("name", name) }.toString()) - } - - if (!response.status.isSuccess()) { - throw errorFromResponse(response) + suspend fun completeSignup(sessionToken: String, fullName: String) { + val signedIn = client(CookieAuth(sessionToken)) + try { + signedIn.forAccount(ROOT).sessions.completeSignup(CompleteSignupBody(fullName = fullName)) + } finally { + signedIn.close() } } - /** Clean up the internal HTTP client. */ + /** Releases the flow's HTTP client. */ fun close() { - httpClient.close() + client.close() } - private suspend fun errorFromResponse(response: HttpResponse): FizzyException { - val status = response.status.value - val requestId = response.headers["X-Request-Id"] - - var message: String = response.status.description.ifEmpty { "Request failed" } - var hint: String? = null + internal companion object { + const val PENDING_COOKIE = "pending_authentication_token" - try { - val bodyText = response.bodyAsText() - if (bodyText.isNotBlank()) { - val jsonBody = json.parseToJsonElement(bodyText) - if (jsonBody is kotlinx.serialization.json.JsonObject) { - jsonBody["error"]?.jsonPrimitive?.content?.let { - message = FizzyException.truncateMessage(it) - } - jsonBody["message"]?.jsonPrimitive?.content?.let { - message = FizzyException.truncateMessage(it) - } - } - } - } catch (_: Exception) { - // Body is not JSON or empty - } + /** + * Sessions are root-scoped: the generated operations ignore the account, so any id + * reaches them. + */ + const val ROOT = "0" - return FizzyException.fromHttpStatus(status, message, hint, requestId) + /** A cookie pair whose value is percent-encoded, so no character of a token can end it. */ + fun cookie(name: String, value: String): String = "$name=${value.encodeURLParameter()}" } } diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt new file mode 100644 index 00000000..bb5c88bf --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt @@ -0,0 +1,109 @@ +package com.basecamp.fizzy + +import com.basecamp.fizzy.auth.MagicLinkFlow +import io.ktor.client.engine.mock.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.http.content.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The magic-link flow speaks Fizzy's sessions contract: the paths, bodies, cookie and statuses it serves. */ +class MagicLinkFlowTest { + private val base = "https://app.fizzy.test" + + private fun bodyOf(request: HttpRequestData): JsonObject { + val content = request.body as TextContent + return Json.parseToJsonElement(content.text).jsonObject + } + + @Test + fun aSignInStartsAtSessionAndRedeemsTheCodeWithThePendingCookie() = runTest { + val seen = mutableListOf() + val engine = MockEngine { request -> + seen += request + when (request.url.encodedPath) { + "/session.json" -> respond("""{"pending_authentication_token":"pend/1+2="}""", HttpStatusCode.Created, headersOf(HttpHeaders.ContentType, "application/json")) + "/session/magic_link.json" -> respond("""{"session_token":"st-1","requires_signup_completion":true}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + else -> respond("", HttpStatusCode.NotFound) + } + } + val flow = MagicLinkFlow(baseUrl = base, engine = engine) + + val pending = flow.requestMagicLink("ada@example.com") + assertEquals("pend/1+2=", pending.pendingAuthenticationToken) + assertEquals("pend/1+2=", flow.pendingToken) + + val session = flow.redeemMagicLink("ABC123") + assertEquals("st-1", session.sessionToken) + assertTrue(session.requiresSignupCompletion) + + assertEquals(HttpMethod.Post, seen[0].method) + assertEquals("/session.json", seen[0].url.encodedPath) + assertEquals("ada@example.com", bodyOf(seen[0])["email_address"]?.jsonPrimitive?.content) + assertNull(seen[0].headers[HttpHeaders.Cookie], "no cookie before a sign-in is pending") + + assertEquals(HttpMethod.Post, seen[1].method) + assertEquals("/session/magic_link.json", seen[1].url.encodedPath) + assertEquals(mapOf("code" to "ABC123"), bodyOf(seen[1]).mapValues { it.value.jsonPrimitive.content }) + assertEquals("pending_authentication_token=pend%2F1%2B2%3D", seen[1].headers[HttpHeaders.Cookie]) + flow.close() + } + + @Test + fun aResumedSignInRedeemsWithTheTokenItWasGiven() = runTest { + var cookie: String? = null + val engine = MockEngine { request -> + cookie = request.headers[HttpHeaders.Cookie] + respond("""{"session_token":"st-2","requires_signup_completion":false}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + val flow = MagicLinkFlow(baseUrl = base, engine = engine, pendingToken = "pend-9") + assertEquals("st-2", flow.redeemMagicLink("XYZ").sessionToken) + assertEquals("pending_authentication_token=pend-9", cookie) + + val other = MagicLinkFlow(baseUrl = base, engine = engine) + other.resume("pend-10") + other.redeemMagicLink("XYZ") + assertEquals("pending_authentication_token=pend-10", cookie) + } + + @Test + fun redeemingWithNoSignInPendingIsAUsageError() = runTest { + val engine = MockEngine { respond("", HttpStatusCode.InternalServerError) } + val flow = MagicLinkFlow(baseUrl = base, engine = engine) + assertFailsWith { flow.redeemMagicLink("ABC123") } + assertTrue(engine.requestHistory.isEmpty(), "nothing is sent without a pending sign-in") + } + + @Test + fun aRefusedCodeIsAnAuthError() = runTest { + val engine = MockEngine { + respond("""{"message":"Try another code."}""", HttpStatusCode.Unauthorized, headersOf(HttpHeaders.ContentType, "application/json")) + } + val flow = MagicLinkFlow(baseUrl = base, engine = engine, pendingToken = "pend-1") + assertFailsWith { flow.redeemMagicLink("WRONG") } + assertEquals(1, engine.requestHistory.size, "a refused code is not resent") + } + + @Test + fun signupCompletesWithTheFullNameOnTheSessionCookieAndAnEmpty201() = runTest { + val engine = MockEngine { respond("", HttpStatusCode.Created) } + val flow = MagicLinkFlow(baseUrl = base, engine = engine) + + flow.completeSignup("st-1", "Ada Lovelace") + + val request = engine.requestHistory.single() + assertEquals(HttpMethod.Post, request.method) + assertEquals("/signup/completion.json", request.url.encodedPath) + assertEquals("Ada Lovelace", bodyOf(request)["full_name"]?.jsonPrimitive?.content) + assertEquals("session_token=st-1", request.headers[HttpHeaders.Cookie]) + } +} From 82b83d01fe550a84df491c3b17302399a65a47c6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 16:55:53 -0700 Subject: [PATCH 08/12] Redeem a Rust magic link with the generated body, now the model says code The flow sent a private {code, token} body so it worked against Fizzy and against a server built from the model, which spelled the code token. The model now says code, so the flow sends the generated RedeemMagicLinkRequestContent, still with the pending cookie the model documents but cannot send, and the module docs say that instead of where the model was wrong. --- rust/fizzy-sdk/src/magic_link.rs | 28 +++++++++++----------------- rust/fizzy-sdk/tests/magic_link.rs | 2 +- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/rust/fizzy-sdk/src/magic_link.rs b/rust/fizzy-sdk/src/magic_link.rs index 4cb333b3..a8e4bf43 100644 --- a/rust/fizzy-sdk/src/magic_link.rs +++ b/rust/fizzy-sdk/src/magic_link.rs @@ -23,26 +23,24 @@ //! # } //! ``` //! -//! # Where this parts company with the model +//! # What the flow adds to the generated operations //! -//! The Smithy model spells `RedeemMagicLink`'s body as `{"token": …}` and says nothing of -//! the cookie; upstream Fizzy reads `code` and requires the cookie. The flow follows -//! upstream, since that is what answers, and sends both spellings of the code so it also -//! works against a server built from the model as written. The generated -//! [`crate::services::sessions::SessionsService::redeem_magic_link`] is the model's shape and -//! cannot complete a login on its own until the model catches up. +//! The model documents the cookie but cannot send it: the generated +//! [`crate::services::sessions::SessionsService::redeem_magic_link`] sends the code alone, so +//! it completes a sign-in only on a client that already carries the pending cookie. The flow +//! sends the generated `RedeemMagicLinkRequestContent` with the cookie set from the token it +//! kept. use std::sync::Mutex; -use serde::Serialize; - use crate::auth::NoAuth; use crate::client::{Client, ClientBuilder}; use crate::config::Config; use crate::error::Error; use crate::generated::routes; use crate::generated::types::{ - CreateSessionRequestContent, PendingAuthentication, SessionAuthorization, + CreateSessionRequestContent, PendingAuthentication, RedeemMagicLinkRequestContent, + SessionAuthorization, }; use crate::http::header::COOKIE; use crate::types::SensitiveString; @@ -57,12 +55,6 @@ pub struct MagicLinkFlow { pending: Mutex>, } -#[derive(Serialize)] -struct Redeem<'a> { - code: &'a str, - token: &'a str, -} - impl MagicLinkFlow { /// A flow against the configured origin, sending no credentials: there are none yet. pub fn new(config: Config) -> Result { @@ -140,7 +132,9 @@ impl MagicLinkFlow { let mut operation = self.client.operation(&routes::REDEEM_MAGIC_LINK, &[])?; operation.operation_name("MagicLinkRedeem"); operation.header(COOKIE, cookie); - operation.json(&Redeem { code, token: code })?; + operation.json(&RedeemMagicLinkRequestContent { + code: SensitiveString::from(code), + })?; let session: SessionAuthorization = self.client.send(operation).await?; *self .pending diff --git a/rust/fizzy-sdk/tests/magic_link.rs b/rust/fizzy-sdk/tests/magic_link.rs index 29737ed4..b1f31445 100644 --- a/rust/fizzy-sdk/tests/magic_link.rs +++ b/rust/fizzy-sdk/tests/magic_link.rs @@ -25,7 +25,7 @@ async fn the_flow_carries_the_pending_token_from_create_to_redeem() { Mock::given(method("POST")) .and(path("/session/magic_link.json")) .and(header("Cookie", "pending_authentication_token=pend-1")) - .and(body_json(json!({"code": "ABC123", "token": "ABC123"}))) + .and(body_json(json!({"code": "ABC123"}))) .respond_with( ResponseTemplate::new(200).set_body_json( json!({"session_token": "sess-1", "requires_signup_completion": false}), From 3cabf6fc74db9b43dfa88a924a6dc9375b17f9c1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 17:28:08 -0700 Subject: [PATCH 09/12] Build the Swift flow's per-request sessions service outside the client's cache The client caches an AccountClient that holds the client, and the account caches the service that holds it, so a client built per request and reached through .sessions was never freed, nor the cookie it signed with. The flow now builds SessionsService(accountClient:) directly, and a test checks a released flow lets go of its transport. --- swift/Sources/Fizzy/Auth/MagicLinkFlow.swift | 21 ++++++++++++------- .../Tests/FizzyTests/MagicLinkFlowTests.swift | 13 ++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift index e6ec7b46..e50a971e 100644 --- a/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift +++ b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift @@ -87,8 +87,8 @@ public final class MagicLinkFlow: @unchecked Sendable { /// - Returns: The pending authentication Fizzy answered. @discardableResult public func start(emailAddress: String) async throws -> PendingAuthentication { - let pendingAuthentication = try await client(auth: CookieHeaderAuth(cookie: nil)) - .sessions.create(req: CreateSessionRequest(emailAddress: emailAddress)) + let pendingAuthentication = try await sessions(auth: CookieHeaderAuth(cookie: nil)) + .create(req: CreateSessionRequest(emailAddress: emailAddress)) setPending(pendingAuthentication.pendingAuthenticationToken) return pendingAuthentication } @@ -107,8 +107,8 @@ public final class MagicLinkFlow: @unchecked Sendable { message: "No sign-in is pending", hint: "Call start(emailAddress:) first, or resume with the pending token") } - return try await client(auth: CookieHeaderAuth(cookie: (Self.pendingCookie, pending))) - .sessions.redeemMagicLink(req: RedeemMagicLinkRequest(code: code)) + return try await sessions(auth: CookieHeaderAuth(cookie: (Self.pendingCookie, pending))) + .redeemMagicLink(req: RedeemMagicLinkRequest(code: code)) } /// Completes signup for a new person after the code is redeemed. Only needed when @@ -118,8 +118,8 @@ public final class MagicLinkFlow: @unchecked Sendable { /// - fullName: The person's full name. /// - sessionToken: The session token ``redeem(code:)`` answered. public func completeSignup(fullName: String, sessionToken: String) async throws { - try await client(auth: CookieHeaderAuth(cookie: ("session_token", sessionToken))) - .sessions.completeSignup(req: CompleteSignupRequest(fullName: fullName)) + try await sessions(auth: CookieHeaderAuth(cookie: ("session_token", sessionToken))) + .completeSignup(req: CompleteSignupRequest(fullName: fullName)) } // MARK: - Private @@ -128,8 +128,13 @@ public final class MagicLinkFlow: @unchecked Sendable { lock.withLock { pending = token } } - private func client(auth: CookieHeaderAuth) -> FizzyClient { - FizzyClient(auth: auth, userAgent: userAgent, config: FizzyConfig(baseURL: baseURL), transport: transport) + /// The sessions service of a client signed with `auth`, built for one request. It is made + /// directly rather than through the client's service cache: a cached account holds the + /// client that holds it, so a client per request would never be freed, and with it the + /// cookie it signs with. + private func sessions(auth: CookieHeaderAuth) -> SessionsService { + let client = FizzyClient(auth: auth, userAgent: userAgent, config: FizzyConfig(baseURL: baseURL), transport: transport) + return SessionsService(accountClient: AccountClient(client: client)) } } diff --git a/swift/Tests/FizzyTests/MagicLinkFlowTests.swift b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift index 6d39f17e..f26ccdfd 100644 --- a/swift/Tests/FizzyTests/MagicLinkFlowTests.swift +++ b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift @@ -63,6 +63,19 @@ struct MagicLinkFlowTests { #expect(flow.pendingToken == "pend;1") } + @Test("A released flow lets go of its transport: the clients it builds per request do not outlive it") + func releasesItsClients() async throws { + weak var released: ScriptedMagicLinkTransport? + do { + let transport = ScriptedMagicLinkTransport(created, ScriptedMagicLinkTransport.Reply(status: 401, body: "{}")) + released = transport + let flow = MagicLinkFlow(userAgent: "test/1.0", transport: transport) + try await flow.start(emailAddress: "jane@example.com") + _ = try? await flow.redeem(code: "WRONG") + } + #expect(released == nil) + } + @Test("Redeeming posts the code to /session/magic_link.json with the pending token as an encoded cookie") func redeemsTheCode() async throws { let transport = ScriptedMagicLinkTransport(created, signedIn) From fcca5d2cb9a25ac0cc44de282287b2cc4a328cf7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 17:45:36 -0700 Subject: [PATCH 10/12] Spend only the sign-in a redeem consumed, in every flow Ruby, Kotlin and Swift kept the pending token after a successful redeem, so pendingToken reported a consumed sign-in and a second redeem sent it to Fizzy instead of failing locally. Go, TypeScript and Rust cleared it unconditionally, which erased a newer sign-in a create started while the redeem was in flight. Each flow now clears the token only if it is still the one it redeemed, and only after success, so a refused code can be retried. Tests assert the spent token and the local usage error on a second redeem. --- go/pkg/fizzy/magic_link.go | 5 ++++- .../kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt | 5 ++++- .../kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt | 3 +++ ruby/lib/fizzy/magic_link_flow.rb | 3 +++ ruby/test/magic_link_flow_test.rb | 3 +++ rust/fizzy-sdk/src/magic_link.rs | 8 ++++++-- swift/Sources/Fizzy/Auth/MagicLinkFlow.swift | 5 ++++- swift/Tests/FizzyTests/MagicLinkFlowTests.swift | 3 +++ typescript/src/auth/magic-link.ts | 6 ++++-- typescript/tests/magic-link.test.ts | 2 ++ 10 files changed, 36 insertions(+), 7 deletions(-) diff --git a/go/pkg/fizzy/magic_link.go b/go/pkg/fizzy/magic_link.go index 06d7725e..40210f6f 100644 --- a/go/pkg/fizzy/magic_link.go +++ b/go/pkg/fizzy/magic_link.go @@ -99,8 +99,11 @@ func (f *MagicLinkFlow) RedeemMagicLink(ctx context.Context, code string) (*gene if err != nil { return nil, err } + // The sign-in redeemed is consumed; one a CreateSession started meanwhile is not. f.mu.Lock() - f.pending = "" + if f.pending == pending { + f.pending = "" + } f.mu.Unlock() return session, nil } diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt index 810f636a..9f5e8d98 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt @@ -99,13 +99,16 @@ class MagicLinkFlow( * @throws FizzyException.Auth when Fizzy refuses the code or the pending sign-in. */ suspend fun redeemMagicLink(code: String): MagicLinkSession { - if (pendingToken == null) { + val pending = pendingToken + if (pending == null) { throw FizzyException.Usage( "No sign-in is pending", hint = "Call requestMagicLink first, or resume with the pending token", ) } val session = client.forAccount(ROOT).sessions.redeemMagicLink(RedeemMagicLinkBody(code = code)) + // The sign-in redeemed is consumed; one a requestMagicLink started meanwhile is not. + if (pendingToken == pending) pendingToken = null return MagicLinkSession( sessionToken = session.sessionToken, requiresSignupCompletion = session.requiresSignupCompletion, diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt index bb5c88bf..cc2ade22 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt @@ -55,6 +55,9 @@ class MagicLinkFlowTest { assertEquals("/session/magic_link.json", seen[1].url.encodedPath) assertEquals(mapOf("code" to "ABC123"), bodyOf(seen[1]).mapValues { it.value.jsonPrimitive.content }) assertEquals("pending_authentication_token=pend%2F1%2B2%3D", seen[1].headers[HttpHeaders.Cookie]) + assertNull(flow.pendingToken, "the redeemed sign-in is spent") + assertFailsWith { flow.redeemMagicLink("ABC123") } + assertEquals(2, seen.size) flow.close() } diff --git a/ruby/lib/fizzy/magic_link_flow.rb b/ruby/lib/fizzy/magic_link_flow.rb index 8d51975f..b386c04e 100644 --- a/ruby/lib/fizzy/magic_link_flow.rb +++ b/ruby/lib/fizzy/magic_link_flow.rb @@ -73,10 +73,13 @@ def redeem(code:) raise UsageError.new("no sign-in is pending; call create_session first, or pass pending_token:") \ if @pending_token.nil? || @pending_token.empty? + pending = @pending_token answer = @client.sessions.redeem_magic_link(code: code) raise APIError.new("Fizzy answered no session token") \ unless answer.is_a?(Hash) && answer["session_token"].is_a?(String) + # The sign-in redeemed is consumed; one a create_session started meanwhile is not. + @pending_token = nil if @pending_token.equal?(pending) answer.slice("session_token", "requires_signup_completion") end diff --git a/ruby/test/magic_link_flow_test.rb b/ruby/test/magic_link_flow_test.rb index 951f06e9..91c67ef7 100644 --- a/ruby/test/magic_link_flow_test.rb +++ b/ruby/test/magic_link_flow_test.rb @@ -41,6 +41,9 @@ def test_redeem_sends_the_code_with_the_pending_token_as_a_cookie assert_equal "/session/magic_link.json", redeem[:path] assert_equal({ "code" => "ABC123" }, redeem[:body]) assert_equal "pending_authentication_token=pend%2F1%2B%3D", redeem[:headers]["Cookie"] + assert_nil flow.pending_token, "the redeemed sign-in is spent" + assert_raises(Fizzy::UsageError) { flow.redeem(code: "ABC123") } + assert_equal 2, @requests.size end def test_redeem_resumes_a_sign_in_another_flow_started diff --git a/rust/fizzy-sdk/src/magic_link.rs b/rust/fizzy-sdk/src/magic_link.rs index a8e4bf43..ddc9b621 100644 --- a/rust/fizzy-sdk/src/magic_link.rs +++ b/rust/fizzy-sdk/src/magic_link.rs @@ -136,10 +136,14 @@ impl MagicLinkFlow { code: SensitiveString::from(code), })?; let session: SessionAuthorization = self.client.send(operation).await?; - *self + // The sign-in redeemed is consumed; one a create_session started meanwhile is not. + let mut current = self .pending .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current.as_ref().map(SensitiveString::expose) == Some(pending.expose()) { + *current = None; + } Ok(session) } } diff --git a/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift index e50a971e..6f7c4cce 100644 --- a/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift +++ b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift @@ -107,8 +107,11 @@ public final class MagicLinkFlow: @unchecked Sendable { message: "No sign-in is pending", hint: "Call start(emailAddress:) first, or resume with the pending token") } - return try await sessions(auth: CookieHeaderAuth(cookie: (Self.pendingCookie, pending))) + let session = try await sessions(auth: CookieHeaderAuth(cookie: (Self.pendingCookie, pending))) .redeemMagicLink(req: RedeemMagicLinkRequest(code: code)) + // The sign-in redeemed is consumed; one a start(emailAddress:) began meanwhile is not. + lock.withLock { if self.pending == pending { self.pending = nil } } + return session } /// Completes signup for a new person after the code is redeemed. Only needed when diff --git a/swift/Tests/FizzyTests/MagicLinkFlowTests.swift b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift index f26ccdfd..d84203e5 100644 --- a/swift/Tests/FizzyTests/MagicLinkFlowTests.swift +++ b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift @@ -92,6 +92,9 @@ struct MagicLinkFlowTests { #expect(request.httpShouldHandleCookies == false) #expect(session.sessionToken == "sess-1") #expect(session.requiresSignupCompletion) + #expect(flow.pendingToken == nil, "the redeemed sign-in is spent") + await #expect(throws: FizzyError.self) { try await flow.redeem(code: "ABC123") } + #expect(transport.requests.count == 2) } @Test("A resumed flow redeems against the pending token it was given") diff --git a/typescript/src/auth/magic-link.ts b/typescript/src/auth/magic-link.ts index 6fb044ef..0b1d3a52 100644 --- a/typescript/src/auth/magic-link.ts +++ b/typescript/src/auth/magic-link.ts @@ -127,14 +127,16 @@ export class MagicLinkFlow { hint: "Call createSession first, or resume with the pending token", }); } - const pendingCookie = new CookieAuth(cookieHeader(PENDING_COOKIE, this.pending)); + const pending = this.pending; + const pendingCookie = new CookieAuth(cookieHeader(PENDING_COOKIE, pending)); const answer = await this.client(pendingCookie).sessions.redeemMagicLink({ code }); const sessionToken = answer?.session_token; if (typeof sessionToken !== "string" || sessionToken === "") { throw new FizzyError("api_error", "No session token in the magic link response"); } this.session = sessionToken; - this.pending = undefined; + // The sign-in redeemed is consumed; one a createSession started meanwhile is not. + if (this.pending === pending) this.pending = undefined; return { sessionToken, requiresSignupCompletion: answer.requires_signup_completion === true, diff --git a/typescript/tests/magic-link.test.ts b/typescript/tests/magic-link.test.ts index f3ffc112..574c7bd6 100644 --- a/typescript/tests/magic-link.test.ts +++ b/typescript/tests/magic-link.test.ts @@ -62,6 +62,8 @@ describe("MagicLinkFlow", () => { const redeemed = await flow.redeem("ABC123"); expect(redeemed.sessionToken).toBe("st/1="); expect(redeemed.requiresSignupCompletion).toBe(false); + expect(flow.pendingToken).toBeUndefined(); + await expect(flow.redeem("ABC123")).rejects.toMatchObject({ code: "usage" }); expect(seen).toEqual([ { path: "/session.json", body: { email_address: "ada@example.com" }, cookie: null }, From 0f3d84d09ab1626cf7f874d3a3d2c0d1c099cd19 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 18:25:57 -0700 Subject: [PATCH 11/12] Bind each redeem's cookie to the token it read, and guard the pending state Kotlin's and Ruby's auth strategy read the flow's pending token when the request was signed, after hooks had run, so a sign-in started or resumed meanwhile could pair this code with another's cookie; Fizzy spends the code before refusing the mismatch. Each redeem now signs with a client bound to the token it read, and the pending state sits behind an atomic (Kotlin) or a mutex (Ruby). A Ruby test starts a second sign-in from a hook mid-redeem and checks the cookie and the surviving pending token. --- .../com/basecamp/fizzy/auth/MagicLinkFlow.kt | 35 ++++++++------ .../com/basecamp/fizzy/MagicLinkFlowTest.kt | 19 ++++++++ ruby/lib/fizzy/magic_link_flow.rb | 46 ++++++++++++------- ruby/test/magic_link_flow_test.rb | 44 ++++++++++++++++-- 4 files changed, 107 insertions(+), 37 deletions(-) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt index 9f5e8d98..d0903fa8 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt @@ -11,6 +11,8 @@ import com.basecamp.fizzy.generated.sessions import io.ktor.client.engine.* import io.ktor.client.request.* import io.ktor.http.* +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Passwordless sign-in with a magic link, on the generated Sessions operations. @@ -33,21 +35,19 @@ import io.ktor.http.* * val client = FizzyClient { auth(CookieAuth(session.sessionToken)) } * ``` */ +@OptIn(ExperimentalAtomicApi::class) class MagicLinkFlow( private val baseUrl: String = FizzyConfig.DEFAULT_BASE_URL, private val engine: HttpClientEngine? = null, pendingToken: String? = null, ) { - /** The pending authentication token of the sign-in under way, or null before one starts. */ - var pendingToken: String? = pendingToken?.takeIf { it.isNotBlank() } - private set + private val pending = AtomicReference(pendingToken?.takeIf { it.isNotBlank() }) - /** Carries the pending sign-in's cookie on the requests that need it, and nothing otherwise. */ - private val pendingAuth = AuthStrategy { request -> - this.pendingToken?.let { request.header(HttpHeaders.Cookie, cookie(PENDING_COOKIE, it)) } - } + /** The pending authentication token of the sign-in under way, or null before one starts. */ + val pendingToken: String? get() = pending.load() - private val client: FizzyClient = client(pendingAuth) + /** Starts a sign-in with no cookie: there is no sign-in yet for one to carry. */ + private val client: FizzyClient = client { } private fun client(auth: AuthStrategy): FizzyClient = FizzyClient { baseUrl = this@MagicLinkFlow.baseUrl @@ -82,14 +82,14 @@ class MagicLinkFlow( */ suspend fun requestMagicLink(emailAddress: String): MagicLinkRequest { val pending = client.forAccount(ROOT).sessions.create(CreateSessionBody(emailAddress = emailAddress)) - pendingToken = pending.pendingAuthenticationToken + this.pending.store(pending.pendingAuthenticationToken) return MagicLinkRequest(pendingAuthenticationToken = pending.pendingAuthenticationToken) } /** Resumes a sign-in another process started, from its [pendingToken]. */ fun resume(pendingToken: String) { require(pendingToken.isNotBlank()) { "Pending token must not be blank" } - this.pendingToken = pendingToken + pending.store(pendingToken) } /** @@ -99,16 +99,21 @@ class MagicLinkFlow( * @throws FizzyException.Auth when Fizzy refuses the code or the pending sign-in. */ suspend fun redeemMagicLink(code: String): MagicLinkSession { - val pending = pendingToken - if (pending == null) { - throw FizzyException.Usage( + val redeeming = pending.load() + ?: throw FizzyException.Usage( "No sign-in is pending", hint = "Call requestMagicLink first, or resume with the pending token", ) + // The cookie is bound to the token read here, so a sign-in that starts or resumes while + // this one is under way cannot pair this code with another's pending token. + val redeemer = client { request -> request.header(HttpHeaders.Cookie, cookie(PENDING_COOKIE, redeeming)) } + val session = try { + redeemer.forAccount(ROOT).sessions.redeemMagicLink(RedeemMagicLinkBody(code = code)) + } finally { + redeemer.close() } - val session = client.forAccount(ROOT).sessions.redeemMagicLink(RedeemMagicLinkBody(code = code)) // The sign-in redeemed is consumed; one a requestMagicLink started meanwhile is not. - if (pendingToken == pending) pendingToken = null + pending.compareAndSet(redeeming, null) return MagicLinkSession( sessionToken = session.sessionToken, requiresSignupCompletion = session.requiresSignupCompletion, diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt index cc2ade22..b443e2c2 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt @@ -86,6 +86,25 @@ class MagicLinkFlowTest { assertTrue(engine.requestHistory.isEmpty(), "nothing is sent without a pending sign-in") } + /** + * A sign-in resumed while a redeem is in flight keeps its own token: the redeem sends the + * cookie it read when it started, and spends only that one. + */ + @Test + fun aSignInResumedMidRedeemIsNeitherSentNorSpentByIt() = runTest { + lateinit var flow: MagicLinkFlow + val engine = MockEngine { request -> + flow.resume("pend-2") + respond("""{"session_token":"st-1","requires_signup_completion":false}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + flow = MagicLinkFlow(baseUrl = base, engine = engine, pendingToken = "pend-1") + + flow.redeemMagicLink("ABC123") + + assertEquals("pending_authentication_token=pend-1", engine.requestHistory.single().headers[HttpHeaders.Cookie]) + assertEquals("pend-2", flow.pendingToken, "the sign-in resumed meanwhile is still pending") + } + @Test fun aRefusedCodeIsAnAuthError() = runTest { val engine = MockEngine { diff --git a/ruby/lib/fizzy/magic_link_flow.rb b/ruby/lib/fizzy/magic_link_flow.rb index b386c04e..126e75d8 100644 --- a/ruby/lib/fizzy/magic_link_flow.rb +++ b/ruby/lib/fizzy/magic_link_flow.rb @@ -33,18 +33,22 @@ class MagicLinkFlow # The token a pending sign-in is waiting on, for a caller that finishes the flow in # another process. # @return [String, nil] - attr_reader :pending_token # @param base_url [String] Fizzy API base URL # @param hooks [Hooks, nil] observability hooks # @param pending_token [String, nil] the pending token of a sign-in another flow started def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil, pending_token: nil) @pending_token = pending_token - @client = Client.new( - config: Config.new(base_url: base_url), - auth_strategy: PendingCookieAuth.new(self), - hooks: hooks - ) + @lock = Mutex.new + @config = Config.new(base_url: base_url) + @hooks = hooks + @client = client_with(PendingCookieAuth.new(nil)) + end + + # The pending authentication token of the sign-in under way, or nil before one starts. + # @return [String, nil] + def pending_token + @lock.synchronize { @pending_token } end # Asks Fizzy to email a sign-in code to the address, and keeps the pending token the @@ -59,7 +63,7 @@ def create_session(email_address:) raise APIError.new("Fizzy answered no pending authentication token") \ unless token.is_a?(String) && !token.empty? - @pending_token = token + @lock.synchronize { @pending_token = token } end # Redeems the code from the email, sending back the pending token as the cookie Fizzy @@ -70,16 +74,19 @@ def create_session(email_address:) # @raise [UsageError] when no sign-in is pending # @raise [AuthError] when Fizzy refuses the code or the pending token def redeem(code:) + pending = pending_token raise UsageError.new("no sign-in is pending; call create_session first, or pass pending_token:") \ - if @pending_token.nil? || @pending_token.empty? + if pending.nil? || pending.empty? - pending = @pending_token - answer = @client.sessions.redeem_magic_link(code: code) + # The cookie is bound to the token read here, so a sign-in that starts while this one is + # under way (a hook can yield before the request is signed) cannot pair this code with + # another's pending token. + answer = client_with(PendingCookieAuth.new(pending)).sessions.redeem_magic_link(code: code) raise APIError.new("Fizzy answered no session token") \ unless answer.is_a?(Hash) && answer["session_token"].is_a?(String) # The sign-in redeemed is consumed; one a create_session started meanwhile is not. - @pending_token = nil if @pending_token.equal?(pending) + @lock.synchronize { @pending_token = nil if @pending_token.equal?(pending) } answer.slice("session_token", "requires_signup_completion") end @@ -90,19 +97,24 @@ def self.cookie(name, value) "#{name}=#{encoded}" end - # Sends the flow's pending token, once there is one, as the cookie Fizzy reads the - # pending sign-in from, and no other credentials: there are none yet. + private + + def client_with(auth_strategy) + Client.new(config: @config, auth_strategy: auth_strategy, hooks: @hooks) + end + + # Sends one pending token, when given one, as the cookie Fizzy reads the pending sign-in + # from, and no other credentials: there are none yet. # @api private class PendingCookieAuth include AuthStrategy - def initialize(flow) - @flow = flow + def initialize(token) + @token = token end def authenticate(headers) - token = @flow.pending_token - headers["Cookie"] = MagicLinkFlow.cookie(PENDING_COOKIE, token) unless token.nil? || token.empty? + headers["Cookie"] = MagicLinkFlow.cookie(PENDING_COOKIE, @token) unless @token.nil? || @token.empty? end end end diff --git a/ruby/test/magic_link_flow_test.rb b/ruby/test/magic_link_flow_test.rb index 91c67ef7..db364291 100644 --- a/ruby/test/magic_link_flow_test.rb +++ b/ruby/test/magic_link_flow_test.rb @@ -46,6 +46,35 @@ def test_redeem_sends_the_code_with_the_pending_token_as_a_cookie assert_equal 2, @requests.size end + # A hook runs before the request is signed, so a sign-in another caller starts from one + # must not change the cookie the code already in flight is sent with, nor be cleared by it. + def test_redeem_keeps_its_own_cookie_when_another_sign_in_starts_mid_request + flow = nil + started = false + hooks = Object.new + hooks.extend(Fizzy::Hooks) + hooks.define_singleton_method(:on_request_start) do |info| + if info.url.include?("magic_link") && !started + started = true + flow.create_session(email_address: "other@example.com") + end + end + answers = [ "pend-1", "pend-2" ] + flow = flow_with(hooks: hooks) do |stubs| + stubs.post("/session.json") { |env| record(env, 201, { pending_authentication_token: answers.shift }) } + stubs.post("/session/magic_link.json") do |env| + record(env, 200, { session_token: "sess-1", requires_signup_completion: false }) + end + end + + flow.create_session(email_address: "jane@example.com") + flow.redeem(code: "ABC123") + + redeem = @requests.find { |request| request[:path] == "/session/magic_link.json" } + assert_equal "pending_authentication_token=pend-1", redeem[:headers]["Cookie"] + assert_equal "pend-2", flow.pending_token, "the sign-in started meanwhile is still pending" + end + def test_redeem_resumes_a_sign_in_another_flow_started flow = flow_with(pending_token: "pend-2") do |stubs| stubs.post("/session/magic_link.json") do |env| @@ -98,16 +127,21 @@ def record(env, status, body) [ status, { "Content-Type" => "application/json" }, JSON.generate(body) ] end - def flow_with(pending_token: nil) + # A flow whose every client, the one it starts with and each it builds to redeem, answers + # from the stubs. + def flow_with(pending_token: nil, hooks: nil) stubs = Faraday::Adapter::Test::Stubs.new yield stubs - flow = Fizzy::MagicLinkFlow.new(base_url: "https://fizzy.do", pending_token: pending_token) - http = flow.instance_variable_get(:@client).http - http.instance_variable_set(:@faraday, Faraday.new(url: "https://fizzy.do") { |f| + faraday = Faraday.new(url: "https://fizzy.do") { |f| f.request :json f.response :raise_error f.adapter :test, stubs - }) + } + flow = Fizzy::MagicLinkFlow.new(base_url: "https://fizzy.do", pending_token: pending_token, hooks: hooks) + stub_client = ->(client) { client.http.instance_variable_set(:@faraday, faraday) && client } + stub_client.(flow.instance_variable_get(:@client)) + builder = flow.method(:client_with) + flow.define_singleton_method(:client_with) { |auth| stub_client.(builder.(auth)) } flow end end From 52d1e99e5ca13adbc43f4baf340ae842596214fd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 18:44:35 -0700 Subject: [PATCH 12/12] Keep no session in the TypeScript flow; completeSignup takes the token An older redeem finishing after a newer one overwrote the flow's stored session, so an implicit completeSignup could sign one person's sign-up under another's session. The flow now keeps no session: completeSignup takes the token redeem answered, as every other SDK's flow already does. --- typescript/src/auth/magic-link.ts | 21 ++++++++++----------- typescript/tests/magic-link.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/typescript/src/auth/magic-link.ts b/typescript/src/auth/magic-link.ts index 0b1d3a52..5c65b4d3 100644 --- a/typescript/src/auth/magic-link.ts +++ b/typescript/src/auth/magic-link.ts @@ -71,8 +71,8 @@ const noCredentials: AuthStrategy = { authenticate: async () => {} }; * await flow.createSession("user@example.com"); * * // Step 2: the user types the code in - * const { auth, requiresSignupCompletion } = await flow.redeem(code); - * if (requiresSignupCompletion) await flow.completeSignup("Ada Lovelace"); + * const { auth, sessionToken, requiresSignupCompletion } = await flow.redeem(code); + * if (requiresSignupCompletion) await flow.completeSignup("Ada Lovelace", sessionToken); * * // Step 3: use the session * const client = createFizzyClient({ auth }); @@ -81,7 +81,6 @@ const noCredentials: AuthStrategy = { authenticate: async () => {} }; export class MagicLinkFlow { private readonly options: MagicLinkFlowOptions; private pending: string | undefined; - private session: string | undefined; constructor(options?: MagicLinkFlowOptions) { this.options = options ?? {}; @@ -134,7 +133,6 @@ export class MagicLinkFlow { if (typeof sessionToken !== "string" || sessionToken === "") { throw new FizzyError("api_error", "No session token in the magic link response"); } - this.session = sessionToken; // The sign-in redeemed is consumed; one a createSession started meanwhile is not. if (this.pending === pending) this.pending = undefined; return { @@ -145,17 +143,18 @@ export class MagicLinkFlow { } /** - * Finishes a new account's sign-up under the session {@link MagicLinkFlow.redeem} answered, - * or the session token given. Fizzy answers 201 with no body. + * Finishes a new account's sign-up under the session token {@link MagicLinkFlow.redeem} + * answered. Fizzy answers 201 with no body. The token is the caller's to pass, as in every + * SDK's flow: a flow keeps no session of its own, so overlapping sign-ins cannot sign one + * person's sign-up under another's session. */ - async completeSignup(fullName: string, sessionToken?: string): Promise { - const token = sessionToken ?? this.session; - if (!token) { + async completeSignup(fullName: string, sessionToken: string): Promise { + if (!sessionToken) { throw new FizzyError("usage", "No session to complete the sign-up under", { - hint: "Redeem the code first, or pass the session token", + hint: "Pass the session token redeem answered", }); } - await this.client(new CookieAuth(cookieHeader(SESSION_COOKIE, token))).sessions.completeSignup({ fullName }); + await this.client(new CookieAuth(cookieHeader(SESSION_COOKIE, sessionToken))).sessions.completeSignup({ fullName }); } private client(auth: AuthStrategy): FizzyClient { diff --git a/typescript/tests/magic-link.test.ts b/typescript/tests/magic-link.test.ts index 574c7bd6..01a3984f 100644 --- a/typescript/tests/magic-link.test.ts +++ b/typescript/tests/magic-link.test.ts @@ -119,9 +119,9 @@ describe("MagicLinkFlow", () => { fizzy(seen, { status: 200, body: { session_token: "st-new", requires_signup_completion: true } }); const flow = MagicLinkFlow.resume("pend", { baseUrl: BASE_URL }); - const { requiresSignupCompletion } = await flow.redeem("ABC123"); + const { sessionToken, requiresSignupCompletion } = await flow.redeem("ABC123"); expect(requiresSignupCompletion).toBe(true); - await flow.completeSignup("Ada Lovelace"); + await flow.completeSignup("Ada Lovelace", sessionToken); expect(seen[1]).toEqual({ path: "/signup/completion.json",