Skip to content

Commit 4ccaa62

Browse files
leogdioncursoragent
andcommitted
Consume CloudKit web auth token rotation from response headers (#462).
Middleware now reads X-Apple-CloudKit-Web-Auth-Token and feeds rotated tokens back through TokenManager so long-lived sessions can extend their window; WebAuthTokenManager becomes an actor to hold mutable state on macOS 11+. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 00d74ba commit 4ccaa62

11 files changed

Lines changed: 341 additions & 9 deletions

‎Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,27 @@ extension AdaptiveTokenManager {
6161

6262
return authenticator
6363
}
64+
65+
/// Adopts a rotated web auth token from a CloudKit response header.
66+
public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) {
67+
guard webAuthToken != nil else {
68+
return
69+
}
70+
71+
let authenticator = try WebAuthTokenAuthenticator(
72+
apiToken: apiToken,
73+
webAuthToken: token
74+
)
75+
self.webAuthToken = token
76+
77+
if let storage = storage {
78+
do {
79+
try await storage.store(authenticator, identifier: apiToken)
80+
} catch {
81+
Logger(subsystem: .auth).warning(
82+
"Failed to store credentials after token rotation: \(error.localizedDescription)"
83+
)
84+
}
85+
}
86+
}
6487
}

‎Sources/MistKit/Authentication/AuthenticationMiddleware.swift‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
internal import Foundation
3131
internal import HTTPTypes
32+
internal import Logging
3233
internal import OpenAPIRuntime
3334

3435
/// Authentication middleware that delegates request mutation to whichever
@@ -50,6 +51,16 @@ internal struct AuthenticationMiddleware: ClientMiddleware {
5051
var modifiedRequest = request
5152
var modifiedBody = body
5253
try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody)
53-
return try await next(modifiedRequest, modifiedBody, baseURL)
54+
let (response, responseBody) = try await next(modifiedRequest, modifiedBody, baseURL)
55+
if let rotated = response.headerFields[.cloudKitWebAuthToken] {
56+
do {
57+
try await tokenManager.didReceiveRotatedWebAuthToken(rotated)
58+
} catch {
59+
let message = "Failed to consume rotated web auth token: \(error.localizedDescription)"
60+
Logger(subsystem: .auth).warning("\(message)")
61+
RotatedWebAuthTokenFailureReporter.assertionHandler(message)
62+
}
63+
}
64+
return (response, responseBody)
5465
}
5566
}

‎Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ extension HTTPField.Name {
4949
"X-Apple-CloudKit-Request-SignatureV1"
5050
)
5151

52+
/// Rotated web authentication token returned on every CloudKit response.
53+
internal static let cloudKitWebAuthToken = Self.knownFieldName(
54+
"X-Apple-CloudKit-Web-Auth-Token"
55+
)
56+
5257
private static func knownFieldName(_ name: String) -> HTTPField.Name {
5358
guard let fieldName = HTTPField.Name(name) else {
5459
preconditionFailure("Invalid HTTP field name: \(name)")
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// RotatedWebAuthTokenFailureReporter.swift
3+
// MistKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2026 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the "Software"), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
/// Trap hook when rotated web auth token adoption fails in middleware.
31+
internal enum RotatedWebAuthTokenFailureReporter {
32+
@TaskLocal internal static var assertionHandler: @Sendable (String) -> Void = { message in
33+
assertionFailure(message)
34+
}
35+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// TokenManager+Rotation.swift
3+
// MistKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2026 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the "Software"), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
internal import Foundation
31+
32+
extension TokenManager {
33+
/// Adopts a rotated web auth token from the `X-Apple-CloudKit-Web-Auth-Token`
34+
/// response header. Default implementation is a no-op for managers that do
35+
/// not use web authentication.
36+
public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) {}
37+
}

‎Sources/MistKit/Authentication/TokenManager.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,8 @@ public protocol TokenManager: Sendable {
4646
/// Returns the authenticator that should be used for the next request,
4747
/// or `nil` if no credentials are available.
4848
func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)?
49+
50+
/// Adopts a rotated web auth token from the `X-Apple-CloudKit-Web-Auth-Token`
51+
/// response header.
52+
func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError)
4953
}

‎Sources/MistKit/Authentication/WebAuthTokenManager.swift‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ internal import Foundation
3131

