Skip to content

Commit 315b571

Browse files
authored
ContainerRegistry: Remember initial auth challenge and send credentials eagerly (#30)
### Motivation Currently, `ContainerRegistry` handles authentication lazily. First it tries an unauthenticated request. If the registry responds with a challenge, `ContainerRegistry` gets a suitable credential from `.netrc` or from an authentication service and re-tries the request. When pushing large blobs to some registries, the client can get stuck in a state where it has received an authentication challenge but is still waiting to finish the blob upload. The symptoms are that metadata uploads and very small image layers can be uploaded, but pushing larger images stalls and times out, as reported in #17. ### Modifications When pushing large blobs, the registry's `401` challenge arrives after the client has sent some of the blob data, but before it has completed the upload. With some registries, `URLSession` gets stuck in this state. Implementing the `didReceiveResponse` delegate allows the client to see the challenge and cancel the task but in some cases, mainly on Linux, `URLSession` can still get stuck. A more reliable solution to this problem is to handle challenges eagerly. Before pushing any data, the client checks whether the registry supports the v2 distribution protocol. If it is challenged during this check, it will then eagerly send a suitable authentication token with all subsequent requests to the registry. This avoids the state where the client receives a challenge part-way through uploading a large blob and gets stuck. ### Result It is possible to upload large images to ECR and Docker Hub from macOS hosts, and to Docker Hub from Linux hosts. Uploading to ECR from Linux hosts continues to lead to a timeout. Fixes #17 ### Test Plan Automated tests continue to pass; tested manually with several different registries.
1 parent 483ad76 commit 315b571

File tree

6 files changed

+95
-84
lines changed

6 files changed

+95
-84
lines changed

Sources/ContainerRegistry/AuthHandler.swift

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,22 @@ func parseChallenge(_ s: String) throws -> BearerChallenge {
110110
return res
111111
}
112112

113+
public enum AuthChallenge: Equatable {
114+
case none
115+
case basic(String)
116+
case bearer(String)
117+
118+
init(challenge: String) {
119+
if challenge.lowercased().starts(with: "basic") {
120+
self = .basic(challenge)
121+
} else if challenge.lowercased().starts(with: "bearer") {
122+
self = .bearer(challenge)
123+
} else {
124+
self = .none
125+
}
126+
}
127+
}
128+
113129
/// AuthHandler manages provides credentials for HTTP requests
114130
public struct AuthHandler {
115131
var username: String?
@@ -127,11 +143,9 @@ public struct AuthHandler {
127143
self.auth = auth
128144
}
129145

130-
/// Get locally-configured credentials, such as netrc or username/password, for a request
131-
func localCredentials(for request: HTTPRequest) -> String? {
132-
guard let requestURL = request.url else { return nil }
133-
134-
if let netrcEntry = auth?.httpAuthorizationHeader(for: requestURL) { return netrcEntry }
146+
/// Get locally-configured credentials, such as netrc or username/password, for a host
147+
func localCredentials(forURL url: URL) -> String? {
148+
if let netrcEntry = auth?.httpAuthorizationHeader(for: url) { return netrcEntry }
135149

136150
if let username, let password {
137151
let authorization = Data("\(username):\(password)".utf8).base64EncodedString()
@@ -142,52 +156,35 @@ public struct AuthHandler {
142156
return nil
143157
}
144158

145-
/// Add authorization to an HTTP rquest before it has been sent to a server.
146-
/// Currently this function always passes the request back unmodified, to trigger a challenge.
147-
/// In future it could provide cached responses from previous challenges.
148-
/// - Parameter request: The request to authorize.
149-
/// - Returns: The request, with an appropriate authorization header added, or nil if no credentials are available.
150-
public func auth(for request: HTTPRequest) -> HTTPRequest? { nil }
151-
152-
/// Add authorization to an HTTP rquest in response to a challenge from the server.
153-
/// - Parameters:
154-
/// - request: The reuqest to authorize.
155-
/// - challenge: The server's challeng.
156-
/// - client: An HTTP client, used to retrieve tokens if necessary.
157-
/// - Returns: The request, with an appropriate authorization header added, or nil if no credentials are available.
158-
/// - Throws: If an error occurs while retrieving a credential.
159-
public func auth(for request: HTTPRequest, withChallenge challenge: String, usingClient client: HTTPClient)
160-
async throws -> HTTPRequest?
161-
{
162-
if challenge.lowercased().starts(with: "basic") {
163-
guard let authHeader = localCredentials(for: request) else { return nil }
164-
var request = request
165-
request.headerFields[.authorization] = authHeader
166-
return request
167-
168-
} else if challenge.lowercased().starts(with: "bearer") {
159+
public func auth(
160+
registry: URL,
161+
repository: String,
162+
actions: [String],
163+
withScheme scheme: AuthChallenge,
164+
usingClient client: HTTPClient
165+
) async throws -> String? {
166+
switch scheme {
167+
case .none: return nil
168+
case .basic: return localCredentials(forURL: registry)
169+
170+
case .bearer(let challenge):
169171
// Preemptively offer suitable basic auth credentials to the token server.
170172
// Instead of challenging, public token servers often return anonymous tokens when no credentials are offered.
171173
// These tokens allow pull access to public repositories, but attempts to push will fail with 'unauthorized'.
172174
// There is no obvious prompt for the client to retry with authentication.
173-
let parsedChallenge = try parseChallenge(
175+
var parsedChallenge = try parseChallenge(
174176
challenge.dropFirst("bearer".count).trimmingCharacters(in: .whitespacesAndNewlines)
175177
)
178+
parsedChallenge.scope = ["repository:\(repository):\(actions.joined(separator: ","))"]
176179
guard let challengeURL = parsedChallenge.url else { return nil }
177180
var tokenRequest = HTTPRequest(url: challengeURL)
178-
if let credentials = localCredentials(for: tokenRequest) {
181+
if let credentials = localCredentials(forURL: challengeURL) {
179182
tokenRequest.headerFields[.authorization] = credentials
180183
}
181184

182185
let (data, _) = try await client.executeRequestThrowing(tokenRequest, expectingStatus: .ok)
183186
let tokenResponse = try JSONDecoder().decode(BearerTokenResponse.self, from: data)
184-
var request = request
185-
request.headerFields[.authorization] = "Bearer \(tokenResponse.token)"
186-
return request
187-
188-
} else {
189-
// No other authentication methods available
190-
return nil
187+
return "Bearer \(tokenResponse.token)"
191188
}
192189
}
193190
}

Sources/ContainerRegistry/CheckAPI.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,29 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
import Foundation
16+
1517
public extension RegistryClient {
16-
/// Returns a boolean value indicating whether the registry supports v2 of the distribution specification.
17-
/// - Returns: `true` if the registry supports the distribution specification, otherwise `false`.
18-
func checkAPI() async throws -> Bool {
18+
/// Checks whether the registry supports v2 of the distribution specification.
19+
/// - Returns: an `true` if the registry supports the distribution specification.
20+
/// - Throws: if the registry does not support the distribution specification.
21+
static func checkAPI(client: HTTPClient, registryURL: URL) async throws -> AuthChallenge {
1922
// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support
23+
2024
// The registry indicates that it supports the v2 protocol by returning a 200 OK response.
2125
// Many registries also set `Content-Type: application/json` and return empty JSON objects,
2226
// but this is not required and some do not.
2327
// The registry may require authentication on this endpoint.
28+
2429
do {
2530
// Using the bare HTTP client because this is the only endpoint which does not include a repository path
26-
let _ = try await executeRequestThrowing(
27-
.get("", url: registryURL.distributionEndpoint),
28-
expectingStatus: .ok,
29-
decodingErrors: [.unauthorized, .notFound]
31+
// and to avoid RegistryClient's auth handling
32+
let _ = try await client.executeRequestThrowing(
33+
.get(registryURL.distributionEndpoint, withAuthorization: nil),
34+
expectingStatus: .ok
3035
)
31-
return true
32-
} catch HTTPClientError.unexpectedStatusCode(status: .notFound, _, _) { return false }
36+
return .none
37+
38+
} catch HTTPClientError.authenticationChallenge(let challenge, _, _) { return .init(challenge: challenge) }
3339
}
3440
}

Sources/ContainerRegistry/HTTPClient.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,13 @@ extension HTTPRequest {
159159
// https://developer.apple.com/forums/thread/89811
160160
if let authorization { headerFields[.authorization] = authorization }
161161
}
162+
163+
static func get(
164+
_ url: URL,
165+
accepting: [String] = [],
166+
contentType: String? = nil,
167+
withAuthorization authorization: String? = nil
168+
) -> HTTPRequest {
169+
.init(method: .get, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization)
170+
}
162171
}

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public struct RegistryClient {
5050

5151
/// Authentication handler
5252
var auth: AuthHandler?
53+
var authChallenge: AuthChallenge
5354

5455
var encoder: JSONEncoder
5556
var decoder: JSONDecoder
@@ -92,7 +93,7 @@ public struct RegistryClient {
9293
self.decoder = decoder ?? JSONDecoder()
9394

9495
// Verify that we can talk to the registry
95-
_ = try await checkAPI()
96+
self.authChallenge = try await RegistryClient.checkAPI(client: self.client, registryURL: self.registryURL)
9697
}
9798

9899
/// Creates a new RegistryClient, constructing a suitable URLSession-based client.
@@ -142,15 +143,14 @@ extension RegistryClient {
142143
var method: HTTPRequest.Method // HTTP method
143144
var repository: String // Repository path on the registry
144145
var destination: Destination // Destination of the operation: can be a subpath or remote URL
146+
var actions: [String] // Actions required by this operation
145147
var accepting: [String] = [] // Acceptable response types
146148
var contentType: String? = nil // Request data type
147149

148150
func url(relativeTo registry: URL) -> URL {
149151
switch destination {
150152
case .url(let url): return url
151-
case .subpath(let path):
152-
let subpath = registry.distributionEndpoint(forRepository: repository, andEndpoint: path)
153-
return subpath
153+
case .subpath(let path): return registry.distributionEndpoint(forRepository: repository, andEndpoint: path)
154154
}
155155
}
156156

@@ -166,6 +166,7 @@ extension RegistryClient {
166166
method: .get,
167167
repository: repository,
168168
destination: .subpath(path),
169+
actions: ["pull"],
169170
accepting: accepting,
170171
contentType: contentType
171172
)
@@ -182,6 +183,7 @@ extension RegistryClient {
182183
method: .get,
183184
repository: repository,
184185
destination: .url(url),
186+
actions: ["pull"],
185187
accepting: accepting,
186188
contentType: contentType
187189
)
@@ -198,6 +200,7 @@ extension RegistryClient {
198200
method: .head,
199201
repository: repository,
200202
destination: .subpath(path),
203+
actions: ["pull"],
201204
accepting: accepting,
202205
contentType: contentType
203206
)
@@ -215,6 +218,7 @@ extension RegistryClient {
215218
method: .put,
216219
repository: repository,
217220
destination: .url(url),
221+
actions: ["push", "pull"],
218222
accepting: accepting,
219223
contentType: contentType
220224
)
@@ -231,6 +235,7 @@ extension RegistryClient {
231235
method: .put,
232236
repository: repository,
233237
destination: .subpath(path),
238+
actions: ["push", "pull"],
234239
accepting: accepting,
235240
contentType: contentType
236241
)
@@ -247,6 +252,7 @@ extension RegistryClient {
247252
method: .post,
248253
repository: repository,
249254
destination: .subpath(path),
255+
actions: ["push", "pull"],
250256
accepting: accepting,
251257
contentType: contentType
252258
)
@@ -268,22 +274,25 @@ extension RegistryClient {
268274
expectingStatus success: HTTPResponse.Status = .ok,
269275
decodingErrors errors: [HTTPResponse.Status]
270276
) async throws -> (data: Data, response: HTTPResponse) {
277+
let authorization = try await auth?
278+
.auth(
279+
registry: registryURL,
280+
repository: operation.repository,
281+
actions: operation.actions,
282+
withScheme: authChallenge,
283+
usingClient: client
284+
)
285+
271286
let request = HTTPRequest(
272287
method: operation.method,
273288
url: operation.url(relativeTo: registryURL),
274289
accepting: operation.accepting,
275-
contentType: operation.contentType
290+
contentType: operation.contentType,
291+
withAuthorization: authorization
276292
)
277293

278294
do {
279-
let authenticatedRequest = auth?.auth(for: request) ?? request
280-
return try await client.executeRequestThrowing(authenticatedRequest, expectingStatus: success)
281-
} catch HTTPClientError.authenticationChallenge(let challenge, let request, let response) {
282-
guard
283-
let authenticatedRequest = try await auth?
284-
.auth(for: request, withChallenge: challenge, usingClient: client)
285-
else { throw HTTPClientError.unauthorized(request: request, response: response) }
286-
return try await client.executeRequestThrowing(authenticatedRequest, expectingStatus: success)
295+
return try await client.executeRequestThrowing(request, expectingStatus: success)
287296
} catch HTTPClientError.unexpectedStatusCode(let status, _, let .some(responseData))
288297
where errors.contains(status)
289298
{
@@ -330,30 +339,25 @@ extension RegistryClient {
330339
expectingStatus success: HTTPResponse.Status,
331340
decodingErrors errors: [HTTPResponse.Status]
332341
) async throws -> (data: Data, response: HTTPResponse) {
342+
let authorization = try await auth?
343+
.auth(
344+
registry: registryURL,
345+
repository: operation.repository,
346+
actions: operation.actions,
347+
withScheme: authChallenge,
348+
usingClient: client
349+
)
350+
333351
let request = HTTPRequest(
334352
method: operation.method,
335353
url: operation.url(relativeTo: registryURL),
336354
accepting: operation.accepting,
337-
contentType: operation.contentType
355+
contentType: operation.contentType,
356+
withAuthorization: authorization
338357
)
339358

340359
do {
341-
let authenticatedRequest = auth?.auth(for: request) ?? request
342-
return try await client.executeRequestThrowing(
343-
authenticatedRequest,
344-
uploading: payload,
345-
expectingStatus: success
346-
)
347-
} catch HTTPClientError.authenticationChallenge(let challenge, let request, let response) {
348-
guard
349-
let authenticatedRequest = try await auth?
350-
.auth(for: request, withChallenge: challenge, usingClient: client)
351-
else { throw HTTPClientError.unauthorized(request: request, response: response) }
352-
return try await client.executeRequestThrowing(
353-
authenticatedRequest,
354-
uploading: payload,
355-
expectingStatus: success
356-
)
360+
return try await client.executeRequestThrowing(request, uploading: payload, expectingStatus: success)
357361
} catch HTTPClientError.unexpectedStatusCode(let status, _, let .some(responseData))
358362
where errors.contains(status)
359363
{

Tests/ContainerRegistryTests/AuthTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import Foundation
1616
import Basics
1717
import XCTest
1818

19-
class AuthTests: XCTestCase {
19+
class AuthTests: XCTestCase, @unchecked Sendable {
2020
// SwiftPM's NetrcAuthorizationProvider does not throw an error if the .netrc file
2121
// does not exist. For simplicity the local vendored version does the same.
2222
func testNonexistentNetrc() async throws {

Tests/ContainerRegistryTests/SmokeTests.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import Foundation
1616
import ContainerRegistry
1717
import XCTest
1818

19-
class SmokeTests: XCTestCase {
19+
class SmokeTests: XCTestCase, @unchecked Sendable {
2020
// These are basic tests to exercise the main registry operations.
2121
// The tests assume that a fresh, empty registry instance is available at
2222
// http://$REGISTRY_HOST:$REGISTRY_PORT
@@ -31,11 +31,6 @@ class SmokeTests: XCTestCase {
3131
client = try await RegistryClient(registry: "\(registryHost):\(registryPort)", insecure: true)
3232
}
3333

34-
func testCheckAPI() async throws {
35-
let supported = try await client.checkAPI()
36-
XCTAssert(supported)
37-
}
38-
3934
func testGetTags() async throws {
4035
let repository = "testgettags"
4136

0 commit comments

Comments
 (0)