Skip to content

Commit 47f7c86

Browse files
committed
Implement access token refreshing
1 parent 496dc4d commit 47f7c86

File tree

2 files changed

+219
-1
lines changed

2 files changed

+219
-1
lines changed

Sources/DropboxClient/Auth.swift

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,57 @@ extension Auth {
185185
return true
186186
},
187187
refreshToken: {
188-
fatalError("Unimplemented")
188+
guard let credentials = await keychain.loadCredentials() else { return }
189+
guard credentials.expiresAt <= now() else { return }
190+
191+
let request: URLRequest = {
192+
var components = URLComponents()
193+
components.scheme = "https"
194+
components.host = "api.dropboxapi.com"
195+
components.path = "/oauth2/token"
196+
197+
var request = URLRequest(url: components.url!)
198+
request.httpMethod = "POST"
199+
request.setValue(
200+
"application/x-www-form-urlencoded",
201+
forHTTPHeaderField: "Content-Type"
202+
)
203+
request.httpBody = [
204+
"grant_type=refresh_token",
205+
"refresh_token=\(credentials.refreshToken)",
206+
"client_id=\(config.appKey)",
207+
].joined(separator: "&").data(using: .utf8)
208+
209+
return request
210+
}()
211+
212+
let (responseData, response) = try await httpClient.data(for: request)
213+
let statusCode = (response as? HTTPURLResponse)?.statusCode
214+
215+
guard let statusCode, (200..<300).contains(statusCode) else {
216+
throw Error.response(statusCode: statusCode, data: responseData)
217+
}
218+
219+
struct ResponseBody: Decodable {
220+
var accessToken: String
221+
var tokenType: String
222+
var expiresIn: Int
223+
}
224+
225+
let responseBody = try JSONDecoder.api.decode(
226+
ResponseBody.self,
227+
from: responseData
228+
)
229+
230+
var newCredentials = credentials
231+
newCredentials.accessToken = responseBody.accessToken
232+
newCredentials.tokenType = responseBody.tokenType
233+
newCredentials.expiresAt = Date(
234+
timeInterval: TimeInterval(responseBody.expiresIn),
235+
since: now()
236+
)
237+
238+
await saveCredentials(newCredentials)
189239
},
190240
signOut: {
191241
await saveCredentials(nil)

Tests/DropboxClientTests/AuthTests.swift

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,174 @@ final class AuthTests: XCTestCase {
257257
}
258258
}
259259

