import AuthenticationServices import CryptoKit import Foundation import UIKit // 应用内 Broker 登录(device-authorization + PKCE,对齐桌面 desktop/platform/oauth.go)。iOS 无法 // 跑 loopback 监听,故用 ASWebAuthenticationSession + 自定义 scheme 回调(cdrop://auth-callback): // 1. GET {broker}/device/authorize?app=cdrop&redirect_uri=cdrop://auth-callback&state&code_challenge&S256 // 2. 用户在 broker SSO 授权 → 回调 cdrop://auth-callback?code=..&state=.. // 3. POST {broker}/device/token {code, code_verifier, redirect_uri} → bootstrap 机器令牌 // 4. POST {api}/api/auth/device-session(Bearer bootstrap)→ cdrop 设备会话(带真名 / device_id) // 这样手机上直接输账号登录,无需另一台设备扫码批准。 // 前置:broker 须为 cdrop 应用注册 redirect_uri cdrop://auth-callback(同桌面 loopback 须注册)。 enum BrokerLoginError: Error { case incompleteConfig case cannotStart case noCallback case stateMismatch case missingCode case tokenFailed(Int) case deviceSessionFailed(Int) case badResponse } // ASWebAuthenticationSession 的 async 封装 + 展示锚点提供者。须被持有至完成(session 的 // presentationContextProvider 是 weak,且 session 自身也须保活),故由 AuthManager 在登录期间强持。 @MainActor final class BrokerAuthFlow: NSObject, ASWebAuthenticationPresentationContextProviding { private var session: ASWebAuthenticationSession? // 打开授权页,等回调 URL(cdrop://auth-callback?code=..&state=..)。用户取消 / 失败抛错。 func run(url: URL, callbackScheme: String) async throws -> URL { try await withCheckedThrowingContinuation { cont in let s = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callback, error in self?.session = nil if let error { cont.resume(throwing: error) } else if let callback { cont.resume(returning: callback) } else { cont.resume(throwing: BrokerLoginError.noCallback) } } s.presentationContextProvider = self // 共享浏览会话:若用户已在 Safari 登录 broker SSO,则免再输密码(无缝 SSO)。 s.prefersEphemeralWebBrowserSession = false self.session = s if !s.start() { cont.resume(throwing: BrokerLoginError.cannotStart) } } } func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.flatMap { $0.windows }.first { $0.isKeyWindow } ?? scenes.first?.windows.first ?? ASPresentationAnchor() } } // 跨登录持久的稳定 device_id(broker meta / 会话↔设备连接键)。存 UserDefaults,登出不清—— // 故重登复用同一 device_id,broker R2 幂等轮换同一会话、设备表 UPSERT 同一行,杜绝重复设备项 // (对齐 web 的 localStorage cdrop.device_id)。首次为空,由后端铸发后回存。 enum DeviceIDStore { private static let key = "cdrop.device_id" static var value: String { get { UserDefaults.standard.string(forKey: key) ?? "" } set { UserDefaults.standard.set(newValue, forKey: key) } } } // PKCE / 随机串(对齐桌面 randString:n 字节 base64url 取前 n 字符,落在 PKCE unreserved 集内)。 enum PKCE { static func randomToken(_ n: Int) -> String { var bytes = [UInt8](repeating: 0, count: n) _ = SecRandomCopyBytes(kSecRandomDefault, n, &bytes) return String(Data(bytes).base64URLEncoded().prefix(n)) } static func challenge(for verifier: String) -> String { let digest = SHA256.hash(data: Data(verifier.utf8)) return Data(digest).base64URLEncoded() } } extension Data { // base64url(无填充,- / _ 替 + /),用于 PKCE challenge / 随机串。 func base64URLEncoded() -> String { base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } extension String { func trimmingTrailingSlash() -> String { hasSuffix("/") ? String(dropLast()) : self } }