A WIP swift OAuth Library that one day I'll get back to
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}