this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

stability and correctness

+235 -57
+1
Sources/CoreATProtocol/APEnvironment.swift
··· 19 19 public var dpopPrivateKey: ES256PrivateKey? 20 20 public var dpopKeys: JWTKeyCollection? 21 21 public let dpopNonceStore = DPoPNonceStore() 22 + public let clockSkewStore = ClockSkewStore() 22 23 public let routerDelegate = APRouterDelegate() 23 24 24 25 private init() {}
+28
Sources/CoreATProtocol/Base64URL.swift
··· 1 + // 2 + // Base64URL.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + 8 + extension Data { 9 + /// Base64URL-encoded representation (RFC 4648 §5, unpadded). 10 + /// 11 + /// Used for DPoP proof generation and JWK encoding, where standard 12 + /// base64 characters (`+`, `/`, `=`) are disallowed. 13 + func base64URLEncodedString() -> String { 14 + base64EncodedString() 15 + .replacingOccurrences(of: "+", with: "-") 16 + .replacingOccurrences(of: "/", with: "_") 17 + .replacingOccurrences(of: "=", with: "") 18 + } 19 + } 20 + 21 + extension String { 22 + /// Convert a standard base64 string to the base64url alphabet (unpadded). 23 + func base64URLEncoded() -> String { 24 + replacingOccurrences(of: "+", with: "-") 25 + .replacingOccurrences(of: "/", with: "_") 26 + .replacingOccurrences(of: "=", with: "") 27 + } 28 + }
+66
Sources/CoreATProtocol/ClockSkewStore.swift
··· 1 + // 2 + // ClockSkewStore.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + 8 + /// Tracks the observed offset between the local clock and a server's clock. 9 + /// 10 + /// DPoP proofs are rejected if `iat` is outside the server's acceptance window. 11 + /// When a device clock drifts, proofs signed with `Date.now` fail with a 12 + /// `invalid_dpop_proof` error. By watching the `Date` HTTP header on responses 13 + /// we can record the skew and apply it to future `iat` values, preventing 14 + /// repeated auth failures on skewed clients. 15 + public actor ClockSkewStore { 16 + private(set) var offset: TimeInterval = 0 17 + 18 + public init(offset: TimeInterval = 0) { 19 + self.offset = offset 20 + } 21 + 22 + public func update(offset: TimeInterval) { 23 + self.offset = offset 24 + } 25 + 26 + /// Parses the `Date` HTTP header and stores the offset between the server 27 + /// time and the local clock. Silently ignores missing or unparseable values. 28 + public func updateFromServerDate(_ dateHeader: String?, localNow: Date = .now) { 29 + guard let dateHeader, 30 + let serverDate = Self.parse(dateHeader) else { return } 31 + offset = serverDate.timeIntervalSince(localNow) 32 + } 33 + 34 + /// Returns "now" adjusted for observed server skew. 35 + public func serverAdjustedNow(localNow: Date = .now) -> Date { 36 + localNow.addingTimeInterval(offset) 37 + } 38 + 39 + // MARK: - Date parsing 40 + 41 + nonisolated static func parse(_ header: String) -> Date? { 42 + for formatter in formatters { 43 + if let date = formatter.date(from: header) { 44 + return date 45 + } 46 + } 47 + return nil 48 + } 49 + 50 + nonisolated private static let formatters: [DateFormatter] = { 51 + // HTTP permits three formats per RFC 7231 §7.1.1.1. RFC 1123 is the 52 + // preferred one; RFC 850 and asctime are retained for legacy servers. 53 + let patterns = [ 54 + "EEE, dd MMM yyyy HH:mm:ss zzz", 55 + "EEEE, dd-MMM-yy HH:mm:ss zzz", 56 + "EEE MMM d HH:mm:ss yyyy", 57 + ] 58 + return patterns.map { pattern in 59 + let formatter = DateFormatter() 60 + formatter.dateFormat = pattern 61 + formatter.locale = Locale(identifier: "en_US_POSIX") 62 + formatter.timeZone = TimeZone(secondsFromGMT: 0) 63 + return formatter 64 + } 65 + }() 66 + }
+14 -28
Sources/CoreATProtocol/Networking.swift
··· 50 50 51 51 @NetworkingKitActor 52 52 public class APRouterDelegate: NetworkRouterDelegate { 53 - private var refreshTask: Task<Bool, Error>? 53 + private let refreshCoordinator = TokenRefreshCoordinator() 54 54 55 55 public func intercept(_ request: inout URLRequest) async { 56 56 guard let accessToken = await APEnvironment.current.accessToken else { return } ··· 98 98 // Read the nonce at proof-generation time so a concurrent update 99 99 // between intercept() and sign() is observed on the next retry. 100 100 let nonce = await APEnvironment.current.dpopNonceStore.get() 101 + let issuedAt = await APEnvironment.current.clockSkewStore.serverAdjustedNow() 101 102 102 103 // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2) 103 104 let hash = SHA256.hash(data: Data(accessToken.utf8)) 104 - let ath = Data(hash).base64EncodedString() 105 - .replacingOccurrences(of: "+", with: "-") 106 - .replacingOccurrences(of: "/", with: "_") 107 - .replacingOccurrences(of: "=", with: "") 105 + let ath = Data(hash).base64URLEncodedString() 108 106 109 107 let payload = DPoPProofPayload( 110 108 htm: method, 111 109 htu: htu, 112 - iat: .init(value: .now), 110 + iat: .init(value: issuedAt), 113 111 jti: .init(value: UUID().uuidString), 114 112 nonce: nonce, 115 113 ath: ath ··· 120 118 header.alg = "ES256" 121 119 122 120 if let keyParams = privateKey.parameters { 123 - let xBase64URL = keyParams.x 124 - .replacingOccurrences(of: "+", with: "-") 125 - .replacingOccurrences(of: "/", with: "_") 126 - .replacingOccurrences(of: "=", with: "") 127 - let yBase64URL = keyParams.y 128 - .replacingOccurrences(of: "+", with: "-") 129 - .replacingOccurrences(of: "/", with: "_") 130 - .replacingOccurrences(of: "=", with: "") 131 - 132 121 header.jwk = [ 133 122 "kty": .string("EC"), 134 123 "crv": .string("P-256"), 135 - "x": .string(xBase64URL), 136 - "y": .string(yBase64URL), 124 + "x": .string(keyParams.x.base64URLEncoded()), 125 + "y": .string(keyParams.y.base64URLEncoded()), 137 126 ] 138 127 } 139 128 ··· 141 130 } 142 131 143 132 public func didReceiveErrorResponse(_ response: HTTPURLResponse) async { 133 + // Record clock skew from any response that carries a Date header. 134 + // DPoP rejections often correlate with skew, so harvest this eagerly. 135 + let dateHeader = response.value(forHTTPHeaderField: "Date") 136 + if let dateHeader { 137 + await APEnvironment.current.clockSkewStore.updateFromServerDate(dateHeader) 138 + } 139 + 144 140 let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce") 145 141 ?? response.value(forHTTPHeaderField: "dpop-nonce") 146 142 if let headerNonce { ··· 188 184 guard let handler = await APEnvironment.current.tokenRefreshHandler else { 189 185 return false 190 186 } 191 - 192 - if let refreshTask { 193 - return try await refreshTask.value 194 - } 195 - 196 - let task = Task { try await handler() } 197 - refreshTask = task 198 - 199 - defer { refreshTask = nil } 200 - 201 - return try await task.value 187 + return try await refreshCoordinator.refresh(using: handler) 202 188 } 203 189 204 190 private func isDPoPNonceError(from error: Error) -> Bool {
+31 -17
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 84 84 case malformedServerMetadata(field: String, value: String) 85 85 case tokenRequestFailed(String) 86 86 case invalidTokenResponse 87 + /// The stored refresh token is missing or expired. Re-authentication required. 88 + case refreshTokenUnavailable 89 + /// The DPoP private key was never persisted, so refresh cannot bind a new token. 90 + case refreshKeyUnavailable 91 + /// Could not determine the authorization server for refresh. 92 + case refreshIssuerUnavailable 87 93 88 94 public var errorDescription: String? { 89 95 switch self { ··· 109 115 "Token request failed: \(detail)" 110 116 case .invalidTokenResponse: 111 117 "Invalid token response from server" 118 + case .refreshTokenUnavailable: 119 + "Refresh token is missing or expired — re-authentication required" 120 + case .refreshKeyUnavailable: 121 + "DPoP private key is not available — re-authentication required" 122 + case .refreshIssuerUnavailable: 123 + "Could not determine the authorization server for refresh" 112 124 } 113 125 } 114 126 } ··· 331 343 } 332 344 333 345 /// Refresh tokens if the stored access token is expired (or if forced). 346 + /// 347 + /// - Returns: the refreshed `Login`, or `nil` when refresh is a no-op 348 + /// (no stored login, or the access token is still valid and `force` is false). 349 + /// - Throws: `ATProtoOAuthError.refreshTokenUnavailable`, 350 + /// `.refreshKeyUnavailable`, or `.refreshIssuerUnavailable` when the session 351 + /// cannot be refreshed and the caller must re-authenticate. 334 352 public func refreshLoginIfNeeded(handle: String? = nil, force: Bool = false) async throws -> Login? { 335 353 try await refreshLoginIfNeeded(accountIdentifier: handle, force: force) 336 354 } 337 355 338 356 /// Refresh tokens if the stored access token is expired (or if forced). 357 + /// 358 + /// See the `handle:force:` overload for documentation. 339 359 public func refreshLoginIfNeeded(accountIdentifier: String? = nil, force: Bool = false) async throws -> Login? { 340 360 guard let login = try await storage.retrieveLogin() else { 361 + // No stored session — genuine no-op. 341 362 return nil 342 363 } 343 364 344 365 if !force, login.accessToken.valid { 366 + // Access token still valid — genuine no-op. 345 367 return nil 346 368 } 347 369 348 370 guard hasPersistedKey else { 349 - return nil 371 + throw ATProtoOAuthError.refreshKeyUnavailable 350 372 } 351 373 352 374 guard login.refreshToken?.valid == true else { 353 - return nil 375 + throw ATProtoOAuthError.refreshTokenUnavailable 354 376 } 355 377 356 378 let resolvedIdentity: IdentityResolver.ResolvedIdentity? ··· 366 388 } else if let identity = resolvedIdentity { 367 389 issuer = identity.authorizationServer 368 390 } else { 369 - return nil 391 + throw ATProtoOAuthError.refreshIssuerUnavailable 370 392 } 371 393 372 394 let provider = URLSession.defaultProvider ··· 398 420 ) 399 421 400 422 guard let refreshProvider = tokenHandling.refreshProvider else { 401 - return nil 423 + throw ATProtoOAuthError.refreshIssuerUnavailable 402 424 } 403 425 404 426 // Use proxy-aware provider when auth proxy is configured ··· 485 507 // Strip query params and fragments from htu per DPoP spec 486 508 let htu = stripQueryAndFragment(from: params.requestEndpoint) 487 509 510 + let issuedAt = await APEnvironment.current.clockSkewStore.serverAdjustedNow() 511 + 488 512 let payload = DPoPPayload( 489 513 htm: params.httpMethod, 490 514 htu: htu, 491 - iat: .init(value: .now), 515 + iat: .init(value: issuedAt), 492 516 jti: .init(value: UUID().uuidString), 493 517 nonce: params.nonce 494 518 ) ··· 500 524 501 525 // Get public key parameters and convert to base64url for JWK 502 526 if let keyParams = privateKey.parameters { 503 - // Convert from base64 to base64url (replace + with -, / with _, remove =) 504 - let xBase64URL = keyParams.x 505 - .replacingOccurrences(of: "+", with: "-") 506 - .replacingOccurrences(of: "/", with: "_") 507 - .replacingOccurrences(of: "=", with: "") 508 - let yBase64URL = keyParams.y 509 - .replacingOccurrences(of: "+", with: "-") 510 - .replacingOccurrences(of: "/", with: "_") 511 - .replacingOccurrences(of: "=", with: "") 512 - 513 527 header.jwk = [ 514 528 "kty": .string("EC"), 515 529 "crv": .string("P-256"), 516 - "x": .string(xBase64URL), 517 - "y": .string(yBase64URL) 530 + "x": .string(keyParams.x.base64URLEncoded()), 531 + "y": .string(keyParams.y.base64URLEncoded()) 518 532 ] 519 533 } 520 534
+66 -12
Sources/CoreATProtocol/OAuth/IdentityResolver.swift
··· 6 6 case resolutionFailed 7 7 case invalidResponse 8 8 case noPDSFound 9 + case invalidPDSEndpoint(String) 9 10 case noAuthServerFound 10 11 case handleVerificationFailed 11 12 } ··· 117 118 } 118 119 119 120 private func resolveViaHTTPS(handle: String) async throws -> String { 120 - guard let url = URL(string: "https://\(handle)/.well-known/atproto-did") else { 121 - throw IdentityError.invalidHandle 122 - } 121 + let url = try Self.makeURL(scheme: "https", host: handle, path: "/.well-known/atproto-did") 123 122 124 123 let (data, response) = try await URLSession.shared.data(from: url) 125 124 ··· 139 138 140 139 private func resolveViaDNS(handle: String) async throws -> String { 141 140 // Use Cloudflare DNS-over-HTTPS for TXT record lookup 142 - let hostname = "_atproto.\(handle)" 143 - guard let dohURL = URL(string: "https://1.1.1.1/dns-query?name=\(hostname)&type=TXT") else { 141 + try Self.validateHostname(handle) 142 + var components = URLComponents() 143 + components.scheme = "https" 144 + components.host = "1.1.1.1" 145 + components.path = "/dns-query" 146 + components.queryItems = [ 147 + URLQueryItem(name: "name", value: "_atproto.\(handle)"), 148 + URLQueryItem(name: "type", value: "TXT"), 149 + ] 150 + guard let dohURL = components.url else { 144 151 throw IdentityError.resolutionFailed 145 152 } 146 153 ··· 178 185 private func resolveDIDDocument(did: String) async throws -> DIDDocument { 179 186 let url: URL 180 187 if did.hasPrefix("did:plc:") { 181 - guard let plcURL = URL(string: "https://plc.directory/\(did)") else { 188 + let identifier = String(did.dropFirst("did:plc:".count)) 189 + guard !identifier.isEmpty, 190 + identifier.allSatisfy({ $0.isLetter || $0.isNumber }) else { 182 191 throw IdentityError.invalidDID 183 192 } 184 - url = plcURL 193 + url = try Self.makeURL(scheme: "https", host: "plc.directory", path: "/\(did)") 185 194 } else if did.hasPrefix("did:web:") { 186 - let domain = did.replacingOccurrences(of: "did:web:", with: "") 187 - guard let webURL = URL(string: "https://\(domain)/.well-known/did.json") else { 188 - throw IdentityError.invalidDID 189 - } 190 - url = webURL 195 + let domain = String(did.dropFirst("did:web:".count)) 196 + url = try Self.makeURL(scheme: "https", host: domain, path: "/.well-known/did.json") 191 197 } else { 192 198 throw IdentityError.invalidDID 193 199 } ··· 208 214 private func pdsEndpoint(from document: DIDDocument) throws -> String { 209 215 guard let pds = document.pdsEndpoint else { 210 216 throw IdentityError.noPDSFound 217 + } 218 + guard let url = URL(string: pds), 219 + url.scheme?.lowercased() == "https", 220 + let host = url.host, !host.isEmpty else { 221 + throw IdentityError.invalidPDSEndpoint(pds) 211 222 } 212 223 return pds 213 224 } ··· 280 291 } 281 292 let port = url.port.map { ":\($0)" } ?? "" 282 293 return "\(scheme)://\(host)\(port)" 294 + } 295 + 296 + // MARK: - URL construction 297 + 298 + /// Build a URL from trusted components after validating the host as a DNS-style 299 + /// name. Using `URLComponents` (rather than string interpolation into 300 + /// `URL(string:)`) ensures hosts containing otherwise-illegal characters fail 301 + /// fast instead of silently producing a valid URL with a mangled authority. 302 + nonisolated private static func makeURL(scheme: String, host: String, path: String) throws -> URL { 303 + try validateHostname(host) 304 + var components = URLComponents() 305 + components.scheme = scheme 306 + components.host = host 307 + components.path = path 308 + guard let url = components.url else { 309 + throw IdentityError.invalidHandle 310 + } 311 + return url 312 + } 313 + 314 + /// Validates that the input is a DNS-style hostname (letters, digits, 315 + /// hyphens, dots) with non-empty labels. Rejects anything that could 316 + /// smuggle a URL authority component (`@`, `/`, `:`, `?`, `#`, spaces, 317 + /// non-ASCII, etc.). 318 + nonisolated static func validateHostname(_ host: String) throws { 319 + guard !host.isEmpty, host.count <= 253 else { 320 + throw IdentityError.invalidHandle 321 + } 322 + let labels = host.split(separator: ".", omittingEmptySubsequences: false) 323 + guard labels.count >= 2 else { 324 + throw IdentityError.invalidHandle 325 + } 326 + for label in labels { 327 + guard !label.isEmpty, label.count <= 63 else { 328 + throw IdentityError.invalidHandle 329 + } 330 + guard label.first != "-", label.last != "-" else { 331 + throw IdentityError.invalidHandle 332 + } 333 + guard label.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) else { 334 + throw IdentityError.invalidHandle 335 + } 336 + } 283 337 } 284 338 } 285 339
+29
Sources/CoreATProtocol/TokenRefreshCoordinator.swift
··· 1 + // 2 + // TokenRefreshCoordinator.swift 3 + // CoreATProtocol 4 + // 5 + 6 + /// Serialises concurrent token-refresh attempts. 7 + /// 8 + /// When several in-flight requests receive a 401 at the same time, each would 9 + /// otherwise race to call the refresh handler. This actor coalesces them onto 10 + /// a single underlying refresh task so the handler is invoked exactly once 11 + /// per burst — all callers await the same result. 12 + actor TokenRefreshCoordinator { 13 + private var refreshTask: Task<Bool, Error>? 14 + 15 + /// Run `handler` if no refresh is currently in flight; otherwise join the 16 + /// existing attempt and return its result. 17 + func refresh(using handler: @Sendable @escaping () async throws -> Bool) async throws -> Bool { 18 + if let refreshTask { 19 + return try await refreshTask.value 20 + } 21 + 22 + let task = Task { try await handler() } 23 + refreshTask = task 24 + 25 + defer { refreshTask = nil } 26 + 27 + return try await task.value 28 + } 29 + }