Files
LabWiseiOS/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift

234 lines
9.4 KiB
Swift
Raw Normal View History

import Foundation
import AuthenticationServices
public struct SignInBody: Encodable, Sendable {
public let email: String
public let password: String
public init(email: String, password: String) {
self.email = email
self.password = password
}
}
/// Better Auth wraps the sign-in response as { data: { user, session } }
public struct SignInResponse: Decodable, Sendable {
public let data: SignInData?
public let error: SignInError?
public struct SignInData: Decodable, Sendable {
public let user: AuthUser
public let session: AuthSession
}
public struct SignInError: Decodable, Sendable {
public let message: String?
}
}
public struct AuthSession: Decodable, Sendable {
public let id: String
public let token: String
public let userId: String
public let expiresAt: String
}
public struct AuthUser: Decodable, Sendable {
public let id: String
public let email: String
public let name: String?
public let emailVerified: Bool?
public let image: String?
}
/// Thin wrapper around the /api/auth/session endpoint response.
private struct SessionResponse: Decodable, Sendable {
let user: AuthUser?
let session: AuthSession?
}
public final class AuthClient: Sendable {
private let api: APIClient
private let baseURL = "https://labwise.wahwa.com"
public init(api: APIClient = .shared) {
self.api = api
}
// MARK: - Email / Password
/// Sign in with email and password. Returns the authenticated user on success.
public func signIn(email: String, password: String) async throws -> AuthUser {
let body = SignInBody(email: email, password: password)
let encoded = try JSONEncoder().encode(body)
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/sign-in/email")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = encoded
let session = sharedURLSession()
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
// Better Auth may return 200 with an error body, or a non-200 status
let signInResponse = try JSONDecoder.api.decode(SignInResponse.self, from: data)
if let errMsg = signInResponse.error?.message {
throw APIError.httpError(statusCode: http.statusCode, data: Data(errMsg.utf8))
}
guard http.statusCode == 200 else {
throw APIError.httpError(statusCode: http.statusCode, data: data)
}
guard let user = signInResponse.data?.user else {
throw APIError.decodingError(DecodingError.dataCorrupted(
.init(codingPath: [], debugDescription: "Missing user in sign-in response")
))
}
return user
}
// MARK: - Google OAuth
/// Initiates Google OAuth via ASWebAuthenticationSession.
///
/// Flow:
/// 1. Opens /api/auth/sign-in/google with callbackURL pointing at /api/ios-callback (HTTPS).
/// 2. ASWebAuthenticationSession follows all redirects through Google and back to the server.
/// 3. Better Auth sets the session cookie on labwise.wahwa.com, then redirects to /api/ios-callback.
/// 4. /api/ios-callback reads the cookie, extracts the raw token, and redirects to labwise://auth?token=
/// 5. ASWebAuthenticationSession intercepts the labwise:// scheme and delivers the URL to us.
/// 6. We parse the token, inject it as a cookie into HTTPCookieStorage.shared, then fetch the user.
///
/// This avoids the cookie-jar isolation problem: ASWebAuthenticationSession uses the Safari jar
/// while URLSession uses HTTPCookieStorage.shared. By extracting the token from the URL we can
/// bridge between the two.
@MainActor
public func signInWithGoogle(presentingWindow: ASPresentationAnchor) async throws -> AuthUser {
let callbackScheme = "labwise"
// callbackURL is a real HTTPS endpoint on our server not the custom scheme.
// Better Auth will redirect there after a successful Google sign-in.
let iosCallbackURL = "\(baseURL)/api/ios-callback"
// /api/ios-google is a GET endpoint on our server that internally calls
// Better Auth's POST /sign-in/social, forwards the state cookie it receives,
// then redirects to Google. Because the whole flow happens within
// ASWebAuthenticationSession's Safari jar, the state cookie is present when
// Google redirects back no state_mismatch.
guard
let encodedCallback = iosCallbackURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let authURL = URL(string: "\(baseURL)/api/ios-google?callbackURL=\(encodedCallback)")
else { throw APIError.invalidURL }
let anchor = WindowAnchorProvider(window: presentingWindow)
// Keep a strong reference to the session outside the continuation closure
// so it isn't deallocated before the callback fires.
var webSession: ASWebAuthenticationSession?
let callbackURL: URL = try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: callbackScheme
) { url, error in
if let error {
continuation.resume(throwing: APIError.networkError(error))
} else if let url {
continuation.resume(returning: url)
} else {
continuation.resume(throwing: APIError.unauthorized)
}
}
session.presentationContextProvider = anchor
// Non-ephemeral so the in-browser Google account chooser can remember the user,
// but the token is delivered via URL so the cookie jar difference doesn't matter.
session.prefersEphemeralWebBrowserSession = false
webSession = session
session.start()
}
_ = webSession // keep alive until continuation resolves
// Parse labwise://auth?token=<value>&error=<value>
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
throw APIError.unauthorized
}
if let errorParam = components.queryItems?.first(where: { $0.name == "error" })?.value {
throw APIError.httpError(statusCode: 401, data: Data(errorParam.utf8))
}
guard let rawToken = components.queryItems?.first(where: { $0.name == "token" })?.value,
!rawToken.isEmpty else {
throw APIError.unauthorized
}
// URLComponents percent-decodes once; if the server double-encoded the token
// (encodeURIComponent on an already-encoded value) decode any remaining %XX sequences.
let token = rawToken.removingPercentEncoding ?? rawToken
injectSessionCookie(token: token)
return try await fetchCurrentUser()
}
// MARK: - Cookie injection
private func injectSessionCookie(token: String) {
guard let serverURL = URL(string: baseURL) else { return }
let properties: [HTTPCookiePropertyKey: Any] = [
.name: "__Secure-better-auth.session_token",
.value: token,
.domain: serverURL.host ?? "labwise.wahwa.com",
.path: "/",
.secure: "TRUE",
// Session cookie no explicit expiry; persists until the app clears it.
]
if let cookie = HTTPCookie(properties: properties) {
HTTPCookieStorage.shared.setCookie(cookie)
}
}
// MARK: - Session
/// Fetch the currently authenticated user from the session endpoint.
public func fetchCurrentUser() async throws -> AuthUser {
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/get-session")!)
req.httpMethod = "GET"
let session = sharedURLSession()
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw APIError.unauthorized
}
let sessionResponse = try JSONDecoder.api.decode(SessionResponse.self, from: data)
guard let user = sessionResponse.user else {
throw APIError.unauthorized
}
return user
}
// MARK: - Sign Out
/// Sign out. Clears the session cookie.
public func signOut() async throws {
_ = try? await api.postRaw("/api/auth/sign-out", body: EmptyBody())
api.clearSessionCookies()
}
// MARK: - Helpers
private func sharedURLSession() -> URLSession {
let config = URLSessionConfiguration.default
config.httpCookieStorage = HTTPCookieStorage.shared
config.httpShouldSetCookies = true
config.httpCookieAcceptPolicy = .always
return URLSession(configuration: config)
}
}
// MARK: - ASWebAuthenticationPresentationContextProviding
private final class WindowAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding, @unchecked Sendable {
let window: ASPresentationAnchor
init(window: ASPresentationAnchor) { self.window = window }
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { window }
}
private struct EmptyBody: Encodable {}