3232
/// Token manager for web authentication with API token + web auth token.
3333
/// Provides user-specific access to CloudKit Web Services.
34-
public final class WebAuthTokenManager: TokenManager, Sendable {
34+
public actor WebAuthTokenManager: TokenManager {
3535
internal let apiToken: String
36-
internal let webAuthToken: String
36+
internal var webAuthToken: String
3737

3838
// MARK: - TokenManager Protocol
3939

@@ -66,4 +66,10 @@ public final class WebAuthTokenManager: TokenManager, Sendable {
6666
public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? {
6767
try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webAuthToken)
6868
}
69+
70+
/// Adopts a rotated web auth token from a CloudKit response header.
71+
public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) {
72+
_ = try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: token)
73+
self.webAuthToken = token
74+
}
6975
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
internal import Foundation
2+
internal import HTTPTypes
3+
internal import OpenAPIRuntime
4+
internal import Testing
5+
6+
@testable import MistKit
7+
8+
extension AuthenticationMiddlewareTests {
9+
@Suite("Token Rotation")
10+
internal struct TokenRotation {
11+
private static let validAPIToken = TestConstants.apiToken
12+
private static let validWebAuthToken = TestConstants.webAuthToken
13+
private static let rotatedWebAuthToken =
14+
"rotatedwebauthtokenabcdef0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
15+
+ "abcdefghijklmnopqrstuvwxyz0123456789AB=="
16+
private static let testOperationID = TestConstants.operationID
17+
18+
private static func makeRequest() -> HTTPRequest {
19+
HTTPRequest(
20+
method: .get,
21+
scheme: "https",
22+
authority: "api.apple-cloudkit.com",
23+
path: "/database/1/iCloud.com.example.app/private/records/query"
24+
)
25+
}
26+
27+
private static func responseWithRotatedToken(_ token: String) -> HTTPResponse {
28+
var response = HTTPResponse(status: .ok)
29+
response.headerFields[.cloudKitWebAuthToken] = token
30+
return response
31+
}
32+
33+
@Test("Middleware forwards rotated token to token manager")
34+
internal func middlewareForwardsRotatedToken() async throws {
35+
let mockManager = MockTokenManagerWithRotation()
36+
let middleware = AuthenticationMiddleware(tokenManager: mockManager)
37+
38+
_ = try await middleware.intercept(
39+
Self.makeRequest(),
40+
body: nil as HTTPBody?,
41+
baseURL: CloudKitService.baseURL,
42+
operationID: Self.testOperationID,
43+
next: { _, _, _ in
44+
(Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil)
45+
}
46+
)
47+
48+
let received = await mockManager.receivedRotatedTokens
49+
#expect(received == [Self.rotatedWebAuthToken])
50+
}
51+
52+
@Test("Middleware skips rotation hook when header is absent")
53+
internal func middlewareSkipsRotationWithoutHeader() async throws {
54+
let mockManager = MockTokenManagerWithRotation()
55+
let middleware = AuthenticationMiddleware(tokenManager: mockManager)
56+
57+
_ = try await middleware.intercept(
58+
Self.makeRequest(),
59+
body: nil as HTTPBody?,
60+
baseURL: CloudKitService.baseURL,
61+
operationID: Self.testOperationID,
62+
next: { _, _, _ in
63+
(HTTPResponse(status: .ok), nil)
64+
}
65+
)
66+
67+
let received = await mockManager.receivedRotatedTokens
68+
#expect(received.isEmpty)
69+
}
70+
71+
@Test("Middleware returns response when rotation adoption fails")
72+
internal func middlewareReturnsResponseWhenRotationFails() async throws {
73+
let mockManager = MockTokenManagerWithRotationFailure()
74+
let middleware = AuthenticationMiddleware(tokenManager: mockManager)
75+
76+
let (response, _) = try await RotatedWebAuthTokenFailureReporter.$assertionHandler.withValue(
77+
{ _ in },
78+
operation: {
79+
try await middleware.intercept(
80+
Self.makeRequest(),
81+
body: nil as HTTPBody?,
82+
baseURL: CloudKitService.baseURL,
83+
operationID: Self.testOperationID,
84+
next: { _, _, _ in
85+
(Self.responseWithRotatedToken("short"), nil)
86+
}
87+
)
88+
}
89+
)
90+
91+
#expect(response.status == .ok)
92+
}
93+
94+
@Test("WebAuthTokenManager adopts rotated token from middleware")
95+
internal func webAuthTokenManagerAdoptsRotatedToken() async throws {
96+
let tokenManager = WebAuthTokenManager(
97+
apiToken: Self.validAPIToken,
98+
webAuthToken: Self.validWebAuthToken
99+
)
100+
let middleware = AuthenticationMiddleware(tokenManager: tokenManager)
101+
102+
_ = try await middleware.intercept(
103+
Self.makeRequest(),
104+
body: nil as HTTPBody?,
105+
baseURL: CloudKitService.baseURL,
106+
operationID: Self.testOperationID,
107+
next: { _, _, _ in
108+
(Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil)
109+
}
110+
)
111+
112+
#expect(await tokenManager.webAuthToken == Self.rotatedWebAuthToken)
113+
let authenticator = try await tokenManager.currentAuthenticator()
114+
let web = try #require(authenticator as? WebAuthTokenAuthenticator)
115+
#expect(web.webAuthToken == Self.rotatedWebAuthToken)
116+
}
117+
118+
@Test("AdaptiveTokenManager adopts rotated token from middleware")
119+
internal func adaptiveTokenManagerAdoptsRotatedToken() async throws {
120+
let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken)
121+
try await tokenManager.upgradeToWebAuthentication(webAuthToken: Self.validWebAuthToken)
122+
let middleware = AuthenticationMiddleware(tokenManager: tokenManager)
123+
124+
_ = try await middleware.intercept(
125+
Self.makeRequest(),
126+
body: nil as HTTPBody?,
127+
baseURL: CloudKitService.baseURL,
128+
operationID: Self.testOperationID,
129+
next: { _, _, _ in
130+
(Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil)
131+
}
132+
)
133+
134+
#expect(await tokenManager.webAuthToken == Self.rotatedWebAuthToken)
135+
}
136+
}
137+
}

