V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206) Co-authored-by: wti <wti@users.noreply.github.com> * feat/auth: Authentication implementation (#205) * feat: added authentication * feat(auth): improvement to error handling * feat(auth): updated errors and tests in OAuthClientRegistrar * feat(auth): updated ci conformance package version * feat(auth): bumped node version * feat(auth): updated ci conformance step * feat(auth): improvement to Linux support * chore: added docc plugin * chore: added spi yml * chore: added documentation step to ci * chore: increased timeout for test from 5 to 10m --------- Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com> Co-authored-by: wti <wti@users.noreply.github.com> Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
This commit is contained in:
parent
973b46b8f7
commit
6132fd4b5b
1 changed files with 10925 additions and 199 deletions
198
README.md
198
README.md
|
|
@ -43,6 +43,15 @@ of the MCP specification.
|
|||
- [Initialize Hook](#initialize-hook)
|
||||
- [Graceful Shutdown](#graceful-shutdown)
|
||||
- [Transports](#transports)
|
||||
- [Authentication](#authentication)
|
||||
- [Client: Client Credentials Flow](#client-client-credentials-flow)
|
||||
- [Client: Authorization Code Flow](#client-authorization-code-flow)
|
||||
- [Client: Custom Token Provider](#client-custom-token-provider)
|
||||
- [Client: Custom Token Storage](#client-custom-token-storage)
|
||||
- [Client: private\_key\_jwt Authentication](#client-private_key_jwt-authentication)
|
||||
- [Client: Endpoint Overrides](#client-endpoint-overrides)
|
||||
- [Server: Serving Protected Resource Metadata](#server-serving-protected-resource-metadata)
|
||||
- [Server: Validating Bearer Tokens](#server-validating-bearer-tokens)
|
||||
- [Platform Availability](#platform-availability)
|
||||
- [Debugging and Logging](#debugging-and-logging)
|
||||
- [Additional Resources](#additional-resources)
|
||||
|
|
@ -1341,6 +1350,195 @@ public actor MyCustomTransport: Transport {
|
|||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
`HTTPClientTransport` supports OAuth 2.1 Bearer token authorization per the
|
||||
[MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
||||
When a server returns `401 Unauthorized` or `403 Forbidden`, the transport automatically:
|
||||
|
||||
1. Discovers Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource`
|
||||
2. Discovers Authorization Server Metadata (RFC 8414 / OIDC Discovery 1.0)
|
||||
3. Registers the client dynamically (RFC 7591) if needed
|
||||
4. Acquires a Bearer token using the configured grant flow (PKCE enforced)
|
||||
5. Retries the original request with the token attached
|
||||
|
||||
Authorization is opt-in and disabled by default.
|
||||
Pass an `OAuthAuthorizer` to `HTTPClientTransport(authorizer:)` to enable it.
|
||||
|
||||
### Client: Client Credentials Flow
|
||||
|
||||
Machine-to-machine authentication using a pre-shared client secret:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
|
||||
)
|
||||
let authorizer = OAuthAuthorizer(configuration: config)
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: URL(string: "https://api.example.com/mcp")!,
|
||||
authorizer: authorizer
|
||||
)
|
||||
let client = Client(name: "MyClient", version: "1.0.0")
|
||||
try await client.connect(transport: transport)
|
||||
```
|
||||
|
||||
### Client: Authorization Code Flow
|
||||
|
||||
Interactive, browser-based authentication with PKCE.
|
||||
Implement `OAuthAuthorizationDelegate` to open the authorization URL and capture the redirect:
|
||||
|
||||
```swift
|
||||
struct MyAuthDelegate: OAuthAuthorizationDelegate {
|
||||
func presentAuthorizationURL(_ url: URL) async throws -> URL {
|
||||
// Open the URL in a browser/webview and wait for the callback redirect URI.
|
||||
// The returned URL must include the authorization code and state parameters.
|
||||
return try await openBrowserAndWaitForCallback(url)
|
||||
}
|
||||
}
|
||||
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .authorizationCode,
|
||||
authentication: .none(clientID: "my-app"),
|
||||
authorizationDelegate: MyAuthDelegate()
|
||||
)
|
||||
let authorizer = OAuthAuthorizer(configuration: config)
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: URL(string: "https://api.example.com/mcp")!,
|
||||
authorizer: authorizer
|
||||
)
|
||||
```
|
||||
|
||||
### Client: Custom Token Provider
|
||||
|
||||
Supply an externally acquired token (e.g., from a system credential store) via `accessTokenProvider`.
|
||||
The SDK calls this closure after discovery completes. Return `nil` to fall back to the configured grant flow:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .none(clientID: "my-app"),
|
||||
accessTokenProvider: { context, session in
|
||||
// context contains the discovered resource URI, token endpoint, scopes, etc.
|
||||
return try await KeychainTokenStore.shared.loadToken(for: context.resource)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Client: Custom Token Storage
|
||||
|
||||
By default, tokens are stored in memory and lost when the process exits.
|
||||
To persist tokens across sessions, implement `TokenStorage` and pass it to `OAuthAuthorizer`:
|
||||
|
||||
```swift
|
||||
final class KeychainTokenStorage: TokenStorage {
|
||||
func save(_ token: OAuthAccessToken) {
|
||||
// Encode and store token.value in the system Keychain
|
||||
}
|
||||
|
||||
func load() -> OAuthAccessToken? {
|
||||
// Load and decode token from the Keychain
|
||||
return nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
// Delete from the Keychain
|
||||
}
|
||||
}
|
||||
|
||||
let authorizer = OAuthAuthorizer(
|
||||
configuration: config,
|
||||
tokenStorage: KeychainTokenStorage()
|
||||
)
|
||||
```
|
||||
|
||||
### Client: `private_key_jwt` Authentication
|
||||
|
||||
Authenticate to the token endpoint using an asymmetric key (RFC 7523).
|
||||
The SDK provides a built-in ES256 helper for P-256 keys:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .privateKeyJWT(
|
||||
clientID: "my-app",
|
||||
assertionFactory: { tokenEndpoint, clientID in
|
||||
try OAuthConfiguration.makePrivateKeyJWTAssertion(
|
||||
clientID: clientID,
|
||||
tokenEndpoint: tokenEndpoint,
|
||||
privateKeyPEM: myEC256PrivateKeyPEM // PEM-encoded P-256 private key
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Client: Endpoint Overrides
|
||||
|
||||
Skip automatic discovery by providing explicit endpoint URLs.
|
||||
Useful when the server does not publish well-known metadata documents:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .clientSecretBasic(clientID: "app", clientSecret: "secret"),
|
||||
endpointOverrides: OAuthConfiguration.EndpointOverrides(
|
||||
tokenEndpoint: URL(string: "https://auth.example.com/oauth/token")!
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Server: Serving Protected Resource Metadata
|
||||
|
||||
Per the MCP authorization specification, servers **MUST** serve Protected Resource Metadata
|
||||
at `/.well-known/oauth-protected-resource` so clients can discover authorization server endpoints.
|
||||
|
||||
Use `ProtectedResourceMetadataValidator` as the first validator in your pipeline so that
|
||||
unauthenticated discovery requests are handled before the bearer token check:
|
||||
|
||||
```swift
|
||||
let metadata = OAuthProtectedResourceServerMetadata(
|
||||
resource: "https://api.example.com",
|
||||
authorizationServers: [URL(string: "https://auth.example.com")!],
|
||||
scopesSupported: ["read", "write"]
|
||||
)
|
||||
let metadataValidator = ProtectedResourceMetadataValidator(metadata: metadata)
|
||||
```
|
||||
|
||||
### Server: Validating Bearer Tokens
|
||||
|
||||
Use `BearerTokenValidator` to authenticate incoming requests.
|
||||
Your `tokenValidator` closure **MUST** verify the token's `aud` claim to prevent
|
||||
token substitution attacks where a token intended for another resource is replayed against your server:
|
||||
|
||||
```swift
|
||||
let resourceIdentifier = URL(string: "https://api.example.com")!
|
||||
|
||||
let bearerValidator = BearerTokenValidator(
|
||||
resourceMetadataURL: URL(string: "https://api.example.com/.well-known/oauth-protected-resource")!,
|
||||
resourceIdentifier: resourceIdentifier,
|
||||
tokenValidator: { token, request, context in
|
||||
guard let claims = try? verifyAndDecodeJWT(token) else {
|
||||
return .invalidToken(errorDescription: "Token verification failed")
|
||||
}
|
||||
// Pass audience and expiry to BearerTokenInfo; the SDK validates the
|
||||
// audience claim against resourceIdentifier automatically.
|
||||
return .valid(BearerTokenInfo(
|
||||
audience: claims.audience,
|
||||
expiresAt: claims.expiresAt
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
let pipeline = StandardValidationPipeline(validators: [
|
||||
metadataValidator, // serves /.well-known/oauth-protected-resource unauthenticated
|
||||
bearerValidator, // validates Bearer tokens on all other requests
|
||||
AcceptHeaderValidator(mode: .sseRequired),
|
||||
ContentTypeValidator(),
|
||||
SessionValidator(),
|
||||
])
|
||||
```
|
||||
|
||||
## Platform Availability
|
||||
|
||||
The Swift SDK has the following platform requirements:
|
||||
|
|
|
|||
Loading…
Reference in a new issue