A WIP swift OAuth Library that one day I'll get back to
1//
2// OAuthClient.swift
3// Gulliver
4//
5// Created by Bailey Townsend on 1/20/26.
6//
7
8import Foundation
9import ATIdentityTools
10import ATCommonWeb
11import CryptoKit
12import Cache
13
14
15public class OAuthClient {
16 private let clientId: String
17 private let redirectUri: String
18 //Who knows, you might need to override this
19 public var clientUrlSession: URLSession
20 private var didResolver: DIDResolver
21 private var handleResolver: HandleResolver
22
23
24 public init(
25 clientId: String,
26 redirectUri: String,
27 clientUrlSession: URLSession = .shared,
28 didOptions: DIDResolverOptions? = nil,
29 didUrlSession: URLSession = .shared,
30 handleResolver: HandleResolver? = nil) {
31
32 self.didResolver = DIDResolver(options: didOptions, urlSession: didUrlSession)
33 if let handleResolver = handleResolver {
34 self.handleResolver = handleResolver
35 }else{
36 self.handleResolver = HandleResolver()
37 }
38
39 self.clientId = clientId
40 self.redirectUri = redirectUri
41 self.clientUrlSession = clientUrlSession
42 }
43
44
45 /// Generic method to fetch and decode JSON from a URL
46 /// - Parameters:
47 /// - url: The URL to fetch from
48 /// - type: The Decodable type to decode the response into
49 /// - decoder: Optional custom JSONDecoder (defaults to standard JSONDecoder)
50 /// - Returns: The decoded object of type T
51 /// - Throws: OAuthClientError if the request fails or response is invalid
52 private func fetchJSON<T: Decodable>(from url: URL, as type: T.Type, decoder: JSONDecoder = JSONDecoder()) async throws -> T {
53 // Create request with proper headers
54 var request = URLRequest(url: url)
55 request.setValue("application/json", forHTTPHeaderField: "Accept")
56
57 // Perform the request
58 let (data, response) = try await clientUrlSession.data(for: request)
59
60 // Validate response
61 guard let httpResponse = response as? HTTPURLResponse else {
62 throw OAuthClientError.webRequestError("Invalid response type")
63 }
64
65 guard httpResponse.statusCode == 200 else {
66 throw OAuthClientError.webRequestError("Unexpected response status: \(httpResponse.statusCode)")
67 }
68
69 // Validate content type
70 guard let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"),
71 contentType.lowercased().contains("application/json") else {
72 throw OAuthClientError.webRequestError("Unexpected content type")
73 }
74
75 // Decode the JSON
76 return try decoder.decode(T.self, from: data)
77 }
78
79
80 /// Gets the clientmetadata from the cliend ID
81 private func getClientMetadata(clientId: String) async throws -> OAuthClientMetadata {
82 // Construct the well-known URL
83 guard let url = URL(string: clientId)else{
84 throw OAuthClientError.metaDatasError("Invalid client ID. Is not a URL")
85 }
86
87 // Fetch and decode the metadata using the generic method
88 return try await fetchJSON(from: url, as: OAuthClientMetadata.self)
89
90 }
91
92 /// Gets the protected metadata at /.well-known/oauth-protected-resource
93 private func getProtectedResourceMetadata(url: URL) async throws -> OAuthProtectedResourceMetadata {
94 // Construct the well-known URL
95 guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
96 throw OAuthClientError.metaDatasError("Invalid URL for metadata server")
97 }
98 components.path = "/.well-known/oauth-protected-resource"
99
100 guard let metadataURL = components.url else {
101 throw OAuthClientError.metaDatasError("Failed to construct protected metadata URL")
102 }
103
104 // Fetch and decode the metadata using the generic method
105 let metadata = try await fetchJSON(from: metadataURL, as: OAuthProtectedResourceMetadata.self)
106
107 // Validate that the resource matches the origin
108 guard let origin = components.scheme.map({ "\($0)://\(components.host ?? "")" }),
109 metadata.resource.absoluteString.hasPrefix(origin) else {
110 throw OAuthClientError.metaDatasError("Unexpected resource identifier in metadata")
111 }
112
113 return metadata
114 }
115
116 /// Gets the protected metadata at /.well-known/oauth-authorization-server
117 private func getOAuthAuthorizationServerMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata {
118 // Construct the well-known URL
119 guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
120 throw OAuthClientError.metaDatasError("Invalid URL for metadata server")
121 }
122 components.path = "/.well-known/oauth-authorization-server"
123
124 guard let metadataURL = components.url else {
125 throw OAuthClientError.metaDatasError("Failed to construct metadata URL")
126 }
127
128 // Fetch and decode the metadata using the generic method
129 return try await fetchJSON(from: metadataURL, as: OAuthAuthorizationServerMetadata.self)
130
131 }
132
133
134 /// Generic call that calls the abstracted endpoints to get the metadatas
135 private func getAuthMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata {
136
137 let protectedResourceMetadata = try await getProtectedResourceMetadata(url: url)
138
139 guard let authorizedServers = protectedResourceMetadata.authorizationServers else {
140 throw OAuthClientError.metaDatasError("No authorization servers found in protected metadata")
141 }
142
143 //Some manual checks atcute did
144 if authorizedServers.count != 1 {
145 throw OAuthClientError.metaDatasError("expected exactly one authorization server in the listing")
146 }
147
148 let issuer = authorizedServers.first
149 guard let issuerUrl = URL(string: issuer ?? "") else {
150 throw OAuthClientError.metaDatasError("Failed to parse issuer URL from protected metadata")
151 }
152 let metadata = try await self.getAuthMetadata(url: issuerUrl)
153
154 if let protectedResources = metadata.protectedResources {
155 if !protectedResources.contains(protectedResourceMetadata.resource) {
156 throw OAuthClientError.metaDatasError("server is not in authorization server's jurisdiction")
157 }
158 }
159
160 return metadata
161 }
162
163
164 /// Generates a random code verifier for PKCE.
165 /// - Returns: A base64url-encoded random string.
166 private func generateCodeVerifier() -> String {
167 let verifierData = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) })
168 return base64URLEncode(verifierData)
169 }
170
171 /// Generates a code challenge from a code verifier.
172 /// - Parameter verifier: The code verifier.
173 /// - Returns: The base64url-encoded SHA-256 hash of the verifier.
174 private func generateCodeChallenge(from verifier: String) -> String {
175 let verifierData = Data(verifier.utf8)
176 let hash = SHA256.hash(data: verifierData)
177 return base64URLEncode(Data(hash))
178 }
179
180 func postPAR(to url: URL, parameters: [String: String], decoder: JSONDecoder = JSONDecoder()) async throws -> OAuthPushedAuthorizationResponse {
181
182 var request = URLRequest(url: url)
183 request.httpMethod = "POST"
184 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
185
186 // Convert parameters to form-encoded string
187 let formString = parameters
188 .map { key, value in
189 let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key
190 let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
191 return "\(encodedKey)=\(encodedValue)"
192 }
193 .joined(separator: "&")
194
195 request.httpBody = formString.data(using: .utf8)
196// request.setValue("\(dpop)", forHTTPHeaderField: "DPoP")
197
198
199 let (data, response) = try await URLSession.shared.data(for: request)
200
201 guard let httpResponse = response as? HTTPURLResponse,
202 (200...299).contains(httpResponse.statusCode) else {
203 throw URLError(.badServerResponse)
204 }
205
206 // Decode the JSON
207 return try decoder.decode(OAuthPushedAuthorizationResponse.self, from: data)
208 }
209
210
211 public func createAuthorizationURL(identifier: String, resourceUrl: URL? = nil) async throws -> URL {
212 do {
213 var pdsUrl: URL? = nil
214 var did: String? = nil
215 var didDoc: DIDDocument? = nil
216
217
218 if identifier.starts(with: "http") {
219 guard let url = URL(string: identifier) else {
220 throw OAuthClientError.identifierParsingFailed
221 }
222 pdsUrl = url
223 }
224
225 if pdsUrl == nil {
226 if identifier.starts(with: "did:") {
227 did = identifier
228 }
229 }
230
231 if did == nil && pdsUrl == nil {
232 did = try await handleResolver.resolve(handle: identifier)
233 let didDocument = try await didResolver.resolve(did: did!, willForceRefresh: false)
234 didDoc = didDocument
235 pdsUrl = didDocument.getPDSEndpoint()
236 }
237
238 if did == nil && pdsUrl == nil{
239 throw OAuthClientError.identifierParsingFailed
240 }
241
242
243
244 if pdsUrl == nil {
245 throw OAuthClientError.catchAll("No PDS URL Found")
246 }
247
248
249 let oauthServerMetadata = try await getOAuthAuthorizationServerMetadata(url: resourceUrl ?? pdsUrl!)
250
251 let pckeCode = generateCodeVerifier()
252 let pcke = generateCodeChallenge(from: pckeCode)
253
254 let clientMetadata = try await getClientMetadata(clientId: self.clientId)
255
256
257 let sessionId = UUID().uuidString
258
259 let parameters: [String: String] = [
260 "client_id": clientId,
261 "response_type": "code",
262 "state": sessionId,
263 "redirect_uri": self.redirectUri,
264 "scope": clientMetadata.scope ?? "",
265 "code_challenge": pcke,
266 "code_challenge_method": "S256",
267 "response_mode": "fragment",
268 "display": "page",
269 ]
270
271
272 guard let parEndpoint = oauthServerMetadata.pushedAuthorizationRequestEndpoint else {
273 throw OAuthClientError.catchAll("No Pushed Authorization Request Endpoint Found")
274 }
275
276 let dpopKey = try await createDPoPKey(for: identifier)
277 let stateKeyStore = getStateKeychainStore()
278 let signer = DPoPSigner(privateKey: dpopKey, keychainStore: stateKeyStore)
279 let requestSign = try signer.createProof(httpMethod: "POST", url: parEndpoint.absoluteString)
280
281 //PAR returns a dpop nonce i need to follow, and can also fail to a dpop needing the nonce
282 //resend with one sign then
283 let parResult = try await postPAR(to: parEndpoint, parameters: parameters)
284
285 guard var components = URLComponents(url: oauthServerMetadata.authorizationEndpoint, resolvingAgainstBaseURL: true) else {
286 throw OAuthClientError.catchAll("Failed to construct authorization URL")
287 }
288
289 let queryItems: [URLQueryItem] = [
290 URLQueryItem(name: "client_id", value: clientId),
291 URLQueryItem(name: "request_uri", value: parResult.requestUri),
292 URLQueryItem(name: "redirect_uri", value: self.redirectUri),
293
294 ]
295 components.queryItems = (components.queryItems ?? []) + queryItems
296
297 guard let fullAuthUrl = components.url else {
298 throw OAuthClientError.catchAll("Failed to construct authorization URL")
299 }
300
301 return fullAuthUrl
302 } catch let error as OAuthClientError {
303 throw error
304 }catch {
305 throw OAuthClientError.unknownError(error)
306 }
307
308
309// public func callback(, resourceUrl: URL? = nil) async throws -> URL {
310// //Need to take the redirected url code and state
311// //Use the state/sessionId to load teh dpop key we started with and load it as the identity/did coming back
312// }
313 }
314}
315