Skip to content

v1.2.0

Choose a tag to compare

@github-actions github-actions released this 28 Sep 14:11
· 14 commits to main since this release
f28ea84

Minor Changes

  • #338 b61bfc0 Thanks @mattzcarey! - Error redirects back to the client no longer need building by hand. AuthorizationError.redirectTo is the ready-made redirect (error, error_description, state, iss), set only when a redirect is safe, i.e. when redirectUri was validated. authorizationErrorRedirect(request, code, description?) builds the same for an error the application decides on, such as a decline at a third-party provider, from a request the library validated. denyConsent() uses it.

  • #339 c801878 Thanks @mattzcarey! - OAuthHelpers.describeConsent(request) returns what a consent page must show, per the MCP authorization spec: the client's name (or ID), its verified domain for a Client ID Metadata Document client, the redirect URI's hostname, whether the redirect goes to a local app (show a warning), and the scopes. Every string may come from the client, so escape it before rendering.

  • #340 b9a0307 Thanks @mattzcarey! - Redirect URIs must use https, or http on a loopback host (localhost, 127.0.0.0/8, ::1), as MCP and OAuth 2.1 require, with no userinfo or fragment. The rule is enforced at dynamic registration, in createClient() and updateClient() (which previously validated nothing), and on every authorization request and completeAuthorization(), so clients registered before the policy are held to it: their authorizations fail with a locally rendered invalid_request, never a redirect. A Client ID Metadata Document, which every server the client uses reads, may still list other redirect URIs next to a compliant one; only a request that uses one is refused.

    Breaking: clients registered with a remote http redirect URI or a private-use scheme stop authorizing. Native apps that rely on RFC 8252 private-use schemes (com.example.app:/cb) keep working with the new allowPrivateUseRedirectUris: true; remote http is never accepted.

  • #381 95924a3 Thanks @mattzcarey! - A resource's required scopes have their own option, requiredScopes, on OAuthResourceServer and OAuthProvider: the scopes any access needs, published as the protected resource metadata's scopes_supported and named in the 401 challenge, so MCP clients request them first. It sits beside resourceMetadata instead of inside it, where it read like the authorization server's scopesSupported catalogue. It is advertised, not enforced: handlers check ctx.auth.scope, since only they know which scopes imply others. resourceMetadata.scopes_supported still works but is deprecated in favour of requiredScopes; setting both throws at construction.

  • #337 9bab931 Thanks @mattzcarey! - tokenExchangeCallback receives the request's env (TokenExchangeCallbackOptions<Env>['env']), as resolveExternalToken already does. A callback that needs secrets or bindings, such as an upstream OAuth client secret, no longer forces the provider to be rebuilt on every request to close over env.

  • #383 d53af65 Thanks @mattzcarey! - An OAuthResourceServer's validateToken can throw OAuthError to choose the response, as a tokenExchangeCallback already can. temporarily_unavailable with statusCode: 429 and Retry-After passes through; insufficient_scope becomes the MCP 403 challenge naming the new OAuthError requiredScopes option (or the resource's requiredScopes); invalid_token becomes a 401 with the Bearer challenge. Anything else thrown is still a 503. This lets one resource validate an upstream API's own credentials in validateToken before falling back to its authorization server. OAuthProvider's resolveExternalToken and ExternalTokenError are unchanged.

