diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift index 0224b9934..45634381f 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift @@ -255,9 +255,13 @@ extension RegistryClient { throw Error.insecureCredentialExchange(message: "registry \(host()) requested authentication over an insecure connection") } let parsedHeaders = Self.parseWWWAuthenticateHeaders(headers: authenticateHeaders) - let bearerChallenge = parsedHeaders.first { $0.type == "Bearer" } + let bearerChallenge = parsedHeaders.first { $0.type == "bearer" } guard let bearerChallenge else { - throw ContainerizationError(.invalidArgument, message: "missing Bearer challenge in \(TokenRequest.authenticateHeaderName) header") + let offeredSchemes = parsedHeaders.map(\.type).joined(separator: ", ") + throw ContainerizationError( + .invalidArgument, + message: "missing Bearer challenge in \(TokenRequest.authenticateHeaderName) header. Server offered schemes: [\(offeredSchemes)]" + ) } guard let realm = bearerChallenge.realm else { throw ContainerizationError(.invalidArgument, message: "cannot parse realm from \(TokenRequest.authenticateHeaderName) header") @@ -268,7 +272,7 @@ extension RegistryClient { } internal static func parseWWWAuthenticateHeaders(headers: [String]) -> [AuthenticateChallenge] { - var parsed: [String: [String: String]] = [:] + var parsedChallenges: [AuthenticateChallenge] = [] for challenge in headers { let trimmedChallenge = challenge.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmedChallenge.split(separator: " ", maxSplits: 1) @@ -287,16 +291,12 @@ extension RegistryClient { if let keyRange = Range(match.range(at: 1), in: header), let valueRange = Range(match.range(at: 2), in: header) { - let key = String(header[keyRange]) + let key = String(header[keyRange]).lowercased() let value = String(header[valueRange]) params[key] = value } } - parsed[String(scheme)] = params - } - var parsedChallenges: [AuthenticateChallenge] = [] - for (type, values) in parsed { - parsedChallenges.append(.init(type: type, values: values)) + parsedChallenges.append(.init(type: String(scheme).lowercased(), values: params)) } return parsedChallenges } diff --git a/Sources/ContainerizationOCI/Client/RegistryClient.swift b/Sources/ContainerizationOCI/Client/RegistryClient.swift index 59c2182b1..1d4edf5ff 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient.swift @@ -208,7 +208,7 @@ public final class RegistryClient: ContentClient { let authHeader = _response.headers[TokenRequest.authenticateHeaderName] let challenges = Self.parseWWWAuthenticateHeaders(headers: authHeader) - if !challenges.contains(where: { $0.type == "Bearer" }), challenges.contains(where: { $0.type == "Basic" }) { + if !challenges.contains(where: { $0.type == "bearer" }), challenges.contains(where: { $0.type == "basic" }) { guard !attemptedBasicAuth, let basicAuth = authentication as? BasicAuthentication else { throw Error.invalidStatus(url: path, _response.status, reason: "access denied or wrong credentials") } diff --git a/Tests/ContainerizationOCITests/AuthChallengeTests.swift b/Tests/ContainerizationOCITests/AuthChallengeTests.swift index be29ec055..29f0fa720 100644 --- a/Tests/ContainerizationOCITests/AuthChallengeTests.swift +++ b/Tests/ContainerizationOCITests/AuthChallengeTests.swift @@ -30,28 +30,37 @@ struct AuthChallengeTests { input: """ Bearer realm="https://domain.io/token",service="domain.io",scope="repository:user/image:pull" """, - expected: .init(type: "Bearer", realm: "https://domain.io/token", service: "domain.io", scope: "repository:user/image:pull", error: nil)), + expected: .init(type: "bearer", realm: "https://domain.io/token", service: "domain.io", scope: "repository:user/image:pull", error: nil)), .init( input: """ Bearer realm="https://foo-bar-registry.com/auth",service="Awesome Registry" """, - expected: .init(type: "Bearer", realm: "https://foo-bar-registry.com/auth", service: "Awesome Registry", scope: nil, error: nil)), + expected: .init(type: "bearer", realm: "https://foo-bar-registry.com/auth", service: "Awesome Registry", scope: nil, error: nil)), .init( input: """ Bearer realm="users.example.com", scope="create delete" """, - expected: .init(type: "Bearer", realm: "users.example.com", service: nil, scope: "create delete", error: nil)), + expected: .init(type: "bearer", realm: "users.example.com", service: nil, scope: "create delete", error: nil)), .init( input: """ Bearer realm="https://auth.server.io/token",service="registry.server.io" """, - expected: .init(type: "Bearer", realm: "https://auth.server.io/token", service: "registry.server.io", scope: nil, error: nil)), + expected: .init(type: "bearer", realm: "https://auth.server.io/token", service: "registry.server.io", scope: nil, error: nil)), .init( input: """ Basic realm="Registry Realm" """, - expected: .init(type: "Basic", realm: "Registry Realm", service: nil, scope: nil, error: nil)), - + expected: .init(type: "basic", realm: "Registry Realm", service: nil, scope: nil, error: nil)), + .init( + input: """ + BASIC realm="Sonatype Nexus Repository Manager" + """, + expected: .init(type: "basic", realm: "Sonatype Nexus Repository Manager", service: nil, scope: nil, error: nil)), + .init( + input: """ + BEARER Realm="https://auth.example.com/token",Service="registry.example.com",Scope="repository:org/app:pull" + """, + expected: .init(type: "bearer", realm: "https://auth.example.com/token", service: "registry.example.com", scope: "repository:org/app:pull", error: nil)), ] @Test(arguments: testCases) @@ -78,10 +87,13 @@ struct AuthChallengeTests { #expect(request.service == nil) } - /// A challenge that supplies `service` still round-trips it. - @Test func challengeWithServiceIsPreserved() throws { + /// A challenge that supplies `service` still round-trips it, regardless of scheme or parameter key case. + @Test(arguments: [ + #"Bearer realm="https://auth.docker.io/token",service="registry.docker.io""#, + #"BEARER Realm="https://auth.docker.io/token",Service="registry.docker.io""#, + ]) + func challengeWithServiceIsPreserved(challenge: String) throws { let client = RegistryClient(host: "registry-1.docker.io", scheme: "https") - let challenge = #"Bearer realm="https://auth.docker.io/token",service="registry.docker.io""# let request = try client.createTokenRequest(parsing: [challenge]) diff --git a/Tests/ContainerizationOCITests/RealmRequestTests.swift b/Tests/ContainerizationOCITests/RealmRequestTests.swift index 53064002c..0e818ab0f 100644 --- a/Tests/ContainerizationOCITests/RealmRequestTests.swift +++ b/Tests/ContainerizationOCITests/RealmRequestTests.swift @@ -271,14 +271,20 @@ struct RealmRequestTests { return RegistryClient(host: "127.0.0.1", scheme: "https", port: port, authentication: authentication, retryOptions: nil, tlsConfiguration: tls) } - /// The `registry:3` shape: Basic challenge, no Bearer. - @Test(.timeLimit(.minutes(1)), .enabled(if: loopbackIsDirect)) - func basicChallengeIsAnsweredOnceOverTLS() async throws { + /// The `registry:3` and Sonatype Nexus (`BASIC`) shape: Basic challenge (any case), no Bearer. + @Test( + .timeLimit(.minutes(1)), + .enabled(if: loopbackIsDirect), + arguments: ["Basic", "BASIC", "basic"] + ) + func basicChallengeIsAnsweredOnceOverTLS(scheme: String) async throws { + let challenge = + "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: \(scheme) realm=\"Registry Realm\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" let registry = try RecordingTLSServer.start { head in if head.contains("Authorization: Basic ") { return "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" } - return basicChallenge + return challenge } defer { registry.stop() }