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
19 changes: 10 additions & 9 deletions Sources/ContainerizationOCI/Client/RegistryClient+Token.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -36,7 +38,7 @@ struct TokenRequest {

init(
realm: String,
service: String,
service: String?,
clientId: String,
scope: String?,
offlineToken: Bool = false
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
60 changes: 60 additions & 0 deletions Tests/ContainerizationOCITests/TokenRequestServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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""#])
}
}
}