Skip to content

Refactor AuthenticationMiddleware so each Authenticator applies itself to the request #290

@leogdion

Description

@leogdion

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.method and dispatches to a
private helper per case (addAPITokenAuthentication,
addWebAuthTokenAuthentication, addServerToServerAuthentication).

Two smells fall out of this:

  1. 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.
  2. 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.

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 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", …

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
}
```

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.
  • Aligns with MistDemo: bundle database choice with its required credentials #285's Option B: DatabaseCredentials.makeTokenManager() can yield a manager whose value already conforms to Authenticator.

Cons:

  • 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:

  1. AuthenticationMiddleware.swift:62 — the switch this issue is rewriting.
  2. 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:

```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 Authenticator doesn't inherit Codableinit(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/AuthenticationMethod.swiftdelete.
  • Sources/MistKit/Authentication/TokenCredentials.swiftdelete.
  • 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.swiftin 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.swiftdelete; 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.

Relation to #285

#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:

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.
  • Protocol name: Authenticator. Concrete types: APITokenAuthenticator,
    WebAuthTokenAuthenticator, ServerToServerAuthenticator.
  • 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 original body 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.

Non-goals

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions