@capgo/capacitor-social-login 7.16.0 → 7.17.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.
- package/README.md +55 -18
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/ee/forgr/capacitor/social/login/AppleProvider.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java +32 -1
- package/android/src/main/java/ee/forgr/capacitor/social/login/TwitterLoginActivity.java +93 -0
- package/android/src/main/java/ee/forgr/capacitor/social/login/TwitterProvider.java +510 -0
- package/dist/docs.json +184 -8
- package/dist/esm/definitions.d.ts +83 -3
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/google-provider.d.ts +3 -1
- package/dist/esm/google-provider.js +25 -3
- package/dist/esm/google-provider.js.map +1 -1
- package/dist/esm/twitter-provider.d.ts +36 -0
- package/dist/esm/twitter-provider.js +346 -0
- package/dist/esm/twitter-provider.js.map +1 -0
- package/dist/esm/web.d.ts +2 -1
- package/dist/esm/web.js +59 -8
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +428 -11
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +428 -11
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/SocialLoginPlugin/SocialLoginPlugin.swift +93 -1
- package/ios/Sources/SocialLoginPlugin/TwitterProvider.swift +381 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|