鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移

后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
2026-06-26 02:07:11 +08:00
parent c79b176b87
commit 10cf36ecee
104 changed files with 7533 additions and 5318 deletions
@@ -0,0 +1,121 @@
import Foundation
// desktop/platform/download.go app
// DocumentssaveWhole begin / append / finalizeabort
// 线 EngineController 线 IO
//
final class DownloadManager
{
private struct Active
{
let handle: FileHandle
let tmpURL: URL
}
private var active: [String: Active] = [:]
private func downloadDir() -> URL
{
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
// Documents .part /
// / / Files app
func receivedFiles() -> [URL]
{
let urls = (try? FileManager.default.contentsOfDirectory(
at: downloadDir(),
includingPropertiesForKeys: [ .contentModificationDateKey ],
options: [ .skipsHiddenFiles ])) ?? []
return urls
.filter { $0.pathExtension != "part" && !$0.lastPathComponent.hasPrefix(".cdrop-") }
.sorted { a, b in modDate(a) > modDate(b) }
}
func deleteReceivedFile(_ url: URL)
{
try? FileManager.default.removeItem(at: url)
}
private func modDate(_ url: URL) -> Date
{
return (try? url.resourceValues(forKeys: [ .contentModificationDateKey ]))?
.contentModificationDate ?? .distantPast
}
// saveDownload
func saveWhole(name: String, data: Data) throws -> String
{
let url = uniqueURL(for: sanitize(name))
try data.write(to: url)
return url.path
}
func begin(sessionId: String) throws
{
cleanup(sessionId) // re-begin
let tmp = downloadDir().appendingPathComponent(".cdrop-\(sanitize(sessionId)).part")
FileManager.default.createFile(atPath: tmp.path, contents: nil)
let handle = try FileHandle(forWritingTo: tmp)
active[sessionId] = Active(handle: handle, tmpURL: tmp)
}
func append(sessionId: String, data: Data) throws
{
guard let a = active[sessionId] else { throw Err.unknownSession }
try a.handle.write(contentsOf: data)
}
func finalize(sessionId: String, name: String) throws -> String
{
guard let a = active[sessionId] else { throw Err.unknownSession }
try a.handle.close()
active[sessionId] = nil
let dest = uniqueURL(for: sanitize(name))
try FileManager.default.moveItem(at: a.tmpURL, to: dest)
return dest.path
}
func abort(sessionId: String)
{
cleanup(sessionId)
}
private func cleanup(_ sessionId: String)
{
guard let a = active[sessionId] else { return }
try? a.handle.close()
try? FileManager.default.removeItem(at: a.tmpURL)
active[sessionId] = nil
}
// (1) (2) uniquePath
private func uniqueURL(for name: String) -> URL
{
let dir = downloadDir()
let base = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
var candidate = dir.appendingPathComponent(name)
var i = 1
while FileManager.default.fileExists(atPath: candidate.path)
{
let next = ext.isEmpty ? "\(base) (\(i))" : "\(base) (\(i)).\(ext)"
candidate = dir.appendingPathComponent(next)
i += 1
}
return candidate
}
// sanitize穿 sanitizeFileName
private func sanitize(_ name: String) -> String
{
let last = (name as NSString).lastPathComponent
let cleaned = last.replacingOccurrences(of: "/", with: "_")
return cleaned.isEmpty ? "download" : cleaned
}
enum Err: Error
{
case unknownSession
}
}
@@ -0,0 +1,537 @@
import Foundation
import Observation
import UIKit
import WebKit
// presence web store DeviceInfolastSeen ms epoch
// Decodable JS WKWebView bool NSNumber
// / JSONDecoder
// web hub.ts handlePresence
struct DeviceItem: Identifiable, Equatable
{
let name: String
let type: String
let online: Bool
let lastSeen: Double
var id: String { name }
}
// web engine/main.ts toWire ice*
// P2P TURN
struct TransferItem: Identifiable, Equatable
{
let sessionId: String
let direction: String
let fileName: String
let fileSize: Int
let state: String
let mode: String?
let peerName: String
let phase: String?
let bytesTransferred: Int?
let bytesPerSec: Double?
let iceConn: String?
let iceLocal: String?
let iceRemote: String?
var id: String { sessionId }
}
// JS web/src/net/ios.tsios/PLAN.md §2
// "cdropEngine" WKScriptMessageHandler { id, method, payload }RPC
// { notify, payload }RPC evaluateJavaScript
// window.__cdropEngineResolve(id, ok, value) window.__cdropEngineEvent
@MainActor
@Observable
final class EngineController: NSObject
{
// SwiftUI
var status: String = t("ios.engine.disconnected")
var deviceName: String = ""
// UI demo
var devices: [DeviceItem] = [] // 线 / 线 presence presence
var transfers: [TransferItem] = [] // transfers
var history: [TransferItem] = [] // transferDone 30
// SSE / + presence +
// warn/error 401
var hubConnected = false
var hubReconnecting = false
var presenceCount = 0
var lastEngineLog = ""
// / /
var clipboardStatus = ""
var deviceActionStatus = ""
// WebRTC origin PLAN R-iOS-3线
// https CDROP_ENGINE_URL http://localhostlocalhost
// WebRTC Windows loopback
private var engineURL: URL
{
if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s)
{
return u
}
return URL(string: "https://drop.commilitia.net/engine.html")!
}
// boot app AppRoot.onAppear
var auth: AuthManager?
private var webView: WKWebView?
private let downloads = DownloadManager()
// id URL cdrop-file://<id>
// WKURLSchemeHandler
private var outgoing: [String: URL] = [:]
override init()
{
super.init()
}
// makeWebView WebView __CDROP_BOOT__device_type:"ios"
// cdrop-file scheme JS
func makeWebView() -> WKWebView
{
if let existing = webView { return existing }
let ucc = WKUserContentController()
ucc.add(self, name: "cdropEngine")
// boot nulldevice_type ios
// api_base /api prod URL
let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
+ "device_name: \(jsString(currentDeviceName())), "
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };"
ucc.addUserScript(WKUserScript(source: boot,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
let cfg = WKWebViewConfiguration()
cfg.userContentController = ucc
cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file")
// Auth Broker refresh_tokenPOST /api/auth/refresh
// broker cdrop_session cookie URLSession cookie WebView
let wv = WKWebView(frame: .zero, configuration: cfg)
// engine.html WebView
// / handler engine.html
// bundle
var request = URLRequest(url: engineURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
wv.load(request)
webView = wv
deviceName = currentDeviceName()
return wv
}
// SSE WebView UI makeWebView
// boot auth.session
func reset()
{
sendCommand("shutdown", payload: [:])
webView = nil
devices = []
transfers = []
history = []
status = t("ios.engine.disconnected")
deviceName = ""
}
// Documents Files app /
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
//
// name (1)退
func receivedFile(matching name: String) -> URL?
{
let files = downloads.receivedFiles()
if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact }
let base = (name as NSString).deletingPathExtension
return files.first { $0.lastPathComponent.hasPrefix(base) }
}
// 线 /
func sendableDevices() -> [DeviceItem]
{
return devices.filter { $0.online && $0.name != deviceName }
}
// UIPasteboard
func uploadClipboard()
{
let content = UIPasteboard.general.string ?? ""
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
clipboardStatus = t("ios.clipboard.uploading")
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// UIPasteboard handleNotify "clipboard"
func pullClipboard()
{
clipboardStatus = t("ios.clipboard.pulling")
sendCommand("clipboardPull", payload: [:])
}
// /
func revokeDevice(_ name: String)
{
deviceActionStatus = ""
sendCommand("revokeDevice", payload: [ "name": name ])
}
// JSwindow.__cdropEngineEvent
func sendCommand(_ name: String, payload: [String: Any])
{
guard let json = jsonString(payload) else { return }
webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));")
}
// sendFile cdrop-file://<id> +
func sendFile(to target: String, fileURL: URL)
{
let ref = stageOutgoingFile(fileURL)
var size = 0
if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let n = attrs[.size] as? Int { size = n }
sendCommand("sendFile", payload: [
"target": target,
"url": ref,
"name": fileURL.lastPathComponent,
"size": size,
])
}
// cdrop-file://<id> URL
// start...Access
private func stageOutgoingFile(_ url: URL) -> String
{
let id = UUID().uuidString
_ = url.startAccessingSecurityScopedResource()
outgoing[id] = url
return "cdrop-file://\(id)"
}
}
// MARK: - JS RPC +
extension EngineController: WKScriptMessageHandler
{
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage)
{
guard let body = message.body as? [String: Any] else { return }
if let id = body["id"] as? Int
{
handleRPC(id: id,
method: body["method"] as? String ?? "",
payload: body["payload"] as? [String: Any] ?? [:])
return
}
if let notify = body["notify"] as? String
{
handleNotify(notify, payload: body["payload"])
}
}
private func handleRPC(id: Int, method: String, payload: [String: Any])
{
do
{
switch method
{
case "saveDownload":
let name = payload["name"] as? String ?? "download"
let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: path)
case "beginDownload":
try downloads.begin(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
case "appendDownload":
try downloads.append(sessionId: payload["sessionId"] as? String ?? "",
data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: nil)
case "finalizeDownload":
let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "",
name: payload["name"] as? String ?? "download")
resolve(id: id, ok: true, value: path)
case "abortDownload":
downloads.abort(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
}
catch
{
resolve(id: id, ok: false, value: "\(error)")
}
}
private func handleNotify(_ name: String, payload: Any?)
{
switch name
{
case "ready":
status = t("ios.engine.ready")
case "probe":
// WebRTC-in-WKWebView arch A R-iOS-3
if let p = payload as? [String: Any]
{
let secure = (p["secure"] as? Bool) ?? false
let rtc = (p["rtc"] as? String) ?? "?"
let host = (p["host"] as? Int) ?? 0
let srflx = (p["srflx"] as? Int) ?? 0
status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)"
}
case "presence":
// JS NSArray`as? [[String: Any]]`
// Foundation 便 [Any]
// as? [String: Any]
if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
{
devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
}
case "transfers":
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
}
case "transferDone":
// sessionId
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
{
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) }
}
case "sendStarted":
// transfers
break
case "hubState":
if let p = payload as? [String: Any]
{
hubConnected = Self.boolOf(p["connected"])
hubReconnecting = Self.boolOf(p["reconnecting"])
presenceCount = Self.intOf(p["presenceCount"])
}
case "log":
if let p = payload as? [String: Any], let msg = p["msg"] as? String
{
lastEngineLog = msg
}
case "clipboard":
// UIPasteboard
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
UIPasteboard.general.string = content
clipboardStatus = t("ios.clipboard.pulled")
}
else
{
clipboardStatus = t("ios.clipboard.empty")
}
case "clipboardUploaded":
clipboardStatus = t("ios.clipboard.uploaded")
case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked")
case "error":
// error /
if let p = payload as? [String: Any]
{
let stage = p["stage"] as? String ?? ""
let msg = p["message"] as? String ?? ""
if stage == "clipboard" { clipboardStatus = t("ios.clipboard.failed") }
else if stage == "revoke"
{
deviceActionStatus = msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
}
else { status = "错误:\(msg)" }
}
case "sessionRotated":
// broker refresh Keychain refresh
if let p = payload as? [String: Any],
let access = p["access_token"] as? String,
let refresh = p["refresh_token"] as? String
{
auth?.updateSession(accessToken: access, refreshToken: refresh)
}
case "authExpired":
// refresh / Keychain
auth?.logout()
reset()
default:
break
}
}
private func resolve(id: Int, ok: Bool, value: Any?)
{
webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
}
}
// MARK: - schemecdrop-file
extension EngineController: WKURLSchemeHandler
{
// cdrop-file://<id> JS fetch
// PLAN §2 / R-iOS-4v1 /
// didReceive jetsam R-iOS-4
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
else
{
respond(urlSchemeTask, status: 404)
return
}
guard let data = try? Data(contentsOf: fileURL)
else
{
respond(urlSchemeTask, status: 404)
return
}
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{
// v1
}
private func respond(_ task: any WKURLSchemeTask, status: Int)
{
guard let url = task.request.url,
let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)
else { return }
task.didReceive(resp)
task.didFinish()
}
}
// MARK: - DeviceItem
extension EngineController
{
// WKWebView NSNumber / Int / Double / Bool
//
static func parseDevice(_ d: [String: Any]) -> DeviceItem?
{
guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
return DeviceItem(name: name,
type: type,
online: boolOf(d["online"]),
lastSeen: doubleOf(d["lastSeen"]))
}
static func parseTransfer(_ t: [String: Any]) -> TransferItem?
{
guard let sessionId = t["sessionId"] as? String else { return nil }
let ice = t["ice"] as? [String: Any]
return TransferItem(sessionId: sessionId,
direction: t["direction"] as? String ?? "outgoing",
fileName: t["fileName"] as? String ?? "",
fileSize: intOf(t["fileSize"]),
state: t["state"] as? String ?? "PENDING",
mode: t["mode"] as? String,
peerName: t["peerName"] as? String ?? "",
phase: t["phase"] as? String,
bytesTransferred: t["bytesTransferred"].map { intOf($0) },
bytesPerSec: t["bytesPerSec"].map { doubleOf($0) },
iceConn: ice?["conn"] as? String,
iceLocal: ice?["local"] as? String,
iceRemote: ice?["remote"] as? String)
}
static func boolOf(_ v: Any?) -> Bool
{
if let b = v as? Bool { return b }
if let n = v as? NSNumber { return n.boolValue }
return false
}
static func intOf(_ v: Any?) -> Int
{
if let i = v as? Int { return i }
if let n = v as? NSNumber { return n.intValue }
if let d = v as? Double { return Int(d) }
return 0
}
static func doubleOf(_ v: Any?) -> Double
{
if let d = v as? Double { return d }
if let n = v as? NSNumber { return n.doubleValue }
if let i = v as? Int { return Double(i) }
return 0
}
}
// MARK: -
private extension EngineController
{
func decodeBase64(_ v: Any?) -> Data
{
guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() }
return d
}
func jsonString(_ obj: [String: Any]) -> String?
{
guard JSONSerialization.isValidJSONObject(obj),
let d = try? JSONSerialization.data(withJSONObject: obj),
let s = String(data: d, encoding: .utf8)
else { return nil }
return s
}
// JS JSON nil null
// JSONSerialization
func jsonValue(_ v: Any?) -> String
{
guard let v = v else { return "null" }
guard let d = try? JSONSerialization.data(withJSONObject: [v]),
let s = String(data: d, encoding: .utf8)
else { return "null" }
return String(s.dropFirst().dropLast())
}
func jsString(_ s: String) -> String
{
return jsonValue(s)
}
// boot JSON nullapi_base
func sessionJSON() -> String
{
guard let s = auth?.session else { return "null" }
return "{ access_token: \(jsString(s.accessToken)), "
+ "refresh_token: \(jsString(s.refreshToken)), "
+ "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }"
}
func currentDeviceName() -> String
{
return auth?.session?.deviceName ?? "iPhone"
}
func apiBaseForBoot() -> String
{
return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? ""
}
}
@@ -0,0 +1,19 @@
import SwiftUI
import WebKit
// EngineController WKWebView SwiftUI UI
// controller JS ios/PLAN.md arch A
struct EngineWebView: UIViewRepresentable
{
let controller: EngineController
func makeUIView(context: Context) -> WKWebView
{
return controller.makeWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context)
{
//
}
}