MCP auth facade SPI — provider and store
The MCP auth facade lets an application front an upstream auth provider (Autheory is the first adapter) on one domain while the upstream stays the sole authorization server and token authority. This page documents the two service-provider interfaces the facade is built on and what ships today.
Status: the SPI layer exists and is consumed by the framework-owned front channel. The provider SPI, the five
store SPIs, the conformance suites, and the fake provider are implemented in Go, TypeScript, and Python. When the
front-channel field set below is declared, the three registrars (RegisterMCPFacade / registerMcpFacade /
register_mcp_facade) mount the client-facing front channel and drive the provider and the stores from a request:
the per-endpoint-kind GET …/authorize terminator, the one stage-global GET /_oauth/mcp/callback, and the
per-endpoint-kind POST …/token authorization_code grant. Nothing outside that field set becomes reachable, and with
Provider absent the facade owns no OAuth surface — the application either supplies its own handler factories or
registers none, exactly as before.
Mounted: the per-endpoint-kind POST …/register RFC 7591 dynamic-client-registration route. Not mounted or answered
yet: the refresh_token grant at /token (the code-exchange response already carries a framework-minted opaque
refresh handle, but that handle cannot be redeemed yet), and the CDK facade wiring. See “What the front channel does”
below.
What the framework owns, and what the application owns
The framework owns composition and mechanics; the application owns policy. The full per-mechanic verdict is
in docs/development/planning/apptheory/mcp-auth-facade/FRAMEWORK_VS_POLICY.md. In short:
| Concern | Owner |
|---|---|
| Provider SPI shape, Autheory adapter, hardened upstream HTTP (deadline, size cap, SSRF guard) | Framework |
| Store SPI shape, atomic consume/CAS/lock/revoke semantics, in-memory reference stores | Framework |
| Conformance suites (provider + store) and the fake upstream provider | Framework |
Scope vocabulary, offline_access lifecycle scope, account_id hint semantics |
Application |
| Durable store backend (e.g. TableTheory/DynamoDB), encryption key/ARN, table shapes | Application |
| DCR mechanics (RFC 7591 validation, https-or-loopback redirect enforcement, server-assigned ≥128-bit client ids, per-route rate limit, bound-route binding, hash-stored record) | Framework |
| DCR redirect allow-list policy beyond the framework’s https-or-loopback floor, legacy-client adoption | Application |
Provider SPI
One interface per runtime, implemented once per provider. It is deliberately the minimum needed to derive the facade from the requirements; Autheory is the first and only adapter in this program.
| Operation | Purpose | Fails closed on |
|---|---|---|
Discovery |
RFC 8414/OIDC discovery bootstrap; verifies the document issuer equals the configured issuer before any endpoint is trusted |
non-HTTPS, issuer mismatch, cross-origin redirect, missing required fields |
AuthorizationURL |
Builds the upstream authorize URL the browser is redirected to | missing upstream client, callback, PKCE challenge, state, resource, or a non-S256 method |
ExchangeCode |
Server-side upstream code exchange with the facade’s own client and PKCE verifier | non-2xx, missing access_token; the upstream error body is never forwarded |
Refresh |
Upstream refresh presenting the OAuth client that owns the recorded material | non-2xx, secret-bearing error body never forwarded |
ValidateAccessToken |
Reuses the RFC 9728 JWKS/RS256 validator; never mutates the token | issuer/audience/azp/signature/expiry/scope failure |
Every upstream call goes through the same hardened HTTP stack the OIDC discovery and JWKS bootstraps use: a bounded deadline (10 s default), a 1 MiB response cap, a connect-time SSRF guard that refuses non-public addresses, and a redirect refusal. There is no separate, unbounded fetch path.
The JWKS key-set fetch is coalesced in Go and TypeScript, but not in Python. The Go and TypeScript validators
single-flight concurrent key-set fetches for one validator, so validations that miss the key cache at the same moment
share a single upstream fetch. The Python validator does not coalesce: every concurrent unknown-kid validation
performs its own key-set fetch. Each of those fetches is still independently bounded by the same 10-second deadline and
still charges the same global unknown-kid fetch budget, so the difference is upstream request volume, not the bound
and not the accept/reject decision.
Refresh must honor the deadline. The framework’s own adapter calls Refresh through that hardened stack, so
the 10-second deadline bounds the upstream call it makes. An implementer’s Refresh (or a durable store that backs
the lock) must not ignore it: the broker refresh lock’s TTL is 15 seconds — the 10-second deadline plus a 5-second
margin, a relationship a pin test enforces — so an upstream call that runs past the deadline lets the lock expire and
a second caller acquire it, admitting a second concurrent upstream refresh. The rotation stays safe even then: it
completes with a versioned compare-and-swap on the broker record, so exactly one winner lands and the loser discards
the successor it minted (or answers 503), and no success is ever returned for state that was not persisted. The
obligation is nonetheless real — honor the deadline so the single-flight guarantee holds at the store and not only at
the compare-and-swap.
Typed errors map to a closed client-facing vocabulary, so a caller never has to string-match an upstream body:
ProviderError class |
Facade HTTP class |
|---|---|
upstream_error |
502 |
temporarily_unavailable |
503 (retryable; Retry-After) |
invalid_grant |
400 (terminal) |
invalid_request |
400 (adapter-side; never reaches the upstream) |
reauth_required |
400 (terminal; only an explicit upstream stale/replaced signal) |
An upstream OAuth invalid_request maps onto the terminal invalid_grant (a client-facing 400, not a 502): a
facade request bug that reaches the upstream surfaces as a request-class error, and every other non-2xx —
including an unrecognized upstream error code — is an upstream_error.
At the callback the class table above collapses to one outcome. When the framework-owned front channel’s
callback calls the provider, any failure — the server-side ExchangeCode, and the ValidateAccessToken that
follows it — is mapped to the gateway-class 502 upstream_error, over the ProviderError class’s own HTTP
status: an ExchangeCode failure is 502 even when its class table row would say 400. The class table describes
the typed errors the SPI produces; the callback deliberately does not translate an upstream failure into the
facade’s own 400 request-class vocabulary (DESIGN §8.2). This is what keeps the callback’s RFC 9207 iss and
upstream state pins non-vacuous: an implementation that skipped either pin would reach the provider call and
change the pinned result instead of coincidentally reproducing a 400, and the two classes stay distinguishable to
a client.
Autheory adapter (first adapter)
The adapter is discovery-driven: it takes authorization_endpoint, token_endpoint and jwks_uri from the
verified RFC 8414 document rather than hard-coding them. Two Autheory facts are load-bearing:
resourceis required upstream. Autheory’s authorize endpoint refuses a request without it, so the adapter always sends the route’sresource.- Refresh material is bound to the issuing client. The refresh leg presents the client that owns the recorded upstream material (the facade’s own client for facade-opened records; the store is the discriminator).
The adapter does not relax Autheory’s own policy. In particular token_endpoint_auth_method=none public clients
only, and Autheory’s application-type-specific DCR redirect policy is stricter than the framework’s https-or-loopback
floor — the framework keeps the stricter upstream behavior.
UNVERIFIED (carried to the lab milestone)
- Whether discovery-driven clients’ authorize requests reach Autheory’s enforcing Go path (
/auth/authorize) or only the published, render-only/oauth/authorizeused as the discoveryauthorization_endpoint. - The exact deployed
AUTHEORY_OAUTH_ISSUER/AUTHEORY_JWT_ISSUERvalues per stage.
Store SPI
Five stores, each with an in-memory reference implementation. Consume-once and compare-and-swap are required atomic operations, not conventions: replay handling and single-flight refresh depend on them. Production needs a durable store; the framework does not ship one — durable implementations are application-owned.
| Store | Key | Atomic operations | TTL |
|---|---|---|---|
TransactionStore |
hash(upstream state) | Put, Consume (single-use) |
≤ 10 min |
AuthorizationCodeStore |
hash(facade code) | Put, Consume (single-use, retained after consume so a replay is distinguishable from an unknown code) |
≤ 600 s (60 s recommended) |
ClientStore |
hash(client id) | Put, Get, Update (compare-and-swap on version) |
none (age-out policy) |
RouteRefreshBrokerStore |
hash(route handle) | Get, AcquireLock (exclusive, expiring), CompareAndSwap (versioned), Revoke (idempotent, terminal) |
≤ min(upstream refresh lifetime, session lifetime, local policy) |
RateLimitStore |
(identifier, resource, operation) | CheckAndIncrement (atomic) |
window |
Every store takes an injected clock, so TTL expiry and lock expiry are deterministic in tests. The in-memory reference stores are non-durable and do not encrypt: they are for tests and local development only.
Encryption is a store obligation. The facade performs no sealing and the framework adds no encryption surface
or key management: it hands the store the upstream material exactly as the provider returned it, which is why one
durable upstream refresh token lives in the broker record and one upstream token response lives in the code record.
A durable store implementation must encrypt every Encrypted* field at rest (for example with
persistence-layer theorydb:"encrypted" tags on a TableTheory-backed store), so nothing upstream is persisted in
the clear. The code record keeping the (encrypted) upstream token response is deliberate: it is what lets the code
exchange return the upstream access token byte-for-byte unmodified. The in-memory reference stores above neither
persist nor encrypt and must not be used as a durable store.
The grace body is retained for the record’s lifetime, not for the grace window. The broker record’s
EncryptedGraceRefreshResponse field holds the successor token response a rotation produced — the successor
handle plus the upstream access token that was already returned to that client — and it is the only link the
reuse walk can follow to revoke that successor, because the record carries no separate successor-handle field.
A store must therefore not clear the field when the 30-second grace window closes: doing so would truncate
a post-expiry whole-grant revoke to the single presented record and leave the successor live. The field expires
with the record, under the record’s own TTL (the broker TTL in the table above), never at the grace deadline,
and a durable store must encrypt it at rest for that whole lifetime like every other Encrypted* field.
Nothing extra is exposed by keeping it: it adds no material that is not already covered by the encryption
obligation (the successor handle and an access token the client already received).
Front-channel configuration
The framework-owned front channel — the /authorize terminator, the stage-global upstream callback, the
/token authorization-code grant, and the /register dynamic-client-registration route of DESIGN §8 — is declared by
one FacadeConfig field set. Declaring the set is what mounts the front channel: with Provider set,
registration validates the rest of the set, fails closed on an incomplete or contradictory set, and mounts the four
route shapes driven by the provider and the five stores; with Provider absent the front channel stays unowned and no
front-channel field may be set (the /register and callback routes then mount only through the application-owned
RegisterHandler / CallbackHandler).
| Field (Go / TS / Python) | Purpose | Default | Validation | Fail-closed rule | Demanded by |
|---|---|---|---|---|---|
Provider / provider / provider |
Anchors the framework-owned front channel: with it set AppTheory owns /authorize, the stage-global callback, and the /token authorization-code grant |
absent — the front channel stays unowned, and the app either supplies its own handler factories or registers no OAuth surface | none (an interface value) | All-or-none set: setting any other front-channel field while Provider is absent is a registration error naming that field |
DESIGN §8; DESIGN §8.1 |
Stores / stores / stores |
The five store SPIs the front channel persists through: transaction, authorization code, client, route-refresh broker, rate limit | absent | all five must be non-nil; a missing store fails closed rather than degrading | Registration error naming every missing store (… requires all five stores; missing Transaction, RateLimit) |
DESIGN §7 |
UpstreamClientID / upstreamClientID / upstream_client_id |
The facade’s own upstream client registration: sent on the upstream authorize request and presented on the server-side code exchange, and the expected azp/client_id of a validated upstream access token |
empty | non-blank, well-formed Unicode, no NUL | Registration error (… upstream client id is required and must be well-formed) |
DESIGN §8.1; DESIGN §5.2, §6 |
UpstreamIssuer / upstreamIssuer / upstream_issuer |
The expected upstream issuer: the RFC 9207 iss the callback pins before consuming the transaction or exchanging the code, and the issuer Discovery must verify before any published endpoint is trusted |
empty | absolute HTTPS URL, no query, no fragment, normalized like IssuerURL |
Registration error (… upstream issuer must be an absolute HTTPS URL without query or fragment) |
DESIGN §8.2; DESIGN §5.2, §6 |
LifecycleScopes / lifecycleScopes / lifecycle_scopes |
Scope values added to every upstream authorize request but never required from the MCP client (for example offline_access) |
absent — adds none | each value trimmed, non-empty, and well-formed Unicode; duplicates collapse | Registration error (lifecycle scope values cannot be empty / … must be well-formed Unicode) |
FRAMEWORK_VS_POLICY row 7 |
ProviderHints / providerHints / provider_hints |
The opaque per-request hint seam for one authorize request (for example account_id); a hint carries no authority |
absent — contributes no hints | none at registration (the function is opaque); a returned error is a provider-call failure in the gateway class (502 upstream_error), never a redirect |
Registration error when set without Provider |
DESIGN §6 |
Every rejection uses the runtime’s existing configuration-error prefix and the same message body (mcpfacade: …
in Go, AppTheoryMcpFacade: … in TypeScript and Python).
The reasoning behind the shape, because each of these was a reviewer question:
Provideris the anchor. The front-channel field set is all-or-none: aProviderwithout the rest is incomplete (fail closed), and the rest without aProvideris dead configuration (fail closed). There is no “partially owned” front channel that half-registers.- The five stores carry no TTL here. Each store owns its own TTL — ≤10 min for a transaction, ≤600 s for an
authorization code, age-out (not TTL) for a client,
min(upstream refresh lifetime, session lifetime, local policy)for the broker, and a window for the rate limiter — so the facade configures none (DESIGN §7 TTL column). The injected clock that makes those TTLs deterministic is the store’s own concern. UpstreamClientIDis both identities on purpose. It is the client the facade presents upstream and the expectedazp/client_idof an upstream access token the facade validates (DESIGN §5.2, §6), so there is no separate audience/azpfield: a token minted for another client is refused against the same value.UpstreamIssueris the upstream issuer, not the client-facing one. It is the RFC 9207issthe callback pins and the issuerDiscoveryverifies (DESIGN §5.2, §8.2). The client-facing issuer is a different value —IssuerURLin family mode, the route URL in route mode — and the two are deliberately distinct.- There is no upstream callback-URL field. The callback URL is derived from
URLMode/PublicBaseURLplusUpstreamCallbackPath(DESIGN §3.2), so an absolute callback URL is never configured and the derived URL cannot drift from the mounted stage-global callback route. LifecycleScopesis the injectable lifecycle scope of FRAMEWORK_VS_POLICY row 7 (theoffline_accessinjection): the framework exposes it as configuration rather than hard-coding the literal, so the scope vocabulary stays application policy.ProviderHintsis the DESIGN §6 opaque hint seam (theaccount_idhint). It carries no authority: the provider derives authentication account context from its own validated binding, never from the hint.- The application-owned factories are mutually exclusive with a
Provider.Providerowns the authorize, token, and callback routes, so configuringAuthorizeHandler/TokenHandlerorCallbackHandleralongside it is a registration error rather than a silently dropped or double-mounted route. (A lone one of the authorize/token pair is already rejected by the pre-existing all-or-none factory rule.)
The framework issues no facade token and needs no signing key. The access token a client receives is the upstream token returned byte-for-byte unmodified, and the facade’s own authorization code is an opaque value — not a signed or self-describing artifact (DESIGN §8.4, §10). So there is no signing-key configuration anywhere in this field set: there is nothing for the framework to mint or verify with a key of its own.
What the front channel does
When the front-channel field set is declared, the registrars mount three route shapes, and the mechanics behind them belong to the framework rather than the application.
GET …/authorize(per endpoint kind) — front-channel termination. Validation order is client, redirect URI,response_type, S256 PKCE,state,resource, scope. A failure before the redirect URI is validated — an unknownclient_id(400 invalid_client) or aredirect_urithat does not exactly match a registered value (400 invalid_request) — renders a JSON error page and never sends aLocation. Once the redirect URI is validated, a later failure (response_typeother thancode, absent/plain/non-S256 PKCE, aresourcethat is not the route resource →invalid_target, or a scope request that clamps to nothing) redirects the browser back to that validated redirect URI as302carrying the fixederror,error_description, the client’s ownstate, and the client-facing RFC 9207iss. On success it records a single-use transaction keyed byhash(upstream state)and sends the browser upstream with the facade’s own client, callback URL, S256 challenge, fresh upstream state,resource, hints, and clamped scope set.GET /_oauth/mcp/callback(one stage-global route). The callback pins the upstream RFC 9207issagainst the configuredUpstreamIssuerbefore consuming the transaction or exchanging the code; an absent or mismatchedissis400 invalid_requestwith noLocation. It then consumes the upstreamstatesingle-use, exchanges the code server-side with the facade’s own client and PKCE verifier, validates the upstream access token, seeds the route-refresh broker with the framework-minted opaque handle and the encrypted upstream refresh material, mints a single-use facade code (durable in the code record with the encrypted token response), and redirects the client to its registered redirect URI withcode, the client’s ownstate, andiss(the route URL in route issuer mode, else the configured family issuer). Upstream codes, refresh tokens, andid_tokennever reach the client.POST …/token— theauthorization_codegrant. The grant requiresclient_id,code,redirect_uri, andcode_verifier; only a registered public client (token_endpoint_auth_methodempty ornone) may redeem. The code is single-use and bound to that client, that redirect URI, the route kind, and the S256 challenge; a mismatch is400 invalid_grant. A replay returns400 invalid_grantand revokes the broker record the code minted, so the refresh handle from that authorization dies with the replay. An expired code burns on its first consume (400 invalid_grant) and the following attempt is then a replay that revokes the broker record. Aresourcethat is not the code’s resource is400 invalid_target. On success the response is200carrying the upstream access token byte-for-byte unmodified, the upstreamtoken_type/expires_in/scope, and the framework-minted opaque refresh handle asrefresh_token; it never carries anid_tokenor upstream refresh material.POST …/token— therefresh_tokengrant. The client presents the opaque handle the code exchange returned and the sameclient_id. A handle that is unknown, locally expired, revoked, bound to another client, or presented at another route’s token endpoint is one and the same400 invalid_grant(no oracle). A successful refresh rotates the handle to a fresh opaque handle and answers200with the upstream access token byte-for-byte plus the successor handle; anid_tokenand upstream refresh material never appear in the response, a header, a redirect, or an error body. A presentresourcemust equal the bound resource (400 invalid_targetotherwise) and a presentscopemust be exactly the granted scope set — a wider set and a subset, including a present-but-emptyscope, are400 invalid_scope, because the upstream access token is returned unmodified and cannot be narrowed. A missing or over-512-byte handle is400 invalid_request. Refresh is single-flighted under the broker store’s lock, so concurrent callers of one handle cause exactly one upstream refresh call: a caller that loses that race waits up to 3 seconds, then receives the winner’s successor tokens or503 temporarily_unavailablewithRetry-After. For 30 seconds after a rotation the previous handle replays the same successor tokens byte-for-byte (an idempotent lost-response path); after that window the previous handle is reuse, which revokes the whole grant and answers the sameinvalid_grant. A terminal upstream rejection is400 invalid_grant(the record is revoked), an upstream gateway failure is502 upstream_error, and a transient upstream, store, lock, or contention failure is503 temporarily_unavailablewithRetry-After. The grace window, the 3-second coalesce wait cap, the 512-byte handle bound, and the broker store’s 15-second lock TTL are framework constants — noFacadeConfigfield and no environment variable. The endpoint still answers400 unsupported_grant_typefor every other grant and400 invalid_requestfor an absent grant type. The refresh503’sRetry-Afteris a fixed framework constant (1 second) and is never computed from the injected framework clock, so a test that advances that clock can observe aRetry-Afterthat disagrees with the harness’s own real elapsed time — a harness artifact of the two clocks, not a framework defect (ADV-1145-5).POST …/register— RFC 7591 dynamic client registration. The framework-owned handler bounds and validates the client metadata, mints a server-assigned opaqueclient_id(≥128 bits), and stores the record through theClientStore, hash-keyed and bound to the advertising route. Only public clients are accepted (token_endpoint_auth_methodabsent ornone);grant_types(if present) must includeauthorization_codeandresponse_types(if present) must includecode; everyredirect_urimust be an absolute URL with no fragment that is eitherhttpsor loopbackhttp; unknown JSON members are ignored. Both the raw body and the metadata are bounded, and the route is per-route rate limited through theRateLimitStore, keyed by the advertising route kind plus the provider-derived source identity (a shared per-route bucket when no source identity is available, so it fails closed to the stricter bucket), with a store or bucket error failing closed too. A refusal is400with a fixed RFC 7591 error code (invalid_client_metadataorinvalid_redirect_uri); a rate-limited or store-failed request is429with the fixed codetemporarily_unavailableand aRetry-Afterheader. The201response carriesclient_id,client_id_issued_at, the normalized metadata, and noclient_secret(a public client has none), withcache-control: no-store.
Provider failures are their own class. A provider AuthorizationURL / ExchangeCode / ValidateAccessToken
failure is never returned in the facade’s 400 vocabulary: it is a distinct 502 carrying the fixed error code
upstream_error (error page, no client redirect), so the facade’s own request-class rejections and a gateway-class
upstream failure stay distinguishable. The rule is the same on both legs. On /authorize, a failure of the provider’s
AuthorizationURL builder or of the app-supplied ProviderHints is that gateway-class 502, while the 400
vocabulary stays the facade’s own client-caused rejections (invalid_client, invalid_request, invalid_target); on
/callback, any exchange or token-validation failure is the 502, which is what keeps the iss and state pins
non-vacuous. The client-facing error vocabulary is closed and fixed — every rejection of one error code carries the
same error_description — so the response never distinguishes an unknown key from a used one.
Encryption is still a store obligation. The front channel hands each store the upstream material it must protect
and relies on the store to seal it: a durable store must encrypt every Encrypted* field at rest, and the
framework never writes upstream material to a log, a redirect, a response, or an error body.
The JSON bodies are semantically identical across runtimes, not byte-identical. The token and error bodies are
JSON encoded by each runtime’s own encoder: Go’s encoding/json HTML-escapes &, <, and > as \u0026,
\u003c, and \u003e, while the TypeScript (JSON.stringify) and Python (json.dumps(..., ensure_ascii=False))
runtimes emit those characters raw. Every form decodes to the same values, and the grace replay is byte-identical
within one runtime (which is what the 30-second replay requires); no cross-runtime byte equality is promised, so
a cross-language golden comparison must normalize or compare decoded values, never raw bodies.
Conformance suites and the fake provider
Three shared conformance tables drive all three runtimes from one source (contract-tests/fixtures/routing/mcp-auth-facade/):
facade-provider-conformance.v1.json— discovery, authorize-URL construction, code exchange, refresh and token validation rows, run against the Autheory adapter and a tiny second adapter (provider-neutrality) over a real local HTTP fake upstream with real RSA keys.facade-store-conformance.v1.json— the atomicity rows (single-use consume, replay vs unknown, rotation/CAS, lock with expiry, idempotent revoke, rate-limit window) including concurrent-consume/rotate/revoke rows, run against the in-memory reference stores.facade-conformance.v1.json— the front-channel behavior rows executed through the real mounted handlers: the RFC 9207isspin, the loopback redirect match, the bounded strict/tokenform parse, the method-mismatch rule, the RFC 7591 DCR (dcr) rows, the DCR route binding (bound_route) rows, and the brokered refresh (refresh) rows (rotation, the grace replay, reuse-after-grace revocation, the byte-identical refusal set, the scope/resource rule, the handle bound, replayed-code revocation, and the upstream terminal/transient failures).
Any store — the in-memory ones now, an application’s durable store later — must pass the store conformance suite. A
fake provider (real RSA keys, real local HTTP, configurable success/error/slow/oversized-filler/oversized-valid-json/
rotated-refresh/missing-iss/bad-signature behaviours) ships in the test-support area for the executed journey tests.
The oversized-valid-json mode serves a well-formed token document whose access_token is itself past the cap, so the
response-size bound is observable rather than indistinguishable from a JSON parse failure.
Where the code lives
| Surface | Go | TypeScript | Python |
|---|---|---|---|
| Provider SPI + Autheory adapter | runtime/oauth/provider.go, runtime/oauth/provider_autheory.go |
ts/src/oauth/provider.ts |
apptheory.oauth.provider, apptheory.oauth.autheory |
| Store SPI + reference stores | runtime/oauth/store |
ts/src/oauth/store.ts |
apptheory.oauth.store |
| Framework-owned front channel | runtime/mcpfacade/front_channel.go, runtime/mcpfacade/front_channel_config.go, runtime/mcpfacade/dcr.go |
ts/src/mcpfacade.ts (front-channel section) |
apptheory.mcpfacade (front-channel section) |
| Store key derivation | oauthstore.HashKey |
hashKey |
hash_key |
| Fake provider (test support) | testkit/oauthprovider |
ts/src/testkit/oauthprovider.ts |
apptheory.oauth.fake_provider |
The Store SPI is a separate Go package because the package oauth already declares the older, narrower
AuthorizationCodeStore/RefreshTokenStore types. The TypeScript rate-limit key type is exported as
RateLimitBucketKey because the TypeScript SDK already exports limited.RateLimitKey.
The store-key helper is intended public API. oauthstore.HashKey / hashKey / hash_key — the unpadded
base64url SHA-256 of the protected value — is ratified as supported public surface, not an internal detail. Every
store SPI is keyed by the hash of the value it protects and never by the value itself, so this helper is the seam
an application uses to pre-register a client while DCR is unmounted (and, once DCR lands, to reconcile a client
it already knows through another path). It is a pure, authority-free derivation: it only reproduces the key the
facade itself derives when it looks a record up, and it grants no access of its own.
Non-goals today
Still absent, by milestone:
- CDK wiring: the
AppTheoryMcpServerconstruct wires the stage-global callback path override throughupstreamCallbackPath, resolved by the sameAppTheoryMcpRouteAlgebra.callbackPathForConfiguredPaththe runtime applies toUpstreamCallbackPath, so the mounted and registered callback routes cannot diverge. The construct’sfacadeattach prop does not yet wire the provider and stores (wired by a follow-on milestone). - A subset-accepting refresh scope: not built and not planned. A present
scopeon a refresh must be exactly the granted set (ruling R4); the facade cannot narrow an upstream access token it returns byte-for-byte. - A durable store: the framework still ships none. The in-memory reference stores are non-durable and do not encrypt; a deployment supplies a durable store that discharges the encryption obligation above.
- Transport-route bearer enforcement is unchanged (M1/M2).