Files
apple--containerization/Sources/ContainerizationOCI/Client/RegistryClient+Token.swift
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

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
}
}