A WIP swift OAuth Library that one day I'll get back to
at main 150 lines 5.0 kB view raw
1// 2// ATProtoClient.swift 3// Gulliver 4// 5// Created by Bailey Townsend on 1/21/26. 6// 7 8import Foundation 9 10actor ATProtoClient { 11 private var accessToken: String? 12 private var refreshToken: String? 13 private var dpopNonce: String? 14 private let session: URLSession 15 private let sessionId: String? 16 private let keychainStore: KeychainStorage 17 private let dpopSigner: DPoPSigner 18 19 enum ATProtoError: Error { 20 case unauthorized 21 case dpopNonceRequired(nonce: String?) 22 case maxRetriesExceeded 23 case networkError(Error) 24 } 25 26 init(sessionId: String, session: URLSession = .shared) throws { 27 self.sessionId = sessionId 28 self.session = session 29 //We will always use the session keychain here since the state is used else where 30 self.keychainStore = getSessionKeychainStore() 31 let dpopKey = try self.keychainStore.retrieveDPoPKey(keyTag: sessionId) 32 self.dpopSigner = DPoPSigner(privateKey: dpopKey, keychainStore: self.keychainStore) 33 } 34 35 36 func request<T: Decodable & Sendable>( 37 _ endpoint: String, 38 method: String = "GET", 39 body: Data? = nil, 40 maxRetries: Int = 3 41 ) async throws -> T { 42 try await Task.retrying( 43 maxRetryCount: maxRetries, 44 retryDelay: 0.5, // Shorter delay for API retries 45 operation: { 46 try await self.performRequest(endpoint, method: method, body: body) 47 }, 48 onRetry: { [weak self] error, attempt in 49 guard let self else { return } 50 51 switch error { 52 case let atError as ATProtoError: 53 switch atError { 54 case .dpopNonceRequired(let nonce): 55 // Nonce already updated, just retry 56 print("Retrying with DPoP nonce (attempt \(attempt + 1))") 57 58 case .unauthorized: 59 // Try to refresh token 60 print("Refreshing access token (attempt \(attempt + 1))") 61 try await self.refreshAccessToken() 62 63 default: 64 throw error // Don't retry other errors 65 } 66 67 default: 68 throw error // Don't retry unknown errors 69 } 70 } 71 ).value 72 } 73 74 private func performRequest<T: Decodable>( 75 _ endpoint: String, 76 method: String, 77 body: Data? 78 ) async throws -> T { 79 guard let url = URL(string: endpoint) else { 80 throw URLError(.badURL) 81 } 82 83 var request = URLRequest(url: url) 84 request.httpMethod = method 85 request.httpBody = body 86 87 if let accessToken { 88 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 89 } 90 91 let dpopProof = try self.dpopSigner.createProof(httpMethod: method, url: endpoint.lowercased()) 92 93 94 request.setValue(dpopProof, forHTTPHeaderField: "DPoP") 95 96 let (data, response) = try await session.data(for: request) 97 98 guard let httpResponse = response as? HTTPURLResponse else { 99 throw URLError(.badServerResponse) 100 } 101 102 // Always capture the nonce if present 103 if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 104 //save to cache now 105 dpopNonce = newNonce 106 } 107 108 //TODO maybe abstract this out to be used? 109 // Handle error cases 110 if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { 111 if let dpopNonce = httpResponse.value(forHTTPHeaderField: "dpop-nonce") 112 { 113 throw ATProtoError.dpopNonceRequired(nonce: dpopNonce) 114 } 115 throw ATProtoError.unauthorized 116 } 117 118 guard (200...299).contains(httpResponse.statusCode) else { 119 throw URLError(.badServerResponse) 120 } 121 122 return try JSONDecoder().decode(T.self, from: data) 123 } 124 125 private func refreshAccessToken() async throws { 126 guard let refreshToken else { 127 throw ATProtoError.unauthorized 128 } 129 130 struct RefreshResponse: Decodable { 131 let accessJwt: String 132 let refreshJwt: String 133 } 134 135 let body = try JSONEncoder().encode(["refreshToken": refreshToken]) 136 137 // Refresh can also require DPoP retries, so it goes through request() 138 let response: RefreshResponse = try await request( 139 "https://your-pds.host/xrpc/com.atproto.server.refreshSession", 140 method: "POST", 141 body: body, 142 maxRetries: 2 // Fewer retries for refresh 143 ) 144 145 accessToken = response.accessJwt 146 self.refreshToken = response.refreshJwt 147 } 148 149 150}