Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Sources/ContainerizationOCI/Client/RegistryClient+Token.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContainerizationOCI/Client/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
30 changes: 21 additions & 9 deletions Tests/ContainerizationOCITests/AuthChallengeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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])

Expand Down
14 changes: 10 additions & 4 deletions Tests/ContainerizationOCITests/RealmRequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand Down