142 lines
5.3 KiB
Swift
142 lines
5.3 KiB
Swift
import Foundation
|
|
import AuthenticationServices
|
|
|
|
/// Imports a user's X (Twitter) profile at reveal time.
|
|
///
|
|
/// X's API (v2) exposes a clean `GET /2/users/me` endpoint that returns the
|
|
/// authenticated user's handle, name, and bio — well-suited for enriching a
|
|
/// revealed connection. The OAuth 2.0 PKCE flow is used, and the token
|
|
/// exchange must happen server-side in production to protect the app secret.
|
|
final class XProfileProvider: NSObject, ProfileProvider {
|
|
|
|
var providerID: ProfileProviderID { .x }
|
|
|
|
private let clientID = "YOUR_X_CLIENT_ID"
|
|
private let redirectURI = "com.proximity.app:/x"
|
|
|
|
func fetchProfile() async throws -> ImportedProfile {
|
|
// 1. OAuth 2.0 PKCE authorization.
|
|
let verifier = generateCodeVerifier()
|
|
let challenge = generateCodeChallenge(verifier)
|
|
|
|
var components = URLComponents(string: "https://twitter.com/i/oauth2/authorize")!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "response_type", value: "code"),
|
|
URLQueryItem(name: "client_id", value: clientID),
|
|
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
URLQueryItem(name: "scope", value: "users.read tweet.read"),
|
|
URLQueryItem(name: "state", value: UUID().uuidString),
|
|
URLQueryItem(name: "code_challenge", value: challenge),
|
|
URLQueryItem(name: "code_challenge_method", value: "S256")
|
|
]
|
|
guard let url = components.url else {
|
|
throw ProfileImportError.authorizationFailed
|
|
}
|
|
|
|
let callback = try await presentAuthSession(url: url)
|
|
guard let code = callback.queryItems?.first(where: { $0.name == "code" })?.value else {
|
|
throw ProfileImportError.authorizationFailed
|
|
}
|
|
|
|
// 2. Exchange code for access token (production: server-side).
|
|
let token = try await exchangeCode(code, verifier: verifier)
|
|
|
|
// 3. Fetch the authenticated user's profile.
|
|
var profileComponents = URLComponents(string: "https://api.twitter.com/2/users/me")!
|
|
profileComponents.queryItems = [
|
|
URLQueryItem(name: "user.fields", value: "name,username,description,profile_image_url")
|
|
]
|
|
var request = URLRequest(url: profileComponents.url!)
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
|
|
let (data, _) = try await URLSession.shared.data(for: request)
|
|
|
|
struct XResponse: Decodable {
|
|
struct User: Decodable {
|
|
let username: String
|
|
let name: String?
|
|
let description: String?
|
|
let profile_image_url: String?
|
|
}
|
|
let data: User
|
|
}
|
|
|
|
let response = try JSONDecoder().decode(XResponse.self, from: data)
|
|
let user = response.data
|
|
|
|
return ImportedProfile(
|
|
providerID: .x,
|
|
handle: user.username,
|
|
displayName: user.name,
|
|
bio: user.description,
|
|
avatarURL: user.profile_image_url.flatMap(URL.init(string:))
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func presentAuthSession(url: URL) async throws -> URLComponents {
|
|
let callback = try await withCheckedThrowingContinuation {
|
|
(continuation: CheckedContinuation<URL, Error>) in
|
|
let session = ASWebAuthenticationSession(
|
|
url: url,
|
|
callbackURLScheme: "com.proximity.app"
|
|
) { callback, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let callback {
|
|
continuation.resume(returning: callback)
|
|
} else {
|
|
continuation.resume(throwing: ProfileImportError.authorizationFailed)
|
|
}
|
|
}
|
|
session.presentationContextProvider = self
|
|
session.start()
|
|
}
|
|
guard let components = URLComponents(url: callback, resolvingAgainstBaseURL: false) else {
|
|
throw ProfileImportError.authorizationFailed
|
|
}
|
|
return components
|
|
}
|
|
|
|
private func exchangeCode(_ code: String, verifier: String) async throws -> String {
|
|
// Production: POST to backend which exchanges code+verifier for a
|
|
// bearer token using the client secret. Returns short-lived token.
|
|
return "bearer-token"
|
|
}
|
|
|
|
// MARK: - PKCE
|
|
|
|
private func generateCodeVerifier() -> String {
|
|
var bytes = [UInt8](repeating: 0, count: 64)
|
|
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
return Data(bytes).base64URLEncodedString()
|
|
}
|
|
|
|
private func generateCodeChallenge(_ verifier: String) -> String {
|
|
let data = Data(verifier.utf8)
|
|
let digest = SHA256.hash(data: data)
|
|
return Data(digest).base64URLEncodedString()
|
|
}
|
|
}
|
|
|
|
extension XProfileProvider: ASWebAuthenticationPresentationContextProviding {
|
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
ASPresentationAnchor()
|
|
}
|
|
}
|
|
|
|
// MARK: - SHA256 + base64url helpers
|
|
|
|
import CryptoKit
|
|
|
|
extension Data {
|
|
/// Base64url (RFC 4648 §5) — no padding, URL-safe alphabet.
|
|
func base64URLEncodedString() -> String {
|
|
base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
}
|