diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift index 57eeb6ff2..442be902d 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift @@ -25,8 +25,10 @@ struct TokenRequest { /// The realm against which the token should be requested. let realm: String - /// The name of the service which hosts the resource. - let service: String + /// The name of the service which hosts the resource. Optional: the parameter is a Docker + /// registry convention, not a requirement of RFC 6750, and some registries (notably Google + /// Artifact Registry) omit it from their challenge entirely. + let service: String? /// Whether to return a refresh token along with the bearer token. let offlineToken: Bool /// String identifying the client. @@ -36,7 +38,7 @@ struct TokenRequest { init( realm: String, - service: String, + service: String?, clientId: String, scope: String?, offlineToken: Bool = false @@ -139,9 +141,11 @@ extension RegistryClient { } try validateRealm(components) components.queryItems = [ - URLQueryItem(name: "client_id", value: request.clientId), - URLQueryItem(name: "service", value: request.service), + URLQueryItem(name: "client_id", value: request.clientId) ] + if let service = request.service { + components.queryItems?.append(URLQueryItem(name: "service", value: service)) + } var scope = "" if let reqScope = request.scope { scope = reqScope @@ -228,11 +232,8 @@ extension RegistryClient { guard let realm = bearerChallenge.realm else { throw ContainerizationError(.invalidArgument, message: "cannot parse realm from \(TokenRequest.authenticateHeaderName) header") } - guard let service = bearerChallenge.service else { - throw ContainerizationError(.invalidArgument, message: "cannot parse service from \(TokenRequest.authenticateHeaderName) header") - } let scope = bearerChallenge.scope - let tokenRequest = TokenRequest(realm: realm, service: service, clientId: self.clientID, scope: scope) + let tokenRequest = TokenRequest(realm: realm, service: bearerChallenge.service, clientId: self.clientID, scope: scope) return tokenRequest } diff --git a/Tests/ContainerizationOCITests/TokenRequestServiceTests.swift b/Tests/ContainerizationOCITests/TokenRequestServiceTests.swift new file mode 100644 index 000000000..13ac8ddf0 --- /dev/null +++ b/Tests/ContainerizationOCITests/TokenRequestServiceTests.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationOCI + +/// `service` is a Docker registry convention, not a requirement of RFC 6750 section 3, so a +/// conforming registry may omit it from the Bearer challenge. `parseWWWAuthenticateHeaders` has +/// always modelled it as optional — see `AuthChallengeTests` — but `createTokenRequest` used to +/// reject a challenge without it, which locked out every registry that omits it. +struct TokenRequestServiceTests { + /// Google Artifact Registry's actual challenge, verbatim: realm only, no `service`. + private static let artifactRegistryChallenge = #"Bearer realm="https://us-central1-docker.pkg.dev/v2/token""# + + /// Docker Hub's challenge, which does carry `service`. + private static let dockerHubChallenge = + #"Bearer realm="https://auth.docker.io/token",service="registry.docker.io""# + + @Test + func acceptsChallengeWithoutService() throws { + let client = RegistryClient(host: "us-central1-docker.pkg.dev", scheme: "https") + let request = try client.createTokenRequest(parsing: [Self.artifactRegistryChallenge]) + + #expect(request.realm == "https://us-central1-docker.pkg.dev/v2/token") + #expect(request.service == nil) + } + + @Test + func preservesServiceWhenPresent() throws { + let client = RegistryClient(host: "registry-1.docker.io", scheme: "https") + let request = try client.createTokenRequest(parsing: [Self.dockerHubChallenge]) + + #expect(request.service == "registry.docker.io") + } + + /// `realm` stays mandatory — without it there is nowhere to send the token request. + @Test + func stillRejectsChallengeWithoutRealm() throws { + let client = RegistryClient(host: "registry.example.com", scheme: "https") + + #expect(throws: (any Error).self) { + try client.createTokenRequest(parsing: [#"Bearer service="registry.example.com""#]) + } + } +}