Skip to content

Commit 3036f2a

Browse files
authored
Merge pull request #7 from darrarski/feature/refresh-token
Automatically refresh access token when needed
2 parents 324481f + 47f7c86 commit 3036f2a

File tree

13 files changed

+369
-9
lines changed

13 files changed

+369
-9
lines changed

Example/DropboxClientExampleApp/Dependencies.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ extension DropboxClient.Client: DependencyKey {
5151
isSignedInStream: { isSignedIn.eraseToStream() },
5252
signIn: { await isSignedIn.setValue(true) },
5353
handleRedirect: { _ in false },
54+
refreshToken: {},
5455
signOut: { await isSignedIn.setValue(false) }
5556
),
5657
listFolder: .init { _ in

Sources/DropboxClient/Auth.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public struct Auth: Sendable {
55
public typealias IsSignedInStream = @Sendable () -> AsyncStream<Bool>
66
public typealias SignIn = @Sendable () async -> Void
77
public typealias HandleRedirect = @Sendable (URL) async throws -> Bool
8+
public typealias RefreshToken = @Sendable () async throws -> Void
89
public typealias SignOut = @Sendable () async -> Void
910

1011
public enum Error: Swift.Error, Sendable, Equatable {
@@ -18,19 +19,22 @@ public struct Auth: Sendable {
1819
isSignedInStream: @escaping IsSignedInStream,
1920
signIn: @escaping SignIn,
2021
handleRedirect: @escaping HandleRedirect,
22+
refreshToken: @escaping RefreshToken,
2123
signOut: @escaping SignOut
2224
) {
2325
self.isSignedIn = isSignedIn
2426
self.isSignedInStream = isSignedInStream
2527
self.signIn = signIn
2628
self.handleRedirect = handleRedirect
29+
self.refreshToken = refreshToken
2730
self.signOut = signOut
2831
}
2932

3033
public var isSignedIn: IsSignedIn
3134
public var isSignedInStream: IsSignedInStream
3235
public var signIn: SignIn
3336
public var handleRedirect: HandleRedirect
37+
public var refreshToken: RefreshToken
3438
public var signOut: SignOut
3539
}
3640

@@ -180,6 +184,59 @@ extension Auth {
180184

181185
return true
182186
},
187+
refreshToken: {
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)
239+
},
183240
signOut: {
184241
await saveCredentials(nil)
185242
}

Sources/DropboxClient/Client.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,34 @@ extension Client {
2929
dateGenerator: DateGenerator = .live,
3030
pkceUtils: PKCEUtils = .live
3131
) -> Client {
32-
Client(
33-
auth: .live(
34-
config: config,
35-
keychain: keychain,
36-
httpClient: httpClient,
37-
openURL: openURL,
38-
dateGenerator: dateGenerator,
39-
pkceUtils: pkceUtils
40-
),
32+
let auth = Auth.live(
33+
config: config,
34+
keychain: keychain,
35+
httpClient: httpClient,
36+
openURL: openURL,
37+
dateGenerator: dateGenerator,
38+
pkceUtils: pkceUtils
39+
)
40+
41+
return Client(
42+
auth: auth,
4143
listFolder: .live(
44+
auth: auth,
4245
keychain: keychain,
4346
httpClient: httpClient
4447
),
4548
downloadFile: .live(
49+
auth: auth,
4650
keychain: keychain,
4751
httpClient: httpClient
4852
),
4953
deleteFile: .live(
54+
auth: auth,
5055
keychain: keychain,
5156
httpClient: httpClient
5257
),
5358
uploadFile: .live(
59+
auth: auth,
5460
keychain: keychain,
5561
httpClient: httpClient
5662
)

Sources/DropboxClient/DeleteFile.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ public struct DeleteFile: Sendable {
4141

4242
extension DeleteFile {
4343
public static func live(
44+
auth: Auth,
4445
keychain: Keychain,
4546
httpClient: HTTPClient
4647
) -> DeleteFile {
4748
DeleteFile { params in
49+
try await auth.refreshToken()
50+
4851
guard let credentials = await keychain.loadCredentials() else {
4952
throw Error.notAuthorized
5053
}

Sources/DropboxClient/DownloadFile.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ public struct DownloadFile: Sendable {
3333

3434
extension DownloadFile {
3535
public static func live(
36+
auth: Auth,
3637
keychain: Keychain,
3738
httpClient: HTTPClient
3839
) -> DownloadFile {
3940
DownloadFile { params in
41+
try await auth.refreshToken()
42+
4043
guard let credentials = await keychain.loadCredentials() else {
4144
throw Error.notAuthorized
4245
}

Sources/DropboxClient/ListFolder.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,13 @@ public struct ListFolder: Sendable {
7171

7272
extension ListFolder {
7373
public static func live(
74+
auth: Auth,
7475
keychain: Keychain,
7576
httpClient: HTTPClient
7677
) -> ListFolder {
7778
ListFolder { params in
79+
try await auth.refreshToken()
80+
7881
guard let credentials = await keychain.loadCredentials() else {
7982
throw Error.notAuthorized
8083
}

Sources/DropboxClient/UploadFile.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ public struct UploadFile: Sendable {
5858

5959
extension UploadFile {
6060
public static func live(
61+
auth: Auth,
6162
keychain: Keychain,
6263
httpClient: HTTPClient
6364
) -> UploadFile {
6465
UploadFile { params in
66+
try await auth.refreshToken()
67+
6568
guard let credentials = await keychain.loadCredentials() else {
6669
throw Error.notAuthorized
6770
}

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)