diff --git a/Yattee/Services/API/InstanceDetector.swift b/Yattee/Services/API/InstanceDetector.swift index 22549888..c418bf80 100644 --- a/Yattee/Services/API/InstanceDetector.swift +++ b/Yattee/Services/API/InstanceDetector.swift @@ -94,11 +94,17 @@ actor InstanceDetector { basicAuthHeader: String? = nil ) async -> Result { let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] } - // If we already supplied credentials and still get 401, those credentials are wrong. - let unauthorizedError: DetectionError = basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid - // Try each detection method in order of specificity - // Check Yattee Server first as it's most specific + // A 401 from a single probe is *not* enough to conclude that credentials are + // invalid. Some probe paths (e.g. Yattee Server's `/info`) trigger an HTTP + // redirect on Invidious, and iOS URLSession strips the Authorization header + // when following redirects, so the redirected request 401s even when the + // credentials are valid. We instead consider credentials bad only if EVERY + // probe failed with 401 and none matched. + var sawUnauthorized = false + + // Try each detection method in order of specificity. + // Check Yattee Server first as it's most specific. do { if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) { return .success(result) @@ -106,7 +112,7 @@ actor InstanceDetector { } catch let error as DetectionError { return .failure(error) } catch APIError.unauthorized { - return .failure(unauthorizedError) + sawUnauthorized = true } catch { // Continue to next detection method } @@ -116,7 +122,7 @@ actor InstanceDetector { return .success(InstanceDetectionResult(type: .peertube)) } } catch APIError.unauthorized { - return .failure(unauthorizedError) + sawUnauthorized = true } catch { // Continue to next detection method } @@ -126,7 +132,7 @@ actor InstanceDetector { return .success(InstanceDetectionResult(type: .invidious)) } } catch APIError.unauthorized { - return .failure(unauthorizedError) + sawUnauthorized = true } catch { // Continue to next detection method } @@ -136,11 +142,17 @@ actor InstanceDetector { return .success(InstanceDetectionResult(type: .piped)) } } catch APIError.unauthorized { - return .failure(unauthorizedError) + sawUnauthorized = true } catch { // Fall through } + // No probe matched. If at least one probe returned 401, the instance is + // (or appears to be) behind HTTP Basic Auth. Distinguish "needs creds" from + // "creds rejected" by whether the caller already supplied a header. + if sawUnauthorized { + return .failure(basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid) + } return .failure(.unknownType) } diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index 206245e7..04d36167 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -348,7 +348,7 @@ actor InvidiousAPI: InstanceAPI { /// - password: The user's password /// - instance: The Invidious instance to log in to /// - Returns: The session ID (SID) cookie value - func login(email: String, password: String, instance: Instance) async throws -> String { + func login(email: String, password: String, instance: Instance, extraHeaders: [String: String]? = nil) async throws -> String { // Build form-urlencoded body using URLComponents for standard encoding var components = URLComponents() components.queryItems = [ @@ -370,6 +370,16 @@ actor InvidiousAPI: InstanceAPI { request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = bodyData + // Apply any extra headers (e.g. an HTTP Basic Auth Authorization header + // for instances behind a reverse proxy). The login endpoint uses its own + // URLSession below to capture Set-Cookie, so it cannot inherit headers + // from the injected httpClient. + if let extraHeaders { + for (key, value) in extraHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + } + // Use a session that doesn't follow redirects so we can capture the Set-Cookie header let sessionConfig = URLSessionConfiguration.ephemeral let session = URLSession(configuration: sessionConfig, delegate: RedirectBlocker(), delegateQueue: nil) diff --git a/Yattee/Views/Settings/InstanceLoginView.swift b/Yattee/Views/Settings/InstanceLoginView.swift index 112d3658..1b463c91 100644 --- a/Yattee/Views/Settings/InstanceLoginView.swift +++ b/Yattee/Views/Settings/InstanceLoginView.swift @@ -177,13 +177,33 @@ struct InstanceLoginView: View { /// Performs the login based on instance type. /// - Returns: The credential (SID for Invidious, token for Piped) private func performLogin(appEnvironment: AppEnvironment) async throws -> String { + // If the instance sits behind an HTTP Basic Auth reverse proxy, the login + // POST must carry that proxy's Authorization header too — otherwise the + // request 401s before reaching the upstream login endpoint. Bake the + // header into a fresh per-instance HTTPClient. + let basicAuthHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) + let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] } + + let httpClient: HTTPClient + if let basicAuthHeader { + httpClient = appEnvironment.httpClientFactory.createClient(for: instance) + await httpClient.setDefaultHeaders(["Authorization": basicAuthHeader]) + } else { + httpClient = appEnvironment.httpClient + } + switch instance.type { case .invidious: - let api = InvidiousAPI(httpClient: appEnvironment.httpClient) - return try await api.login(email: username, password: password, instance: instance) + // InvidiousAPI.login uses its own URLSession (to handle redirect/Set-Cookie), + // so it doesn't inherit defaultHeaders from the injected HTTPClient. Pass + // the basic-auth header explicitly so the login POST passes the proxy. + let api = InvidiousAPI(httpClient: httpClient) + return try await api.login(email: username, password: password, instance: instance, extraHeaders: extraHeaders) case .piped: - let api = PipedAPI(httpClient: appEnvironment.httpClient) + // PipedAPI.login uses httpClient.fetch which DOES inherit defaultHeaders, + // so the basic-auth header on the per-instance client is sufficient. + let api = PipedAPI(httpClient: httpClient) return try await api.login(username: username, password: password, instance: instance) default: