Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/App/Controllers/AuthController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/App/Services/Auth0Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
}
Expand Down
43 changes: 31 additions & 12 deletions Tests/AppTests/AppTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading