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/fizzy/magic_link.go b/go/pkg/fizzy/magic_link.go index 2d7e522e..40210f6f 100644 --- a/go/pkg/fizzy/magic_link.go +++ b/go/pkg/fizzy/magic_link.go @@ -2,149 +2,141 @@ 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) + // The sign-in redeemed is consumed; one a CreateSession started meanwhile is not. + f.mu.Lock() + if f.pending == pending { + f.pending = "" } - defer func() { _ = resp.Body.Close() }() + 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) + } +} 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/auth/MagicLinkFlow.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/auth/MagicLinkFlow.kt index e09ec4ce..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 @@ -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.* +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** - * 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)) } * ``` */ +@OptIn(ExperimentalAtomicApi::class) 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 - } + private val pending = AtomicReference(pendingToken?.takeIf { it.isNotBlank() }) - private val httpClient: HttpClient = if (engine != null) { - HttpClient(engine) { configureClient() } - } else { - HttpClient { configureClient() } - } + /** The pending authentication token of the sign-in under way, or null before one starts. */ + val pendingToken: String? get() = pending.load() - private fun HttpClientConfig<*>.configureClient() { - expectSuccess = false - install(HttpTimeout) { - requestTimeoutMillis = 30_000 - connectTimeoutMillis = 30_000 - socketTimeoutMillis = 30_000 - } + /** 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 + 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,79 @@ 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)) + this.pending.store(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" } + pending.store(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 { + 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 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 - + // The sign-in redeemed is consumed; one a requestMagicLink started meanwhile is not. + pending.compareAndSet(redeeming, null) 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/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/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..b443e2c2 --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/fizzy/MagicLinkFlowTest.kt @@ -0,0 +1,131 @@ +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]) + assertNull(flow.pendingToken, "the redeemed sign-in is spent") + assertFailsWith { flow.redeemMagicLink("ABC123") } + assertEquals(2, seen.size) + 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") + } + + /** + * 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 { + 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]) + } +} 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/ruby/lib/fizzy/magic_link_flow.rb b/ruby/lib/fizzy/magic_link_flow.rb index 899140e2..126e75d8 100644 --- a/ruby/lib/fizzy/magic_link_flow.rb +++ b/ruby/lib/fizzy/magic_link_flow.rb @@ -1,56 +1,120 @@ # 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] + # @param base_url [String] Fizzy API base URL # @param hooks [Hooks, nil] observability hooks - def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil) + # @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 + @lock = Mutex.new @config = Config.new(base_url: base_url) - @hooks = hooks || NoopHooks.new - @http = Http.new(config: @config, auth_strategy: NullAuth.new, hooks: @hooks) + @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 - # 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? + + @lock.synchronize { @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:) + pending = pending_token + raise UsageError.new("no sign-in is pending; call create_session first, or pass pending_token:") \ + if pending.nil? || pending.empty? + + # 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. + @lock.synchronize { @pending_token = nil if @pending_token.equal?(pending) } + 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 + + 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(token) + @token = token + end + def authenticate(headers) - # No authentication needed for magic link flow initiation + 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..db364291 --- /dev/null +++ b/ruby/test/magic_link_flow_test.rb @@ -0,0 +1,147 @@ +# 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"] + 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 + + # 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| + 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 + + # 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 + 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 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/fizzy-sdk/src/magic_link.rs b/rust/fizzy-sdk/src/magic_link.rs index 4cb333b3..ddc9b621 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,12 +132,18 @@ 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 + // 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/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}), 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/Auth/MagicLinkFlow.swift b/swift/Sources/Fizzy/Auth/MagicLinkFlow.swift index 81a8708a..6f7c4cce 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,104 @@ 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 sessions(auth: CookieHeaderAuth(cookie: nil)) + .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) + 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 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 sessions(auth: CookieHeaderAuth(cookie: ("session_token", sessionToken))) + .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) + /// 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)) + } +} - 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/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/swift/Tests/FizzyTests/MagicLinkFlowTests.swift b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift new file mode 100644 index 00000000..d84203e5 --- /dev/null +++ b/swift/Tests/FizzyTests/MagicLinkFlowTests.swift @@ -0,0 +1,147 @@ +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("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) + 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) + #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") + 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") + } +} 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/auth/magic-link.ts b/typescript/src/auth/magic-link.ts index c44043a8..5c65b4d3 100644 --- a/typescript/src/auth/magic-link.ts +++ b/typescript/src/auth/magic-link.ts @@ -1,128 +1,163 @@ /** - * 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, sessionToken, requiresSignupCompletion } = await flow.redeem(code); + * if (requiresSignupCompletion) await flow.completeSignup("Ada Lovelace", sessionToken); * - * // 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; 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 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"); + } + // 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, + 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 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 { + if (!sessionToken) { + throw new FizzyError("usage", "No session to complete the sign-up under", { + hint: "Pass the session token redeem answered", + }); + } + await this.client(new CookieAuth(cookieHeader(SESSION_COOKIE, sessionToken))).sessions.completeSignup({ fullName }); + } - return { sessionToken, auth }; + private client(auth: AuthStrategy): FizzyClient { + return createFizzyClient({ ...this.options, auth }); } } 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..b1d97500 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), ); } diff --git a/typescript/tests/magic-link.test.ts b/typescript/tests/magic-link.test.ts new file mode 100644 index 00000000..01a3984f --- /dev/null +++ b/typescript/tests/magic-link.test.ts @@ -0,0 +1,137 @@ +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(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 }, + { + 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 { sessionToken, requiresSignupCompletion } = await flow.redeem("ABC123"); + expect(requiresSignupCompletion).toBe(true); + await flow.completeSignup("Ada Lovelace", sessionToken); + + 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" }); + }); +});