You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AuthenticationMiddleware.intercept (Sources/MistKit/AuthenticationMiddleware.swift)
currently owns all the per-method knowledge of how to attach credentials to a
CloudKit request: API token → query item, web auth → two query items with
character-map encoding, server-to-server → ECDSA-signed headers + body access.
The middleware switches over TokenCredentials.method and dispatches to a
private helper per case (addAPITokenAuthentication, addWebAuthTokenAuthentication, addServerToServerAuthentication).
Two smells fall out of this:
Open switch over a closed enum. Adding a new auth method (e.g. an OAuth /
user-token mode if Apple ever exposes one) means editing the middleware.
Conditional downcast for one case. The S2S branch does tokenManager as? ServerToServerAuthManager to reach signRequest(...),
plus an assertionFailure for the impossible-but-typeable case where the
manager doesn't match the credentials it just produced. The middleware is
reaching back into the manager because the credential payload alone isn't
enough to produce the signature.
Push the request-mutation logic onto the credential itself. The middleware then
becomes a one-liner: get credentials, ask them to authenticate the request,
forward to next.
Pros: small, additive, no new public types.
Cons: still a switch (just relocated); still needs an injected signer for the
S2S branch because the enum payload is (keyID, privateKey) and signing also
wants the configured Crypto machinery from ServerToServerAuthManager.
Option B — public Authenticator protocol + concrete types (delete AuthenticationMethod and TokenCredentials)
```swift
public protocol Authenticator: Sendable {
static var storageKey: String { get } // "api-token", "web-auth-token", …
// Each authenticator owns its own wire format.
func encoded() throws -> Data
init(decoding data: Data) throws
}
public struct APITokenAuthenticator: Authenticator {
public let token: String
public init(token: String) throws { /* format validation here / }
}
public struct WebAuthTokenAuthenticator: Authenticator {
public init(apiToken: String, webAuthToken: String) throws { / validation */ }
}
public struct ServerToServerAuthenticator: Authenticator {
let keyID: String
let privateKey: P256.Signing.PrivateKey
// owns its own signing — no manager downcast needed
}
```
Authenticator deliberately does not inherit Equatable — that would
introduce a Self requirement and prevent its use as any Authenticator,
which storage and currentAuthenticator() need. Tests compare via as? APITokenAuthenticator + field equality on the concrete type.
AuthenticationMiddleware.intercept collapses to:
```swift
guard let authenticator = try await tokenManager.currentAuthenticator() else {
throw TokenManagerError.invalidCredentials(.noCredentialsAvailable)
}
var mutableBody = body
try await authenticator.authenticate(request: &request, body: &mutableBody)
return try await next(request, mutableBody, baseURL)
```
Pros:
No switch in the middleware; new auth modes are additive.
S2S signing lives with the S2S authenticator — kills the as? ServerToServerAuthManager cast and the assertionFailure.
Each authenticator is independently unit-testable (no middleware harness, no fake next closure).
Authenticator is public, so consumers can write custom auth (test fakes, future Apple-flavored auth modes) without forking MistKit.
AuthenticationMethod and TokenCredentials are public, but MistKit is
pre-1.0 (v1.0.0-alpha.5) — straight removal in one PR is fine.
Recommendation: Option B, deleting AuthenticationMethod and TokenCredentials outright. The concrete *Authenticator types become the
user-facing values. See "Why drop the enum and wrapper?" below.
Why drop the enum and wrapper?
AuthenticationMethod is dead weight
A grep across Sources/ finds exactly two production consumers of AuthenticationMethod outside its own definition:
AuthenticationMiddleware.swift:62 — the switch this issue is rewriting.
InMemoryTokenStorage+Convenience.swift:41,64,81 — a second switch on credentials.method plus methodType used as a stable storage key.
Both jobs the enum was doing — carrying the payload, and tagging the variant
for switch dispatch — belong on the concrete authenticator types under
Option B. The third job, methodType: String as a storage key, becomes Authenticator.storageKey. InMemoryTokenStorage+Convenience then dispatches
on type identity (or storageKey lookup) instead of enum pattern-matching.
The remaining ~30 references are all test code asserting "the value I stored
came back". Those rewrite naturally (a switch on the enum becomes an if let api = retrieved as? APITokenAuthenticator).
TokenCredentials.metadata is unread
metadata: [String: String] is written by credentialsWithMetadata(_:) on
each manager and stored on TokenCredentials, but a grep across Sources/
finds zero production reads. Tests verify round-trip storage; nothing
consumes the dictionary. It's pure surface area with no observable behavior.
With metadata removed and method replaced, TokenCredentials has nothing
left to wrap — the authenticator is the credential. So TokenCredentials
goes too.
Migration shape
Pre-1.0, so this lands as one PR with no deprecation cycle:
Delete AuthenticationMethod, TokenCredentials, credentialsWithMetadata(_:) on each manager, and TokenManager.getCurrentCredentials().
Replace with Authenticator protocol + three concrete types and TokenManager.currentAuthenticator() -> (any Authenticator)?.
Bump the alpha version on the same PR.
Validation moves to authenticator init
TokenManager.validateAPITokenFormat(_:) and validateWebAuthTokenFormat(_:)
(static helpers on the protocol extension) move to throws initializers on the
concrete authenticators. An invalid token string can't construct an APITokenAuthenticator, so unrepresentable states stay unrepresentable. The
managers stop calling validate* separately — the precondition is in the type.
Serialization lives on the authenticator
Each concrete authenticator owns its wire format via encoded() throws -> Data and init(decoding:) throws. InMemoryTokenStorage
becomes a flat [storageKey: Data] (or in-memory equivalent) and routes
decoding by storageKey:
This is also why Authenticator doesn't inherit Codable — init(from:) is a Self requirement and would block existential use the same way Equatable
would. Hand-rolled encoded() / init(decoding:) keep the protocol
existential-friendly while putting on-disk format decisions where they belong
(next to the type's invariants).
Scope of change (Option B)
Files touched:
Sources/MistKit/AuthenticationMiddleware.swift — drop helpers, collapse intercept to single delegation call.
Sources/MistKit/Authentication/TokenManager.swift — replace getCurrentCredentials() with currentAuthenticator() -> (any Authenticator)?.
Drop validateAPITokenFormat / validateWebAuthTokenFormat static helpers
(logic moves to authenticator initializers).
Sources/MistKit/Authentication/{APIToken,WebAuthToken,ServerToServer}Manager.swift
— drop credentialsWithMetadata(_:). Each manager now stores and vends a
concrete authenticator instead of building TokenCredentials.
Sources/MistKit/Authentication/AdaptiveTokenManager.swift and AdaptiveTokenManager+Transitions.swift — in scope, not skipped.
This is the manager whose job is switching auth modes at runtime. currentAuthenticator() returns whichever authenticator the current state
holds; transitions swap the stored value. The new abstraction is what makes
this manager's logic clean (no longer rebuilds a TokenCredentials envelope
on every transition).
Sources/MistKit/Authentication/InMemoryTokenStorage.swift and InMemoryTokenStorage+Convenience.swift — storage holds [storageKey: Data] of encoded authenticators (per the serialization
section); the convenience extension's enum switches go away.
Sources/MistKit/Authentication/ServerToServerAuthManager+RequestSigning.swift
— move the request-signing primitive onto ServerToServerAuthenticator,
leaving the manager as a pure storage/lifecycle component (or delete if the
manager's only job was signing).
Sources/MistKit/Authentication/ — add the public Authenticator protocol +
three concrete types (APITokenAuthenticator, WebAuthTokenAuthenticator, ServerToServerAuthenticator), each with throws init for format
validation and encoded() / init(decoding:) for serialization.
Tests:
Tests/MistKitTests/AuthenticationMiddlewareTests* shrink to verifying
"middleware calls authenticator and forwards" — one happy-path + one
no-credentials-throws case.
Tests/MistKitTests/Authentication/Protocol/TokenManagerAuthenticationMethodTests.swift
and TokenManagerTokenCredentialsTests.swift — delete; coverage moves
to the per-authenticator test files.
New Tests/MistKitTests/Authentication/{APIToken,WebAuthToken,ServerToServer}AuthenticatorTests.swift
cover (a) the per-method query/header construction, (b) format-validation
throwing from init, (c) encoded() ↔ init(decoding:) round-trip.
#285 lives in MistDemo and proposes bundling Database + credentials so
mismatch is caught at the type system. This issue is the MistKit-side complement:
bundle credential payload + how to apply it to a request. The two compose:
tokenManager.currentAuthenticator() returns an Authenticator (this issue)
AuthenticationMiddleware only sees the latter
Either issue can land first; resolving this one removes one of #285's "knock-on
impacts" (MistKitClientFactory.create(for:) collapses further because the
factory no longer has to know about S2S vs token auth at the middleware layer).
Decisions locked in
Authenticator is public — consumers can write custom auth (test fakes,
future modes) without forking.
TokenCredentials.metadata is removed — zero production reads; TokenCredentials itself goes with it.
No Equatable conformance on Authenticator — would introduce a Self
requirement and prevent any Authenticator use. Tests compare via type-cast +
field equality on the concrete type.
No deprecation cycle. Pre-1.0; delete the old types and bump alpha in one PR.
Format validation moves to authenticator init(... ) throws —
unrepresentable states stay unrepresentable; managers stop calling validate* separately.
Serialization lives on the authenticator via encoded() / init(decoding:). No Codable inheritance (same Self-requirement issue
as Equatable).
AdaptiveTokenManager is in scope. It's the central case for the new
abstraction, not an exception.
body is inout HTTPBody? on Authenticator.authenticate — symmetric
with request: inout, lets S2S signing replace the body after consuming it
to read bytes for the signature.
Implementation notes
HTTPBody re-use after S2S signing. Current code reads body via Data(collecting: body, upTo: 1 MiB) then forwards the originalbody to next(...). For HTTPBody.iterationBehavior == .single this drains the
body — next receives an empty stream. Probably hasn't bitten anyone because
the OpenAPI generator usually produces .multiple bodies for JSON requests,
but it's a latent footgun. The body: inout HTTPBody? parameter on Authenticator.authenticate is what lets ServerToServerAuthenticator fix
this: read once to sign, then reassign body to a fresh HTTPBody wrapping
the buffered Data so downstream sees the same bytes regardless of
iteration behavior. Needs a regression test (mock .single body, run S2S
auth, assert downstream still receives the bytes).
Open questions
Body buffering for S2S signing currently caps at 1 MiB
(AuthenticationMiddleware.swift:157). Move that cap onto ServerToServerAuthenticator and expose it as a configurable property.
Summary
AuthenticationMiddleware.intercept(Sources/MistKit/AuthenticationMiddleware.swift)currently owns all the per-method knowledge of how to attach credentials to a
CloudKit request: API token → query item, web auth → two query items with
character-map encoding, server-to-server → ECDSA-signed headers + body access.
The middleware switches over
TokenCredentials.methodand dispatches to aprivate helper per case (
addAPITokenAuthentication,addWebAuthTokenAuthentication,addServerToServerAuthentication).Two smells fall out of this:
user-token mode if Apple ever exposes one) means editing the middleware.
tokenManager as? ServerToServerAuthManagerto reachsignRequest(...),plus an
assertionFailurefor the impossible-but-typeable case where themanager doesn't match the credentials it just produced. The middleware is
reaching back into the manager because the credential payload alone isn't
enough to produce the signature.
Push the request-mutation logic onto the credential itself. The middleware then
becomes a one-liner: get credentials, ask them to authenticate the request,
forward to
next.Proposed direction
Option A — method on the existing enum
Add a single async-throwing method:
```swift
extension AuthenticationMethod {
func apply(
to request: inout HTTPRequest,
body: HTTPBody?,
encoder: CharacterMapEncoder,
signer: RequestSigner // see below
) async throws { ... }
}
```
Pros: small, additive, no new public types.
Cons: still a switch (just relocated); still needs an injected
signerfor theS2S branch because the enum payload is
(keyID, privateKey)and signing alsowants the configured
Cryptomachinery fromServerToServerAuthManager.Option B — public
Authenticatorprotocol + concrete types (deleteAuthenticationMethodandTokenCredentials)```swift
public protocol Authenticator: Sendable {
static var storageKey: String { get } // "api-token", "web-auth-token", …
func authenticate(
request: inout HTTPRequest,
body: inout HTTPBody?
) async throws
// Each authenticator owns its own wire format.
func encoded() throws -> Data
init(decoding data: Data) throws
}
public struct APITokenAuthenticator: Authenticator {
public let token: String
public init(token: String) throws { /* format validation here / }
}
public struct WebAuthTokenAuthenticator: Authenticator {
public init(apiToken: String, webAuthToken: String) throws { / validation */ }
}
public struct ServerToServerAuthenticator: Authenticator {
let keyID: String
let privateKey: P256.Signing.PrivateKey
// owns its own signing — no manager downcast needed
}
```
Authenticatordeliberately does not inheritEquatable— that wouldintroduce a
Selfrequirement and prevent its use asany Authenticator,which storage and
currentAuthenticator()need. Tests compare viaas? APITokenAuthenticator+ field equality on the concrete type.AuthenticationMiddleware.interceptcollapses to:```swift
guard let authenticator = try await tokenManager.currentAuthenticator() else {
throw TokenManagerError.invalidCredentials(.noCredentialsAvailable)
}
var mutableBody = body
try await authenticator.authenticate(request: &request, body: &mutableBody)
return try await next(request, mutableBody, baseURL)
```
Pros:
as? ServerToServerAuthManagercast and theassertionFailure.nextclosure).Authenticatorispublic, so consumers can write custom auth (test fakes, future Apple-flavored auth modes) without forking MistKit.DatabaseCredentials.makeTokenManager()can yield a manager whose value already conforms toAuthenticator.Cons:
AuthenticationMethodandTokenCredentialsare public, but MistKit ispre-1.0 (
v1.0.0-alpha.5) — straight removal in one PR is fine.Recommendation: Option B, deleting
AuthenticationMethodandTokenCredentialsoutright. The concrete*Authenticatortypes become theuser-facing values. See "Why drop the enum and wrapper?" below.
Why drop the enum and wrapper?
AuthenticationMethodis dead weightA grep across
Sources/finds exactly two production consumers ofAuthenticationMethodoutside its own definition:AuthenticationMiddleware.swift:62— the switch this issue is rewriting.InMemoryTokenStorage+Convenience.swift:41,64,81— a second switch oncredentials.methodplusmethodTypeused as a stable storage key.Both jobs the enum was doing — carrying the payload, and tagging the variant
for
switchdispatch — belong on the concrete authenticator types underOption B. The third job,
methodType: Stringas a storage key, becomesAuthenticator.storageKey.InMemoryTokenStorage+Conveniencethen dispatcheson type identity (or
storageKeylookup) instead of enum pattern-matching.The remaining ~30 references are all test code asserting "the value I stored
came back". Those rewrite naturally (a switch on the enum becomes an
if let api = retrieved as? APITokenAuthenticator).TokenCredentials.metadatais unreadmetadata: [String: String]is written bycredentialsWithMetadata(_:)oneach manager and stored on
TokenCredentials, but a grep acrossSources/finds zero production reads. Tests verify round-trip storage; nothing
consumes the dictionary. It's pure surface area with no observable behavior.
With
metadataremoved andmethodreplaced,TokenCredentialshas nothingleft to wrap — the authenticator is the credential. So
TokenCredentialsgoes too.
Migration shape
Pre-1.0, so this lands as one PR with no deprecation cycle:
AuthenticationMethod,TokenCredentials,credentialsWithMetadata(_:)on each manager, andTokenManager.getCurrentCredentials().Authenticatorprotocol + three concrete types andTokenManager.currentAuthenticator() -> (any Authenticator)?.Validation moves to authenticator init
TokenManager.validateAPITokenFormat(_:)andvalidateWebAuthTokenFormat(_:)(static helpers on the protocol extension) move to
throwsinitializers on theconcrete authenticators. An invalid token string can't construct an
APITokenAuthenticator, so unrepresentable states stay unrepresentable. Themanagers stop calling
validate*separately — the precondition is in the type.Serialization lives on the authenticator
Each concrete authenticator owns its wire format via
encoded() throws -> Dataandinit(decoding:) throws.InMemoryTokenStoragebecomes a flat
[storageKey: Data](or in-memory equivalent) and routesdecoding by
storageKey:```swift
let factories: [String: (Data) throws -> any Authenticator] = [
APITokenAuthenticator.storageKey: { try APITokenAuthenticator(decoding: $0) },
WebAuthTokenAuthenticator.storageKey: { try WebAuthTokenAuthenticator(decoding: $0) },
ServerToServerAuthenticator.storageKey: { try ServerToServerAuthenticator(decoding: $0) },
]
```
This is also why
Authenticatordoesn't inheritCodable—init(from:)is aSelfrequirement and would block existential use the same wayEquatablewould. Hand-rolled
encoded()/init(decoding:)keep the protocolexistential-friendly while putting on-disk format decisions where they belong
(next to the type's invariants).
Scope of change (Option B)
Files touched:
Sources/MistKit/AuthenticationMiddleware.swift— drop helpers, collapseinterceptto single delegation call.Sources/MistKit/Authentication/AuthenticationMethod.swift— delete.Sources/MistKit/Authentication/TokenCredentials.swift— delete.Sources/MistKit/Authentication/TokenManager.swift— replacegetCurrentCredentials()withcurrentAuthenticator() -> (any Authenticator)?.Drop
validateAPITokenFormat/validateWebAuthTokenFormatstatic helpers(logic moves to authenticator initializers).
Sources/MistKit/Authentication/{APIToken,WebAuthToken,ServerToServer}Manager.swift— drop
credentialsWithMetadata(_:). Each manager now stores and vends aconcrete authenticator instead of building
TokenCredentials.Sources/MistKit/Authentication/AdaptiveTokenManager.swiftandAdaptiveTokenManager+Transitions.swift— in scope, not skipped.This is the manager whose job is switching auth modes at runtime.
currentAuthenticator()returns whichever authenticator the current stateholds; transitions swap the stored value. The new abstraction is what makes
this manager's logic clean (no longer rebuilds a
TokenCredentialsenvelopeon every transition).
Sources/MistKit/Authentication/InMemoryTokenStorage.swiftandInMemoryTokenStorage+Convenience.swift— storage holds[storageKey: Data]of encoded authenticators (per the serializationsection); the convenience extension's enum switches go away.
Sources/MistKit/Authentication/ServerToServerAuthManager+RequestSigning.swift— move the request-signing primitive onto
ServerToServerAuthenticator,leaving the manager as a pure storage/lifecycle component (or delete if the
manager's only job was signing).
Sources/MistKit/Authentication/— add the publicAuthenticatorprotocol +three concrete types (
APITokenAuthenticator,WebAuthTokenAuthenticator,ServerToServerAuthenticator), each withthrowsinit for formatvalidation and
encoded()/init(decoding:)for serialization.Tests/MistKitTests/AuthenticationMiddlewareTests*shrink to verifying"middleware calls authenticator and forwards" — one happy-path + one
no-credentials-throws case.
Tests/MistKitTests/Authentication/Protocol/TokenManagerAuthenticationMethodTests.swiftand
TokenManagerTokenCredentialsTests.swift— delete; coverage movesto the per-authenticator test files.
Tests/MistKitTests/Authentication/{APIToken,WebAuthToken,ServerToServer}AuthenticatorTests.swiftcover (a) the per-method query/header construction, (b) format-validation
throwing from init, (c)
encoded()↔init(decoding:)round-trip.Relation to #285
#285 lives in MistDemo and proposes bundling
Database + credentialssomismatch is caught at the type system. This issue is the MistKit-side complement:
bundle
credential payload + how to apply it to a request. The two compose:DatabaseCredentials.makeTokenManager()(MistDemo: bundle database choice with its required credentials #285) returns aTokenManagertokenManager.currentAuthenticator()returns anAuthenticator(this issue)AuthenticationMiddlewareonly sees the latterEither issue can land first; resolving this one removes one of #285's "knock-on
impacts" (
MistKitClientFactory.create(for:)collapses further because thefactory no longer has to know about S2S vs token auth at the middleware layer).
Decisions locked in
Authenticatoris public — consumers can write custom auth (test fakes,future modes) without forking.
Authenticator. Concrete types:APITokenAuthenticator,WebAuthTokenAuthenticator,ServerToServerAuthenticator.TokenCredentials.metadatais removed — zero production reads;TokenCredentialsitself goes with it.Equatableconformance onAuthenticator— would introduce aSelfrequirement and prevent
any Authenticatoruse. Tests compare via type-cast +field equality on the concrete type.
init(... ) throws—unrepresentable states stay unrepresentable; managers stop calling
validate*separately.encoded()/init(decoding:). NoCodableinheritance (sameSelf-requirement issueas
Equatable).AdaptiveTokenManageris in scope. It's the central case for the newabstraction, not an exception.
bodyisinout HTTPBody?onAuthenticator.authenticate— symmetricwith
request: inout, lets S2S signing replace the body after consuming itto read bytes for the signature.
Implementation notes
bodyviaData(collecting: body, upTo: 1 MiB)then forwards the originalbodytonext(...). ForHTTPBody.iterationBehavior == .singlethis drains thebody —
nextreceives an empty stream. Probably hasn't bitten anyone becausethe OpenAPI generator usually produces
.multiplebodies for JSON requests,but it's a latent footgun. The
body: inout HTTPBody?parameter onAuthenticator.authenticateis what letsServerToServerAuthenticatorfixthis: read once to sign, then reassign
bodyto a freshHTTPBodywrappingthe buffered
Dataso downstream sees the same bytes regardless ofiteration behavior. Needs a regression test (mock
.singlebody, run S2Sauth, assert downstream still receives the bytes).
Open questions
(
AuthenticationMiddleware.swift:157). Move that cap ontoServerToServerAuthenticatorand expose it as a configurable property.Non-goals