···1919 public var dpopPrivateKey: ES256PrivateKey?
2020 public var dpopKeys: JWTKeyCollection?
2121 public let dpopNonceStore = DPoPNonceStore()
2222+ public let clockSkewStore = ClockSkewStore()
2223 public let routerDelegate = APRouterDelegate()
23242425 private init() {}
+28
Sources/CoreATProtocol/Base64URL.swift
···11+//
22+// Base64URL.swift
33+// CoreATProtocol
44+//
55+66+import Foundation
77+88+extension Data {
99+ /// Base64URL-encoded representation (RFC 4648 §5, unpadded).
1010+ ///
1111+ /// Used for DPoP proof generation and JWK encoding, where standard
1212+ /// base64 characters (`+`, `/`, `=`) are disallowed.
1313+ func base64URLEncodedString() -> String {
1414+ base64EncodedString()
1515+ .replacingOccurrences(of: "+", with: "-")
1616+ .replacingOccurrences(of: "/", with: "_")
1717+ .replacingOccurrences(of: "=", with: "")
1818+ }
1919+}
2020+2121+extension String {
2222+ /// Convert a standard base64 string to the base64url alphabet (unpadded).
2323+ func base64URLEncoded() -> String {
2424+ replacingOccurrences(of: "+", with: "-")
2525+ .replacingOccurrences(of: "/", with: "_")
2626+ .replacingOccurrences(of: "=", with: "")
2727+ }
2828+}
+66
Sources/CoreATProtocol/ClockSkewStore.swift
···11+//
22+// ClockSkewStore.swift
33+// CoreATProtocol
44+//
55+66+import Foundation
77+88+/// Tracks the observed offset between the local clock and a server's clock.
99+///
1010+/// DPoP proofs are rejected if `iat` is outside the server's acceptance window.
1111+/// When a device clock drifts, proofs signed with `Date.now` fail with a
1212+/// `invalid_dpop_proof` error. By watching the `Date` HTTP header on responses
1313+/// we can record the skew and apply it to future `iat` values, preventing
1414+/// repeated auth failures on skewed clients.
1515+public actor ClockSkewStore {
1616+ private(set) var offset: TimeInterval = 0
1717+1818+ public init(offset: TimeInterval = 0) {
1919+ self.offset = offset
2020+ }
2121+2222+ public func update(offset: TimeInterval) {
2323+ self.offset = offset
2424+ }
2525+2626+ /// Parses the `Date` HTTP header and stores the offset between the server
2727+ /// time and the local clock. Silently ignores missing or unparseable values.
2828+ public func updateFromServerDate(_ dateHeader: String?, localNow: Date = .now) {
2929+ guard let dateHeader,
3030+ let serverDate = Self.parse(dateHeader) else { return }
3131+ offset = serverDate.timeIntervalSince(localNow)
3232+ }
3333+3434+ /// Returns "now" adjusted for observed server skew.
3535+ public func serverAdjustedNow(localNow: Date = .now) -> Date {
3636+ localNow.addingTimeInterval(offset)
3737+ }
3838+3939+ // MARK: - Date parsing
4040+4141+ nonisolated static func parse(_ header: String) -> Date? {
4242+ for formatter in formatters {
4343+ if let date = formatter.date(from: header) {
4444+ return date
4545+ }
4646+ }
4747+ return nil
4848+ }
4949+5050+ nonisolated private static let formatters: [DateFormatter] = {
5151+ // HTTP permits three formats per RFC 7231 §7.1.1.1. RFC 1123 is the
5252+ // preferred one; RFC 850 and asctime are retained for legacy servers.
5353+ let patterns = [
5454+ "EEE, dd MMM yyyy HH:mm:ss zzz",
5555+ "EEEE, dd-MMM-yy HH:mm:ss zzz",
5656+ "EEE MMM d HH:mm:ss yyyy",
5757+ ]
5858+ return patterns.map { pattern in
5959+ let formatter = DateFormatter()
6060+ formatter.dateFormat = pattern
6161+ formatter.locale = Locale(identifier: "en_US_POSIX")
6262+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
6363+ return formatter
6464+ }
6565+ }()
6666+}
+14-28
Sources/CoreATProtocol/Networking.swift
···50505151@NetworkingKitActor
5252public class APRouterDelegate: NetworkRouterDelegate {
5353- private var refreshTask: Task<Bool, Error>?
5353+ private let refreshCoordinator = TokenRefreshCoordinator()
54545555 public func intercept(_ request: inout URLRequest) async {
5656 guard let accessToken = await APEnvironment.current.accessToken else { return }
···9898 // Read the nonce at proof-generation time so a concurrent update
9999 // between intercept() and sign() is observed on the next retry.
100100 let nonce = await APEnvironment.current.dpopNonceStore.get()
101101+ let issuedAt = await APEnvironment.current.clockSkewStore.serverAdjustedNow()
101102102103 // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2)
103104 let hash = SHA256.hash(data: Data(accessToken.utf8))
104104- let ath = Data(hash).base64EncodedString()
105105- .replacingOccurrences(of: "+", with: "-")
106106- .replacingOccurrences(of: "/", with: "_")
107107- .replacingOccurrences(of: "=", with: "")
105105+ let ath = Data(hash).base64URLEncodedString()
108106109107 let payload = DPoPProofPayload(
110108 htm: method,
111109 htu: htu,
112112- iat: .init(value: .now),
110110+ iat: .init(value: issuedAt),
113111 jti: .init(value: UUID().uuidString),
114112 nonce: nonce,
115113 ath: ath
···120118 header.alg = "ES256"
121119122120 if let keyParams = privateKey.parameters {
123123- let xBase64URL = keyParams.x
124124- .replacingOccurrences(of: "+", with: "-")
125125- .replacingOccurrences(of: "/", with: "_")
126126- .replacingOccurrences(of: "=", with: "")
127127- let yBase64URL = keyParams.y
128128- .replacingOccurrences(of: "+", with: "-")
129129- .replacingOccurrences(of: "/", with: "_")
130130- .replacingOccurrences(of: "=", with: "")
131131-132121 header.jwk = [
133122 "kty": .string("EC"),
134123 "crv": .string("P-256"),
135135- "x": .string(xBase64URL),
136136- "y": .string(yBase64URL),
124124+ "x": .string(keyParams.x.base64URLEncoded()),
125125+ "y": .string(keyParams.y.base64URLEncoded()),
137126 ]
138127 }
139128···141130 }
142131143132 public func didReceiveErrorResponse(_ response: HTTPURLResponse) async {
133133+ // Record clock skew from any response that carries a Date header.
134134+ // DPoP rejections often correlate with skew, so harvest this eagerly.
135135+ let dateHeader = response.value(forHTTPHeaderField: "Date")
136136+ if let dateHeader {
137137+ await APEnvironment.current.clockSkewStore.updateFromServerDate(dateHeader)
138138+ }
139139+144140 let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce")
145141 ?? response.value(forHTTPHeaderField: "dpop-nonce")
146142 if let headerNonce {
···188184 guard let handler = await APEnvironment.current.tokenRefreshHandler else {
189185 return false
190186 }
191191-192192- if let refreshTask {
193193- return try await refreshTask.value
194194- }
195195-196196- let task = Task { try await handler() }
197197- refreshTask = task
198198-199199- defer { refreshTask = nil }
200200-201201- return try await task.value
187187+ return try await refreshCoordinator.refresh(using: handler)
202188 }
203189204190 private func isDPoPNonceError(from error: Error) -> Bool {
+31-17
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
···8484 case malformedServerMetadata(field: String, value: String)
8585 case tokenRequestFailed(String)
8686 case invalidTokenResponse
8787+ /// The stored refresh token is missing or expired. Re-authentication required.
8888+ case refreshTokenUnavailable
8989+ /// The DPoP private key was never persisted, so refresh cannot bind a new token.
9090+ case refreshKeyUnavailable
9191+ /// Could not determine the authorization server for refresh.
9292+ case refreshIssuerUnavailable
87938894 public var errorDescription: String? {
8995 switch self {
···109115 "Token request failed: \(detail)"
110116 case .invalidTokenResponse:
111117 "Invalid token response from server"
118118+ case .refreshTokenUnavailable:
119119+ "Refresh token is missing or expired — re-authentication required"
120120+ case .refreshKeyUnavailable:
121121+ "DPoP private key is not available — re-authentication required"
122122+ case .refreshIssuerUnavailable:
123123+ "Could not determine the authorization server for refresh"
112124 }
113125 }
114126}
···331343 }
332344333345 /// Refresh tokens if the stored access token is expired (or if forced).
346346+ ///
347347+ /// - Returns: the refreshed `Login`, or `nil` when refresh is a no-op
348348+ /// (no stored login, or the access token is still valid and `force` is false).
349349+ /// - Throws: `ATProtoOAuthError.refreshTokenUnavailable`,
350350+ /// `.refreshKeyUnavailable`, or `.refreshIssuerUnavailable` when the session
351351+ /// cannot be refreshed and the caller must re-authenticate.
334352 public func refreshLoginIfNeeded(handle: String? = nil, force: Bool = false) async throws -> Login? {
335353 try await refreshLoginIfNeeded(accountIdentifier: handle, force: force)
336354 }
337355338356 /// Refresh tokens if the stored access token is expired (or if forced).
357357+ ///
358358+ /// See the `handle:force:` overload for documentation.
339359 public func refreshLoginIfNeeded(accountIdentifier: String? = nil, force: Bool = false) async throws -> Login? {
340360 guard let login = try await storage.retrieveLogin() else {
361361+ // No stored session — genuine no-op.
341362 return nil
342363 }
343364344365 if !force, login.accessToken.valid {
366366+ // Access token still valid — genuine no-op.
345367 return nil
346368 }
347369348370 guard hasPersistedKey else {
349349- return nil
371371+ throw ATProtoOAuthError.refreshKeyUnavailable
350372 }
351373352374 guard login.refreshToken?.valid == true else {
353353- return nil
375375+ throw ATProtoOAuthError.refreshTokenUnavailable
354376 }
355377356378 let resolvedIdentity: IdentityResolver.ResolvedIdentity?
···366388 } else if let identity = resolvedIdentity {
367389 issuer = identity.authorizationServer
368390 } else {
369369- return nil
391391+ throw ATProtoOAuthError.refreshIssuerUnavailable
370392 }
371393372394 let provider = URLSession.defaultProvider
···398420 )
399421400422 guard let refreshProvider = tokenHandling.refreshProvider else {
401401- return nil
423423+ throw ATProtoOAuthError.refreshIssuerUnavailable
402424 }
403425404426 // Use proxy-aware provider when auth proxy is configured
···485507 // Strip query params and fragments from htu per DPoP spec
486508 let htu = stripQueryAndFragment(from: params.requestEndpoint)
487509510510+ let issuedAt = await APEnvironment.current.clockSkewStore.serverAdjustedNow()
511511+488512 let payload = DPoPPayload(
489513 htm: params.httpMethod,
490514 htu: htu,
491491- iat: .init(value: .now),
515515+ iat: .init(value: issuedAt),
492516 jti: .init(value: UUID().uuidString),
493517 nonce: params.nonce
494518 )
···500524501525 // Get public key parameters and convert to base64url for JWK
502526 if let keyParams = privateKey.parameters {
503503- // Convert from base64 to base64url (replace + with -, / with _, remove =)
504504- let xBase64URL = keyParams.x
505505- .replacingOccurrences(of: "+", with: "-")
506506- .replacingOccurrences(of: "/", with: "_")
507507- .replacingOccurrences(of: "=", with: "")
508508- let yBase64URL = keyParams.y
509509- .replacingOccurrences(of: "+", with: "-")
510510- .replacingOccurrences(of: "/", with: "_")
511511- .replacingOccurrences(of: "=", with: "")
512512-513527 header.jwk = [
514528 "kty": .string("EC"),
515529 "crv": .string("P-256"),
516516- "x": .string(xBase64URL),
517517- "y": .string(yBase64URL)
530530+ "x": .string(keyParams.x.base64URLEncoded()),
531531+ "y": .string(keyParams.y.base64URLEncoded())
518532 ]
519533 }
520534
···11+//
22+// TokenRefreshCoordinator.swift
33+// CoreATProtocol
44+//
55+66+/// Serialises concurrent token-refresh attempts.
77+///
88+/// When several in-flight requests receive a 401 at the same time, each would
99+/// otherwise race to call the refresh handler. This actor coalesces them onto
1010+/// a single underlying refresh task so the handler is invoked exactly once
1111+/// per burst — all callers await the same result.
1212+actor TokenRefreshCoordinator {
1313+ private var refreshTask: Task<Bool, Error>?
1414+1515+ /// Run `handler` if no refresh is currently in flight; otherwise join the
1616+ /// existing attempt and return its result.
1717+ func refresh(using handler: @Sendable @escaping () async throws -> Bool) async throws -> Bool {
1818+ if let refreshTask {
1919+ return try await refreshTask.value
2020+ }
2121+2222+ let task = Task { try await handler() }
2323+ refreshTask = task
2424+2525+ defer { refreshTask = nil }
2626+2727+ return try await task.value
2828+ }
2929+}