Patch Changes

  • #385 ebb2efd Thanks @mattzcarey! - OAuthAuthorizationServer's authorizeEndpoint and tokenEndpoint are optional and default to ${issuer}/authorize and ${issuer}/oauth/token, under the issuer's path if it has one. OAuthProvider still requires both.

    Construction also rejects any endpoint another would claim, so it could never be reached: a token, registration or authorization endpoint on the metadata path with a query (metadata is served whatever the query), an endpoint that is the token endpoint plus a query, or an authorization endpoint equal to the token or registration endpoint.

  • #388 bd97dc1 Thanks @mattzcarey! - OAuthAuthorizationServer.fetch() no longer writes env.OAUTH_PROVIDER into the caller's env when it answers a path that isn't one of its endpoints. It answers 404 directly; a dead branch meant it fell through to a placeholder handler after setting the helpers, which only the combined OAuthProvider's defaultHandler reads. Use authorizationServer.getOAuthApi(env) for the helpers, as documented.

  • #361 36cb1b4 Thanks @mattzcarey! - tokenExchangeCallback lifetimes no longer fail requests or silently remove expiry. refreshTokenTTL: undefined in a callback result now keeps the provider's lifetime; it used to make the grant never expire, which is what a callback passing through an upstream's missing refresh_expires_in did. refreshTokenTTL is validated (0, or an integer of at least 60 seconds) instead of failing the code exchange with a storage error. Each lifetime is ignored where it doesn't apply, rather than rejected after the callback ran: returning refreshTokenTTL on refresh, or refreshTokenIdleTTL at code exchange or token exchange, used to fail the request after the callback's side effects, such as rotating an upstream refresh token.

  • #372 78b3c80 Thanks @mattzcarey! - createClient() and updateClient() check grant and response types against what the server implements, as dynamic registration does, so a client can no longer be stored with, say, grantTypes: ['implicit'] and then fail every authorization. updateClient() refuses a Client ID Metadata Document client while CIMD is enabled, instead of writing a stored copy that did nothing then and would have become the client if CIMD were later turned off. deleteClient() on a CIMD client revokes its grants, as before, and is now documented as doing so.

  • #363 958d792 Thanks @mattzcarey! - deleteClient() deletes the client before revoking its grants, so the client stops working at once even if revocation fails partway; calling it again finishes the revocation. It finds the client's grants from their KV key metadata instead of reading every grant in the namespace one by one, which could exceed a Worker's subrequest limit and, because the client record was deleted last, leave the client active.

  • #366 0cecdba Thanks @mattzcarey! - With disallowPublicClientRegistration, a dynamic registration that prefers none but also supports a secret method is registered with the secret method instead of being refused. none is simply not on offer during negotiation; a client that supports only none is still refused.

  • #371 a6d99ad Thanks @mattzcarey! - A grant issued without a refresh token (refreshTokenTTL: 0, from the option or a tokenExchangeCallback) now expires with its access token. It used to be stored with no expiry, so every such authorization left a grant record in KV for good.

  • #365 3c940ff Thanks @mattzcarey! - Six internal helpers are no longer exported from the package: base64UrlToBytes, parseJwtJsonPart, getJwtCryptoAlgorithms, isValidOAuthScopeToken, resourceMatches and validateResourceUri. They were exported by accident in 1.0 (the first three only so the enterprise-managed authorization modules could import them back from the package entry) and were never documented. A test now pins the package's runtime exports.

  • #342 696045a Thanks @mattzcarey! - A bearer token in this provider's own userId:grantId:secret format that isn't found in storage (an expired or revoked access token, or a forgery of the format) is now answered invalid_token directly instead of being handed to resolveExternalToken. Resolvers that validate foreign credentials upstream were forwarding our expired tokens to third-party APIs: about 24k calls an hour in one production deployment, from MCP clients presenting a stale access token before refreshing.

  • #362 23c151e Thanks @mattzcarey! - purgeExpiredData() is resumable. It returns a cursor whenever the sweep isn't finished, and takes it back as PurgeOptions.cursor. Previously every invocation started from the first grant, so with more than batchSize live grants a scheduled sweep re-checked the same records forever, never reached later grants, and never swept orphaned tokens at all. Persist the cursor between runs; the example in docs/advanced-configuration.md stores it in KV.

  • #368 bf61067 Thanks @mattzcarey! - A token request whose scope names only scopes the grant doesn't hold is refused with invalid_scope (RFC 6749 §6) at code exchange, refresh and token exchange, instead of succeeding with a token that carries no scope. Requests that name at least one granted scope are still narrowed silently, as before.

  • #374 fe5e1e2 Thanks @mattzcarey! - Dynamic client registration stops reading a request body at 1 MiB even when it has no Content-Length (a chunked body), instead of reading all of it first. When clientRegistrationCallback throws, the client gets a fixed Client registration callback failed description rather than the error's message, which may be internal; onError still receives the error.

  • #344 19c4d96 Thanks @mattzcarey! - completeAuthorization() rejects a userId containing :, as the enterprise-managed authorization mapper already did. : separates the parts of issued tokens and grant keys, so such a user's tokens could never be validated, and its grant keys (grant:a:b:…) matched another user's grant:a: prefix. listUserGrants() and the revocation of earlier grants now also skip those legacy keys, so a stored grant for user a:b never appears in, or is revoked by, user a's operations.

  • #369 a8843d8 Thanks @mattzcarey! - The implicit grant is removed, along with the allowImplicitFlow option. OAuth 2.1 dropped it and MCP requires the authorization code flow with PKCE. response_type=token is now always answered with unsupported_response_type, and passing allowImplicitFlow: true throws at construction instead of being silently ignored. Implicit grants were stored without an expiry, so each implicit authorization left a grant record in KV for good.

  • #370 5234cc8 Thanks @mattzcarey! - The allowPlainPKCE option is removed; only S256 PKCE is accepted, as MCP requires. Passing allowPlainPKCE: true throws at construction instead of being silently ignored. An authorization code issued with a plain challenge before the upgrade is refused at the token endpoint with invalid_grant.

  • #375 9db9a62 Thanks @mattzcarey! - The revokeExistingGrantsBatchSize option of completeAuthorization() is removed. Since 1.0 every grant carries key metadata, so earlier grants are found without reading them; the option only set how many pre-1.0 grants were read at once, and those disappear as they refresh. They are now read 50 at a time.

  • #376 baf2787 Thanks @mattzcarey! - CORS headers an API handler sets itself (Access-Control-Allow-Origin, -Methods, -Headers, -Max-Age) are kept instead of being overwritten, so a handler can narrow its own CORS policy; Vary: Origin and the exposed WWW-Authenticate and Retry-After headers are still added. OAuthProvider and OAuthResourceServer now share one implementation of CORS, header merging and resource scope filtering.

  • #373 9e97803 Thanks @mattzcarey! - A token from an allowed cross-client token exchange is issued to the requesting client (RFC 8693) instead of being recorded as the subject token's client. ctx.auth.clientId and unwrapToken() now name the client that holds the token, and that client can revoke it; previously revocation failed its ownership check. The token still lives under the subject's grant, so revoking the grant removes it.

  • #364 cd28310 Thanks @mattzcarey! - refreshTokenTTL and clientRegistrationTTL are validated at construction, like accessTokenTTL. A value Cloudflare KV can't store (under 60 seconds, or not an integer) used to surface only at runtime, as a 500 on every code exchange or every dynamic client registration. refreshTokenTTL accepts 0 (no refresh tokens), undefined (no expiry) or an integer of at least 60; clientRegistrationTTL accepts undefined (no expiry) or an integer of at least 60. 0 is no longer accepted for clientRegistrationTTL, where it used to be treated as "no expiry" in one place and as a TTL in another.