mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -94,11 +94,17 @@ actor InstanceDetector {
|
||||
basicAuthHeader: String? = nil
|
||||
) async -> Result<InstanceDetectionResult, DetectionError> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user