123 lines
4.7 KiB
Swift
123 lines
4.7 KiB
Swift
import AuthenticationServices
|
||
import CryptoKit
|
||
import Foundation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#elseif canImport(AppKit)
|
||
import AppKit
|
||
#endif
|
||
|
||
// 应用内 Broker 登录(device-authorization + PKCE,对齐桌面 desktop/platform/oauth.go)。iOS 无法
|
||
// 跑 loopback 监听,故用 ASWebAuthenticationSession + 自定义 scheme 回调
|
||
// (commilitia-drop://auth-callback):
|
||
// 1. GET {broker}/device/authorize?app=commilitia-drop&redirect_uri=commilitia-drop://auth-callback&...
|
||
// 2. 用户在 broker SSO 授权 → 回调 commilitia-drop://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 须为 Commilitia Drop 应用注册 redirect_uri
|
||
// commilitia-drop://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(commilitia-drop://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
|
||
{
|
||
#if os(iOS)
|
||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||
return scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
|
||
?? scenes.first?.windows.first
|
||
?? ASPresentationAnchor()
|
||
#else
|
||
return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor()
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// 跨登录持久的稳定 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
|
||
}
|
||
}
|