@capgo/capacitor-social-login 7.16.0 → 7.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ import Foundation
2
+ import AuthenticationServices
3
+ import CryptoKit
4
+ import UIKit
5
+
6
+ struct TwitterTokenResponse: Codable {
7
+ let token_type: String
8
+ let expires_in: Int
9
+ let access_token: String
10
+ let scope: String
11
+ let refresh_token: String?
12
+ }
13
+
14
+ struct TwitterUserEnvelope: Codable {
15
+ struct TwitterUserData: Codable {
16
+ let id: String
17
+ let name: String?
18
+ let username: String
19
+ let profile_image_url: String?
20
+ let verified: Bool?
21
+ let email: String?
22
+ }
23
+
24
+ let data: TwitterUserData
25
+ }
26
+
27
+ struct TwitterProfileResponse {
28
+ let accessToken: TwitterAccessToken
29
+ let profile: TwitterUserEnvelope.TwitterUserData
30
+ let scope: [String]
31
+ let expiresIn: Int
32
+ let tokenType: String
33
+ }
34
+
35
+ struct TwitterAccessToken: Codable {
36
+ let token: String
37
+ let expiresIn: Int?
38
+ let refreshToken: String?
39
+ let tokenType: String?
40
+ }
41
+
42
+ struct TwitterStoredTokens: Codable {
43
+ let accessToken: String
44
+ let refreshToken: String?
45
+ let expiresAt: Date
46
+ let scope: [String]
47
+ let tokenType: String
48
+ let userId: String?
49
+ }
50
+
51
+ class TwitterProvider: NSObject {
52
+ private var clientId: String?
53
+ private var redirectUri: String?
54
+ private var defaultScopes = ["tweet.read", "users.read"]
55
+ private var forceLogin = false
56
+ private var audience: String?
57
+
58
+ private var currentSession: ASWebAuthenticationSession?
59
+ private var currentState: String?
60
+ private let tokenStorageKey = "CapgoTwitterProviderTokens"
61
+
62
+ func initialize(clientId: String, redirectUri: String, defaultScopes: [String]?, forceLogin: Bool, audience: String?) {
63
+ self.clientId = clientId
64
+ self.redirectUri = redirectUri
65
+ if let scopes = defaultScopes, !scopes.isEmpty {
66
+ self.defaultScopes = scopes
67
+ }
68
+ self.forceLogin = forceLogin
69
+ self.audience = audience
70
+ }
71
+
72
+ func login(payload: [String: Any], completion: @escaping (Result<TwitterProfileResponse, Error>) -> Void) {
73
+ guard let clientId = clientId, let redirectUri = redirectUri else {
74
+ completion(.failure(NSError(domain: "TwitterProvider", code: -1, userInfo: [NSLocalizedDescriptionKey: "Twitter client not configured. Call initialize()."])))
75
+ return
76
+ }
77
+
78
+ let scopes = payload["scopes"] as? [String] ?? defaultScopes
79
+ let state = payload["state"] as? String ?? UUID().uuidString
80
+ let codeVerifier = payload["codeVerifier"] as? String ?? generateCodeVerifier()
81
+ let forceLoginFlag = payload["forceLogin"] as? Bool ?? forceLogin
82
+ let redirect = payload["redirectUrl"] as? String ?? redirectUri
83
+
84
+ currentState = state
85
+
86
+ var components = URLComponents(string: "https://x.com/i/oauth2/authorize")
87
+ var query: [URLQueryItem] = [
88
+ URLQueryItem(name: "response_type", value: "code"),
89
+ URLQueryItem(name: "client_id", value: clientId),
90
+ URLQueryItem(name: "redirect_uri", value: redirect),
91
+ URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
92
+ URLQueryItem(name: "state", value: state),
93
+ URLQueryItem(name: "code_challenge", value: generateCodeChallenge(from: codeVerifier)),
94
+ URLQueryItem(name: "code_challenge_method", value: "S256")
95
+ ]
96
+ if forceLoginFlag {
97
+ query.append(URLQueryItem(name: "force_login", value: "true"))
98
+ }
99
+ if let audience = audience {
100
+ query.append(URLQueryItem(name: "audience", value: audience))
101
+ }
102
+ components?.queryItems = query
103
+
104
+ guard let authUrl = components?.url, let callbackScheme = URL(string: redirect)?.scheme else {
105
+ completion(.failure(NSError(domain: "TwitterProvider", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid redirect URL configuration."])) )
106
+ return
107
+ }
108
+
109
+ let session = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in
110
+ guard let self = self else { return }
111
+ if let error = error {
112
+ completion(.failure(error))
113
+ return
114
+ }
115
+ guard let callbackURL = callbackURL, let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems else {
116
+ completion(.failure(NSError(domain: "TwitterProvider", code: -3, userInfo: [NSLocalizedDescriptionKey: "Invalid callback URL."])) )
117
+ return
118
+ }
119
+
120
+ let returnedState = queryItems.first(where: { $0.name == "state" })?.value
121
+ if returnedState != self.currentState {
122
+ completion(.failure(NSError(domain: "TwitterProvider", code: -4, userInfo: [NSLocalizedDescriptionKey: "State mismatch detected during Twitter login."])) )
123
+ return
124
+ }
125
+
126
+ if let errorCode = queryItems.first(where: { $0.name == "error" })?.value {
127
+ let errorDescription = queryItems.first(where: { $0.name == "error_description" })?.value ?? errorCode
128
+ completion(.failure(NSError(domain: "TwitterProvider", code: -5, userInfo: [NSLocalizedDescriptionKey: errorDescription])))
129
+ return
130
+ }
131
+
132
+ guard let code = queryItems.first(where: { $0.name == "code" })?.value else {
133
+ completion(.failure(NSError(domain: "TwitterProvider", code: -6, userInfo: [NSLocalizedDescriptionKey: "Authorization code missing."])) )
134
+ return
135
+ }
136
+
137
+ self.exchangeCode(code: code, redirectUri: redirect, codeVerifier: codeVerifier) { exchangeResult in
138
+ completion(exchangeResult)
139
+ }
140
+ }
141
+
142
+ if #available(iOS 13.0, *) {
143
+ session.presentationContextProvider = self
144
+ }
145
+
146
+ session.prefersEphemeralWebBrowserSession = true
147
+ currentSession = session
148
+ session.start()
149
+ }
150
+
151
+ func logout(completion: @escaping (Result<Void, Error>) -> Void) {
152
+ UserDefaults.standard.removeObject(forKey: tokenStorageKey)
153
+ completion(.success(()))
154
+ }
155
+
156
+ func isLoggedIn(completion: @escaping (Result<Bool, Error>) -> Void) {
157
+ if let tokens = loadTokens() {
158
+ completion(.success(tokens.expiresAt > Date()))
159
+ } else {
160
+ completion(.success(false))
161
+ }
162
+ }
163
+
164
+ func getAuthorizationCode(completion: @escaping (Result<TwitterAccessToken, Error>) -> Void) {
165
+ guard let tokens = loadTokens() else {
166
+ completion(.failure(NSError(domain: "TwitterProvider", code: -7, userInfo: [NSLocalizedDescriptionKey: "User not logged in"])))
167
+ return
168
+ }
169
+ completion(.success(TwitterAccessToken(token: tokens.accessToken, expiresIn: Int(tokens.expiresAt.timeIntervalSince(Date())), refreshToken: tokens.refreshToken, tokenType: tokens.tokenType)))
170
+ }
171
+
172
+ func refresh(completion: @escaping (Result<TwitterProfileResponse, Error>) -> Void) {
173
+ guard let tokens = loadTokens(), let refreshToken = tokens.refreshToken else {
174
+ completion(.failure(NSError(domain: "TwitterProvider", code: -8, userInfo: [NSLocalizedDescriptionKey: "No refresh token available. Include offline.access scope."])) )
175
+ return
176
+ }
177
+
178
+ refreshTokens(refreshToken: refreshToken) { result in
179
+ completion(result)
180
+ }
181
+ }
182
+
183
+ private func exchangeCode(code: String, redirectUri: String, codeVerifier: String, completion: @escaping (Result<TwitterProfileResponse, Error>) -> Void) {
184
+ guard let clientId = clientId else {
185
+ completion(.failure(NSError(domain: "TwitterProvider", code: -9, userInfo: [NSLocalizedDescriptionKey: "Missing client id"])))
186
+ return
187
+ }
188
+
189
+ let body = [
190
+ "grant_type": "authorization_code",
191
+ "client_id": clientId,
192
+ "code": code,
193
+ "redirect_uri": redirectUri,
194
+ "code_verifier": codeVerifier
195
+ ]
196
+
197
+ performTokenRequest(body: body) { [weak self] result in
198
+ switch result {
199
+ case .success(let tokenResponse):
200
+ self?.handleTokenSuccess(tokenResponse: tokenResponse, completion: completion)
201
+ case .failure(let error):
202
+ completion(.failure(error))
203
+ }
204
+ }
205
+ }
206
+
207
+ private func refreshTokens(refreshToken: String, completion: @escaping (Result<TwitterProfileResponse, Error>) -> Void) {
208
+ guard let clientId = clientId else {
209
+ completion(.failure(NSError(domain: "TwitterProvider", code: -9, userInfo: [NSLocalizedDescriptionKey: "Missing client id"])))
210
+ return
211
+ }
212
+
213
+ let body = [
214
+ "grant_type": "refresh_token",
215
+ "refresh_token": refreshToken,
216
+ "client_id": clientId
217
+ ]
218
+
219
+ performTokenRequest(body: body) { [weak self] result in
220
+ switch result {
221
+ case .success(let tokenResponse):
222
+ self?.handleTokenSuccess(tokenResponse: tokenResponse, completion: completion)
223
+ case .failure(let error):
224
+ completion(.failure(error))
225
+ }
226
+ }
227
+ }
228
+
229
+ private func performTokenRequest(body: [String: String], completion: @escaping (Result<TwitterTokenResponse, Error>) -> Void) {
230
+ guard let url = URL(string: "https://api.x.com/2/oauth2/token") else {
231
+ completion(.failure(NSError(domain: "TwitterProvider", code: -10, userInfo: [NSLocalizedDescriptionKey: "Invalid token endpoint."])) )
232
+ return
233
+ }
234
+
235
+ var request = URLRequest(url: url)
236
+ request.httpMethod = "POST"
237
+ request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
238
+ request.httpBody = body.percentEncoded()
239
+
240
+ URLSession.shared.dataTask(with: request) { data, response, error in
241
+ if let error = error {
242
+ completion(.failure(error))
243
+ return
244
+ }
245
+ guard let httpResponse = response as? HTTPURLResponse else {
246
+ completion(.failure(NSError(domain: "TwitterProvider", code: -11, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])))
247
+ return
248
+ }
249
+ guard let data = data, httpResponse.statusCode == 200 else {
250
+ let message = String(data: data ?? Data(), encoding: .utf8) ?? "Unknown error"
251
+ completion(.failure(NSError(domain: "TwitterProvider", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: message])))
252
+ return
253
+ }
254
+
255
+ do {
256
+ let decoded = try JSONDecoder().decode(TwitterTokenResponse.self, from: data)
257
+ completion(.success(decoded))
258
+ } catch {
259
+ completion(.failure(error))
260
+ }
261
+ }
262
+ .resume()
263
+ }
264
+
265
+ private func handleTokenSuccess(tokenResponse: TwitterTokenResponse, completion: @escaping (Result<TwitterProfileResponse, Error>) -> Void) {
266
+ fetchProfile(accessToken: tokenResponse.access_token) { [weak self] profileResult in
267
+ switch profileResult {
268
+ case .success(let profile):
269
+ let expiresAt = Date().addingTimeInterval(TimeInterval(tokenResponse.expires_in))
270
+ let scopeArray = tokenResponse.scope.split(separator: " ").map { String($0) }
271
+
272
+ let stored = TwitterStoredTokens(
273
+ accessToken: tokenResponse.access_token,
274
+ refreshToken: tokenResponse.refresh_token,
275
+ expiresAt: expiresAt,
276
+ scope: scopeArray,
277
+ tokenType: tokenResponse.token_type,
278
+ userId: profile.id
279
+ )
280
+ self?.persistTokens(tokens: stored)
281
+
282
+ let response = TwitterProfileResponse(
283
+ accessToken: TwitterAccessToken(token: tokenResponse.access_token, expiresIn: tokenResponse.expires_in, refreshToken: tokenResponse.refresh_token, tokenType: tokenResponse.token_type),
284
+ profile: profile,
285
+ scope: scopeArray,
286
+ expiresIn: tokenResponse.expires_in,
287
+ tokenType: tokenResponse.token_type
288
+ )
289
+ completion(.success(response))
290
+ case .failure(let error):
291
+ completion(.failure(error))
292
+ }
293
+ }
294
+ }
295
+
296
+ private func fetchProfile(accessToken: String, completion: @escaping (Result<TwitterUserEnvelope.TwitterUserData, Error>) -> Void) {
297
+ var components = URLComponents(string: "https://api.x.com/2/users/me")
298
+ components?.queryItems = [URLQueryItem(name: "user.fields", value: "profile_image_url,verified,name,username")]
299
+
300
+ guard let url = components?.url else {
301
+ completion(.failure(NSError(domain: "TwitterProvider", code: -12, userInfo: [NSLocalizedDescriptionKey: "Invalid user endpoint."])) )
302
+ return
303
+ }
304
+
305
+ var request = URLRequest(url: url)
306
+ request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
307
+
308
+ URLSession.shared.dataTask(with: request) { data, response, error in
309
+ if let error = error {
310
+ completion(.failure(error))
311
+ return
312
+ }
313
+ guard let httpResponse = response as? HTTPURLResponse else {
314
+ completion(.failure(NSError(domain: "TwitterProvider", code: -13, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])))
315
+ return
316
+ }
317
+ guard let data = data, httpResponse.statusCode == 200 else {
318
+ let message = String(data: data ?? Data(), encoding: .utf8) ?? "Unknown error"
319
+ completion(.failure(NSError(domain: "TwitterProvider", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: message])))
320
+ return
321
+ }
322
+ do {
323
+ let decoded = try JSONDecoder().decode(TwitterUserEnvelope.self, from: data)
324
+ completion(.success(decoded.data))
325
+ } catch {
326
+ completion(.failure(error))
327
+ }
328
+ }
329
+ .resume()
330
+ }
331
+
332
+ private func persistTokens(tokens: TwitterStoredTokens) {
333
+ do {
334
+ let data = try JSONEncoder().encode(tokens)
335
+ UserDefaults.standard.set(data, forKey: tokenStorageKey)
336
+ } catch {
337
+ print("TwitterProvider persistTokens error: \(error.localizedDescription)")
338
+ }
339
+ }
340
+
341
+ private func loadTokens() -> TwitterStoredTokens? {
342
+ guard let data = UserDefaults.standard.data(forKey: tokenStorageKey) else {
343
+ return nil
344
+ }
345
+ do {
346
+ return try JSONDecoder().decode(TwitterStoredTokens.self, from: data)
347
+ } catch {
348
+ print("TwitterProvider loadTokens error: \(error.localizedDescription)")
349
+ return nil
350
+ }
351
+ }
352
+
353
+ private func generateCodeVerifier() -> String {
354
+ var data = Data(count: 64)
355
+ _ = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 64, $0.baseAddress!) }
356
+ return data.base64EncodedString().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
357
+ }
358
+
359
+ private func generateCodeChallenge(from verifier: String) -> String {
360
+ let data = verifier.data(using: .utf8) ?? Data()
361
+ let digest = SHA256.hash(data: data)
362
+ return Data(digest).base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
363
+ }
364
+ }
365
+
366
+ extension TwitterProvider: ASWebAuthenticationPresentationContextProviding {
367
+ @available(iOS 13.0, *)
368
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
369
+ return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
370
+ }
371
+ }
372
+
373
+ private extension Dictionary where Key == String, Value == String {
374
+ func percentEncoded() -> Data? {
375
+ map { key, value in
376
+ "\(key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key)=\(value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value)"
377
+ }
378
+ .joined(separator: "&")
379
+ .data(using: .utf8)
380
+ }
381
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-social-login",
3
- "version": "7.16.0",
3
+ "version": "7.18.0",
4
4
  "description": "All social logins in one plugin",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",