Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite
Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation: KMP Architecture: - Fix subscription purchase/restore response contract (VerificationResponse aligned) - Add feature benefits auth token + APILayer init flow - Remove ResidenceFormScreen direct API bypass (use APILayer) - Wire paywall purchase/restore to real SubscriptionApi calls iOS Platform: - Add iOS Keychain token storage via Swift KeychainHelper - Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager) - DocumentViewModelWrapper observes DataManager for auto-updates - Add missing accessibility identifiers (document, task columns, Google Sign-In) XCUITest Rewrite: - Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups - Create AuthCriticalPathTests and NavigationCriticalPathTests - Delete 14 legacy brittle test files (Suite0-10, templates) - Fix CaseraTests module import (@testable import Casera) All platforms build clean. TEST BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
171
iosApp/iosApp/Login/GoogleSignInManager.swift
Normal file
171
iosApp/iosApp/Login/GoogleSignInManager.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import ComposeApp
|
||||
|
||||
/// Handles Google OAuth flow using ASWebAuthenticationSession.
|
||||
/// Obtains a Google ID token, then sends it to the backend via APILayer.
|
||||
@MainActor
|
||||
final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding {
|
||||
static let shared = GoogleSignInManager()
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// Called on successful sign-in with (isVerified: Bool)
|
||||
var onSignInSuccess: ((Bool) -> Void)?
|
||||
|
||||
private var webAuthSession: ASWebAuthenticationSession?
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func signIn() {
|
||||
guard !isLoading else { return }
|
||||
|
||||
let clientId = ApiConfig.shared.GOOGLE_WEB_CLIENT_ID
|
||||
guard ApiConfig.shared.isGoogleSignInConfigured else {
|
||||
errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required."
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
// Build Google OAuth URL
|
||||
let redirectScheme = "com.tt.casera"
|
||||
let redirectURI = "\(redirectScheme):/oauth2callback"
|
||||
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: "access_type", value: "offline"),
|
||||
URLQueryItem(name: "prompt", value: "select_account"),
|
||||
]
|
||||
|
||||
guard let authURL = components.url else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to build authentication URL"
|
||||
return
|
||||
}
|
||||
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: redirectScheme
|
||||
) { [weak self] callbackURL, error in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
|
||||
if let error {
|
||||
self.isLoading = false
|
||||
// Don't show error for user cancellation
|
||||
if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
||||
self.errorMessage = "Sign in failed: \(error.localizedDescription)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let callbackURL,
|
||||
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to get authorization code from Google"
|
||||
return
|
||||
}
|
||||
|
||||
await self.exchangeCodeForToken(code: code, redirectURI: redirectURI, clientId: clientId)
|
||||
}
|
||||
}
|
||||
|
||||
session.presentationContextProvider = self
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
webAuthSession = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
// Return the key window for presentation
|
||||
let scenes = UIApplication.shared.connectedScenes
|
||||
let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
|
||||
return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Exchange authorization code for ID token via Google's token endpoint
|
||||
private func exchangeCodeForToken(code: String, redirectURI: String, clientId: String) async {
|
||||
let tokenURL = URL(string: "https://oauth2.googleapis.com/token")!
|
||||
var request = URLRequest(url: tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = [
|
||||
"code": code,
|
||||
"client_id": clientId,
|
||||
"redirect_uri": redirectURI,
|
||||
"grant_type": "authorization_code",
|
||||
]
|
||||
request.httpBody = body
|
||||
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
|
||||
.joined(separator: "&")
|
||||
.data(using: .utf8)
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to exchange authorization code"
|
||||
return
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let idToken = json["id_token"] as? String else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
return
|
||||
}
|
||||
|
||||
// Send ID token to backend
|
||||
await sendToBackend(idToken: idToken)
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = "Network error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Send Google ID token to backend for verification and authentication
|
||||
private func sendToBackend(idToken: String) async {
|
||||
let request = GoogleSignInRequest(idToken: idToken)
|
||||
let result = try? await APILayer.shared.googleSignIn(request: request)
|
||||
|
||||
guard let result else {
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed. Please try again."
|
||||
return
|
||||
}
|
||||
|
||||
if let success = result as? ApiResultSuccess<GoogleSignInResponse>, let response = success.data {
|
||||
isLoading = false
|
||||
|
||||
// Share token and API URL with widget extension
|
||||
WidgetDataManager.shared.saveAuthToken(response.token)
|
||||
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
|
||||
|
||||
// Track Google Sign In
|
||||
AnalyticsManager.shared.track(.userSignedInGoogle(isNewUser: response.isNewUser))
|
||||
|
||||
// Call success callback with verification status
|
||||
onSignInSuccess?(response.user.verified)
|
||||
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
isLoading = false
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user