|
1 | 1 | import AuthenticationServices |
2 | | -import Combine |
3 | 2 | import Foundation |
4 | 3 | import WebKit |
5 | 4 | import os.log |
6 | 5 |
|
7 | | -public class OAuth2Client: NSObject { |
8 | | - |
9 | | - private var cancellables: [AnyCancellable] = [] |
| 6 | +public actor OAuth2Client: NSObject { |
10 | 7 |
|
11 | 8 | var logger: Logger |
12 | 9 |
|
13 | 10 | public init(logger: Logger = .init()) { |
14 | 11 | self.logger = logger |
15 | 12 | } |
16 | 13 |
|
17 | | - public func signIn(with request: Request) -> Future<Credential, OAuth2Error> { |
18 | | - return Future { [weak self, logger] completion in |
19 | | - guard let self = self else { return } |
20 | | - guard let components = URLComponents(string: request.redirectUri), |
21 | | - let callbackScheme = components.scheme |
22 | | - else { |
23 | | - completion(.failure(OAuth2Error.invalidRedirectUri)) |
24 | | - return |
25 | | - } |
26 | | - let pkce = PKCE() |
27 | | - self.requestAuth(url: request.buildAuthorizeURL(pkce: pkce), callbackScheme: callbackScheme) |
28 | | - .flatMap { return self.requestToken(for: request.buildTokenURL(code: $0, pkce: pkce)) } |
29 | | - .sink { (result) in |
30 | | - switch result { |
31 | | - case .failure(let error): |
32 | | - logger.error("\(error.localizedDescription)") |
33 | | - completion(.failure(error)) |
34 | | - default: break |
35 | | - } |
36 | | - } receiveValue: { [logger] credential in |
37 | | - logger.debug("\(credential.accessToken)") |
38 | | - completion(.success(credential)) |
39 | | - } |
40 | | - .store(in: &self.cancellables) |
41 | | - |
| 14 | + public func signIn(with request: Request) async throws -> Credential { |
| 15 | + guard let components = URLComponents(string: request.redirectUri), |
| 16 | + let callbackScheme = components.scheme |
| 17 | + else { |
| 18 | + throw OAuth2Error.invalidRedirectUri |
42 | 19 | } |
| 20 | + let pkce = PKCE() |
| 21 | + let code = try await requestAuth(url: request.buildAuthorizeURL(pkce: pkce), callbackScheme: callbackScheme) |
| 22 | + let url = request.buildTokenURL(code: code, pkce: pkce) |
| 23 | + return try await requestToken(for: url) |
43 | 24 | } |
44 | 25 |
|
45 | | - public func signOut(with request: Request) -> Future<Credential, OAuth2Error> { |
46 | | - Future { finalCompletion in |
47 | | - let dataTypes = Set([ |
48 | | - WKWebsiteDataTypeCookies, WKWebsiteDataTypeSessionStorage, WKWebsiteDataTypeLocalStorage, |
49 | | - WKWebsiteDataTypeWebSQLDatabases, WKWebsiteDataTypeIndexedDBDatabases, |
50 | | - ]) |
51 | | - Future<Void, Never> { completion in |
52 | | - WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: Date.distantPast) |
53 | | - { |
54 | | - completion(.success(())) |
55 | | - } |
56 | | - }.flatMap { |
57 | | - self.signIn(with: request) |
58 | | - } |
59 | | - .sink { (completion) in |
60 | | - switch completion { |
61 | | - case let .failure(error): |
62 | | - finalCompletion(.failure(error)) |
63 | | - default: |
64 | | - break |
65 | | - } |
66 | | - } receiveValue: { (value) in |
67 | | - finalCompletion(.success(value)) |
68 | | - } |
69 | | - .store(in: &self.cancellables) |
70 | | - } |
| 26 | + public func signOut(with request: Request) async throws -> Credential { |
| 27 | + let dataTypes = Set([ |
| 28 | + WKWebsiteDataTypeCookies, WKWebsiteDataTypeSessionStorage, WKWebsiteDataTypeLocalStorage, |
| 29 | + WKWebsiteDataTypeWebSQLDatabases, WKWebsiteDataTypeIndexedDBDatabases, |
| 30 | + ]) |
| 31 | + await WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: Date.distantPast) |
| 32 | + return try await signIn(with: request) |
71 | 33 | } |
72 | 34 |
|
73 | | - public func refresh(with request: Request, refreshToken: String) -> Future< |
74 | | - Credential, OAuth2Error |
75 | | - > { |
76 | | - Future { [weak self] completion in |
77 | | - guard let self = self else { return } |
78 | | - self.requestToken(for: request.buildRefreshTokenURL(refreshToken: refreshToken)) |
79 | | - .sink { (result) in |
80 | | - switch result { |
81 | | - case .failure(let error): |
82 | | - completion(.failure(error)) |
83 | | - default: break |
84 | | - } |
85 | | - } receiveValue: { credential in |
86 | | - credential.save() |
87 | | - completion(.success(credential)) |
88 | | - } |
89 | | - .store(in: &self.cancellables) |
90 | | - } |
| 35 | + public func refresh(with request: Request, refreshToken: String) async throws -> Credential { |
| 36 | + try await requestToken(for: request.buildRefreshTokenURL(refreshToken: refreshToken)) |
91 | 37 | } |
92 | 38 | } |
93 | 39 |
|
94 | 40 | extension OAuth2Client: ASWebAuthenticationPresentationContextProviding { |
95 | | - public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { |
| 41 | + nonisolated public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { |
96 | 42 | .init() |
97 | 43 | } |
98 | 44 | } |
99 | 45 |
|
100 | 46 | extension OAuth2Client { |
101 | | - fileprivate func requestAuth(url: URL, callbackScheme: String) -> Future<String, OAuth2Error> { |
102 | | - Future { [weak self] finalCompletion in |
103 | | - guard let self = self else { return } |
104 | | - Future<URL, OAuth2Error> { completion in |
105 | | - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { |
106 | | - (url, error) in |
107 | | - if let error = error { |
108 | | - completion(.failure(OAuth2Error.authError(error as NSError))) |
109 | | - } else if let url = url { |
110 | | - completion(.success(url)) |
111 | | - } |
| 47 | + fileprivate func requestAuth(url: URL, callbackScheme: String) async throws -> String { |
| 48 | + let url: URL = try await withCheckedThrowingContinuation { continuation in |
| 49 | + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { url, error in |
| 50 | + if let error = error { |
| 51 | + continuation.resume(throwing: OAuth2Error.authError(error as NSError)) |
| 52 | + } else if let url = url { |
| 53 | + continuation.resume(returning: url) |
112 | 54 | } |
113 | | - session.presentationContextProvider = self |
114 | | - session.prefersEphemeralWebBrowserSession = true |
115 | | - session.start() |
116 | | - }.tryMap { url in |
117 | | - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
118 | | - let code = components.queryItems?.first(where: { $0.name == "code" })?.value |
119 | | - else { |
120 | | - throw OAuth2Error.codeNotFound |
121 | | - } |
122 | | - return code |
123 | | - }.sink { (completion) in |
124 | | - switch completion { |
125 | | - case .failure(let error): |
126 | | - finalCompletion(.failure(OAuth2Error.authError(error as NSError))) |
127 | | - default: |
128 | | - break |
129 | | - } |
130 | | - } receiveValue: { code in |
131 | | - finalCompletion(.success(code)) |
132 | 55 | } |
133 | | - .store(in: &self.cancellables) |
| 56 | + session.presentationContextProvider = self |
| 57 | + session.prefersEphemeralWebBrowserSession = true |
| 58 | + session.start() |
| 59 | + } |
| 60 | + |
| 61 | + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| 62 | + let code = components.queryItems?.first(where: { $0.name == "code" })?.value |
| 63 | + else { |
| 64 | + throw OAuth2Error.codeNotFound |
134 | 65 | } |
| 66 | + return code |
135 | 67 | } |
136 | 68 |
|
137 | | - fileprivate func requestToken(for url: URL) -> AnyPublisher<Credential, OAuth2Error> { |
| 69 | + fileprivate func requestToken(for url: URL) async throws -> Credential { |
138 | 70 | var request = URLRequest(url: url) |
139 | 71 | request.httpMethod = "POST" |
140 | 72 | request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") |
141 | 73 | request.addValue("application/json", forHTTPHeaderField: "Accept") |
142 | 74 |
|
143 | | - return URLSession.shared.dataTaskPublisher(for: request) |
144 | | - .tryMap { data, response in |
145 | | - guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode |
146 | | - else { |
147 | | - throw OAuth2Error.urlError(URLError(.badServerResponse)) |
148 | | - } |
149 | | - return data |
150 | | - } |
151 | | - .decode(type: Credential.self, decoder: JSONDecoder.convertFromSnakeCase) |
152 | | - .mapError { OAuth2Error.decodingError($0 as NSError) } |
153 | | - .eraseToAnyPublisher() |
| 75 | + let (data, response) = try await URLSession.shared.data(for: request) |
| 76 | + guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else { |
| 77 | + throw OAuth2Error.urlError(URLError(.badServerResponse)) |
| 78 | + } |
| 79 | + do { |
| 80 | + return try JSONDecoder.convertFromSnakeCase.decode(Credential.self, from: data) |
| 81 | + } catch { |
| 82 | + throw OAuth2Error.decodingError(error as NSError) |
| 83 | + } |
154 | 84 | } |
155 | 85 | } |
0 commit comments