Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .conformance-catalog-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf
583a6d92412543ea352251c88f15f2c5a39d2593
22 changes: 14 additions & 8 deletions .github/workflows/conformance-catalog-drift.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
name: Conformance Catalog Drift

# Weekly early-warning check: does the LATEST conformance catalog (the default
# branch of AuthPlane/conformance, unpinned) contain cases the SDK does not yet
# cover? This runs the catalog-alignment meta-test against the tip of the
# catalog instead of the SHA pinned in `.conformance-catalog-ref`.
# Weekly early-warning check: has the LATEST conformance catalog (the default
# branch of AuthPlane/conformance, unpinned) moved away from what the SDK
# covers — either by carrying a case the SDK does not yet cover, or by renaming
# or dropping one the SDK still declares? This runs the catalog-alignment
# meta-test, which asserts both directions, against the tip of the catalog
# instead of the SHA pinned in `.conformance-catalog-ref`.
#
# It never blocks PR CI — this workflow has no `pull_request` trigger, so it
# cannot gate a PR. On drift the alignment step FAILS, turning the scheduled run
Expand Down Expand Up @@ -91,15 +93,19 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
;;
failure)
echo "::warning::Conformance catalog drift — the latest catalog (default branch of AuthPlane/conformance) has cases the SDK does not yet cover. Add the missing conformanceCase(...) coverage, then bump .conformance-catalog-ref to the new SHA."
echo "::warning::Conformance catalog drift — the latest catalog (default branch of AuthPlane/conformance) no longer agrees with the SDK's conformanceCase(...) declarations. Read the failure for the direction and the case ids, then bump .conformance-catalog-ref to the new SHA."
{
echo "### Conformance catalog: drift detected ⚠️"
echo ""
echo "The latest catalog (default branch) contains cases not yet covered by the SDK conformance suite."
echo "The latest catalog (default branch) and the SDK's declarations disagree. The alignment test asserts both directions, so this is one of:"
echo ""
echo "- the catalog carries a case the SDK does not cover — the usual case, a new case upstream;"
echo "- the SDK declares a case id the catalog no longer carries — a case renamed or dropped upstream;"
echo "- or the harness could not read a usable catalog at all — a truncated clone, a wrong \`CONFORMANCE_CATALOG_PATH\`. The failure text says so in as many words (\"this is a harness problem, not catalog drift\"), and nothing below applies until it is fixed."
echo ""
echo "**Next steps:**"
echo "1. Inspect the alignment-test failure in the job log above for the missing case ids."
echo "2. Add the corresponding \`conformanceCase(...)\` coverage under \`packages/sdk/\`."
echo "1. Inspect the alignment-test failure in the job log above — each line names the case id and which direction it broke."
echo "2. Add the missing \`conformanceCase(...)\` coverage under \`packages/sdk/\`, or update the declarations whose ids the catalog dropped."
echo "3. Bump \`.conformance-catalog-ref\` to the new catalog SHA so CI pins the adopted cases."
} >> "$GITHUB_STEP_SUMMARY"
;;
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

### Added

