diff --git a/Demo App/Twift_SwiftUI/Twift_SwiftUIApp.swift b/Demo App/Twift_SwiftUI/Twift_SwiftUIApp.swift index 33eaaea..589d059 100644 --- a/Demo App/Twift_SwiftUI/Twift_SwiftUIApp.swift +++ b/Demo App/Twift_SwiftUI/Twift_SwiftUIApp.swift @@ -13,6 +13,7 @@ extension Twift { switch authenticationType { case .appOnly(_): return false case .userAccessTokens(_, _): return true + case .oauth2UserContext(_): return true } } } @@ -43,20 +44,26 @@ struct Twift_SwiftUIApp: App { header: Text("User Access Tokens"), footer: Text("Use this authentication method for most cases.") ) { - Button { - Twift.Authentication().requestUserCredentials(clientCredentials: clientCredentials, callbackURL: URL(string: TWITTER_CALLBACK_URL)!) { (userCredentials, error) in - if let error = error { - print(error.localizedDescription) - } - - if let creds = userCredentials { - DispatchQueue.main.async { - container.client = Twift(.userAccessTokens(clientCredentials: clientCredentials, userCredentials: creds)) - } - } - } +// Button { +// Twift.Authentication().requestUserCredentials(clientCredentials: clientCredentials, callbackURL: URL(string: TWITTER_CALLBACK_URL)!) { (userCredentials, error) in +// if let error = error { +// print(error.localizedDescription) +// } +// +// if let creds = userCredentials { +// DispatchQueue.main.async { +// container.client = Twift(.userAccessTokens(clientCredentials: clientCredentials, userCredentials: creds)) +// } +// } +// } +// } label: { +// Text("Sign In With Twitter") +// } + + AsyncButton { + let result = await Twift.Authentication().authorizeUser(clientId: "Sm5PSUhRNW9EZ3NXb0tJQkI5WU06MTpjaQ", redirectUri: URL(string: TWITTER_CALLBACK_URL)!, scope: [.offlineAccess, .usersRead, .tweetRead]) } label: { - Text("Sign In With Twitter") + Text("Sign in") } } diff --git a/Sources/Twift+API.swift b/Sources/Twift+API.swift index 8cefdf0..cc02107 100644 --- a/Sources/Twift+API.swift +++ b/Sources/Twift+API.swift @@ -70,6 +70,8 @@ extension Twift { consumerCredentials: clientCredentials, userCredentials: userCredentials ) + case .oauth2UserContext(let oauthUser): + request.addValue("Bearer \(oauthUser.accessToken)", forHTTPHeaderField: "Authorization") } } } diff --git a/Sources/Twift+Authentication.swift b/Sources/Twift+Authentication.swift index 1cf52f4..7a14919 100644 --- a/Sources/Twift+Authentication.swift +++ b/Sources/Twift+Authentication.swift @@ -14,6 +14,12 @@ extension Twift { case userAccessTokens(clientCredentials: OAuthCredentials, userCredentials: OAuthCredentials) + + /// OAuth 2.0 User Context authentication. + /// + /// When this authentication method is used, the `oauth2User` access token may be automatically refreshed by the client if it has expired. + case oauth2UserContext(oauth2User: OAuth2User) + /// App-only authentication case appOnly(bearerToken: String) } @@ -23,6 +29,9 @@ extension Twift { /// A value representing ``AuthenticationType.userAccessTokens(_, _)`` case userAccessTokens + /// A value representing ``AuthenticationType.oauth2UserContext(_)`` + case oauth2UserContext + /// A value representing ``AuthenticationType.appOnly(_)`` case appOnly } @@ -123,3 +132,168 @@ extension Twift { } } } + +extension Twift.Authentication { + public func authorizeUser(clientId: String, + redirectUri: URL, + scope: Set, + presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil + ) async -> (OAuth2User?, Error?) { + let state = UUID().uuidString + + let authUrlQueryItems: [URLQueryItem] = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "redirect_uri", value: redirectUri.absoluteString), + URLQueryItem(name: "scope", value: scope.map(\.rawValue).joined(separator: " ")), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: "challenge"), + URLQueryItem(name: "code_challenge_method", value: "plain"), + ] + + var authUrl = URLComponents() + authUrl.scheme = "https" + authUrl.host = "twitter.com" + authUrl.path = "/i/oauth2/authorize" + authUrl.queryItems = authUrlQueryItems + + let (returnedUrl, error): (URL?, Error?) = await withCheckedContinuation { continuation in + guard let authUrl = authUrl.url else { + return continuation.resume(returning: (nil, TwiftError.UnknownError(nil))) + } + + let authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: redirectUri.scheme) { (url, error) in + return continuation.resume(returning: (url, error)) + } + + authSession.presentationContextProvider = presentationContextProvider ?? self + authSession.start() + } + + if let error = error { + print(error.localizedDescription) + return (nil, error) + } + + guard let returnedUrl = returnedUrl else { + return (nil, TwiftError.UnknownError("No returned OAuth URL")) + } + + let returnedUrlComponents = URLComponents(string: returnedUrl.absoluteString) + + let returnedState = returnedUrlComponents?.queryItems?.first(where: { $0.name == "state" })?.value + guard let returnedState = returnedState, + returnedState == state else { + return (nil, TwiftError.UnknownError("Bad state returned from OAuth flow")) + } + + let returnedCode = returnedUrlComponents?.queryItems?.first(where: { $0.name == "code" })?.value + guard let returnedCode = returnedCode else { + return (nil, TwiftError.UnknownError("No code returned")) + } + + var codeRequest = URLRequest(url: URL(string: "https://api.twitter.com/2/oauth2/token")!) + let body = [ + "code": returnedCode, + "grant_type": "authorization_code", + "client_id": clientId, + "redirect_uri": redirectUri.absoluteString, + "code_verifier": "challenge" + ] + + let encodedBody = try? JSONSerialization.data(withJSONObject: body) + + codeRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + codeRequest.httpMethod = "POST" + codeRequest.httpBody = encodedBody + + do { + let (data, _) = try await URLSession.shared.data(for: codeRequest) + + print(String(data: data, encoding: .utf8)) + } catch { + print(error.localizedDescription) + } + + return (nil, nil) + } +} + +public struct OAuth2User { + public var clientId: String + public var userId: String? + public var accessToken: String + public var refreshToken: String? + public var expiresAt: Date + public var scope: [OAuth2Scope] + + public var expired: Bool { + expiresAt < .now + } +} + +public enum OAuth2Scope: String, CaseIterable, RawRepresentable { + /// All the Tweets you can view, including Tweets from protected accounts. + case tweetRead = "tweet.read" + + /// Tweet and Retweet for you. + case tweetWrite = "tweet.write" + + /// Hide and unhide replies to your Tweets. + case tweetModerateWrite = "tweet.moderate.write" + + /// Any account you can view, including protected accounts. + case usersRead = "users.read" + + /// People who follow you and people who you follow. + case followsRead = "follows.read" + + /// Follow and unfollow people for you. + case followsWrite = "follows.write" + + /// Stay connected to your account until you revoke access. + case offlineAccess = "offline.access" + + /// All the Spaces you can view. + case spaceRead = "space.read" + + /// Accounts you’ve muted. + case muteRead = "mute.read" + + /// Mute and unmute accounts for you. + case muteWrite = "mute.write" + + /// Tweets you’ve liked and likes you can view. + case likeRead = "like.read" + + /// Like and un-like Tweets for you. + case likeWrite = "like.write" + + /// Lists, list members, and list followers of lists you’ve created or are a member of, including private lists. + case listRead = "list.read" + + /// Create and manage Lists for you. + case listWrite = "list.write" + + /// Accounts you’ve blocked. + case blockRead = "block.read" + + /// Block and unblock accounts for you. + case blockWrite = "block.write" + + /// Get Bookmarked Tweets from an authenticated user. + case bookmarkRead = "bookmark.read" + + /// Bookmark and remove Bookmarks from Tweets + case bookmarkWrite = "bookmark.write" + + /// All write-permission scopes. + static var allWriteScopes: Set { + [.likeWrite, .listWrite, .muteWrite, .blockWrite, .tweetWrite, .followsWrite, .bookmarkWrite, .tweetModerateWrite] + } + + /// All read-permission scopes. + static var allReadScopes: Set { + [.likeRead, .listRead, .muteRead, .blockRead, .spaceRead, .tweetRead, .usersRead, .followsRead, .bookmarkRead] + } +} diff --git a/Sources/Twift.swift b/Sources/Twift.swift index cfa2dd3..6d4ac3a 100644 --- a/Sources/Twift.swift +++ b/Sources/Twift.swift @@ -25,6 +25,8 @@ public class Twift: NSObject, ObservableObject { return userCredentials.userId case .appOnly(_): return nil + case .oauth2UserContext(let user): + return user.userId } }