680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
211 lines
8.5 KiB
Swift
211 lines
8.5 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 AsyncHTTPClient
|
|
import ContainerizationError
|
|
import Foundation
|
|
|
|
struct TokenRequest {
|
|
public static let authenticateHeaderName = "WWW-Authenticate"
|
|
|
|
/// The credentials that will be used in the authentication header when fetching the token.
|
|
let authentication: Authentication?
|
|
/// The realm against which the token should be requested.
|
|
let realm: String
|
|
/// The name of the service which hosts the resource.
|
|
let service: String
|
|
/// Whether to return a refresh token along with the bearer token.
|
|
let offlineToken: Bool
|
|
/// String identifying the client.
|
|
let clientId: String
|
|
/// The resource in question, formatted as one of the space-delimited entries from the scope parameters from the WWW-Authenticate header shown above.
|
|
let scope: String?
|
|
|
|
init(
|
|
realm: String,
|
|
service: String,
|
|
clientId: String,
|
|
scope: String?,
|
|
offlineToken: Bool = false,
|
|
authentication: Authentication? = nil
|
|
) {
|
|
self.realm = realm
|
|
self.service = service
|
|
self.offlineToken = offlineToken
|
|
self.clientId = clientId
|
|
self.scope = scope
|
|
self.authentication = authentication
|
|
}
|
|
}
|
|
|
|
struct TokenResponse: Codable, Hashable {
|
|
/// An opaque Bearer token that clients should supply to subsequent requests in the Authorization header.
|
|
let token: String?
|
|
/// For compatibility with OAuth 2.0, we will also accept token under the name access_token.
|
|
/// At least one of these fields must be specified, but both may also appear (for compatibility with older clients).
|
|
/// When both are specified, they should be equivalent; if they differ the client's choice is undefined.
|
|
let accessToken: String?
|
|
/// The duration in seconds since the token was issued that it will remain valid.
|
|
/// When omitted, this defaults to 60 seconds.
|
|
let expiresIn: UInt?
|
|
/// The RFC3339-serialized UTC standard time at which a given token was issued.
|
|
/// If issued_at is omitted, the expiration is from when the token exchange completed.
|
|
let issuedAt: String?
|
|
/// Token which can be used to get additional access tokens for the same subject with different scopes.
|
|
/// This token should be kept secure by the client and only sent to the authorization server which issues bearer tokens.
|
|
/// This field will only be set when `offline_token=true` is provided in the request.
|
|
let refreshToken: String?
|
|
|
|
var scope: String?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case token = "token"
|
|
case accessToken = "access_token"
|
|
case expiresIn = "expires_in"
|
|
case issuedAt = "issued_at"
|
|
case refreshToken = "refresh_token"
|
|
}
|
|
|
|
func getToken() -> String? {
|
|
if let t = token ?? accessToken {
|
|
return "Bearer \(t)"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isValid(scope: String?) -> Bool {
|
|
guard let issuedAt else {
|
|
return false
|
|
}
|
|
let isoFormatter = ISO8601DateFormatter()
|
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
guard let issued = isoFormatter.date(from: issuedAt) else {
|
|
return false
|
|
}
|
|
let expiresIn = expiresIn ?? 0
|
|
let now = Date()
|
|
let elapsed = now.timeIntervalSince(issued)
|
|
guard elapsed < Double(expiresIn) else {
|
|
return false
|
|
}
|
|
if let requiredScope = scope {
|
|
return requiredScope == self.scope
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
struct AuthenticateChallenge: Equatable {
|
|
let type: String
|
|
let realm: String?
|
|
let service: String?
|
|
let scope: String?
|
|
let error: String?
|
|
|
|
init(type: String, realm: String?, service: String?, scope: String?, error: String?) {
|
|
self.type = type
|
|
self.realm = realm
|
|
self.service = service
|
|
self.scope = scope
|
|
self.error = error
|
|
}
|
|
|
|
init(type: String, values: [String: String]) {
|
|
self.type = type
|
|
self.realm = values["realm"]
|
|
self.service = values["service"]
|
|
self.scope = values["scope"]
|
|
self.error = values["error"]
|
|
}
|
|
}
|
|
|
|
extension RegistryClient {
|
|
/// Fetch an auto token for all subsequent HTTP requests
|
|
/// See https://docs.docker.com/registry/spec/auth/token/
|
|
internal func fetchToken(request: TokenRequest) async throws -> TokenResponse {
|
|
guard var components = URLComponents(string: request.realm) else {
|
|
throw ContainerizationError(.invalidArgument, message: "cannot create URL from \(request.realm)")
|
|
}
|
|
components.queryItems = [
|
|
URLQueryItem(name: "client_id", value: request.clientId),
|
|
URLQueryItem(name: "service", value: request.service),
|
|
]
|
|
var scope = ""
|
|
if let reqScope = request.scope {
|
|
scope = reqScope
|
|
components.queryItems?.append(URLQueryItem(name: "scope", value: reqScope))
|
|
}
|
|
|
|
if request.offlineToken {
|
|
components.queryItems?.append(URLQueryItem(name: "offline_token", value: "true"))
|
|
}
|
|
var response: TokenResponse = try await requestJSON(components: components, headers: [])
|
|
response.scope = scope
|
|
return response
|
|
}
|
|
|
|
internal func createTokenRequest(parsing authenticateHeaders: [String]) throws -> TokenRequest {
|
|
let parsedHeaders = Self.parseWWWAuthenticateHeaders(headers: authenticateHeaders)
|
|
let bearerChallenge = parsedHeaders.first { $0.type == "Bearer" }
|
|
guard let bearerChallenge else {
|
|
throw ContainerizationError(.invalidArgument, message: "missing Bearer challenge in \(TokenRequest.authenticateHeaderName) header")
|
|
}
|
|
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, authentication: self.authentication)
|
|
return tokenRequest
|
|
}
|
|
|
|
internal static func parseWWWAuthenticateHeaders(headers: [String]) -> [AuthenticateChallenge] {
|
|
var parsed: [String: [String: String]] = [:]
|
|
for challenge in headers {
|
|
let trimmedChallenge = challenge.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = trimmedChallenge.split(separator: " ", maxSplits: 1)
|
|
guard parts.count == 2 else {
|
|
continue
|
|
}
|
|
guard let scheme = parts.first else {
|
|
continue
|
|
}
|
|
var params: [String: String] = [:]
|
|
let header = String(parts[1])
|
|
let pattern = #"(\w+)="([^"]+)"#
|
|
let regex = try! NSRegularExpression(pattern: pattern, options: [])
|
|
let matches = regex.matches(in: header, options: [], range: NSRange(header.startIndex..., in: header))
|
|
for match in matches {
|
|
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 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))
|
|
}
|
|
return parsedChallenges
|
|
}
|
|
}
|