- `@authplane/sdk` — new `resourceMetadataUrl` option on `client.resource(...)` (and on every adapter) pointing the `resource_metadata` challenge parameter at a PRM document hosted elsewhere, such as the one authserver 0.2.0 serves per registered Resource; the default stays the URL derived from `resource`, and the locally served document and its route are unchanged. Validated at construction with a `TypeError`: absolute with a scheme and a host, `http`/`https` only, no fragment, no userinfo, an RFC 3986 §3.4 query, and no `"`, `\`, whitespace or C0 control anywhere — the value is advertised exactly as typed, so those octets would reach the `WWW-Authenticate` quoted-string.
- `@authplane/sdk` — new `@authplane/sdk/core` helper `validateResourceIndicator(resource)`: the RFC 8707 §2 resource-indicator gate the `AuthplaneResource` constructor runs, exported for callers that want to check a configured identifier ahead of construction. Throws `TypeError`; see the corresponding entry under **Changed** for what it rejects.
- `@authplane/sdk` — new `@authplane/sdk/core` helper `validateIssuerIdentifier(issuer)`, the issuer-side counterpart to `validateResourceIndicator`.
- `@authplane/sdk` — new `AccessDeniedError` (`access_denied`, 403: the exchanging client is not allowlisted on the target Resource — an operator fix, unlike `ConsentRequiredError`) and `InvalidTargetError` (`invalid_target`, 400, RFC 8707 §2.2: `resource` does not match a granted resource byte for byte), mapped by `mapOAuthError` and exported from `@authplane/sdk/core` and `@authplane/sdk/auth`.
- `@authplane/sdk` — new `@authplane/sdk/core` helper `wwwAuthenticateChallenges(error, { schemes, algs, ... })`: one RFC 6750 §3 header value per scheme, so a resource accepting both `Bearer` and `DPoP` can advertise both (RFC 9449 §7.2) and emit the §7.1 `algs` parameter, whose values are validated against the supported set rather than escaped.
- `@authplane/sdk` — new `@authplane/sdk/core` helper `errorResponseBody(error, options)`: the RFC 6750 §3 JSON error body, built from the error code and fixed description `wwwAuthenticate()` already uses.

### Changed

- `@authplane/sdk` — a resource indicator carrying a URI fragment is now rejected with a `TypeError` at construction (RFC 8707 §2) instead of being silently dropped, where it survived into the PRM document's `resource` member and made conformant clients discard it. **Migration**: drop the fragment before upgrading — a deployment configured with one now fails at startup.
- `@authplane/sdk` — a resource identifier must now be an absolute URL with a scheme and a host, rejected with a `TypeError` at construction otherwise (RFC 8707 §2, RFC 9728 §3). A relative `/mcp`, a scheme-relative `//api.example.com/mcp` or an opaque `urn:example:api` previously derived a malformed document URL. **Migration**: configure `resource` as a full URL; `http://localhost:8080/mcp` is still accepted.
- `@authplane/sdk` — the derived PRM document URL now preserves the resource identifier's query (RFC 9728 §3), and a query outside the RFC 3986 §3.4 grammar is rejected at construction, since those octets would corrupt the `resource_metadata` quoted-string. **Migration**: update any hard-coded expectation of the query-less URL, and percent-encode brackets, spaces and quotes in the configured `resource`.
- `@authplane/sdk` — a resource identifier whose authority carries userinfo is now rejected with a `TypeError` at construction (RFC 9110 §4.2.4). The credential was published to unauthenticated callers in the PRM document and the 401 challenge, and the derivations drop userinfo, so the document never matched the URL it was fetched from. A port, an IPv6 literal and an `@` in the path stay accepted. **Migration**: carry the credential in a header instead.
- `@authplane/sdk` — `buildPrm(issuer, resource, scopes)` now runs the same resource-identifier gate the `AuthplaneResource` constructor runs. It was the one public path that could still emit a document whose `resource` member conformant clients discard. Only direct callers of the export change. **Migration**: correct a fragment-bearing, relative, opaque, out-of-grammar or userinfo-bearing identifier before upgrading.
- `@authplane/sdk` — `buildPrm()` and `AuthplaneClient.create()` now reject an issuer carrying a query, fragment or userinfo component, or that is not an absolute URL with a scheme and a host (RFC 8414 §2/§3.1, RFC 9110 §4.2.4). **Migration**: `buildPrm` previously published the issuer verbatim in `authorization_servers`, credentials included — correct any such issuer before upgrading.
- **BREAKING** `@authplane/sdk` — an issuer identifier containing whitespace or a control character is now rejected with a `TypeError` by `AuthplaneClient.create()`, `buildPrm()` and `buildMetadataUrl()` (RFC 3986 §2). `new URL` silently trimmed or stripped the byte, so the `.well-known` location was derived from the cleaned string while the raw one was published in `authorization_servers` and compared as the expected `iss` (RFC 8414 §3.3) — every token rejected on an identity comparison that reads as identical in a log. The message names the codepoint and its offset, never the value. **Migration**: remove the stray byte before upgrading — a trailing newline in an environment variable is the usual source.
- `@authplane/sdk` — `access_denied` and `invalid_target` are now listed explicitly as OAuth answers that never trip the circuit breaker (a healthy AS enforcing policy is not an outage).
- `@authplane/sdk` — introspection revocation configured without complete AS credentials (`asCredentials`, or `clientId` + `clientSecret`) now warns at construction that authserver ≥ 0.1.2 answers unauthenticated introspection with `active: false` and every token will be rejected; the first `active: false` on a token that passed local JWT verification logs the runtime-client requirement, once per resource.
- Docs — token-exchange sections explain `access_denied` vs `consent_required` and the `PATCH /admin/resources/{id}` allowlist step; introspection sections state the RS client must be confidential and the issuing client or a runtime-client of the Resource; README gains a Compatibility section (tested against authserver 0.2.0, introspection-based revocation needs ≥ 0.1.2); demo docs and `scripts/manual-e2e-setup.sh` drop `AUTHPLANE_*_ENABLED=true` (on by default since authserver 0.2.0) and the setup script accepts `AUTHSERVER_REF`.
- `@authplane/sdk` — **BREAKING**: `wwwAuthenticate(error, options)` now emits a fixed `error_description` chosen by the `error=` code instead of the exception message, which disclosed the unknown `kid`, the rejected `typ` or the expected `aud` to an unauthenticated caller; sanitising prevented header injection, not disclosure. **Migration**: log `error.message` server-side, or pass `verboseDescription: true` to restore the old text for local debugging only.
- **BREAKING** `@authplane/mcp`, `@authplane/hono`, `@authplane/nestjs` — the JSON error body now carries the fixed `error_description` the challenge carries, not the exception message, and names `invalid_dpop_proof` where it previously said `invalid_token`. **Migration**: log `error.message` server-side; these adapters do not accept `verboseDescription`, which is a core-only escape hatch.
- **BREAKING** `@authplane/mcp` — `tokenVerifier.verifyAccessToken()` now rejects with the fixed per-code sentence instead of core's message, which the MCP SDK's `requireBearerAuth` splices into `error_description=`. The original error stays on `.cause`.

