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) 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: "") } }