initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project

This commit is contained in:
2026-08-12 00:22:01 +00:00
commit 9d1b0f8dd9
52 changed files with 6271 additions and 0 deletions

View 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()
}
}

View 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?
}

View 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
}

View 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()
}
}

View 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()
}
}

View 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)
}
}

View 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
}

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