234 lines
9.4 KiB
Swift
234 lines
9.4 KiB
Swift
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 {}
|