‎Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,26 @@ extension WebAuthTokenManagerTests {
2222

2323
/// Tests WebAuthTokenManager initialization with valid tokens
2424
@Test("WebAuthTokenManager initialization with valid tokens")
25-
internal func initializationWithValidTokens() {
25+
internal func initializationWithValidTokens() async {
2626
let manager = WebAuthTokenManager(
2727
apiToken: Self.validAPIToken,
2828
webAuthToken: Self.validWebAuthToken
2929
)
3030

31-
#expect(manager.apiToken == Self.validAPIToken)
32-
#expect(manager.webAuthToken == Self.validWebAuthToken)
31+
#expect(await manager.apiToken == Self.validAPIToken)
32+
#expect(await manager.webAuthToken == Self.validWebAuthToken)
3333
}
3434

3535
/// Tests WebAuthTokenManager initialization with storage
3636
@Test("WebAuthTokenManager initialization with storage")
37-
internal func initializationWithStorage() {
37+
internal func initializationWithStorage() async {
3838
let manager = WebAuthTokenManager(
3939
apiToken: Self.validAPIToken,
4040
webAuthToken: Self.validWebAuthToken
4141
)
4242

43-
#expect(manager.apiToken == Self.validAPIToken)
44-
#expect(manager.webAuthToken == Self.validWebAuthToken)
43+
#expect(await manager.apiToken == Self.validAPIToken)
44+
#expect(await manager.webAuthToken == Self.validWebAuthToken)
4545
}
4646

4747
// MARK: - TokenManager Protocol Tests
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// MockTokenManagerWithRotation.swift
3+
// MistKit
4+
//
5+
6+
internal import Foundation
7+
internal import HTTPTypes
8+
internal import OpenAPIRuntime
9+
10+
@testable import MistKit
11+
12+
/// Mock TokenManager that records rotated web auth tokens from responses.
13+
internal final class MockTokenManagerWithRotation: TokenManager {
14+
private actor State {
15+
private var receivedTokens: [String] = []
16+
17+
func append(_ token: String) {
18+
receivedTokens.append(token)
19+
}
20+
21+
func tokens() -> [String] {
22+
receivedTokens
23+
}
24+
}
25+
26+
private let state = State()
27+
28+
internal var hasCredentials: Bool {
29+
get async { true }
30+
}
31+
32+
internal var receivedRotatedTokens: [String] {
33+
get async { await state.tokens() }
34+
}
35+
36+
internal func validateCredentials() async throws(TokenManagerError) -> Bool {
37+
true
38+
}
39+
40+
internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? {
41+
try APITokenAuthenticator(token: TestConstants.apiToken)
42+
}
43+
44+
internal func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) {
45+
await state.append(token)
46+
}
47+
}

0 commit comments

Comments
 (0)