diff --git a/Sources/ParaSwift/Auth/AuthInfo.swift b/Sources/ParaSwift/Auth/AuthInfo.swift index 48476a1..d75558e 100644 --- a/Sources/ParaSwift/Auth/AuthInfo.swift +++ b/Sources/ParaSwift/Auth/AuthInfo.swift @@ -84,6 +84,8 @@ public enum AuthStage: String, Codable { case signup /// Login stage case login + /// Terminal stage used by SLO flows once the portal completes + case done } /// Authentication state returned by signUpOrLogIn @@ -112,6 +114,14 @@ public struct AuthState: Codable { public let passwordUrl: String? /// Biometric hints for the user's devices public let biometricHints: [BiometricHint]? + /// URL to launch a web-based auth flow (SLO) + public let loginUrl: String? + /// Indicates the next stage after completing the current one (used by SLO) + public let nextStage: AuthStage? + /// Available login auth methods returned by the bridge + public let loginAuthMethods: [String]? + /// Available signup auth methods returned by the bridge + public let signupAuthMethods: [String]? /// Information about a biometric authentication device public struct BiometricHint: Codable { @@ -147,7 +157,11 @@ public struct AuthState: Codable { passkeyId: String? = nil, passkeyKnownDeviceUrl: String? = nil, passwordUrl: String? = nil, - biometricHints: [BiometricHint]? = nil + biometricHints: [BiometricHint]? = nil, + loginUrl: String? = nil, + nextStage: AuthStage? = nil, + loginAuthMethods: [String]? = nil, + signupAuthMethods: [String]? = nil ) { self.stage = stage self.userId = userId @@ -161,6 +175,10 @@ public struct AuthState: Codable { self.passkeyKnownDeviceUrl = passkeyKnownDeviceUrl self.passwordUrl = passwordUrl self.biometricHints = biometricHints + self.loginUrl = loginUrl + self.nextStage = nextStage + self.loginAuthMethods = loginAuthMethods + self.signupAuthMethods = signupAuthMethods } // MARK: - Codable implementation @@ -170,6 +188,7 @@ public struct AuthState: Codable { case stage, userId, displayName, pfpUrl, username case email, phone case passkeyUrl, passkeyId, passkeyKnownDeviceUrl, passwordUrl, biometricHints + case loginUrl, nextStage, loginAuthMethods, signupAuthMethods } /// Initialize from decoder @@ -191,5 +210,9 @@ public struct AuthState: Codable { passkeyKnownDeviceUrl = try container.decodeIfPresent(String.self, forKey: .passkeyKnownDeviceUrl) passwordUrl = try container.decodeIfPresent(String.self, forKey: .passwordUrl) biometricHints = try container.decodeIfPresent([BiometricHint].self, forKey: .biometricHints) + loginUrl = try container.decodeIfPresent(String.self, forKey: .loginUrl) + nextStage = try container.decodeIfPresent(AuthStage.self, forKey: .nextStage) + loginAuthMethods = try container.decodeIfPresent([String].self, forKey: .loginAuthMethods) + signupAuthMethods = try container.decodeIfPresent([String].self, forKey: .signupAuthMethods) } } diff --git a/Sources/ParaSwift/Auth/ParaManager+OAuth.swift b/Sources/ParaSwift/Auth/ParaManager+OAuth.swift index 04c724c..e5ac09f 100644 --- a/Sources/ParaSwift/Auth/ParaManager+OAuth.swift +++ b/Sources/ParaSwift/Auth/ParaManager+OAuth.swift @@ -139,6 +139,10 @@ extension ParaManager { let displayName = resultDict["displayName"] as? String let pfpUrl = resultDict["pfpUrl"] as? String let username = resultDict["username"] as? String + let loginUrl = resultDict["loginUrl"] as? String + let nextStage = (resultDict["nextStage"] as? String).flatMap(AuthStage.init(rawValue:)) + let loginAuthMethods = resultDict["loginAuthMethods"] as? [String] + let signupAuthMethods = resultDict["signupAuthMethods"] as? [String] // Extract email and phone directly from the response var email: String? = nil @@ -167,6 +171,10 @@ extension ParaManager { passkeyKnownDeviceUrl: passkeyKnownDeviceUrl, passwordUrl: passwordUrl, biometricHints: biometricHints, + loginUrl: loginUrl, + nextStage: nextStage, + loginAuthMethods: loginAuthMethods, + signupAuthMethods: signupAuthMethods ) // Update session state based on authentication stage @@ -174,6 +182,8 @@ extension ParaManager { case .login, .signup, .verify: // For all OAuth stages, we set the state to active sessionState = .active + case .done: + sessionState = .activeLoggedIn } return authState @@ -274,9 +284,11 @@ extension ParaManager { } case .verify: - // This shouldn't happen with OAuth - logger.error("Unexpected verify stage in OAuth flow") - throw ParaError.error("Unexpected authentication stage") + logger.debug("OAuth verify stage received; waiting for completion via portal") + + case .done: + logger.debug("OAuth flow completed with DONE stage for user ID: \(authState.userId)") + sessionState = .activeLoggedIn } } } diff --git a/Sources/ParaSwift/Core/ParaManager+Auth.swift b/Sources/ParaSwift/Core/ParaManager+Auth.swift index 5dc58f1..11b7cf1 100644 --- a/Sources/ParaSwift/Core/ParaManager+Auth.swift +++ b/Sources/ParaSwift/Core/ParaManager+Auth.swift @@ -42,6 +42,15 @@ extension ParaManager { let displayName = resultDict["displayName"] as? String let pfpUrl = resultDict["pfpUrl"] as? String let username = resultDict["username"] as? String + let loginUrl = resultDict["loginUrl"] as? String + + var nextStage: AuthStage? = nil + if let nextStageString = resultDict["nextStage"] as? String { + nextStage = AuthStage(rawValue: nextStageString) + } + + let loginAuthMethods = resultDict["loginAuthMethods"] as? [String] + let signupAuthMethods = resultDict["signupAuthMethods"] as? [String] // Extract biometric hints if available var biometricHints: [AuthState.BiometricHint]? @@ -79,6 +88,10 @@ extension ParaManager { passkeyKnownDeviceUrl: passkeyKnownDeviceUrl, passwordUrl: passwordUrl, biometricHints: biometricHints, + loginUrl: loginUrl, + nextStage: nextStage, + loginAuthMethods: loginAuthMethods, + signupAuthMethods: signupAuthMethods ) } } @@ -86,6 +99,78 @@ extension ParaManager { // MARK: - Authentication Types and Methods public extension ParaManager { + private struct EmptyPayload: Encodable {} + + private struct WaitPayload: Encodable { + let timeoutMs: Int? + + enum CodingKeys: String, CodingKey { + case timeoutMs + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let timeoutMs { + try container.encode(timeoutMs, forKey: .timeoutMs) + } + } + } + + @discardableResult + func waitForSignup(timeoutMs: Int? = nil) async throws -> Bool { + try await ensureWebViewReady() + + let payload = WaitPayload(timeoutMs: timeoutMs) + let result = try await postMessage(method: "waitForSignup", payload: payload) + + if let boolResult = result as? Bool { + return boolResult + } + + if let numberResult = result as? NSNumber { + return numberResult.boolValue + } + + return true + } + + @discardableResult + func waitForLogin(timeoutMs: Int? = nil) async throws -> [String: Any]? { + try await ensureWebViewReady() + + let payload = WaitPayload(timeoutMs: timeoutMs) + let result = try await postMessage(method: "waitForLogin", payload: payload) + return result as? [String: Any] + } + + func touchSession() async throws -> [String: Any]? { + try await ensureWebViewReady() + let result = try await postMessage(method: "touchSession", payload: EmptyPayload()) + return result as? [String: Any] + } + + private struct GetLoginUrlArgs: Encodable { + let authMethod: String + let shorten: Bool? + + init(authMethod: String, shorten: Bool? = nil) { + self.authMethod = authMethod + self.shorten = shorten + } + + private enum CodingKeys: String, CodingKey { + case authMethod + case shorten + } + } + + func getLoginUrl(authMethod: String = "BASIC_LOGIN", shorten: Bool? = nil) async throws -> String { + try await ensureWebViewReady() + let payload = GetLoginUrlArgs(authMethod: authMethod, shorten: shorten) + let result = try await postMessage(method: "getLoginUrl", payload: payload) + return try decodeResult(result, expectedType: String.self, method: "getLoginUrl") + } + /// Enum defining the possible methods for login when the stage is .login enum LoginMethod: String, CustomStringConvertible { case passkey @@ -377,6 +462,20 @@ public extension ParaManager { // MARK: - Password Authentication public extension ParaManager { + @discardableResult + func presentAuthUrl( + _ url: String, + context: String, + webAuthenticationSession: WebAuthenticationSession + ) async throws -> URL? { + try await presentWebAuthenticationUrl( + url, + context: context, + loadTransmissionKeyshares: false, + webAuthenticationSession: webAuthenticationSession + ) + } + /// Presents a password URL using a web authentication session and verifies authentication completion. /// The caller is responsible for handling subsequent steps like wallet creation if needed. /// @@ -385,46 +484,88 @@ public extension ParaManager { /// - webAuthenticationSession: The session to use for presenting the URL /// - Returns: The callback URL if authentication was successful via direct callback, or a success placeholder URL if the window was closed (interpreted as success). Returns nil on failure. func presentPasswordUrl(_ url: String, webAuthenticationSession: WebAuthenticationSession) async throws -> URL? { - let logger = Logger(subsystem: "com.paraSwift", category: "PasswordAuth") - guard let originalPasswordUrl = URL(string: url) else { - throw ParaError.error("Invalid password authentication URL") + let callbackURL = try await presentWebAuthenticationUrl( + url, + context: "password", + loadTransmissionKeyshares: true, + webAuthenticationSession: webAuthenticationSession + ) + + do { + wallets = try await fetchWallets() + } catch { + logger.warning("Failed to refresh wallets after password auth: \(error.localizedDescription)") } - // Add nativeCallbackUrl query parameter for ASWebAuthenticationSession - var components = URLComponents(url: originalPasswordUrl, resolvingAgainstBaseURL: false) - let callbackQueryItem = URLQueryItem(name: "nativeCallbackUrl", value: appScheme + "://") - // Resolve overlapping access warning by modifying a local variable + return callbackURL + } +} + +private extension ParaManager { + func presentWebAuthenticationUrl( + _ url: String, + context: String, + loadTransmissionKeyshares: Bool, + webAuthenticationSession: WebAuthenticationSession + ) async throws -> URL? { + let contextLogger = Logger(subsystem: "com.paraSwift", category: "WebAuthSession") + + guard let originalUrl = URL(string: url) else { + throw ParaError.error("Invalid \(context) authentication URL") + } + + var components = URLComponents(url: originalUrl, resolvingAgainstBaseURL: false) + let callbackValue: String + let callbackScheme: String + if appScheme.contains("://") { + callbackValue = appScheme + if let parsedScheme = URL(string: appScheme)?.scheme { + callbackScheme = parsedScheme + } else if let schemeRange = appScheme.range(of: "://") { + callbackScheme = String(appScheme[..