initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
128
Proximity/Services/Auth/AppleAuthProvider.swift
Normal file
128
Proximity/Services/Auth/AppleAuthProvider.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import CryptoKit
|
||||
|
||||
/// Apple "Sign in with Apple" — the primary, privacy-respecting auth path.
|
||||
///
|
||||
/// Sign in with Apple is ideal for Proximity because it is designed around
|
||||
/// privacy: users can hide their real email (relay), and we get a stable
|
||||
/// opaque user identifier without a social graph. This keeps *account*
|
||||
/// identity cleanly separated from any *revealed* social identity.
|
||||
final class AppleAuthProvider: NSObject, AuthProvider, ASAuthorizationControllerDelegate {
|
||||
|
||||
var providerID: AuthProviderID { .apple }
|
||||
|
||||
private var continuation: CheckedContinuation<AuthResult, Error>?
|
||||
private var currentNonce: String?
|
||||
|
||||
// MARK: - AuthProvider
|
||||
|
||||
func signIn() async throws -> AuthResult {
|
||||
let nonce = randomNonceString()
|
||||
currentNonce = nonce
|
||||
|
||||
let request = ASAuthorizationAppleIDProvider().createRequest()
|
||||
request.requestedScopes = [.fullName, .email]
|
||||
request.nonce = sha256(nonce)
|
||||
|
||||
let controller = ASAuthorizationController(authorizationRequests: [request])
|
||||
controller.delegate = self
|
||||
controller.presentationContextProvider = self
|
||||
controller.performRequests()
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
// Sign in with Apple has no server-side session to revoke here;
|
||||
// our backend session is revoked separately.
|
||||
}
|
||||
|
||||
// MARK: - ASAuthorizationControllerDelegate
|
||||
|
||||
func authorizationController(controller: ASAuthorizationController,
|
||||
didCompleteWithAuthorization authorization: ASAuthorization) {
|
||||
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
|
||||
let idTokenData = credential.identityToken,
|
||||
let idToken = String(data: idTokenData, encoding: .utf8) else {
|
||||
continuation?.resume(throwing: AuthError.invalidCredential)
|
||||
continuation = nil
|
||||
return
|
||||
}
|
||||
|
||||
let profile = ProviderProfile(
|
||||
name: credential.fullName?.formatted(),
|
||||
email: credential.email,
|
||||
givenName: credential.fullName?.givenName,
|
||||
familyName: credential.fullName?.familyName
|
||||
)
|
||||
|
||||
continuation?.resume(returning: AuthResult(
|
||||
providerID: .apple,
|
||||
idToken: idToken,
|
||||
nonce: currentNonce,
|
||||
profile: profile
|
||||
))
|
||||
continuation = nil
|
||||
}
|
||||
|
||||
func authorizationController(controller: ASAuthorizationController,
|
||||
didCompleteWithError error: Error) {
|
||||
continuation?.resume(throwing: error)
|
||||
continuation = nil
|
||||
}
|
||||
|
||||
// MARK: - Nonce helpers
|
||||
|
||||
private func randomNonceString(length: Int = 32) -> String {
|
||||
precondition(length > 0)
|
||||
let charset: [Character] =
|
||||
Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
|
||||
var result = ""
|
||||
var remaining = length
|
||||
while remaining > 0 {
|
||||
let randoms: [UInt8] = (0..<16).map { _ in
|
||||
var r: UInt8 = 0
|
||||
SecRandomCopyBytes(kSecRandomDefault, 1, &r)
|
||||
return r
|
||||
}
|
||||
randoms.forEach { random in
|
||||
if remaining == 0 { return }
|
||||
if random < charset.count {
|
||||
result.append(charset[Int(random)])
|
||||
remaining -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func sha256(_ input: String) -> String {
|
||||
let hashed = SHA256.hash(data: Data(input.utf8))
|
||||
return hashed.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors thrown by the auth layer.
|
||||
enum AuthError: Error, LocalizedError {
|
||||
case invalidCredential
|
||||
case providerUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidCredential: return "The sign-in credential was invalid."
|
||||
case .providerUnavailable: return "This sign-in option is unavailable."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presentation context provider
|
||||
|
||||
extension AppleAuthProvider: ASAuthorizationControllerPresentationContextProviding {
|
||||
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||
// In production, return the active window scene.
|
||||
ASPresentationAnchor()
|
||||
}
|
||||
}
|
||||
46
Proximity/Services/Auth/AuthProvider.swift
Normal file
46
Proximity/Services/Auth/AuthProvider.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// A provider that can authenticate a user and return an identity token
|
||||
/// that our backend exchanges for a Proximity session.
|
||||
protocol AuthProvider {
|
||||
var providerID: AuthProviderID { get }
|
||||
/// Begin the sign-in flow and return an ID token + raw profile claims.
|
||||
func signIn() async throws -> AuthResult
|
||||
/// Sign out locally.
|
||||
func signOut() async throws
|
||||
}
|
||||
|
||||
/// Supported authentication providers.
|
||||
enum AuthProviderID: String, Codable, CaseIterable {
|
||||
case apple
|
||||
case google
|
||||
// case x // X (Twitter) OAuth — see note in AuthService
|
||||
// case instagram // Instagram is NOT an OAuth provider for login
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .apple: return "Apple"
|
||||
case .google: return "Google"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a successful provider sign-in.
|
||||
struct AuthResult {
|
||||
let providerID: AuthProviderID
|
||||
/// Provider-issued ID token (JWT) — sent to our backend for verification.
|
||||
let idToken: String
|
||||
/// Nonce used to prevent replay (Apple requires it).
|
||||
let nonce: String?
|
||||
/// Raw profile claims the provider returned (name, email, etc.).
|
||||
let profile: ProviderProfile
|
||||
}
|
||||
|
||||
/// Raw profile claims returned by a provider at sign-in time.
|
||||
struct ProviderProfile {
|
||||
var name: String?
|
||||
var email: String?
|
||||
var givenName: String?
|
||||
var familyName: String?
|
||||
var pictureURL: URL?
|
||||
}
|
||||
102
Proximity/Services/Auth/AuthService.swift
Normal file
102
Proximity/Services/Auth/AuthService.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Orchestrates authentication and owns the authenticated session.
|
||||
///
|
||||
/// **Philosophy:** Auth establishes *account* identity (who you are to the
|
||||
/// app) — it never, by itself, reveals anything to other users. Social
|
||||
/// identity is a separate, opt-in concern handled by `ProfileImportService`
|
||||
/// at reveal time. This separation is what keeps the "anonymous until mutual
|
||||
/// consent" promise intact.
|
||||
@MainActor
|
||||
final class AuthService: ObservableObject {
|
||||
|
||||
@Published private(set) var state: AuthState = .signedOut
|
||||
@Published private(set) var account: Account?
|
||||
|
||||
private let providers: [AuthProviderID: AuthProvider]
|
||||
private let apiClient: APIClient
|
||||
private let keychain: KeychainStore
|
||||
|
||||
init(
|
||||
apiClient: APIClient,
|
||||
keychain: KeychainStore = KeychainStore(),
|
||||
providers: [AuthProviderID: AuthProvider] = [
|
||||
.apple: AppleAuthProvider(),
|
||||
.google: GoogleAuthProvider()
|
||||
]
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.keychain = keychain
|
||||
self.providers = providers
|
||||
}
|
||||
|
||||
/// Restore a previously authenticated session from the keychain.
|
||||
func restoreSession() async {
|
||||
guard let session = keychain.loadSession() else { return }
|
||||
state = .authenticated(session)
|
||||
account = session.account
|
||||
}
|
||||
|
||||
/// Sign in with a given provider.
|
||||
func signIn(with providerID: AuthProviderID) async throws {
|
||||
guard let provider = providers[providerID] else {
|
||||
throw AuthError.providerUnavailable
|
||||
}
|
||||
|
||||
state = .authenticating
|
||||
do {
|
||||
let result = try await provider.signIn()
|
||||
|
||||
// Exchange the provider ID token for a Proximity session token.
|
||||
let session = try await apiClient.exchangeToken(result)
|
||||
|
||||
keychain.save(session)
|
||||
state = .authenticated(session)
|
||||
account = session.account
|
||||
} catch {
|
||||
state = .signedOut
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out locally and revoke the backend session.
|
||||
func signOut() async {
|
||||
if case let .authenticated(session) = state {
|
||||
await apiClient.revokeSession(session)
|
||||
}
|
||||
keychain.clearSession()
|
||||
state = .signedOut
|
||||
account = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's authenticated account (account identity only).
|
||||
struct Account: Codable, Hashable {
|
||||
let id: String
|
||||
let providerID: AuthProviderID
|
||||
let displayName: String
|
||||
let email: String?
|
||||
}
|
||||
|
||||
/// The session state machine.
|
||||
enum AuthState: Equatable {
|
||||
case signedOut
|
||||
case authenticating
|
||||
case authenticated(Session)
|
||||
|
||||
static func == (lhs: AuthState, rhs: AuthState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.signedOut, .signedOut): return true
|
||||
case (.authenticating, .authenticating): return true
|
||||
case (.authenticated(let a), .authenticated(let b)): return a.token == b.token
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A verified Proximity session.
|
||||
struct Session: Codable {
|
||||
let token: String
|
||||
let account: Account
|
||||
}
|
||||
73
Proximity/Services/Auth/GoogleAuthProvider.swift
Normal file
73
Proximity/Services/Auth/GoogleAuthProvider.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
/// Google OAuth (via ASWebAuthenticationSession) — an alternative account
|
||||
/// identity path. Google is a true OAuth provider, unlike Instagram/X which
|
||||
/// are not suitable for login (see AuthService notes).
|
||||
final class GoogleAuthProvider: NSObject, AuthProvider {
|
||||
|
||||
var providerID: AuthProviderID { .google }
|
||||
|
||||
private let clientID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com"
|
||||
private let redirectURI = "com.proximity.app:/oauth2redirect"
|
||||
|
||||
func signIn() async throws -> AuthResult {
|
||||
// Build the Google OAuth2 authorization URL.
|
||||
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: "openid email profile"),
|
||||
URLQueryItem(name: "nonce", value: UUID().uuidString)
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
throw AuthError.invalidCredential
|
||||
}
|
||||
|
||||
// Present the ASWebAuthenticationSession and await the callback.
|
||||
let callbackURL = 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: AuthError.invalidCredential)
|
||||
}
|
||||
}
|
||||
session.presentationContextProvider = self
|
||||
session.start()
|
||||
}
|
||||
|
||||
// Extract the authorization code from the callback.
|
||||
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
throw AuthError.invalidCredential
|
||||
}
|
||||
|
||||
// In production: exchange `code` for an ID token at our backend,
|
||||
// which verifies it with Google. Here we pass the code through.
|
||||
return AuthResult(
|
||||
providerID: .google,
|
||||
idToken: code,
|
||||
nonce: nil,
|
||||
profile: ProviderProfile()
|
||||
)
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
// Revoke handled by backend.
|
||||
}
|
||||
}
|
||||
|
||||
extension GoogleAuthProvider: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
ASPresentationAnchor()
|
||||
}
|
||||
}
|
||||
111
Proximity/Services/Auth/InstagramProfileProvider.swift
Normal file
111
Proximity/Services/Auth/InstagramProfileProvider.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
/// Imports a user's Instagram profile at reveal time.
|
||||
///
|
||||
/// **Important platform reality:** Instagram's Graph API does **not** expose a
|
||||
/// general "get my profile" endpoint for arbitrary third-party apps the way
|
||||
/// X does. Instagram is primarily a *login* provider (Instagram Login), and
|
||||
/// profile data access is restricted. In practice, an Instagram import here
|
||||
/// would either:
|
||||
/// 1. Use Instagram Login to confirm identity + fetch basic profile fields
|
||||
/// that Meta exposes to approved apps, or
|
||||
/// 2. Rely on the user pasting their handle (verified by presence).
|
||||
///
|
||||
/// This provider scaffolds the OAuth flow and fetches what's available; the
|
||||
/// exact fields depend on Meta's approval and API tier.
|
||||
final class InstagramProfileProvider: NSObject, ProfileProvider {
|
||||
|
||||
var providerID: ProfileProviderID { .instagram }
|
||||
|
||||
private let clientID = "YOUR_INSTAGRAM_APP_ID"
|
||||
private let redirectURI = "com.proximity.app:/instagram"
|
||||
|
||||
func fetchProfile() async throws -> ImportedProfile {
|
||||
// 1. OAuth authorization via ASWebAuthenticationSession.
|
||||
var components = URLComponents(string: "https://api.instagram.com/oauth/authorize")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||
URLQueryItem(name: "scope", value: "user_profile"),
|
||||
URLQueryItem(name: "response_type", value: "code")
|
||||
]
|
||||
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 the code for a short-lived token (production: via backend).
|
||||
let token = try await exchangeCode(code)
|
||||
|
||||
// 3. Fetch the profile.
|
||||
// Instagram's Graph API: GET /me?fields=username,full_name
|
||||
var profileComponents = URLComponents(string: "https://graph.instagram.com/me")!
|
||||
profileComponents.queryItems = [
|
||||
URLQueryItem(name: "fields", value: "username,full_name"),
|
||||
URLQueryItem(name: "access_token", value: token)
|
||||
]
|
||||
guard let profileURL = profileComponents.url else {
|
||||
throw ProfileImportError.noProfile
|
||||
}
|
||||
|
||||
struct IGResponse: Decodable {
|
||||
let username: String?
|
||||
let full_name: String?
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: profileURL)
|
||||
let response = try JSONDecoder().decode(IGResponse.self, from: data)
|
||||
guard let username = response.username else {
|
||||
throw ProfileImportError.noProfile
|
||||
}
|
||||
|
||||
return ImportedProfile(
|
||||
providerID: .instagram,
|
||||
handle: username,
|
||||
displayName: response.full_name
|
||||
)
|
||||
}
|
||||
|
||||
// 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) async throws -> String {
|
||||
// In production this must happen server-side to keep the app secret
|
||||
// private. Returns a short-lived access token.
|
||||
return "short-lived-token"
|
||||
}
|
||||
}
|
||||
|
||||
extension InstagramProfileProvider: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
ASPresentationAnchor()
|
||||
}
|
||||
}
|
||||
47
Proximity/Services/Auth/KeychainStore.swift
Normal file
47
Proximity/Services/Auth/KeychainStore.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Secure persistence for the session token using the iOS Keychain.
|
||||
/// Never stores the session in UserDefaults or plain files.
|
||||
struct KeychainStore {
|
||||
|
||||
private let service = "com.proximity.app.session"
|
||||
|
||||
func save(_ session: Session) {
|
||||
let data = (try? JSONEncoder().encode(session)) ?? Data()
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: "session"
|
||||
]
|
||||
// Delete existing, then add.
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var add = query
|
||||
add[kSecValueData as String] = data
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func loadSession() -> Session? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: "session",
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data else { return nil }
|
||||
return try? JSONDecoder().decode(Session.self, from: data)
|
||||
}
|
||||
|
||||
func clearSession() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: "session"
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
116
Proximity/Services/Auth/ProfileImportService.swift
Normal file
116
Proximity/Services/Auth/ProfileImportService.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Imports a user's **social profile** (Instagram, X) to enrich a revealed
|
||||
/// connection.
|
||||
///
|
||||
/// **Critical privacy boundary:** This service is *never* used to build the
|
||||
/// user's own discoverable account. It is only invoked at **reveal time**,
|
||||
/// after a mutual encounter, when the user explicitly chooses to share a
|
||||
/// richer identity with someone they've actually met.
|
||||
///
|
||||
/// Two separate identities exist in Proximity:
|
||||
/// - **Account identity** (AuthService): who you are *to the app* — private.
|
||||
/// - **Social identity** (this service): what you *choose to reveal* — opt-in.
|
||||
///
|
||||
/// Importing a profile is like handing someone your business card after
|
||||
/// meeting them — not broadcasting it to the world.
|
||||
@MainActor
|
||||
final class ProfileImportService: ObservableObject {
|
||||
|
||||
@Published private(set) var importedProfiles: [ImportedProfile] = []
|
||||
|
||||
private let providers: [ProfileProviderID: ProfileProvider]
|
||||
|
||||
init(providers: [ProfileProviderID: ProfileProvider] = [
|
||||
.instagram: InstagramProfileProvider(),
|
||||
.x: XProfileProvider()
|
||||
]) {
|
||||
self.providers = providers
|
||||
}
|
||||
|
||||
/// Import a profile from a provider. Called only at reveal time.
|
||||
func importProfile(from providerID: ProfileProviderID) async throws -> ImportedProfile {
|
||||
guard let provider = providers[providerID] else {
|
||||
throw ProfileImportError.providerUnavailable
|
||||
}
|
||||
let profile = try await provider.fetchProfile()
|
||||
importedProfiles.append(profile)
|
||||
return profile
|
||||
}
|
||||
|
||||
/// Attach an imported profile to a revealed connection.
|
||||
/// This is the *only* place a social identity can be linked to a person.
|
||||
func attach(_ profile: ImportedProfile, to connectionID: UUID) {
|
||||
// In production: persist the link locally, E2E-encrypted.
|
||||
// The server never stores the raw social profile by default.
|
||||
}
|
||||
|
||||
/// Remove an imported profile (user revokes a shared identity).
|
||||
func remove(_ profile: ImportedProfile) {
|
||||
importedProfiles.removeAll { $0.id == profile.id }
|
||||
}
|
||||
}
|
||||
|
||||
/// A social profile imported at reveal time.
|
||||
struct ImportedProfile: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let providerID: ProfileProviderID
|
||||
let handle: String
|
||||
let displayName: String?
|
||||
let bio: String?
|
||||
let avatarURL: URL?
|
||||
let importedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
providerID: ProfileProviderID,
|
||||
handle: String,
|
||||
displayName: String? = nil,
|
||||
bio: String? = nil,
|
||||
avatarURL: URL? = nil,
|
||||
importedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.providerID = providerID
|
||||
self.handle = handle
|
||||
self.displayName = displayName
|
||||
self.bio = bio
|
||||
self.avatarURL = avatarURL
|
||||
self.importedAt = importedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Providers that can supply a social profile.
|
||||
enum ProfileProviderID: String, Codable, CaseIterable {
|
||||
case instagram
|
||||
case x
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .instagram: return "Instagram"
|
||||
case .x: return "X"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from profile import.
|
||||
enum ProfileImportError: Error, LocalizedError {
|
||||
case providerUnavailable
|
||||
case authorizationFailed
|
||||
case noProfile
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .providerUnavailable: return "This profile provider is unavailable."
|
||||
case .authorizationFailed: return "Authorization with the provider failed."
|
||||
case .noProfile: return "No profile was returned."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider capable of fetching a user's own social profile.
|
||||
protocol ProfileProvider {
|
||||
var providerID: ProfileProviderID { get }
|
||||
func fetchProfile() async throws -> ImportedProfile
|
||||
}
|
||||
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