Fix three basic-auth regressions surfaced by end-to-end testing

- InstanceDetector: a single 401 from one probe was over-eagerly concluded
  as "credentials invalid" / "credentials required". On instances behind a
  reverse proxy where one probe path (e.g. Yattee Server's /info) hits a
  same-origin redirect, iOS URLSession strips the Authorization header on
  the redirect and the request 401s even with valid credentials. Track 401s
  across all probes and only conclude basicAuthRequired/basicAuthInvalid
  when no probe matched and at least one returned 401.

- InstanceLoginView: the Invidious/Piped login flow constructed an API
  client backed by the shared appEnvironment.httpClient, which has no
  per-instance basic-auth headers. For instances behind a reverse proxy,
  the login POST 401d before reaching the upstream login endpoint. Build a
  per-instance HTTPClient with the basic-auth Authorization header baked in
  via setDefaultHeaders, mirroring ContentService.httpClientWithBasicAuth.

- InvidiousAPI.login: the login function constructs its own URLSession (to
  capture Set-Cookie via a redirect-blocking delegate), so it never
  inherits headers from the injected httpClient. Add an optional
  extraHeaders parameter and have InstanceLoginView pass the basic-auth
  header through when present. PipedAPI.login uses httpClient.fetch and
  inherits defaultHeaders correctly, so no change is needed there.
This commit is contained in:
Arkadiusz Fal
2026-04-06 22:11:55 +02:00
parent 3dd4073db7
commit eefd49f743
3 changed files with 54 additions and 12 deletions

View File

@@ -94,11 +94,17 @@ actor InstanceDetector {
basicAuthHeader: String? = nil basicAuthHeader: String? = nil
) async -> Result<InstanceDetectionResult, DetectionError> { ) async -> Result<InstanceDetectionResult, DetectionError> {
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] } 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 // A 401 from a single probe is *not* enough to conclude that credentials are
// Check Yattee Server first as it's most specific // 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 { do {
if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) { if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) {
return .success(result) return .success(result)
@@ -106,7 +112,7 @@ actor InstanceDetector {
} catch let error as DetectionError { } catch let error as DetectionError {
return .failure(error) return .failure(error)
} catch APIError.unauthorized { } catch APIError.unauthorized {
return .failure(unauthorizedError) sawUnauthorized = true
} catch { } catch {
// Continue to next detection method // Continue to next detection method
} }
@@ -116,7 +122,7 @@ actor InstanceDetector {
return .success(InstanceDetectionResult(type: .peertube)) return .success(InstanceDetectionResult(type: .peertube))
} }
} catch APIError.unauthorized { } catch APIError.unauthorized {
return .failure(unauthorizedError) sawUnauthorized = true
} catch { } catch {
// Continue to next detection method // Continue to next detection method
} }
@@ -126,7 +132,7 @@ actor InstanceDetector {
return .success(InstanceDetectionResult(type: .invidious)) return .success(InstanceDetectionResult(type: .invidious))
} }
} catch APIError.unauthorized { } catch APIError.unauthorized {
return .failure(unauthorizedError) sawUnauthorized = true
} catch { } catch {
// Continue to next detection method // Continue to next detection method
} }
@@ -136,11 +142,17 @@ actor InstanceDetector {
return .success(InstanceDetectionResult(type: .piped)) return .success(InstanceDetectionResult(type: .piped))
} }
} catch APIError.unauthorized { } catch APIError.unauthorized {
return .failure(unauthorizedError) sawUnauthorized = true
} catch { } catch {
// Fall through // 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) return .failure(.unknownType)
} }

View File

@@ -348,7 +348,7 @@ actor InvidiousAPI: InstanceAPI {
/// - password: The user's password /// - password: The user's password
/// - instance: The Invidious instance to log in to /// - instance: The Invidious instance to log in to
/// - Returns: The session ID (SID) cookie value /// - 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 // Build form-urlencoded body using URLComponents for standard encoding
var components = URLComponents() var components = URLComponents()
components.queryItems = [ components.queryItems = [
@@ -370,6 +370,16 @@ actor InvidiousAPI: InstanceAPI {
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = bodyData 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 // Use a session that doesn't follow redirects so we can capture the Set-Cookie header
let sessionConfig = URLSessionConfiguration.ephemeral let sessionConfig = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: sessionConfig, delegate: RedirectBlocker(), delegateQueue: nil) let session = URLSession(configuration: sessionConfig, delegate: RedirectBlocker(), delegateQueue: nil)

View File

@@ -177,13 +177,33 @@ struct InstanceLoginView: View {
/// Performs the login based on instance type. /// Performs the login based on instance type.
/// - Returns: The credential (SID for Invidious, token for Piped) /// - Returns: The credential (SID for Invidious, token for Piped)
private func performLogin(appEnvironment: AppEnvironment) async throws -> String { 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 { switch instance.type {
case .invidious: case .invidious:
let api = InvidiousAPI(httpClient: appEnvironment.httpClient) // InvidiousAPI.login uses its own URLSession (to handle redirect/Set-Cookie),
return try await api.login(email: username, password: password, instance: instance) // 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: 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) return try await api.login(username: username, password: password, instance: instance)
default: default: