Files
Nearfield_Friends/Proximity/Services/Auth/XProfileProvider.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: "")
}
}