Files
Termind/Frameworks/TermindApplication/MCPAuthorization.swift
T
Kaede Akino 4d6e4481c0 feat(mcp): add OAuth and 2025-03-26 compatibility
Integrate AgentKit 0.18.1 with native authorization on macOS and iOS, device-local OAuth credentials, V43 configuration persistence, and Assistant authorization prompts.

Add HTTPS callback metadata, keep native callbacks safe across actor boundaries, and distinguish authorization setup failures from cancellation. Extend the external MCP server to negotiate 2025-03-26.
2026-09-28 12:36:32 +08:00

352 lines
15 KiB
Swift

import AgentKit
import Foundation
import Observation
/// A device-local secure store. OAuth grants never use the AI secret migration path.
@MainActor
public protocol MCPAuthorizationStoring: AnyObject, Sendable {
func credentials(for serverID: UUID) throws -> [MCPOAuthCredential]
func setCredentials(_ credentials: [MCPOAuthCredential], for serverID: UUID) throws
func clientSecret(for server: MCPServer) throws -> String?
func setClientSecret(_ value: String?, for server: MCPServer) throws
func remove(serverID: UUID) throws
}
@MainActor
public final class InMemoryMCPAuthorizationStore: MCPAuthorizationStoring {
private var grants: [UUID: [MCPOAuthCredential]] = [:]
private var secrets: [UUID: (String, MCPOAuthConfiguration?, String)] = [:]
public init() {}
public func credentials(for serverID: UUID) -> [MCPOAuthCredential] { grants[serverID] ?? [] }
public func setCredentials(_ credentials: [MCPOAuthCredential], for serverID: UUID) {
grants[serverID] = credentials
}
public func clientSecret(for server: MCPServer) -> String? {
guard let entry = secrets[server.id], entry.0 == server.url, entry.1 == server.oauth else { return nil }
return entry.2
}
public func setClientSecret(_ value: String?, for server: MCPServer) {
secrets[server.id] = value.map { (server.url, server.oauth, $0) }
}
public func remove(serverID: UUID) {
grants[serverID] = nil
secrets[serverID] = nil
}
}
/// Checks the current configuration again at the persistence boundary, after asynchronous OAuth work.
@MainActor
public final class MCPAuthorizationGrantStore: MCPOAuthCredentialStoring {
let backing: any MCPAuthorizationStoring
var servers: [UUID: MCPServer] = [:]
var changed: (() -> Void)?
var committingIDs: Set<UUID> = []
public init(backing: any MCPAuthorizationStoring) { self.backing = backing }
public func credentials(for serverID: UUID) throws -> [MCPOAuthCredential] {
guard let server = servers[serverID] else { throw CancellationError() }
return try backing.credentials(for: serverID).filter { Self.matches($0, server: server) }
}
public func save(_ credentials: [MCPOAuthCredential], for serverID: UUID) throws {
try Task.checkCancellation()
guard let server = servers[serverID], credentials.allSatisfy({ Self.matches($0, server: server) }) else {
throw CancellationError()
}
try backing.setCredentials(credentials, for: serverID)
changed?()
}
public func clientSecret(for server: MCPServer) throws -> String? {
guard let current = servers[server.id], Self.sameBinding(current, server) else { throw CancellationError() }
return try backing.clientSecret(for: server)
}
public static func sameBinding(_ lhs: MCPServer, _ rhs: MCPServer) -> Bool {
lhs.url == rhs.url && lhs.oauth == rhs.oauth && lhs.transport == rhs.transport
&& lhs.credentialRef == rhs.credentialRef && lhs.headers == rhs.headers
}
static func matches(_ credential: MCPOAuthCredential, server: MCPServer) -> Bool {
guard var url = URLComponents(string: server.url.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return false
}
url.scheme = url.scheme?.lowercased()
url.host = url.host?.lowercased()
if url.scheme == "https", url.port == 443 { url.port = nil }
if url.path == "/" { url.path = "" }
return credential.resource == url.string && credential.configuration == (server.oauth ?? .init())
}
}
@MainActor @Observable
public final class MCPAuthorizationModel: MCPAuthorizationProviding {
public static let redirectURI = URL(string: "https://termind.akinokaede.com/oauth/mcp/callback/")!
public static let clientMetadataURL = URL(string: "https://termind.akinokaede.com/oauth/mcp-client.json")!
public let coordinator: MCPOAuthCoordinator
private let grants: MCPAuthorizationGrantStore
private var revision = 0
public private(set) var error: String?
public init(storage: any MCPAuthorizationStoring) {
let grants = MCPAuthorizationGrantStore(backing: storage)
self.grants = grants
coordinator = MCPOAuthCoordinator(
store: grants, redirectURI: Self.redirectURI, clientMetadataURL: Self.clientMetadataURL,
clientName: "Termind")
grants.changed = { [weak self] in self?.revision += 1 }
}
public var client: MCPClient {
MCPClient(
clientInfo: .init(
name: "Termind", version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"),
authorization: self)
}
public func token(
for server: MCPServer, challenge: MCPAuthorizationChallenge?, rejectedToken: String?,
interaction: MCPAuthorizationInteraction?
) async throws -> String? {
guard grants.servers[server.id].map({ MCPAuthorizationGrantStore.sameBinding($0, server) }) == true else {
throw CancellationError()
}
let token = try await coordinator.token(
for: server, challenge: challenge, rejectedToken: rejectedToken, interaction: interaction)
guard grants.servers[server.id].map({ MCPAuthorizationGrantStore.sameBinding($0, server) }) == true else {
throw CancellationError()
}
return token
}
public func synchronize(_ servers: [MCPServer]) {
let updated = Dictionary(uniqueKeysWithValues: servers.map { ($0.id, $0) })
let retired = grants.servers.values.filter { previous in
updated[previous.id].map { !MCPAuthorizationGrantStore.sameBinding(previous, $0) } ?? true
}
grants.servers = updated
for server in retired {
let retiredCredentials =
grants.committingIDs.contains(server.id)
? [] : ((try? grants.backing.credentials(for: server.id)) ?? [])
do { if !grants.committingIDs.contains(server.id) { try grants.backing.remove(serverID: server.id) } } catch
{ self.error = error.localizedDescription }
Task {
await coordinator.cancel(serverID: server.id)
await coordinator.revoke(
retiredCredentials.filter { MCPAuthorizationGrantStore.matches($0, server: server) })
}
}
revision += 1
}
public func isSignedIn(_ server: MCPServer) -> Bool {
_ = revision
guard grants.servers[server.id].map({ MCPAuthorizationGrantStore.sameBinding($0, server) }) == true else {
return false
}
return (try? grants.credentials(for: server.id).contains { $0.accessToken != nil || $0.refreshToken != nil })
?? false
}
public func clientSecret(for server: MCPServer) throws -> String {
try grants.backing.clientSecret(for: server) ?? ""
}
public func setClientSecret(_ secret: String, for server: MCPServer) throws {
guard (try grants.backing.clientSecret(for: server) ?? "") != secret else { return }
try grants.backing.setClientSecret(secret.isEmpty ? nil : secret, for: server)
try grants.backing.setCredentials([], for: server.id)
revision += 1
Task { await coordinator.cancel(serverID: server.id) }
}
public func signOut(_ server: MCPServer) async {
do {
try await coordinator.signOut(serverID: server.id)
try grants.backing.setClientSecret(nil, for: server)
error = nil
} catch { self.error = error.localizedDescription }
revision += 1
}
public func prepareReauthorization(_ server: MCPServer) async {
do {
try await coordinator.signOut(serverID: server.id)
error = nil
} catch { self.error = error.localizedDescription }
revision += 1
}
public func draft(for server: MCPServer) throws -> MCPAuthorizationDraft {
try MCPAuthorizationDraft(server: server, live: grants, liveCoordinator: coordinator)
}
}
/// Unsaved mobile grants stay in memory; cancelling an editor cannot replace a saved login.
@MainActor @Observable
public final class MCPAuthorizationDraft: MCPOAuthCredentialStoring, MCPAuthorizationProviding {
private let live: MCPAuthorizationGrantStore
private let liveCoordinator: MCPOAuthCoordinator
private let original: MCPServer
private var current: MCPServer
private var values: [MCPOAuthCredential]
private var baseline: [MCPOAuthCredential]
public var secret: String {
didSet {
if secret != oldValue {
values = []
let service = coordinatorStorage
let id = current.id
Task { await service?.cancel(serverID: id) }
}
}
}
private let originalSecret: String
private var discarded = false
private var coordinatorStorage: MCPOAuthCoordinator?
public var coordinator: MCPOAuthCoordinator {
if let coordinatorStorage { return coordinatorStorage }
let value = MCPOAuthCoordinator(
store: self, redirectURI: MCPAuthorizationModel.redirectURI,
clientMetadataURL: MCPAuthorizationModel.clientMetadataURL, clientName: "Termind")
coordinatorStorage = value
return value
}
init(server: MCPServer, live: MCPAuthorizationGrantStore, liveCoordinator: MCPOAuthCoordinator) throws {
self.original = server
self.current = server
self.live = live
self.liveCoordinator = liveCoordinator
let saved = live.servers[server.id] == nil ? [] : try live.credentials(for: server.id)
baseline = saved
values = saved
originalSecret = try live.backing.clientSecret(for: server) ?? ""
secret = originalSecret
}
public var isSignedIn: Bool { values.contains { $0.accessToken != nil || $0.refreshToken != nil } }
public func update(_ server: MCPServer) {
if !MCPAuthorizationGrantStore.sameBinding(current, server) {
values = []
if current.url != server.url || current.oauth != server.oauth { secret = "" }
let service = coordinatorStorage
Task { await service?.cancel(serverID: server.id) }
}
current = server
}
/// Saved grants share the runtime's refresh flight. Only newly authorized grants belong to the draft.
public func token(
for server: MCPServer, challenge: MCPAuthorizationChallenge?, rejectedToken: String?,
interaction: MCPAuthorizationInteraction?
) async throws -> String? {
guard !discarded, MCPAuthorizationGrantStore.sameBinding(current, server) else { throw CancellationError() }
if values == baseline, secret == originalSecret,
MCPAuthorizationGrantStore.sameBinding(original, current),
live.servers[server.id].map({ MCPAuthorizationGrantStore.sameBinding($0, current) }) == true
{
do {
let token = try await liveCoordinator.token(
for: server, challenge: challenge, rejectedToken: rejectedToken, interaction: nil)
baseline = try live.credentials(for: server.id)
values = baseline
return token
} catch MCPOAuthError.authorizationRequired {
baseline = try live.credentials(for: server.id)
values = baseline
}
}
return try await coordinator.token(
for: server, challenge: challenge, rejectedToken: rejectedToken, interaction: interaction)
}
public func credentials(for serverID: UUID) throws -> [MCPOAuthCredential] {
guard !discarded, serverID == current.id else { throw CancellationError() }
return values.filter { MCPAuthorizationGrantStore.matches($0, server: current) }
}
public func save(_ credentials: [MCPOAuthCredential], for serverID: UUID) throws {
try Task.checkCancellation()
guard !discarded, serverID == current.id,
credentials.allSatisfy({ MCPAuthorizationGrantStore.matches($0, server: current) })
else {
throw CancellationError()
}
values = credentials
}
public func saveRefreshed(_ credentials: [MCPOAuthCredential], for serverID: UUID) throws {
if values != baseline {
try save(credentials, for: serverID)
return
}
guard !discarded, MCPAuthorizationGrantStore.sameBinding(original, current),
live.servers[serverID].map({ MCPAuthorizationGrantStore.sameBinding($0, original) }) == true
else {
throw CancellationError()
}
try live.save(credentials, for: serverID)
values = credentials
baseline = credentials
}
public func clientSecret(for server: MCPServer) throws -> String? {
guard !discarded, MCPAuthorizationGrantStore.sameBinding(current, server) else { throw CancellationError() }
return secret.isEmpty ? nil : secret
}
/// Commit the secure half before the configuration transaction, and restore it if that transaction fails.
public func commit(server: MCPServer, persist: () throws -> Void) throws {
guard !discarded else { throw CancellationError() }
let previous = try live.backing.credentials(for: server.id)
let unchanged = values == baseline && secret == originalSecret
guard unchanged || previous == baseline || !MCPAuthorizationGrantStore.sameBinding(original, server) else {
throw SettingsDraftMerge.Conflict.changed
}
let committed = unchanged && MCPAuthorizationGrantStore.sameBinding(original, server) ? previous : values
let previousSecret = try live.backing.clientSecret(for: original)
live.committingIDs.insert(server.id)
defer { live.committingIDs.remove(server.id) }
do {
try live.backing.setCredentials(
committed.filter { MCPAuthorizationGrantStore.matches($0, server: server) }, for: server.id)
try live.backing.setClientSecret(secret.isEmpty ? nil : secret, for: server)
try persist()
live.changed?()
if !MCPAuthorizationGrantStore.sameBinding(original, server) {
Task { await liveCoordinator.revoke(previous.filter { !committed.contains($0) }) }
}
} catch {
try? live.backing.setCredentials(previous, for: server.id)
try? live.backing.setClientSecret(previousSecret, for: original)
throw error
}
}
public func resetAfterSignOut() {
values = []
baseline = []
secret = ""
}
public func requireAuthorization() {
for index in values.indices {
values[index].accessToken = nil
values[index].refreshToken = nil
values[index].expiresAt = nil
}
}
public func discard() {
discarded = true
values = []
secret = ""
let service = coordinatorStorage
let serverID = current.id
coordinatorStorage = nil
Task { await service?.cancel(serverID: serverID) }
}
}