Files

355 lines
15 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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) }
}
}