learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLAuthUI.swift
saravanakumardb1 2c330387fc feat(auth): native SDK passkey + BLAuthUI Swift + Kotlin social/MFA
SmartAuth v2 SDK extensions for both Swift and Kotlin platform SDKs:

Swift (BLAuthClient.swift):
- Social login, MFA, passkeys, providers, devices, step-up, login history
- New types: BLMfaChallenge, BLTotpSetup, BLMfaStatus, BLAuthProvider, etc.
- BLAuthState: added .mfaRequired case

Swift (BLAuthUI.swift) — 4 reusable views:
- BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet

Kotlin (BLAuthClient.kt):
- Social login, MFA, providers, devices, step-up, login history
- MFA challenge detection in login(), encodeMap() helper

Kotlin (BLPasskeyManager.kt) — Credential Manager passkey wrapper
Kotlin (BLAuthUI.kt) — 5 Compose screens matching Swift BLAuthUI
Kotlin build.gradle.kts — Credential Manager dependencies

Tests: Swift (6 methods), Kotlin (5 methods)
2026-03-12 10:55:32 -07:00

622 lines
21 KiB
Swift

// Auth UI Kit
// Reusable SwiftUI auth views for all ByteLyst iOS/macOS apps.
// BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet.
// Themed via @Environment injection always matches host product.
import SwiftUI
import os
#if canImport(AuthenticationServices)
import AuthenticationServices
#endif
private let logger = Logger(subsystem: "com.bytelyst.platform", category: "BLAuthUI")
// MARK: - Auth UI Configuration
/// Configuration for BLAuthUI views passed via Environment.
public struct BLAuthUIConfig {
public let productName: String
public let accentColor: Color
public let backgroundColor: Color
public let textColor: Color
public let secondaryTextColor: Color
public let cardColor: Color
public let enabledProviders: [BLAuthUIProvider]
public init(
productName: String = "ByteLyst",
accentColor: Color = .blue,
backgroundColor: Color = Color(.systemBackground),
textColor: Color = .primary,
secondaryTextColor: Color = .secondary,
cardColor: Color = Color(.secondarySystemBackground),
enabledProviders: [BLAuthUIProvider] = [.google, .apple]
) {
self.productName = productName
self.accentColor = accentColor
self.backgroundColor = backgroundColor
self.textColor = textColor
self.secondaryTextColor = secondaryTextColor
self.cardColor = cardColor
self.enabledProviders = enabledProviders
}
}
/// OAuth providers supported by BLAuthUI.
public enum BLAuthUIProvider: String, CaseIterable, Sendable {
case google
case microsoft
case apple
}
// MARK: - Environment Key
private struct BLAuthUIConfigKey: EnvironmentKey {
static let defaultValue = BLAuthUIConfig()
}
extension EnvironmentValues {
public var blAuthUIConfig: BLAuthUIConfig {
get { self[BLAuthUIConfigKey.self] }
set { self[BLAuthUIConfigKey.self] = newValue }
}
}
// MARK: - BLLoginView
/// Full login view with email/password + social buttons + passkey option.
/// Host product injects theme via `.environment(\.blAuthUIConfig, config)`.
public struct BLLoginView: View {
@Environment(\.blAuthUIConfig) private var config
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
/// Called with email + password when user taps Sign In.
public var onLogin: (String, String) async throws -> Void
/// Called with provider name when user taps a social button.
public var onSocialLogin: (BLAuthUIProvider) -> Void
/// Called when user taps "Use Passkey".
public var onPasskeyLogin: (() -> Void)?
/// Called when user taps "Forgot Password?".
public var onForgotPassword: (() -> Void)?
/// Called when user taps "Create Account".
public var onCreateAccount: (() -> Void)?
public init(
onLogin: @escaping (String, String) async throws -> Void,
onSocialLogin: @escaping (BLAuthUIProvider) -> Void,
onPasskeyLogin: (() -> Void)? = nil,
onForgotPassword: (() -> Void)? = nil,
onCreateAccount: (() -> Void)? = nil
) {
self.onLogin = onLogin
self.onSocialLogin = onSocialLogin
self.onPasskeyLogin = onPasskeyLogin
self.onForgotPassword = onForgotPassword
self.onCreateAccount = onCreateAccount
}
public var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Text("Sign in to \(config.productName)")
.font(.title2.bold())
.foregroundColor(config.textColor)
Text("Welcome back")
.font(.subheadline)
.foregroundColor(config.secondaryTextColor)
}
.padding(.top, 40)
// Social Buttons
if !config.enabledProviders.isEmpty {
VStack(spacing: 12) {
ForEach(config.enabledProviders, id: \.self) { provider in
socialButton(for: provider)
}
}
dividerRow
}
// Email / Password
VStack(spacing: 16) {
TextField("Email", text: $email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
}
// Error
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
}
// Sign In Button
Button {
Task { await performLogin() }
} label: {
HStack {
if isLoading {
ProgressView()
.tint(.white)
}
Text("Sign In")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(email.isEmpty || password.isEmpty || isLoading)
// Passkey
if let onPasskeyLogin {
Button {
logger.debug("Passkey login tapped")
onPasskeyLogin()
} label: {
HStack {
Image(systemName: "person.badge.key.fill")
Text("Sign in with Passkey")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.cardColor)
.foregroundColor(config.textColor)
.cornerRadius(10)
}
}
// Forgot Password / Create Account
VStack(spacing: 12) {
if let onForgotPassword {
Button("Forgot Password?") {
onForgotPassword()
}
.font(.subheadline)
.foregroundColor(config.accentColor)
}
if let onCreateAccount {
HStack {
Text("Don't have an account?")
.foregroundColor(config.secondaryTextColor)
Button("Create Account") {
onCreateAccount()
}
.foregroundColor(config.accentColor)
}
.font(.subheadline)
}
}
}
.padding(.horizontal, 24)
}
.background(config.backgroundColor.ignoresSafeArea())
}
private func performLogin() async {
isLoading = true
errorMessage = nil
do {
logger.debug("Attempting email/password login for \(email)")
try await onLogin(email, password)
} catch let error as BLAuthError {
// MFA required is not an error for the user handled upstream
logger.info("MFA required for \(email)")
_ = error // Suppress unused warning
} catch {
logger.error("Login failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription
}
isLoading = false
}
@ViewBuilder
private func socialButton(for provider: BLAuthUIProvider) -> some View {
Button {
logger.debug("Social login: \(provider.rawValue)")
onSocialLogin(provider)
} label: {
HStack {
providerIcon(for: provider)
Text("Continue with \(providerDisplayName(provider))")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.cardColor)
.foregroundColor(config.textColor)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
}
@ViewBuilder
private func providerIcon(for provider: BLAuthUIProvider) -> some View {
switch provider {
case .google:
Image(systemName: "globe")
case .microsoft:
Image(systemName: "building.2")
case .apple:
Image(systemName: "applelogo")
}
}
private func providerDisplayName(_ provider: BLAuthUIProvider) -> String {
switch provider {
case .google: return "Google"
case .microsoft: return "Microsoft"
case .apple: return "Apple"
}
}
private var dividerRow: some View {
HStack {
Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1)
Text("or")
.font(.caption)
.foregroundColor(config.secondaryTextColor)
Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1)
}
}
}
// MARK: - BLMfaChallengeView
/// 6-digit TOTP code entry with countdown and recovery code fallback.
public struct BLMfaChallengeView: View {
@Environment(\.blAuthUIConfig) private var config
@State private var code = ""
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showRecovery = false
public let challenge: BLMfaChallenge
public var onVerify: (String, String, String) async throws -> Void
public var onCancel: (() -> Void)?
public init(
challenge: BLMfaChallenge,
onVerify: @escaping (String, String, String) async throws -> Void,
onCancel: (() -> Void)? = nil
) {
self.challenge = challenge
self.onVerify = onVerify
self.onCancel = onCancel
}
public var body: some View {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 48))
.foregroundColor(config.accentColor)
Text("Two-Factor Authentication")
.font(.title3.bold())
.foregroundColor(config.textColor)
Text(showRecovery
? "Enter a recovery code"
: "Enter the 6-digit code from your authenticator app")
.font(.subheadline)
.foregroundColor(config.secondaryTextColor)
.multilineTextAlignment(.center)
}
// Code Input
TextField(showRecovery ? "Recovery Code" : "000000", text: $code)
.textFieldStyle(.roundedBorder)
.keyboardType(showRecovery ? .default : .numberPad)
.multilineTextAlignment(.center)
.font(.title2.monospaced())
.frame(maxWidth: 200)
// Error
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
// Verify Button
Button {
Task { await verify() }
} label: {
HStack {
if isLoading { ProgressView().tint(.white) }
Text("Verify")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(code.isEmpty || isLoading)
// Toggle recovery / cancel
VStack(spacing: 12) {
Button(showRecovery ? "Use authenticator code" : "Use recovery code") {
showRecovery.toggle()
code = ""
errorMessage = nil
}
.font(.subheadline)
.foregroundColor(config.accentColor)
if let onCancel {
Button("Cancel") { onCancel() }
.font(.subheadline)
.foregroundColor(config.secondaryTextColor)
}
}
}
.padding(24)
.background(config.backgroundColor)
}
private func verify() async {
isLoading = true
errorMessage = nil
let method = showRecovery ? "recovery" : "totp"
do {
logger.debug("Verifying MFA: method=\(method)")
try await onVerify(challenge.mfaChallenge, code, method)
} catch {
logger.error("MFA verify failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription
code = ""
}
isLoading = false
}
}
// MARK: - BLPasskeyView
/// Passkey prompt with biometric hint text.
/// Triggers ASAuthorizationController for platform passkey authentication.
public struct BLPasskeyView: View {
@Environment(\.blAuthUIConfig) private var config
@State private var isLoading = false
@State private var errorMessage: String?
public var onAuthenticate: () async throws -> Void
public var onCancel: (() -> Void)?
public init(
onAuthenticate: @escaping () async throws -> Void,
onCancel: (() -> Void)? = nil
) {
self.onAuthenticate = onAuthenticate
self.onCancel = onCancel
}
public var body: some View {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "person.badge.key.fill")
.font(.system(size: 48))
.foregroundColor(config.accentColor)
Text("Sign in with Passkey")
.font(.title3.bold())
.foregroundColor(config.textColor)
Text("Use Face ID, Touch ID, or your security key to sign in")
.font(.subheadline)
.foregroundColor(config.secondaryTextColor)
.multilineTextAlignment(.center)
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
Button {
Task { await authenticate() }
} label: {
HStack {
if isLoading { ProgressView().tint(.white) }
Image(systemName: "faceid")
Text("Continue")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(isLoading)
if let onCancel {
Button("Use another method") { onCancel() }
.font(.subheadline)
.foregroundColor(config.accentColor)
}
}
.padding(24)
.background(config.backgroundColor)
}
private func authenticate() async {
isLoading = true
errorMessage = nil
do {
logger.debug("Starting passkey authentication")
try await onAuthenticate()
} catch {
logger.error("Passkey auth failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// MARK: - BLStepUpSheet
/// Re-authentication sheet for sensitive operations.
/// Supports password re-entry or biometric confirmation.
public struct BLStepUpSheet: View {
@Environment(\.blAuthUIConfig) private var config
@Environment(\.dismiss) private var dismiss
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
public let reason: String
public var onStepUp: (String, String) async throws -> String
public var onComplete: (String) -> Void
public init(
reason: String = "This action requires re-authentication",
onStepUp: @escaping (String, String) async throws -> String,
onComplete: @escaping (String) -> Void
) {
self.reason = reason
self.onStepUp = onStepUp
self.onComplete = onComplete
}
public var body: some View {
NavigationStack {
VStack(spacing: 24) {
VStack(spacing: 8) {
Image(systemName: "lock.fill")
.font(.system(size: 36))
.foregroundColor(config.accentColor)
Text("Confirm Your Identity")
.font(.title3.bold())
.foregroundColor(config.textColor)
Text(reason)
.font(.subheadline)
.foregroundColor(config.secondaryTextColor)
.multilineTextAlignment(.center)
}
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
#if canImport(LocalAuthentication)
Button {
Task { await biometricStepUp() }
} label: {
HStack {
Image(systemName: "faceid")
Text("Use Biometrics")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.cardColor)
.foregroundColor(config.textColor)
.cornerRadius(10)
}
#endif
Button {
Task { await passwordStepUp() }
} label: {
HStack {
if isLoading { ProgressView().tint(.white) }
Text("Confirm")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(config.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(password.isEmpty || isLoading)
Spacer()
}
.padding(24)
.background(config.backgroundColor)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
private func passwordStepUp() async {
isLoading = true
errorMessage = nil
do {
logger.debug("Step-up: password method")
let token = try await onStepUp("password", password)
onComplete(token)
dismiss()
} catch {
logger.error("Step-up failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription
}
isLoading = false
}
#if canImport(LocalAuthentication)
private func biometricStepUp() async {
let success = await BLBiometricAuth.authenticate(reason: "Confirm your identity")
if success {
isLoading = true
errorMessage = nil
do {
logger.debug("Step-up: biometric method")
let token = try await onStepUp("biometric", "biometric_verified")
onComplete(token)
dismiss()
} catch {
logger.error("Biometric step-up failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription
}
isLoading = false
} else {
errorMessage = "Biometric authentication failed"
}
}
#endif
}