### Deprecated

- `@authplane/sdk` — `VerifiedClaims.mayAct`: authserver 0.2.0 no longer issues `may_act`, so the getter always returns `undefined` against it; removed in the next minor.

### Fixed

- `@authplane/sdk` — the SSRF guard no longer rejects every address when the SDK is loaded by Node's own ESM loader: `ipaddr.js` is CommonJS, so the namespace import resolved to an object without `parse`, the resulting `TypeError` was swallowed, and `isIpAllowed()` returned `false` for all issuers (only `ssrfProtection: false` got past it). The import is now a default import, a `TypeError` from the import shape propagates instead of being read as "blocked", and a test loads the built module under `node` itself, since vitest's interop hid the defect.
- `@authplane/sdk` — a failed metadata or JWKS fetch now opens a retry floor of `fetchFailureBackoffSeconds` (new `AuthplaneClient.create()` option, default `30`, clamped to `[1s, the cache's refresh interval]`; opt out with a refresh interval of `0`). **Impact:** on a cold start against an unreachable AS every `verify()` now fails fast with the stored error until the window closes, even if the AS recovers inside it; cached documents keep being served, and the first suppressed attempt per window logs a warning naming the document.
- `@authplane/sdk` — a resource server that only verifies tokens now re-reads the authorization server metadata once `metadataRefreshSeconds` elapses, and follows a rotated `jwks_uri`. Previously metadata was read once at `create()` and the JWKS kept being fetched from the URI captured then, until the AS withdrew it and every token failed. A `kid` miss forces a re-read, floored at one per `min(metadataRefreshSeconds, 60)` seconds; `metadataRefreshSeconds: 0` opts out of both the refresh interval and that floor.
- `@authplane/sdk` — a server expiry at or before the moment the document was cached (`Cache-Control: max-age=0`, or a stale `Expires:` header) is now treated as no preference, so the configured refresh interval governs. Previously it left the metadata or JWKS document permanently expired, so every verification took the synchronous re-fetch path — one upstream fetch per request, driven by an unauthenticated caller, since the cache is read before the signature is checked. A future server expiry still shortens the interval.
- `@authplane/sdk` — the issuer and resource gates now quote and escape the identifier they echo, so a rejection can no longer carry a raw control character into a startup log; the redaction itself is unchanged, and a `\` or `"` in an echoed identifier now appears as its JSON escape.
- `@authplane/mcp` — `requireScope(scope, authInfo)` now throws core `InsufficientScope` carrying the missing scope instead of a plain `Error`, so a host that maps `AuthplaneError` answers 403 with an `insufficient_scope` challenge naming that scope rather than a JSON-RPC internal error or a generic 500.
- `@authplane/sdk` — `InsufficientScope` now carries the optional `requiredScopes` it was raised for, and `wwwAuthenticate()` falls back to it for `scope="…"` when the caller passes none; an explicit `scope` option still wins.
- `@authplane/sdk` — a DPoP proof nonce mismatch no longer names the expected or the received nonce, so the RFC 9449 §9 resource-server nonce is no longer handed back in the 401 challenge it was rejected by.

## [0.4.0] - 2026-08-27

### Added
Expand Down
11 changes: 7 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,16 @@ parent-dir/
└── oauth-sdk-conformance-catalog.yaml
```

With that layout in place, `npm test` auto-discovers the catalog — no configuration required. Override the path with `CONFORMANCE_CATALOG_PATH` if the catalog lives elsewhere:
With that layout in place, `npm test` auto-discovers the catalog — no configuration required. Keep the sibling checkout **at the pinned SHA**: the alignment test asserts that the catalog and the SDK's `conformanceCase(...)` declarations agree in both directions, so a sibling checkout sitting on any other revision fails it. Running against a catalog older than the pin reports the cases adopted since as declarations the catalog does not carry; running against a newer one reports its new cases as uncovered. Neither is a defect in your branch — re-run `git -C conformance checkout "$(cat ts-sdk/.conformance-catalog-ref)"` first.

Override the path with `CONFORMANCE_CATALOG_PATH` if the catalog lives elsewhere:

```bash
CONFORMANCE_CATALOG_PATH=/path/to/oauth-sdk-conformance-catalog.yaml \
npm run test -w @authplane/sdk
```

If the catalog isn't available at all, skip the two catalog-dependent tests with:
If the catalog isn't available at all, skip the catalog-dependent tests with:

```bash
AUTHPLANE_CONFORMANCE_SKIP_CATALOG=1 npm test
Expand Down Expand Up @@ -164,7 +166,7 @@ The end-to-end demo exercises the FastMCP and MCP adapters against a local Authp

Prerequisites:

1. OAuth server running locally on `:9000`/`:9001` with client credentials, token exchange, and DPoP enabled.
1. OAuth server (authserver 0.2.0) running locally on `:9000`/`:9001` — client credentials, token exchange and DPoP are on by default since authserver 0.2.0.
2. Demo client registration with required grant types and scopes.
3. Adapter demo server (`packages/fastmcp/demo/run.sh` or `packages/mcp/demo/run.sh`).
4. Demo client execution (matrix client).
Expand Down Expand Up @@ -192,12 +194,13 @@ bash scripts/manual-e2e-smoke.sh --adapter fastmcp --skip-setup
Optional overrides:

- `AUTHSERVER_DIR=/path/to/authserver`
- `AUTHSERVER_REF=v0.2.0` — check out that ref of the authserver checkout before building (default: leave it as is)
- `ISSUER_URL=http://localhost:9000`
- `RESOURCE_URL=http://localhost:8080/mcp`

### Common demo failures

- `client_credentials grant is not enabled` — OAuth server is missing `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true`.
- `access_denied` on a token exchange — the exchanging client is not allowlisted on the target Resource (`policy.exchange.allowed_client_ids` / `policy.runtime.client_ids`).
- `client is not authorized for this grant type` — client registration is missing `urn:ietf:params:oauth:grant-type:token-exchange`.
- `requested scope is invalid or not allowed` — requested scopes are not registered or assigned to the demo client.
- `invalid API key` — admin API requests are using a different key than the server startup key.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ OAuth, JWT validation, and MCP-authentication primitives for Node.js. Ships fram
- Node.js 22 LTS (or newer)
- TypeScript consumers: `moduleResolution` set to `bundler`, `node16`, or `nodenext` (required for the package `exports` subpaths)

## Compatibility

- Tested against authserver 0.2.0.
- Introspection-based revocation (`IntrospectionRevocation`) requires authserver 0.1.2 or newer, and a resource-server client that is confidential and either the issuing client or a runtime-client of the Resource.

## Quickstart

```ts
Expand Down
Loading
Loading