From c1db8701578fa99639dcba6890ebeb26a8e857a3 Mon Sep 17 00:00:00 2001 From: Paul Schifferer Date: Mon, 5 Oct 2026 07:28:41 -0700 Subject: [PATCH] fix(link): redirect with link=, matching users-web's actual contract linkErrorRedirect sent ?link_error= on failure and a bare redirect with no outcome param at all on success. users-web's SettingsController only ever reads req.query[String.self, at: "link"] and settings.leaf only recognizes linkOutcome == "conflict"/"error"/"success" - so neither path ever produced a banner. Confirmed on dev: a real conflicting-account link attempt correctly failed server-side (?link_error=conflict in the URL) but showed no error to the user at all. Maps every LinkErrorReason except .conflict to the generic "error" outcome - users-web never surfaces the denied/expired/unavailable distinction itself, only "conflict" has its own banner copy. Success now redirects with ?link=success. No existing test exercised linkCallback's redirect at all, which is how this shipped unnoticed. Added a regression test for the earliest error branch (Auth0 returning ?error=) - reproducible without mocking Auth0's HTTPS token endpoint, which the existing linkComplete fake-server tests deliberately avoid for the same reason (see their own comment). --- Sources/App/Controllers/AuthController.swift | 10 ++++++++-- Tests/AppTests/AppTests.swift | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/App/Controllers/AuthController.swift b/Sources/App/Controllers/AuthController.swift index c2e2fd360..3f448d5bd 100644 --- a/Sources/App/Controllers/AuthController.swift +++ b/Sources/App/Controllers/AuthController.swift @@ -247,10 +247,16 @@ struct AuthController: RouteCollection { case conflict } + /// Redirects with `?link=`, the query key/value contract `users-web`'s + /// `SettingsController`/`settings.leaf` actually reads (`req.query[String.self, at: "link"]`, + /// recognizing only "conflict", "error", and "success"). `.conflict` is the one reason with + /// its own banner copy; every other reason collapses to the generic "error" banner - `users-web` + /// never surfaces the specific denied/expired/unavailable distinction to the visitor. private func linkErrorRedirect(_ req: Request, to returnTo: String, reason: LinkErrorReason) -> Response { - req.redirect(to: "\(returnTo)?link_error=\(reason.rawValue)") + let outcome = reason == .conflict ? "conflict" : "error" + return req.redirect(to: "\(returnTo)?link=\(outcome)") } /// Keyed by the link ticket itself (used as Auth0's `state`) rather than a separate locally @@ -373,6 +379,6 @@ struct AuthController: RouteCollection { return linkErrorRedirect(req, to: returnTo, reason: .unavailable) } - return req.redirect(to: returnTo) + return req.redirect(to: "\(returnTo)?link=success") } } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 77c94242d..42b90a6ba 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -480,6 +480,26 @@ struct LinkStartTests { } } + // MARK: - AuthController.linkCallback error redirects + + @Test("linkCallback redirects with link=error, not the stale link_error= query key") + func linkCallbackErrorUsesLinkQueryKey() async throws { + try await withApp(configure: configure) { app in + // Auth0 itself returning an `error` param is the earliest branch in linkCallback - reached + // before any pending-link session lookup or network call, so this needs no fake Auth0/ + // users-api server to exercise. Regression test for the contract users-web's + // SettingsController actually reads (`req.query[String.self, at: "link"]`) - a prior + // version redirected with `?link_error=` instead, which users-web silently ignored + // (no banner shown, confirmed on dev with a real conflict). + try await app.testing().test(.GET, "auth/link/callback?error=access_denied") { res in + #expect(res.status == .seeOther) + let location = res.headers.first(name: .location) ?? "" + #expect(location.contains("link=error")) + #expect(!location.contains("link_error=")) + } + } + } + // MARK: - UsersAPIClient.linkComplete // // Exercised against a real, locally listening fake `users-api` rather than a mocked `Client`,