authplane-mcp 0.1.0

Authplane adapter helpers for the official Rust MCP SDK.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# authplane-mcp User Guide

OAuth 2.1 JWT authentication for the [official Rust MCP SDK (`rmcp`)](https://github.com/modelcontextprotocol/rust-sdk), powered by the [Authplane Rust SDK (`authplane-sdk`)](../../core).

`authplane-mcp` is intentionally small: the heavy lifting — JWKS discovery, JWT validation, DPoP proof verification, revocation — lives in `authplane-sdk`. This crate adds the MCP-specific glue: translating `AuthplaneError::ConsentRequired` into the JSON-RPC URL-elicitation error (`-32042`) that MCP clients understand.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Server Shape](#server-shape)
- [Bearer Middleware](#bearer-middleware)
- [Scope Enforcement](#scope-enforcement)
- [Accessing Token Claims](#accessing-token-claims)
- [Protected Resource Metadata (PRM)](#protected-resource-metadata-prm)
- [Token Revocation Checking](#token-revocation-checking)
- [Token Exchange (RFC 8693)](#token-exchange-rfc-8693)
- [URL Elicitation for Consent](#url-elicitation-for-consent)
- [DPoP (RFC 9449)](#dpop-rfc-9449)
- [Development Mode and SSRF](#development-mode-and-ssrf)
- [Error Handling](#error-handling)
- [API Reference](#api-reference)
- [Security Properties](#security-properties)

---

## Installation

```toml
[dependencies]
authplane-sdk = "0.1"
authplane-mcp = "0.1"
rmcp = "0.8"                 # official Rust MCP SDK
axum = "0.8"                 # transport for streamable-http
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

Requires Rust **1.91** (edition 2024) or newer.

## Quick Start

The end-to-end example below mirrors `mcp/demo/http_calculator_demo.rs` in the repo:

```rust
use authplane_sdk::{AuthplaneClient, FetchSettings, VerifiedClaims};
use axum::extract::{Request, State};
use axum::http::header::{AUTHORIZATION, WWW_AUTHENTICATE};
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::{Next, from_fn_with_state};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use rmcp::model::ErrorData;
use rmcp::service::RequestContext;
use rmcp::RoleServer;

const SCOPES: &[&str] = &["tools/add", "tools/multiply"];

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = AuthplaneClient::create(
        "https://auth.company.com",
        FetchSettings::default(),
    )
    .await?;
    let scopes: Vec<String> = SCOPES.iter().map(|s| (*s).to_string()).collect();
    let verifier = client
        .resource("https://mcp.company.com/mcp", &scopes)
        .await?;
    let prm = verifier.prm_response();
    let state = DemoState { verifier, prm };

    let app = Router::new()
        .route(
            "/.well-known/oauth-protected-resource/mcp",
            get(protected_resource_metadata),
        )
        .route("/healthz", get(|| async { "ok" }))
        .nest_service(
            "/mcp",
            tower::ServiceBuilder::new()
                .layer(from_fn_with_state(state.clone(), bearer_auth))
                .service(build_mcp_service()),
        )
        .with_state(state);

    axum::serve(
        tokio::net::TcpListener::bind("127.0.0.1:8080").await?,
        app,
    )
    .await?;
    Ok(())
}
```

The three things to notice:

1. `AuthplaneClient::create` discovers the AS metadata (`RFC 8414`) and is the factory for per-resource verifiers.
2. `client.resource(resource_url, &scopes)` returns an `AuthplaneResource` that can `verify(token)` incoming JWTs.
3. The `bearer_auth` middleware rejects unauthorised requests with a `WWW-Authenticate: Bearer` challenge (RFC 6750 §3) that carries the `resource_metadata` URL (RFC 9728 §5.1) MCP clients use to find the authorization server.

## Server Shape

Rust MCP servers are built out of three layers:

| Layer | Role |
| --- | --- |
| `rmcp::ServerHandler` | The MCP protocol state machine and tool dispatch. |
| `StreamableHttpService` | Streams JSON-RPC over HTTP chunked responses. |
| `axum::Router` | HTTP routing (healthchecks, PRM document, the `/mcp` endpoint). |

The adapter attaches to the `axum::Router` as middleware on the `/mcp` nested service — not inside the MCP handler — so that unauthenticated requests never reach `ServerHandler` code.

## Bearer / DPoP Middleware

The crate ships a reusable axum middleware that drives the full
`verify_with_context` pipeline (RFC 6750 bearer + RFC 9449 DPoP) and
stashes the verified claims on the request extensions:

```rust
use std::sync::Arc;
use axum::{Router, middleware::from_fn_with_state, routing::post};
use authplane_mcp::{AuthplaneMcpAuth, authplane_mcp_auth_middleware};

let auth = AuthplaneMcpAuth::new(
    Arc::new(verifier),
    "https://mcp.example.com".parse()?,
)
.with_realm("authplane-rmcp-demo");

let app = Router::new()
    .route("/mcp", post(handler))
    .layer(from_fn_with_state(auth, authplane_mcp_auth_middleware));
```

On success the middleware inserts:

* `VerifiedClaims` — the validated JWT payload
* `RawAccessToken` — the original JWT (for downstream RFC 8693 token
  exchange)
* `AuthplaneMcpAuth` — the verifier handle itself, in case nested code
  needs it

On failure it returns the appropriate `401` / `403` / `503` plus the
spec-compliant `WWW-Authenticate` challenge — `Bearer error="invalid_token"`
for plain failures, `DPoP error="invalid_token"` for proof-validation
failures (RFC 9449 §7.1 names `invalid_dpop_proof`, but the shared
conformance catalog pins `dpop_error → invalid_token`, so that is the
code core emits), etc. Every challenge ends with
`resource_metadata="<url>"` (RFC 9728 §5.1) — the verifier's
`resource_metadata_url()`, derived from the resource identifier unless
you set `ResourceOptions::with_resource_metadata_url` — so a client can
discover the AS from the `401` alone. A request with no credentials gets
`realm` and `resource_metadata` only, per RFC 6750 §3.1.

### Why `resource_origin` is required

`htu` reconstruction reads from the `resource_origin` you pass to
`AuthplaneMcpAuth::new`, **not** the inbound `Host`/scheme. A
misconfigured reverse proxy can let an attacker forge the `Host` header
and shift `htu` to a different origin; binding `htu` to the operator-
declared canonical origin closes that gap. The value must match what
the resource advertises in its PRM (RFC 9728).

### Three-mode dispatch

The middleware delegates mode selection to `verify_with_context`:

| `ResourceOptions::inbound_dpop` | Bearer-only request          | Bearer-only with stray `DPoP` header | DPoP-bound token + proof |
| ------------------------------- | ---------------------------- | ------------------------------------ | ------------------------ |
| `None` (Mode 3, default)        | accepted                     | rejected (`DpopNotSupported`)        | rejected                 |
| `Some(InboundDPoPOptions::default())` (Mode 2) | accepted          | rejected (`DpopBindingMismatch`)     | accepted                 |
| `Some(InboundDPoPOptions::required())` (Mode 1) | rejected (`DpopBindingMismatch`) | rejected             | accepted                 |

See `mcp/demo/http_calculator_demo.rs` for a complete wiring example.

## Scope Enforcement

Tool handlers pull the `VerifiedClaims` out of the request extensions and call `require_scope`:

```rust
use authplane_sdk::VerifiedClaims;

fn require_scope(ctx: &RequestContext<RoleServer>, scope: &str) -> Result<(), ErrorData> {
    let parts = ctx
        .extensions
        .get::<axum::http::request::Parts>()
        .ok_or_else(|| ErrorData::internal_error("http request context unavailable", None))?;
    let claims = parts
        .extensions
        .get::<VerifiedClaims>()
        .ok_or_else(|| ErrorData::internal_error("verified claims missing from request", None))?;
    claims
        .require_scope(scope)
        .map_err(|error| ErrorData::invalid_request(error.to_string(), None))
}

#[tool(name = "delete_all", description = "Drop every row")]
async fn delete_all(
    &self,
    ctx: RequestContext<RoleServer>,
) -> Result<String, ErrorData> {
    require_scope(&ctx, "tools/admin")?;
    Ok("cleared".to_string())
}
```

`VerifiedClaims::require_scope` returns `VerifierError::InsufficientScope` when the token is valid but missing the scope — which maps to HTTP 403 via `http_status`.

## Accessing Token Claims

`VerifiedClaims` exposes the RFC 9068 fields:

| Field | Type | Description |
| --- | --- | --- |
| `issuer` | `String` | `iss` claim. |
| `sub` | `String` | Subject (often the end-user id). |
| `audience` | `Vec<String>` | `aud` claim, always parsed as a list. |
| `client_id` | `String` | OAuth client that obtained the token. |
| `scopes` | `Vec<String>` | Parsed `scope` claim. |
| `expires_at` | `i64` | Unix timestamp of expiry. |
| `issued_at` | `i64` | Unix timestamp of issuance. |
| `not_before` | `Option<i64>` | `nbf` if present. |
| `jti` | `String` | JWT ID. |
| `raw` | `serde_json::Value` | Full payload for vendor claims. |

```rust
#[tool(name = "whoami", description = "Return the verified subject")]
async fn whoami(
    &self,
    Extension(parts): Extension<axum::http::request::Parts>,
) -> Result<String, ErrorData> {
    let claims = parts
        .extensions
        .get::<VerifiedClaims>()
        .ok_or_else(|| ErrorData::internal_error("verified claims missing", None))?;
    Ok(format!("sub={} scopes={}", claims.sub, claims.scopes.join(",")))
}
```

## Protected Resource Metadata (PRM)

RFC 9728 defines a discovery document at `/.well-known/oauth-protected-resource/{path}` that MCP clients use to find the authorization server. `AuthplaneResource` produces both the URL and the document:

```rust
let prm_url = verifier.prm_document_url()?;     // "https://mcp.company.com/.well-known/oauth-protected-resource/mcp"
let prm     = verifier.prm_response();          // ProtectedResourceMetadata struct
```

Serve the document from a plain `axum` route — no auth middleware, since PRM must be discoverable without a token:

```rust
async fn protected_resource_metadata(
    State(state): State<DemoState>,
) -> Json<ProtectedResourceMetadata> {
    Json(state.prm.clone())
}

Router::new().route("/.well-known/oauth-protected-resource/mcp",
                    get(protected_resource_metadata))
```

The document includes the `issuer`, supported `scopes`, and `bearer_methods_supported = ["header"]`.

## Token Revocation Checking

`AuthplaneResource::create` defaults to offline validation (signature + claims). To enable RFC 7662 introspection, pass `ResourceOptions::revocation`:

```rust
use authplane_sdk::{ResourceOptions, RevocationConfig};

let options = ResourceOptions {
    revocation: Some(RevocationConfig {
        client_id: "my-resource-server".to_string(),
        client_secret: std::env::var("AS_CLIENT_SECRET")?,
        fail_open: false, // strict: reject when AS is unreachable
    }),
    ..ResourceOptions::default()
};
let verifier = client
    .resource_with_options("https://mcp.company.com/mcp", &scopes, options)
    .await?;
```

- `fail_open: true` — the token is accepted when introspection is unreachable (availability over strictness).
- `fail_open: false` — the token is rejected when introspection fails (strictness over availability).
- When introspection responds with `active: false`, the verifier returns `VerifierError::TokenRevoked`.

The credentials must belong to a **confidential** client that is either the issuing client or a runtime-client of the Resource named in the token's `aud`. Since authserver 0.1.2 any other caller gets `{"active": false}` for every token — a public (secret-less) client cannot introspect at all — so a resource server with the wrong credentials would reject all traffic as revoked. Empty credentials are refused when the resource is constructed. Link the resource server's client with:

```bash
authserver admin resource runtime-client add --client-id <rs-client-id> --slug <resource-slug>
```

## Token Exchange (RFC 8693)

Use `AuthplaneClient::exchange_token` to swap an inbound token for a narrowly-scoped downstream token, typically from inside a tool handler that needs to call another service on behalf of the user:

```rust
use authplane_sdk::{TokenExchangeOptions, TOKEN_TYPE_ACCESS_TOKEN};

let exchanged = client
    .exchange_token(
        "my-resource-server",
        &as_client_secret,
        &TokenExchangeOptions {
            subject_token: inbound_token.to_string(),
            subject_token_type: TOKEN_TYPE_ACCESS_TOKEN.to_string(),
            scope: "downstream/write".to_string(),
            resources: vec!["https://downstream.example".to_string()],
            ..Default::default()
        },
    )
    .await?;
// exchanged.access_token    — present to the downstream service
// exchanged.expires_in      — lifetime in seconds
// exchanged.token_type      — "Bearer" or "DPoP"
// exchanged.issued_token_type — the RFC 8693 URI returned by the AS
```

`TokenExchangeOptions` fields:

| Field | Purpose |
| --- | --- |
| `subject_token` | Required. Token being exchanged (usually the inbound caller's token). |
| `subject_token_type` | RFC 8693 token-type URI; default `urn:ietf:params:oauth:token-type:access_token`. |
| `actor_token` / `actor_token_type` | Optional delegation token. |
| `scope` | Space-separated scopes to request on the downstream token. |
| `resources` | RFC 8707 resource indicators. Binds the downstream token's audience. |
| `audiences` | Explicit audiences when resources are not appropriate. |

**Operator step.** Token exchange is on by default since authserver 0.2.0, but a cross-client exchange is allowlisted per Resource. For each MCP server that exchanges for a downstream resource it does not act as, register the exchanging client on that Resource:

```http
PATCH /admin/resources/{id}
{"policy": {"exchange": {"allowed_client_ids": ["<exchanging-client-id>"]}}}
```

A client exchanging a token issued to itself, fronted exchanges, and Broker resources need nothing.

Errors from the AS come back as `AuthplaneError::Auth { code, message, status_code }` (generic OAuth errors) or `AuthplaneError::ConsentRequired` (when the user must complete interactive consent — see the next section). Two `Auth` codes deserve their own handling:

- `access_denied` (HTTP 403, `AuthError::is_access_denied()`) — the operator has not allowlisted the exchanging client on the target Resource. Unlike `consent_required`, re-prompting the user will not fix it; the `PATCH` above will.
- `invalid_target` (HTTP 400, `AuthError::is_invalid_target()`) — the `resource` string does not match a granted resource byte for byte (a trailing slash is enough).

Neither counts toward the circuit breaker.

## URL Elicitation for Consent

Some exchanges require the user to complete interactive consent at the AS before a downstream token can be issued. MCP defines a dedicated JSON-RPC error code (`-32042`, `URL_ELICITATION_REQUIRED`) for this case. `authplane-mcp` translates a `ConsentRequiredError` with a `consent_url` into that error transparently.

**Wrapper form** — most concise; wrap the part of your handler that might produce a consent error:

```rust
use authplane_mcp::wrap_tool_with_url_elicitation;

#[tool(name = "call_downstream")]
async fn call_downstream(
    &self,
    Parameters(body): Parameters<Payload>,
    ctx: RequestContext<RoleServer>,
) -> Result<String, ErrorData> {
    wrap_tool_with_url_elicitation(|| async {
        let subject_token = extract_token(&ctx)?;
        let exchanged = self
            .client
            .exchange_token(&client_id, &client_secret, &TokenExchangeOptions {
                subject_token,
                scope: "downstream/write".into(),
                resources: vec!["https://downstream.example".into()],
                ..Default::default()
            })
            .await?;
        call_downstream_api(&exchanged.access_token, &body).await
    })
    .await
}
```

**Explicit form** — call `to_url_elicitation_required_error` after catching the error:

```rust
use authplane_mcp::to_url_elicitation_required_error;

match client.exchange_token(...).await {
    Ok(token) => Ok(/* ... */),
    Err(error) => match to_url_elicitation_required_error(error) {
        Err(mcp_error) => Err(mcp_error),             // -32042 URL elicitation
        Ok(other) => Err(ErrorData::internal_error(other.to_string(), None)),
    },
}
```

The produced error carries:

```json
{
  "code": -32042,
  "message": "Consent is required to proceed",
  "data": {
    "elicitations": [
      {
        "mode": "url",
        "url": "https://auth.company.com/consent?service=drive&...",
        "elicitationId": "…uuid…",
        "message": "Consent is required to proceed (drive: approval_pending)"
      }
    ]
  }
}
```

Consent errors **without** a `consent_url` pass through unchanged — the resource can't point the user anywhere, so MCP treats it as a generic auth failure. Non-consent errors (expired tokens, invalid scope) are wrapped as `internal_error`.

## DPoP (RFC 9449)

`authplane-sdk` ships full DPoP primitives — `create_dpop_proof`, `verify_dpop_proof`, `AuthplaneResource::verify_with_context`. When the inbound token is DPoP-bound (carries `cnf.jkt`), the resource server must also verify the `DPoP` header.

The unified dispatcher `AuthplaneResource::verify_with_context(token, ctx)` — Mode 1 (DPoP required), Mode 2 (DPoP supported), Mode 3 (no DPoP) — is documented in the core SDK guide: [Three-mode inbound DPoP dispatch](../../core/docs/user-guide.md#three-mode-inbound-dpop-dispatch). The adapter already drives it for you: wire `authplane_mcp_auth_middleware` with `AuthplaneMcpAuth::new(verifier, resource_origin)` as shown under [Bearer / DPoP Middleware](#bearer--dpop-middleware), and the `ResourceOptions::inbound_dpop` builder decides which mode the resource runs in.

If you must hand-roll the middleware instead, it looks like this. `AuthplaneMcpAuth` carries both pieces the context needs — the verifier and the canonical origin `htu` is rebuilt from:

```rust
use authplane_mcp::{AuthplaneMcpAuth, dpop_request_context_from_axum};
use authplane_sdk::VerifierError;

async fn bearer_auth_with_dpop(
    State(auth): State<AuthplaneMcpAuth>,
    mut request: Request,
    next: Next,
) -> Response {
    let token = match bearer_token(&request) {
        Some(token) => token,
        None => return unauthorized_err(&VerifierError::TokenMissing),
    };
    // Build the RFC 9449 §4.3 request context once and let the verifier
    // dispatch. Do NOT branch on the DPoP header and fall back to
    // `verify`: `verify` does not enforce `InboundDPoPOptions::required()`,
    // so a bearer-only token would pass a DPoP-required resource.
    // `verify_with_context` applies the resource's configured mode, and
    // answers `DpopProofMissing` for a bound token that arrives without a
    // proof.
    let ctx = match dpop_request_context_from_axum(&request, auth.resource_origin()) {
        Ok(ctx) => ctx,
        Err(err) => return unauthorized_err(&err),
    };

    let result = auth.verifier().verify_with_context(token, &ctx).await;

    match result {
        Ok(claims) => {
            request.extensions_mut().insert(claims);
            next.run(request).await
        }
        Err(error) => unauthorized_err(&error),
    }
}
```

When you exchange tokens from a DPoP-capable client, pass `Some(&DpopProofOptions { ... })` as the final argument to `exchange_token` — it attaches a fresh proof to the token-endpoint call. Bearer-only callers pass `None`.

A fully in-process DPoP example lives at `core/examples/dpop_roundtrip.rs`:

```bash
cargo run -p authplane-sdk --example dpop_roundtrip
```

## Development Mode and SSRF

`FetchSettings::default()` enforces SSRF protections that are correct for production:

| Check | Default | Description |
| --- | --- | --- |
| HTTPS required | Yes | Blocks plain HTTP. |
| Localhost blocked | Yes | Blocks `127.0.0.0/8`. |
| Private networks blocked | Yes | Blocks `10.x`, `172.16-31.x`, `192.168.x`. |
| Cloud metadata blocked | **Always** | Blocks `169.254.x` (cannot be disabled). |
| Redirect blocking | Yes | Prevents open-redirect attacks on metadata/JWKS fetches. |
| Request timeout | 10 s | Applied to every outbound fetch. |

For local dev, relax HTTP + localhost + private networks with `FetchSettings::from_dev_mode(true)`. Cloud-metadata addresses remain blocked.

```rust
let client = AuthplaneClient::create(
    "http://localhost:9000",
    FetchSettings::from_dev_mode(true),
)
.await?;
```

Or drive it from an env var:

```bash
AUTHPLANE_DEV_MODE=true cargo run -p authplane-mcp --example http_calculator_demo
```

## Error Handling

`authplane-mcp` itself can produce two shapes of `ErrorData`:

| Origin | Shape |
| --- | --- |
| `ConsentRequiredError` with `consent_url` | `ErrorData::url_elicitation_required(message, Some(data))` (code `-32042`). |
| Any other `AuthplaneError` (inside `wrap_tool_with_url_elicitation`) | `ErrorData::internal_error(error.to_string(), None)`. |

`authplane-sdk` produces two error enums you should expect to surface:

| Error | When to expect it | Spec HTTP status |
| --- | --- | --- |
| `VerifierError::TokenMissing` | No `Authorization: Bearer …` header. | 401 |
| `VerifierError::TokenExpired` | JWT `exp` passed. | 401 |
| `VerifierError::InvalidSignature { message }` | Signature or key failure. | 401 |
| `VerifierError::InvalidClaims { message }` | RFC 9068 claim violation, `typ` mismatch, etc. | 401 |
| `VerifierError::TokenRevoked` | Introspection returned `active=false`: revoked, or the AS does not recognise this resource server as the token's owner (see [Token Revocation Checking](#token-revocation-checking)). | 401 |
| `VerifierError::InsufficientScope { required, available }` | Valid token, missing scope. | 403 |
| `VerifierError::MetadataUnavailable { message }` | AS metadata not reachable. | 503 |
| `VerifierError::JwksUnavailable { message }` | JWKS not reachable. | 503 |
| `AuthplaneError::Auth { code, status_code, message }` | Token-endpoint or admin-call error. | `status_code` if present, else 400. |
| `AuthplaneError::ConsentRequired(_)` | AS requested interactive consent. | 401 (or `-32042` for MCP). |

`authplane_sdk::http_status(&err)` and `verifier.www_authenticate(&err, realm)` produce the canonical status + challenge header for `VerifierError` (the free `authplane_sdk::www_authenticate` is the same without the `resource_metadata` parameter); `http_status_for_auth_error` covers `AuthplaneError`.

## API Reference

### `to_url_elicitation_required_error`

```rust
pub fn to_url_elicitation_required_error(
    error: AuthplaneError,
) -> Result<AuthplaneError, ErrorData>
```

- Input: an `authplane_sdk::AuthplaneError`.
- Output:
  - `Err(ErrorData)` — the error carried a `consent_url`, mapped to `-32042`.
  - `Ok(AuthplaneError)` — the error did not qualify for URL elicitation; the caller should handle it.

### `wrap_tool_with_url_elicitation`

```rust
pub async fn wrap_tool_with_url_elicitation<F, Fut, T>(
    handler: F,
) -> Result<T, ErrorData>
where
    F: FnOnce() -> Fut,
    Fut: Future<Output = Result<T, AuthplaneError>>,
```

- Runs `handler`, maps consent errors to `-32042`, wraps every other `AuthplaneError` as `ErrorData::internal_error`. Works for any `Fn` that returns `AuthplaneError`.

### Re-exports from `authplane-sdk`

These types are used throughout the guide and are stable public API:

| Type | Purpose |
| --- | --- |
| `AuthplaneClient` | AS discovery + factory for `AuthplaneResource`. |
| `AuthplaneResource` | Per-resource verifier (`verify`, `verify_with_context`, `prm_response`). |
| `VerifiedClaims` | Validated JWT payload (RFC 9068). |
| `VerifierError` | Verifier failure enum; maps to HTTP status via `http_status`. |
| `AuthplaneError` | Client-side OAuth error enum. |
| `ConsentRequiredError` | Payload delivered with `AuthplaneError::ConsentRequired`. |
| `TokenExchangeOptions` | RFC 8693 exchange parameters. |
| `TokenResponse` | AS token-endpoint response. |
| `FetchSettings` | SSRF, timeout, and redirect controls. |
| `ResourceOptions` | `allowed_algorithms`, `clock_skew_seconds`, `revocation`. |
| `RevocationConfig` | Revocation credentials + fail-open flag. |
| `ProtectedResourceMetadata` | RFC 9728 PRM document. |
| `www_authenticate` / `http_status` | RFC 6750 §3 challenge + status helpers. |

## Security Properties

Through `authplane-sdk`, this adapter enforces:

- **RFC 9068 compliance** — validates `iss`, `aud`, `sub`, `client_id`, `exp`, `nbf`, `iat`, `jti`, `typ`.
- **Type header enforcement** — only accepts `typ: "at+jwt"`.
- **Asymmetric algorithms only** — `HS*` and `none` are rejected; default allow-list is `RS256` and `ES256`.
- **JWKS refresh** — re-fetches on cache miss with a minimum 30 s interval.
- **SSRF-safe fetches** — DNS pinning, IP blocklists, protocol allowlists, redirect blocking.
- **DPoP cnf.jkt binding** — `verify_with_context` checks both the proof signature and the `cnf.jkt` thumbprint match (RFC 9449 §6.1).
- **RFC 6750 §3 challenges** — `www_authenticate` emits the correct `error=…` parameter per failure class.