From 4509949a54cab227b38c18513735b6693d295156 Mon Sep 17 00:00:00 2001 From: Paul Schifferer Date: Sat, 3 Oct 2026 13:38:04 -0700 Subject: [PATCH 1/2] fix(link): force Auth0's login prompt for the second identity linkStart's authorizeURL call never passed prompt=login, so a visitor with an active Auth0 SSO session (from their first identity) got silently re-authenticated as that same identity and bounced straight back through link/callback - no chance to enter a different account's credentials. Add prompt= support to authorizeURL and pass prompt=login specifically for the link flow; the normal /auth/login path is unaffected (no prompt argument passed there). --- Sources/App/Controllers/AuthController.swift | 7 ++++++- Sources/App/Services/Auth0Config.swift | 3 ++- Tests/AppTests/AppTests.swift | 13 +++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/App/Controllers/AuthController.swift b/Sources/App/Controllers/AuthController.swift index 155e21dc7..c2e2fd360 100644 --- a/Sources/App/Controllers/AuthController.swift +++ b/Sources/App/Controllers/AuthController.swift @@ -302,8 +302,13 @@ struct AuthController: RouteCollection { } req.session.data[Self.pendingLinkKey(state: ticket)] = sanitizedReturnTo(query.returnTo) + // prompt=login forces Auth0's credentials screen even though the visitor already has an + // active SSO session for their first identity - without it, Auth0 silently re-authenticates + // that same session and the round trip completes before the visitor can enter a different + // identity's credentials. return req.redirect( - to: config.authorizeURL(state: ticket, redirectURI: config.linkCallbackURL)) + to: config.authorizeURL( + state: ticket, redirectURI: config.linkCallbackURL, prompt: "login")) } /// Completes the account-linking round trip. Exchanges Auth0's code for the second identity's diff --git a/Sources/App/Services/Auth0Config.swift b/Sources/App/Services/Auth0Config.swift index 48ecad6b1..2d0e6b8ab 100644 --- a/Sources/App/Services/Auth0Config.swift +++ b/Sources/App/Services/Auth0Config.swift @@ -42,7 +42,7 @@ struct Auth0Config { }.joined(separator: "&") } - func authorizeURL(state: String, redirectURI: String? = nil) -> String { + func authorizeURL(state: String, redirectURI: String? = nil, prompt: String? = nil) -> String { var components = URLComponents(string: "https://\(domain)/authorize")! var items: [(name: String, value: String)] = [ ("response_type", "code"), @@ -52,6 +52,7 @@ struct Auth0Config { ("state", state), ] if let audience { items.append(("audience", audience)) } + if let prompt { items.append(("prompt", prompt)) } components.percentEncodedQuery = Self.percentEncodedQuery(items) return components.url!.absoluteString } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 5375886ee..80a673409 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -63,6 +63,19 @@ struct Auth0ConfigTests { #expect(url.contains("state=QqSBv%2Bzs88jhodF3G3QjDg%3D%3D")) #expect(!url.contains("state=QqSBv+zs88jhodF3G3QjDg")) } + + @Test("authorizeURL omits prompt by default but includes it when passed") + func authorizeURLPrompt() throws { + let config = Auth0Config( + domain: "sweetrpg-dev.us.auth0.com", + clientID: "client-id", + clientSecret: "secret", + callbackURL: "https://dev.sweetrpg.com/auth/callback", + audience: nil + ) + #expect(!config.authorizeURL(state: "s").contains("prompt=")) + #expect(config.authorizeURL(state: "s", prompt: "login").contains("prompt=login")) + } } @Suite("App") From 06f9fcbc7b46c0565e3a797bec6c6b5b9a3484c0 Mon Sep 17 00:00:00 2001 From: Paul Schifferer Date: Sat, 3 Oct 2026 14:24:54 -0700 Subject: [PATCH 2/2] fix(test): merge suites that race on the shared USERS_API_URL env var LinkStartTests, UsersAPIClientLinkCompleteTests, and ProvisionedUserIDTests each mutated the process-global USERS_API_URL env var via setenv/unsetenv, each marked .serialized - but .serialized only orders tests within one suite, not across suites, so Swift Testing's cross-suite parallelism raced them against each other. One suite's fake-server URL could leak into another suite's in-flight request mid-test. Observed in CI as PR #67's 'a users-api link/start failure surfaces as a 503' test getting a spurious 404 instead - the request landed on a different suite's fake server, which had no matching route for /internal/identities/link/start. Merging the three suites into one .serialized suite makes all tests that share this mutable state truly serialize against each other. Verified by running the full suite 3x locally with no failures (previously flaky). --- Tests/AppTests/AppTests.swift | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 80a673409..77c94242d 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -408,7 +408,14 @@ struct SessionUserTests { } } -@Suite("AuthController.linkStart", .serialized) +// LinkStartTests, UsersAPIClientLinkCompleteTests, and ProvisionedUserIDTests are merged into one +// .serialized suite because each mutates the process-global USERS_API_URL env var via +// setenv/unsetenv - .serialized only orders tests within a single suite, so as separate suites +// they raced against each other under Swift Testing's cross-suite parallelism. That race let one +// suite's fake-server URL leak into another suite's in-flight request mid-test (observed in CI as +// a spurious 404: a linkStart call landed on a fake server with no matching route instead of the +// connection-refused address it expected). +@Suite("AuthController+UsersAPIClient tests sharing USERS_API_URL env mutation", .serialized) struct LinkStartTests { @Test("linkStart requires an active session") func requiresActiveSession() async throws { @@ -472,15 +479,15 @@ struct LinkStartTests { } } } -} -/// `UsersAPIClient.linkComplete` is exercised against a real, locally listening fake `users-api` -/// rather than a mocked `Client`, matching this file's existing preference for real network -/// behavior over mocks (see the connection-refused pattern used throughout). This is the same -/// code path `AuthController.linkCallback` calls, so it stands in for "callback forwards ticket -/// + token correctly" without needing to also mock Auth0's HTTPS token endpoint. -@Suite("UsersAPIClient.linkComplete", .serialized) -struct UsersAPIClientLinkCompleteTests { + // MARK: - UsersAPIClient.linkComplete + // + // Exercised against a real, locally listening fake `users-api` rather than a mocked `Client`, + // matching this file's existing preference for real network behavior over mocks (see the + // connection-refused pattern used throughout). This is the same code path + // `AuthController.linkCallback` calls, so it stands in for "callback forwards ticket + token + // correctly" without needing to also mock Auth0's HTTPS token endpoint. + private static let fakeUsersAPIPort = 18999 private func withFakeUsersAPI( @@ -555,10 +562,9 @@ struct UsersAPIClientLinkCompleteTests { } } } -} -@Suite("AuthController.provisionedUserID", .serialized) -struct ProvisionedUserIDTests { + // MARK: - AuthController.provisionedUserID + @Test("a users-api provisioning failure degrades to nil rather than throwing") func provisioningFailureDegradesToNil() async throws { try await withApp(configure: configure) { app in