+1
-10
Package.resolved
+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
+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
+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
+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
+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
+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