this repo has no description

oauth wip

+1 -10
Package.resolved
··· 1 1 { 2 - "originHash" : "aef8443d2c26c1b290a85bc86c844a258c5dd2c9b4979e9f7b3da92cf56bb581", 2 + "originHash" : "76f98c0d52def5e7d5c79516d2e7a4fdfa68c70d7d07bb400dd0095cef1c0b26", 3 3 "pins" : [ 4 4 { 5 5 "identity" : "jwt-kit", ··· 8 8 "state" : { 9 9 "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", 10 10 "version" : "5.3.0" 11 - } 12 - }, 13 - { 14 - "identity" : "oauthenticator", 15 - "kind" : "remoteSourceControl", 16 - "location" : "https://github.com/radmakr/OAuthenticator.git", 17 - "state" : { 18 - "branch" : "CoreAtProtocol", 19 - "revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70" 20 11 } 21 12 }, 22 13 {
+2 -1
Package.swift
··· 21 21 // Using fork with fix for WebAuthenticationSession platform guards 22 22 // PR pending at https://github.com/ChimeHQ/OAuthenticator 23 23 // .package(url: "https://github.com/ChimeHQ/OAuthenticator.git", branch: "main"), 24 - .package(url: "https://github.com/radmakr/OAuthenticator.git", branch: "CoreAtProtocol"), 24 + // .package(url: "https://github.com/radmakr/OAuthenticator.git", branch: "CoreAtProtocol"), 25 + .package(path: "../OAuthenticator"), 25 26 .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), 26 27 ], 27 28 targets: [
+1 -1
Sources/CoreATProtocol/APEnvironment.swift
··· 13 13 public var accessToken: String? 14 14 public var refreshToken: String? 15 15 public var atProtocoldelegate: CoreATProtocolDelegate? 16 + public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 16 17 public let routerDelegate = APRouterDelegate() 17 18 18 19 private init() {} ··· 23 24 // self.userAgent = userAgent 24 25 // } 25 26 } 26 -
+5
Sources/CoreATProtocol/CoreATProtocol.swift
··· 46 46 } 47 47 48 48 @APActor 49 + public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) { 50 + APEnvironment.current.tokenRefreshHandler = handler 51 + } 52 + 53 + @APActor 49 54 public func updateTokens(access: String?, refresh: String?) { 50 55 APEnvironment.current.accessToken = access 51 56 APEnvironment.current.refreshToken = refresh
+28 -18
Sources/CoreATProtocol/Networking.swift
··· 33 33 @APActor 34 34 public class APRouterDelegate: NetworkRouterDelegate { 35 35 private var shouldRefreshToken = false 36 + private var refreshTask: Task<Bool, Error>? 36 37 37 38 public func intercept(_ request: inout URLRequest) async { 38 39 if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { ··· 44 45 } 45 46 46 47 public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 47 - func getNewToken() async throws -> Bool { 48 - // shouldRefreshToken = true 49 - // let newSession = try await AtProtoLexicons().refresh(attempts: attempts + 1) 50 - // APEnvironment.current.accessToken = newSession.accessJwt 51 - // APEnvironment.current.refreshToken = newSession.refreshJwt 52 - // await delegate?.sessionUpdated(newSession) 53 - // 54 - // return true 55 - false 48 + func refreshViaOAuth() async throws -> Bool { 49 + guard let handler = APEnvironment.current.tokenRefreshHandler else { 50 + return false 51 + } 52 + 53 + if let refreshTask { 54 + return try await refreshTask.value 55 + } 56 + 57 + let task = Task { try await handler() } 58 + refreshTask = task 59 + 60 + defer { refreshTask = nil } 61 + 62 + return try await task.value 56 63 } 57 - 58 - // TODO: verify this works! 59 - if case .network(let networkError) = error as? AtError, 64 + 65 + if attempts == 1, 66 + case .network(let networkError) = error as? AtError, 60 67 case .statusCode(let statusCode, _) = networkError, 61 - let statusCode = statusCode?.rawValue, (400..<500).contains(statusCode), 68 + let statusCode = statusCode?.rawValue, 69 + statusCode == 401 || statusCode == 403 { 70 + return try await refreshViaOAuth() 71 + } 72 + 73 + if case .message(let message) = error as? AtError, 74 + message.error == AtErrorType.expiredToken.rawValue, 62 75 attempts == 1 { 63 - return try await getNewToken() 64 - } else if case .message(let message) = error as? AtError, 65 - message.error == AtErrorType.expiredToken.rawValue { 66 - return try await getNewToken() 76 + return try await refreshViaOAuth() 67 77 } 68 - 78 + 69 79 return false 70 80 } 71 81 }
+143 -7
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 76 76 private let config: ATProtoOAuthConfig 77 77 private let storage: ATProtoAuthStorage 78 78 private let identityResolver: IdentityResolver 79 + private let dpopRequestActor = DPoPRequestActor() 80 + private var hasPersistedKey: Bool 79 81 80 82 // JWT signing keys (pattern from AtProtocol) 81 83 private var keys: JWTKeyCollection ··· 87 89 self.identityResolver = IdentityResolver() 88 90 89 91 // Initialize JWT keys (from AtProto.swift lines 19-23) 90 - self.privateKey = ES256PrivateKey() 92 + if let storedKeyData = try? await storage.retrievePrivateKey(), 93 + let pem = String(data: storedKeyData, encoding: .utf8), 94 + let restoredKey = try? ES256PrivateKey(pem: pem) { 95 + self.privateKey = restoredKey 96 + self.hasPersistedKey = true 97 + } else { 98 + self.privateKey = ES256PrivateKey() 99 + self.hasPersistedKey = false 100 + } 91 101 self.keys = JWTKeyCollection() 92 102 await self.keys.add(ecdsa: privateKey) 93 103 } ··· 100 110 101 111 // Restore existing key 102 112 self.privateKey = try ES256PrivateKey(pem: privateKeyPEM) 113 + self.hasPersistedKey = true 103 114 self.keys = JWTKeyCollection() 104 115 await self.keys.add(ecdsa: privateKey) 105 116 } ··· 122 133 } 123 134 124 135 // Step 2: Store private key for future sessions 125 - let keyPEM = privateKey.pemRepresentation 126 - guard let keyData = keyPEM.data(using: .utf8) else { 127 - throw ATProtoOAuthError.privateKeyExportFailed 128 - } 129 - try await storage.storePrivateKey(keyData) 136 + try await persistPrivateKey() 130 137 131 138 // Step 3: Load client metadata 132 139 let provider = URLSession.defaultProvider ··· 184 191 do { 185 192 login = try await authenticator.authenticate() 186 193 } catch { 187 - throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 194 + if shouldRecoverFromRefreshFailure(error) { 195 + try await storage.storeLogin(Login(token: "invalid", validUntilDate: .distantPast)) 196 + try await resetDPoPKey() 197 + do { 198 + login = try await authenticator.authenticate() 199 + } catch { 200 + throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 201 + } 202 + } else { 203 + throw ATProtoOAuthError.authenticationFailed("OAuth flow failed: \(error.localizedDescription)") 204 + } 188 205 } 189 206 190 207 // Step 9: Setup CoreATProtocol environment ··· 205 222 ) 206 223 } 207 224 225 + /// Refresh tokens if the stored access token is expired (or if forced). 226 + public func refreshLoginIfNeeded(handle: String? = nil, force: Bool = false) async throws -> Login? { 227 + guard let login = try await storage.retrieveLogin() else { 228 + return nil 229 + } 230 + 231 + if !force, login.accessToken.valid { 232 + return nil 233 + } 234 + 235 + guard hasPersistedKey else { 236 + return nil 237 + } 238 + 239 + guard login.refreshToken?.valid == true else { 240 + return nil 241 + } 242 + 243 + let issuer: String 244 + if let issuingServer = login.issuingServer { 245 + issuer = issuingServer 246 + } else if let handle { 247 + let identity = try await identityResolver.resolve(handle: handle) 248 + issuer = identity.authorizationServer 249 + } else { 250 + return nil 251 + } 252 + 253 + let provider = URLSession.defaultProvider 254 + let serverHost = stripScheme(from: issuer) 255 + let serverConfig = try await ServerMetadata.load(for: serverHost, provider: provider) 256 + let clientConfig = try await ClientMetadata.load(for: config.clientMetadataURL, provider: provider) 257 + let jwtGenerator: DPoPSigner.JWTGenerator = { [self] params in 258 + try await self.generateJWT(params: params) 259 + } 260 + let tokenHandling = ATProto.tokenHandling( 261 + account: handle, 262 + server: serverConfig, 263 + jwtGenerator: jwtGenerator 264 + ) 265 + 266 + guard let refreshProvider = tokenHandling.refreshProvider else { 267 + return nil 268 + } 269 + 270 + let responseProvider: URLResponseProvider = { request in 271 + try await self.dpopRequestActor.response( 272 + request: request, 273 + jwtGenerator: jwtGenerator, 274 + provider: provider, 275 + issuingServer: issuer 276 + ) 277 + } 278 + 279 + let refreshedLogin = try await refreshProvider(login, clientConfig.credentials, responseProvider) 280 + try await storage.storeLogin(refreshedLogin) 281 + return refreshedLogin 282 + } 283 + 208 284 /// Export private key PEM for persistence 209 285 public var privateKeyPEM: String { 210 286 privateKey.pemRepresentation ··· 268 344 269 345 return end == -1 ? url : String(url.prefix(end)) 270 346 } 347 + 348 + private func stripScheme(from url: String) -> String { 349 + if url.hasPrefix("https://") { 350 + return String(url.dropFirst(8)) 351 + } else if url.hasPrefix("http://") { 352 + return String(url.dropFirst(7)) 353 + } 354 + return url 355 + } 356 + 357 + private func persistPrivateKey() async throws { 358 + let keyPEM = privateKey.pemRepresentation 359 + guard let keyData = keyPEM.data(using: .utf8) else { 360 + throw ATProtoOAuthError.privateKeyExportFailed 361 + } 362 + try await storage.storePrivateKey(keyData) 363 + hasPersistedKey = true 364 + } 365 + 366 + private func resetDPoPKey() async throws { 367 + privateKey = ES256PrivateKey() 368 + keys = JWTKeyCollection() 369 + await keys.add(ecdsa: privateKey) 370 + hasPersistedKey = false 371 + try await persistPrivateKey() 372 + } 373 + 374 + private func shouldRecoverFromRefreshFailure(_ error: Error) -> Bool { 375 + guard let authError = error as? AuthenticatorError else { 376 + return false 377 + } 378 + 379 + switch authError { 380 + case .refreshNotPossible, .unauthorizedRefreshFailed, .dpopTokenExpected, .httpResponseExpected: 381 + return true 382 + default: 383 + return false 384 + } 385 + } 271 386 } 272 387 273 388 // MARK: - DPoP Payload (from AtProto.swift lines 88-98) ··· 281 396 282 397 func verify(using key: some JWTAlgorithm) throws { 283 398 // No additional verification needed for DPoP 399 + } 400 + } 401 + 402 + private actor DPoPRequestActor { 403 + private let signer = DPoPSigner() 404 + 405 + func response( 406 + request: URLRequest, 407 + jwtGenerator: DPoPSigner.JWTGenerator, 408 + provider: URLResponseProvider, 409 + issuingServer: String? 410 + ) async throws -> (Data, URLResponse) { 411 + try await signer.response( 412 + isolation: self, 413 + for: request, 414 + using: jwtGenerator, 415 + token: nil, 416 + tokenHash: nil, 417 + issuingServer: issuingServer, 418 + provider: provider 419 + ) 284 420 } 285 421 } 286 422