initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
141
Proximity/Services/Auth/XProfileProvider.swift
Normal file
141
Proximity/Services/Auth/XProfileProvider.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
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: "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user