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..77c94242d 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") @@ -395,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 { @@ -459,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( @@ -542,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