260+
func testDontRefreshTokenWithoutCredentials() async throws {
261+
let auth = Auth.live(
262+
config: .test,
263+
keychain: {
264+
var keychain = Keychain.unimplemented()
265+
keychain.loadCredentials = { nil }
266+
return keychain
267+
}(),
268+
httpClient: .unimplemented(),
269+
openURL: .unimplemented(),
270+
dateGenerator: .unimplemented(),
271+
pkceUtils: .unimplemented()
272+
)
273+
274+
try await auth.refreshToken()
275+
}
276+
277+
func testDontRefreshTokenIfNotExpired() async throws {
278+
let date = Date(timeIntervalSince1970: 1_000_000)
279+
let credentials = ActorIsolated<Credentials?>(Credentials(
280+
accessToken: "",
281+
tokenType: "",
282+
expiresAt: date.addingTimeInterval(1),
283+
refreshToken: "",
284+
scope: "",
285+
uid: "",
286+
accountId: ""
287+
))
288+
let auth = Auth.live(
289+
config: .test,
290+
keychain: {
291+
var keychain = Keychain.unimplemented()
292+
keychain.loadCredentials = { await credentials.value }
293+
return keychain
294+
}(),
295+
httpClient: .unimplemented(),
296+
openURL: .unimplemented(),
297+
dateGenerator: .init { date },
298+
pkceUtils: .unimplemented()
299+
)
300+
301+
try await auth.refreshToken()
302+
}
303+
304+
func testRefreshExpiredToken() async throws {
305+
let httpRequests = ActorIsolated<[URLRequest]>([])
306+
let date = Date(timeIntervalSince1970: 1_000_000)
307+
let credentials = ActorIsolated<Credentials?>(Credentials(
308+
accessToken: "access-token-1",
309+
tokenType: "token-type-1",
310+
expiresAt: date.addingTimeInterval(-1),
311+
refreshToken: "refresh-token-1",
312+
scope: "scope-1",
313+
uid: "uid-1",
314+
accountId: "accountId-1"
315+
))
316+
let auth = Auth.live(
317+
config: .test,
318+
keychain: {
319+
var keychain = Keychain.unimplemented()
320+
keychain.loadCredentials = { await credentials.value }
321+
keychain.saveCredentials = { await credentials.setValue($0) }
322+
return keychain
323+
}(),
324+
httpClient: .init { request in
325+
await httpRequests.withValue { $0.append(request) }
326+
return (
327+
"""
328+
{
329+
"access_token": "access-token-2",
330+
"expires_in": 4321,
331+
"token_type": "token-type-2"
332+
}
333+
""".data(using: .utf8)!,
334+
HTTPURLResponse(
335+
url: URL(filePath: "/"),
336+
statusCode: 200,
337+
httpVersion: nil,
338+
headerFields: nil
339+
)!
340+
)
341+
},
342+
openURL: .unimplemented(),
343+
dateGenerator: .init { date },
344+
pkceUtils: .unimplemented()
345+
)
346+
347+
try await auth.refreshToken()
348+
349+
await httpRequests.withValue {
350+
let url = URL(string: "https://api.dropboxapi.com/oauth2/token")!
351+
var expectedRequest = URLRequest(url: url)
352+
expectedRequest.httpMethod = "POST"
353+
expectedRequest.allHTTPHeaderFields = [
354+
"Content-Type": "application/x-www-form-urlencoded"
355+
]
356+
expectedRequest.httpBody = [
357+
"grant_type=refresh_token",
358+
"refresh_token=refresh-token-1",
359+
"client_id=\(Config.test.appKey)",
360+
].joined(separator: "&").data(using: .utf8)!
361+
362+
XCTAssertEqual($0, [expectedRequest])
363+
XCTAssertEqual($0.first?.httpBody, expectedRequest.httpBody!)
364+
}
365+
await credentials.withValue {
366+
XCTAssertEqual($0, Credentials(
367+
accessToken: "access-token-2",
368+
tokenType: "token-type-2",
369+
expiresAt: date.addingTimeInterval(4321),
370+
refreshToken: "refresh-token-1",
371+
scope: "scope-1",
372+
uid: "uid-1",
373+
accountId: "accountId-1"
374+
))
375+
}
376+
}
377+
378+
func testRefreshTokenErrorResponse() async {
379+
let date = Date(timeIntervalSince1970: 1_000_000)
380+
let credentials = ActorIsolated<Credentials?>(Credentials(
381+
accessToken: "access-token-1",
382+
tokenType: "token-type-1",
383+
expiresAt: date.addingTimeInterval(-1),
384+
refreshToken: "refresh-token-1",
385+
scope: "scope-1",
386+
uid: "uid-1",
387+
accountId: "accountId-1"
388+
))
389+
let auth = Auth.live(
390+
config: .test,
391+
keychain: {
392+
var keychain = Keychain.unimplemented()
393+
keychain.loadCredentials = { await credentials.value }
394+
keychain.saveCredentials = { await credentials.setValue($0) }
395+
return keychain
396+
}(),
397+
httpClient: .init { _ in
398+
(
399+
"Error!!!".data(using: .utf8)!,
400+
HTTPURLResponse(
401+
url: URL(filePath: "/"),
402+
statusCode: 500,
403+
httpVersion: nil,
404+
headerFields: nil
405+
)!
406+
)
407+
},
408+
openURL: .unimplemented(),
409+
dateGenerator: .init { date },
410+
pkceUtils: .unimplemented()
411+
)
412+
413+
do {
414+
try await auth.refreshToken()
415+
XCTFail("Expected to throw, but didn't")
416+
} catch {
417+
XCTAssertEqual(
418+
error as? Auth.Error,
419+
.response(
420+
statusCode: 500,
421+
data: "Error!!!".data(using: .utf8)!
422+
),
423+
"Expected to throw response error, got \(error)"
424+
)
425+
}
426+
}
427+
260428
func testSignOut() async {
261429
let credentials = ActorIsolated<Credentials?>(Credentials(
262430
accessToken: "",

0 commit comments

Comments
 (0)