From 1545ffb57356bf683c166b3a89955568fc9e4908 Mon Sep 17 00:00:00 2001 From: muralx Date: Wed, 10 Jun 2026 20:08:25 +0100 Subject: [PATCH] feat: add Hono and NestJS framework adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @authplane/hono and @authplane/nestjs: new framework adapters (Bearer + DPoP per RFC 9449 §7.1, RFC 9728 PRM, RFC 6750 §3 challenges). - @authplane/sdk: bounded TokenCache (configurable maxEntries, LRU) and new @authplane/sdk/core helpers; PRM document URL normalises trailing slashes (RFC 9728 §3.1). - @authplane/mcp / @authplane/fastmcp: accept DPoP-scheme Authorization headers; mcp returns 401 (not 500) on a non-URL aud claim (RFC 8707). --- .github/workflows/publish-npm.yml | 102 +- .github/workflows/release.yml | 6 +- .github/workflows/workflows-lint.yml | 55 + CHANGELOG.md | 23 + README.md | 10 +- RELEASE_GUIDE.md | 68 +- llm-full.txt | 2 +- package-lock.json | 3290 ++++++++++------- package.json | 8 +- packages/fastmcp/src/auth.ts | 78 +- packages/fastmcp/tests/auth.test.ts | 46 +- packages/hono/README.md | 53 + packages/hono/demo/.env.example | 12 + packages/hono/demo/README.md | 94 + packages/hono/demo/run.sh | 51 + packages/hono/demo/server.ts | 166 + packages/hono/docs/user-guide.md | 320 ++ packages/hono/package.json | 66 + packages/hono/src/authplaneHonoAuth.ts | 250 ++ packages/hono/src/bearerAuth.ts | 174 + packages/hono/src/index.ts | 13 + packages/hono/src/prmHandler.ts | 29 + packages/hono/src/requireScope.ts | 56 + packages/hono/src/types.ts | 34 + packages/hono/tests/auth.integration.test.ts | 268 ++ packages/hono/tests/authplaneHonoAuth.test.ts | 325 ++ packages/hono/tests/bearerAuth.test.ts | 473 +++ packages/hono/tests/index.test.ts | 23 + packages/hono/tests/prmHandler.test.ts | 72 + packages/hono/tests/requireScope.test.ts | 91 + packages/hono/tests/types.test.ts | 46 + packages/hono/tsconfig.json | 10 + packages/hono/vitest.config.ts | 26 + packages/mcp/demo/README.md | 2 +- packages/mcp/src/auth.ts | 100 +- packages/mcp/src/verifier.ts | 15 +- packages/mcp/tests/auth.middleware.test.ts | 142 +- packages/mcp/tests/auth.test.ts | 40 + packages/mcp/tests/verifier.test.ts | 29 + packages/nestjs/README.md | 63 + packages/nestjs/biome.json | 30 + packages/nestjs/demo/.env.example | 12 + packages/nestjs/demo/README.md | 99 + packages/nestjs/demo/register.mjs | 4 + packages/nestjs/demo/run.sh | 63 + packages/nestjs/demo/server.ts | 148 + packages/nestjs/demo/tsconfig.json | 24 + packages/nestjs/docs/user-guide.md | 445 +++ packages/nestjs/package.json | 79 + .../application/authplane.exception-filter.ts | 173 + .../nestjs/src/application/authplane.guard.ts | 199 + packages/nestjs/src/application/decorators.ts | 123 + .../nestjs/src/application/metadata-keys.ts | 12 + packages/nestjs/src/index.ts | 58 + .../src/infrastructure/request-adapter.ts | 158 + .../nestjs/src/module/authplane.module.ts | 355 ++ .../nestjs/src/module/authplane.options.ts | 157 + .../src/module/authplane.shutdown-hook.ts | 31 + .../nestjs/src/module/authplane.tokens.ts | 36 + .../nestjs/src/presentation/prm.controller.ts | 45 + .../authplane.exception-filter.test.ts | 371 ++ .../tests/application/authplane.guard.test.ts | 359 ++ .../tests/application/decorators.test.ts | 165 + packages/nestjs/tests/index.test.ts | 55 + .../infrastructure/request-adapter.test.ts | 234 ++ .../integration/auth.integration.test.ts | 273 ++ .../tests/module/authplane.module.test.ts | 532 +++ .../tests/module/authplane.options.test.ts | 62 + .../tests/presentation/prm.controller.test.ts | 72 + packages/nestjs/tsconfig.json | 12 + packages/nestjs/vitest.config.ts | 33 + .../test_jwt_and_dpop_conformance.test.ts | 9 +- packages/sdk/docs/user-guide.md | 34 +- packages/sdk/src/auth/introspection.ts | 22 + packages/sdk/src/auth/oauth/parsing.ts | 9 - packages/sdk/src/auth/oauth/types.ts | 1 - packages/sdk/src/core/bearerToken.ts | 39 + packages/sdk/src/core/cache.ts | 68 +- packages/sdk/src/core/claims.ts | 23 + packages/sdk/src/core/client.ts | 8 + packages/sdk/src/core/constants.ts | 3 + packages/sdk/src/core/dpop.ts | 83 +- packages/sdk/src/core/errors.ts | 33 +- packages/sdk/src/core/index.ts | 2 + packages/sdk/src/core/prm.ts | 36 +- packages/sdk/src/core/requestContext.ts | 93 + packages/sdk/src/core/resource.ts | 7 +- packages/sdk/tests/core/bearerToken.test.ts | 73 + packages/sdk/tests/core/claims.test.ts | 60 + packages/sdk/tests/core/clientUnit.test.ts | 93 +- packages/sdk/tests/core/dpopHelpers.test.ts | 59 +- packages/sdk/tests/core/errors.test.ts | 10 + packages/sdk/tests/core/introspection.test.ts | 37 + .../sdk/tests/core/prmDocumentUrl.test.ts | 57 +- .../sdk/tests/core/requestContext.test.ts | 108 + packages/sdk/tests/core/tokenCache.test.ts | 140 + packages/sdk/tests/core/verifier.test.ts | 4 +- scripts/release/set-package-versions.mjs | 10 +- tsconfig.json | 4 +- 99 files changed, 10725 insertions(+), 1550 deletions(-) create mode 100644 .github/workflows/workflows-lint.yml create mode 100644 packages/hono/README.md create mode 100644 packages/hono/demo/.env.example create mode 100644 packages/hono/demo/README.md create mode 100755 packages/hono/demo/run.sh create mode 100644 packages/hono/demo/server.ts create mode 100644 packages/hono/docs/user-guide.md create mode 100644 packages/hono/package.json create mode 100644 packages/hono/src/authplaneHonoAuth.ts create mode 100644 packages/hono/src/bearerAuth.ts create mode 100644 packages/hono/src/index.ts create mode 100644 packages/hono/src/prmHandler.ts create mode 100644 packages/hono/src/requireScope.ts create mode 100644 packages/hono/src/types.ts create mode 100644 packages/hono/tests/auth.integration.test.ts create mode 100644 packages/hono/tests/authplaneHonoAuth.test.ts create mode 100644 packages/hono/tests/bearerAuth.test.ts create mode 100644 packages/hono/tests/index.test.ts create mode 100644 packages/hono/tests/prmHandler.test.ts create mode 100644 packages/hono/tests/requireScope.test.ts create mode 100644 packages/hono/tests/types.test.ts create mode 100644 packages/hono/tsconfig.json create mode 100644 packages/hono/vitest.config.ts create mode 100644 packages/nestjs/README.md create mode 100644 packages/nestjs/biome.json create mode 100644 packages/nestjs/demo/.env.example create mode 100644 packages/nestjs/demo/README.md create mode 100644 packages/nestjs/demo/register.mjs create mode 100755 packages/nestjs/demo/run.sh create mode 100644 packages/nestjs/demo/server.ts create mode 100644 packages/nestjs/demo/tsconfig.json create mode 100644 packages/nestjs/docs/user-guide.md create mode 100644 packages/nestjs/package.json create mode 100644 packages/nestjs/src/application/authplane.exception-filter.ts create mode 100644 packages/nestjs/src/application/authplane.guard.ts create mode 100644 packages/nestjs/src/application/decorators.ts create mode 100644 packages/nestjs/src/application/metadata-keys.ts create mode 100644 packages/nestjs/src/index.ts create mode 100644 packages/nestjs/src/infrastructure/request-adapter.ts create mode 100644 packages/nestjs/src/module/authplane.module.ts create mode 100644 packages/nestjs/src/module/authplane.options.ts create mode 100644 packages/nestjs/src/module/authplane.shutdown-hook.ts create mode 100644 packages/nestjs/src/module/authplane.tokens.ts create mode 100644 packages/nestjs/src/presentation/prm.controller.ts create mode 100644 packages/nestjs/tests/application/authplane.exception-filter.test.ts create mode 100644 packages/nestjs/tests/application/authplane.guard.test.ts create mode 100644 packages/nestjs/tests/application/decorators.test.ts create mode 100644 packages/nestjs/tests/index.test.ts create mode 100644 packages/nestjs/tests/infrastructure/request-adapter.test.ts create mode 100644 packages/nestjs/tests/integration/auth.integration.test.ts create mode 100644 packages/nestjs/tests/module/authplane.module.test.ts create mode 100644 packages/nestjs/tests/module/authplane.options.test.ts create mode 100644 packages/nestjs/tests/presentation/prm.controller.test.ts create mode 100644 packages/nestjs/tsconfig.json create mode 100644 packages/nestjs/vitest.config.ts create mode 100644 packages/sdk/src/core/bearerToken.ts create mode 100644 packages/sdk/src/core/requestContext.ts create mode 100644 packages/sdk/tests/core/bearerToken.test.ts create mode 100644 packages/sdk/tests/core/requestContext.test.ts create mode 100644 packages/sdk/tests/core/tokenCache.test.ts diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index df792b6..d42b596 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,18 +1,35 @@ name: Publish to npm -# Tag-triggered publish. The three @authplane/* packages go to npm from +# Tag-triggered publish. The five @authplane/* packages go to npm from # this workflow; release.yml only pushes the tag and lets this workflow # take over. # # Runs on any v*.*.* tag regardless of which branch it points to. The # tag itself is the authority — OIDC claim ref = refs/tags/vX.Y.Z, which # matches the `npm` environment's tag-only deployment policy. +# +# Manual re-dispatch: enabled via `workflow_dispatch` so a failed run can +# be retried for an existing tag without re-publishing from a developer +# machine. The OIDC claim is built from `GITHUB_REF` at run time, not +# from any input — so you MUST dispatch this workflow against the tag +# ref itself (e.g. `gh workflow run publish-npm.yml -r vX.Y.Z` or the +# UI's Run-workflow dropdown set to the tag). Dispatching from a branch +# (main, develop, …) emits an OIDC token with `ref=refs/heads/` +# which the tag-only Trusted Publisher policy on npmjs.com will reject. on: push: tags: - "v*.*.*" + workflow_dispatch: +# Shared with `workflow_dispatch` retries against the same tag — see +# RELEASE_GUIDE.md → Troubleshooting → "Re-running the publish workflow +# against a tag" → "Shared concurrency group with the original tag-push +# run" admonition. `cancel-in-progress` stays `false` deliberately: +# cancelling mid-publish would risk a partial multi-package upload, the +# exact failure mode the partial-upload recovery section exists to clean +# up after. Don't "fix" this group key without reading those docs first. concurrency: group: publish-npm-${{ github.ref }} cancel-in-progress: false @@ -40,10 +57,20 @@ jobs: # and version-dependent. Letting npm use its default registry keeps # .npmrc untouched; the @authplane/* packages publish to npmjs.org # via npm's defaults. + # + # Node 24 LTS bundles npm 11.x, which is the floor for OIDC Trusted + # Publishing (npm 11.5.1+). Node 22 LTS bundles npm 10.9.x, which + # silently skips the OIDC token exchange and falls back to demanding + # a classic credential — the failure mode that crashed the `0.2.0` + # release tag with `ENEEDAUTH`. Keep this at 24+ so the publish step + # actually performs the OIDC handshake and produces the `--provenance` + # Sigstore attestation without an in-place npm upgrade. Engines + # are satisfied (`>=22` root, `>=20` per-package) and `ci.yml` + # already exercises both 22 and 24 in matrix. - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22 + node-version: 24 cache: npm - name: Derive version from tag @@ -71,7 +98,9 @@ jobs: for pkg in \ packages/sdk/package.json \ packages/mcp/package.json \ - packages/fastmcp/package.json + packages/fastmcp/package.json \ + packages/hono/package.json \ + packages/nestjs/package.json do pv="$(node -p "require('./$pkg').version")" if [[ "$pv" != "$v" ]]; then @@ -79,7 +108,7 @@ jobs: exit 1 fi done - echo "All three packages report version $v ✓" + echo "All five packages report version $v ✓" - name: Install dependencies run: npm ci @@ -92,6 +121,8 @@ jobs: cd packages/sdk && npm pack cd ../mcp && npm pack cd ../fastmcp && npm pack + cd ../hono && npm pack + cd ../nestjs && npm pack - name: Upload built tarballs (for partial-publish recovery) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -101,6 +132,8 @@ jobs: packages/sdk/*.tgz packages/mcp/*.tgz packages/fastmcp/*.tgz + packages/hono/*.tgz + packages/nestjs/*.tgz if-no-files-found: error retention-days: 30 @@ -124,6 +157,65 @@ jobs: - name: Publish @authplane/fastmcp to npm run: npm publish --provenance --access public -w @authplane/fastmcp + - name: Publish @authplane/hono to npm + run: npm publish --provenance --access public -w @authplane/hono + + - name: Publish @authplane/nestjs to npm + run: npm publish --provenance --access public -w @authplane/nestjs + + # The whole point of the OIDC Trusted Publishing flow is the Sigstore + # provenance attestation. `npm publish --provenance` can succeed + # without attaching one (e.g. the publish credential exchange worked + # but the attestation upload silently degraded — exactly the kind of + # invisible regression that masked `0.2.0`'s ENEEDAUTH until release + # time). Read each manifest back and require dist.attestations to + # exist. Versions are already on npm at this point, so a failure + # here is a loud signal for the maintainer, not a recovery path — + # the next release is the first chance to re-attach provenance. + # + # `if: always()` so a partial-upload failure mid-sequence (sdk OK, + # mcp publish fails) still verifies whatever did land on the registry + # — that's exactly the case where the loud signal matters most. The + # per-package skip below guards against false-failing on packages + # this run never managed to publish. + # + # Brief retry absorbs registry CDN propagation delay between publish + # and the read-side manifest reflecting the attestation. + - name: Verify provenance attached for each published package + if: always() + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + fail=0 + for p in sdk mcp fastmcp hono nestjs; do + # Skip packages that aren't on the registry at this version + # (publish step never ran or failed before pushing). Only + # verify what actually landed. + published="$(npm view --prefer-online "@authplane/${p}@${VERSION}" version 2>/dev/null || true)" + if [[ -z "${published}" ]]; then + echo "@authplane/${p}@${VERSION} not on registry — skipping provenance check" + continue + fi + pred="" + for attempt in 1 2 3; do + pred="$(npm view --prefer-online "@authplane/${p}@${VERSION}" dist.attestations.provenance.predicateType 2>/dev/null || true)" + if [[ -n "${pred}" ]]; then + break + fi + if [[ "${attempt}" -lt 3 ]]; then + echo "attempt ${attempt}: no provenance yet for @authplane/${p}@${VERSION}; retrying in 5s..." + sleep 5 + fi + done + if [[ -z "${pred}" ]]; then + echo "::error::@authplane/${p}@${VERSION} published WITHOUT provenance attestation" + fail=1 + else + echo "@authplane/${p}@${VERSION} provenance: ${pred} ✓" + fi + done + exit "${fail}" + - name: Summary if: always() run: | @@ -132,5 +224,5 @@ jobs: echo "" echo "- **Tag**: \`${{ steps.version.outputs.tag }}\`" echo "- **Version**: \`${{ steps.version.outputs.version }}\`" - echo "- [@authplane/sdk](https://www.npmjs.com/package/@authplane/sdk/v/${{ steps.version.outputs.version }}) · [@authplane/mcp](https://www.npmjs.com/package/@authplane/mcp/v/${{ steps.version.outputs.version }}) · [@authplane/fastmcp](https://www.npmjs.com/package/@authplane/fastmcp/v/${{ steps.version.outputs.version }})" + echo "- [@authplane/sdk](https://www.npmjs.com/package/@authplane/sdk/v/${{ steps.version.outputs.version }}) · [@authplane/mcp](https://www.npmjs.com/package/@authplane/mcp/v/${{ steps.version.outputs.version }}) · [@authplane/fastmcp](https://www.npmjs.com/package/@authplane/fastmcp/v/${{ steps.version.outputs.version }}) · [@authplane/hono](https://www.npmjs.com/package/@authplane/hono/v/${{ steps.version.outputs.version }}) · [@authplane/nestjs](https://www.npmjs.com/package/@authplane/nestjs/v/${{ steps.version.outputs.version }})" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c08ee4..3663dc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,7 +169,9 @@ jobs: fi for pkg in \ packages/mcp/package.json \ - packages/fastmcp/package.json + packages/fastmcp/package.json \ + packages/hono/package.json \ + packages/nestjs/package.json do v="$(node -p "require('./$pkg').version")" if [[ "$v" != "$pkg_version" ]]; then @@ -236,6 +238,8 @@ jobs: cd packages/sdk && npm pack cd ../mcp && npm pack cd ../fastmcp && npm pack + cd ../hono && npm pack + cd ../nestjs && npm pack - name: Dry run short-circuit notice if: ${{ inputs.dryRun }} diff --git a/.github/workflows/workflows-lint.yml b/.github/workflows/workflows-lint.yml new file mode 100644 index 0000000..d9c7187 --- /dev/null +++ b/.github/workflows/workflows-lint.yml @@ -0,0 +1,55 @@ +name: Lint workflows + +# Catches workflow YAML / shell-in-`run:` regressions at PR time so a +# typo can't reach a release tag and surface only when a publish run +# fails. Scoped to changes under `.github/workflows/**` to keep CI +# overhead off unrelated PRs. + +on: + pull_request: + paths: + - ".github/workflows/**" + push: + branches: + - main + paths: + - ".github/workflows/**" + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Pulls the matching actionlint binary release from GitHub Releases + # via the upstream download script, version-pinned in the URL. This + # is a lint-only path with no secrets / no publish surface, so the + # supply-chain risk of a raw.githubusercontent.com fetch is bounded + # to "the lint job lies about workflow validity" — bump the version + # deliberately rather than tracking a moving tag. + # + # Install dir is passed explicitly as the script's second positional + # arg so the workflow doesn't couple to the script's internal default + # of $PWD (which happens to be $GITHUB_WORKSPACE after checkout — + # a coincidence, not a contract). + - name: Install actionlint + env: + ACTIONLINT_VERSION: "1.7.7" + ACTIONLINT_INSTALL_DIR: ${{ runner.temp }}/actionlint + run: | + mkdir -p "${ACTIONLINT_INSTALL_DIR}" + bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/v${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") \ + "${ACTIONLINT_VERSION}" "${ACTIONLINT_INSTALL_DIR}" + echo "${ACTIONLINT_INSTALL_DIR}" >> "${GITHUB_PATH}" + "${ACTIONLINT_INSTALL_DIR}/actionlint" -version + + # `-shellcheck=shellcheck` makes the shellcheck dependency explicit + # rather than relying on actionlint's implicit lookup against the + # runner image's $PATH; if the Ubuntu image ever drops shellcheck the + # job fails loudly instead of silently degrading. + - name: Run actionlint + run: actionlint -color -shellcheck=shellcheck diff --git a/CHANGELOG.md b/CHANGELOG.md index 57937c4..de62178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- `@authplane/hono` — new Hono adapter: `authplaneHonoAuth()`, `bearerAuth` middleware (Bearer + DPoP, RFC 9449 §7.1), `requireScope` guard, and an RFC 9728 PRM handler. +- `@authplane/nestjs` — new NestJS adapter: `AuthplaneModule.forRoot()` / `forRootAsync()`, `AuthplaneAuthGuard`, `@SkipAuth()` / `@RequireScopes()` decorators, `AuthplaneExceptionFilter` (RFC 6750 §3), and a PRM controller (RFC 9728). Supports Express and Fastify. +- `@authplane/sdk` — `TokenCache` is now bounded by a configurable `maxEntries` cap (default `10_000`) with LRU eviction, via `AuthplaneClient.create({ cacheMaxEntries })`. +- `@authplane/sdk` — new `@authplane/sdk/core` helpers: `extractBearerToken`, `buildRequestUrl`, `pathAndQueryOf`, `extractDpopProof`, `oauthProtectedResourceMetadataPath`. + +### Changed + +- `@authplane/sdk` — `oauthProtectedResourceMetadataDocumentUrl(resource)` now normalises trailing slashes (RFC 9728 §3.1) and throws a typed `TypeError` on invalid URLs. **Migration**: drop any deliberate trailing slash from `resource` before upgrading — the canonical form is no-trailing-slash. +- `@authplane/mcp` — now accepts a DPoP-bound token presented under the `DPoP` scheme (RFC 9449 §7.1) instead of rejecting it with 401. +- `@authplane/fastmcp` — stricter RFC 6750 §2.1 `Authorization` parsing, consistent with the other adapters. + +### Fixed + +- `@authplane/mcp` — a non-URL `aud` claim now returns 401 `invalid_token` (RFC 8707) instead of a 500. + ## [0.2.0] - 2026-05-22 - `@authplane/fastmcp`: support `fastmcp` 4.x in addition to 3.35+. Consumers using `OAuthProxy` directly must set `allowedRedirectUriPatterns` themselves — fastmcp 4.0 removed the default value (CWE-601 fix). @@ -19,3 +36,9 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [0.1.0] - 2026-05-11 - Initial release. +- `@authplane/fastmcp` supports `fastmcp` 4.x in addition to 3.35+. The adapter only consumes `ServerOptions["authenticate"]` and `oauth.protectedResource.*`, which are byte-identical between 3.35.1 and 4.0.1. Consumers who use `OAuthProxy` directly must configure `allowedRedirectUriPatterns` themselves — fastmcp 4.0 removed the default value (CWE-601 fix); see the fastmcp v4.0.0 release notes. +- `@authplane/sdk` — `wwwAuthenticate(error, options)` accepts new `resourceMetadataUrl` (RFC 9728 §5.1) and `scope` (RFC 6750) options, sanitises every interpolated value against header injection (RFC 9110 §11.4), and emits the `Bearer` scheme for `DPoPNotSupported` (carve-out — although it extends `DPoPError`, the request was not DPoP-bound). Canonical error → status/challenge tables now live in the SDK user guide under "HTTP status and WWW-Authenticate challenge". +- `@authplane/fastmcp` and `@authplane/mcp` — every authentication failure now emits a spec-compliant per-error `WWW-Authenticate` challenge via `httpStatus` + `wwwAuthenticate` from `@authplane/sdk/core`. DPoP failures (replay, binding mismatch, missing/invalid proof) get the `DPoP` scheme per RFC 9449 §7.1; `resource_metadata` is always included and `scope` is included when `requiredScopes` is configured. Previously every failure collapsed to a generic `Bearer error="invalid_token", error_description="Invalid access token"`. +- **Migration — `@authplane/fastmcp`**: `AuthplaneTokenVerifier.verifyAccessToken` no longer swallows `AuthplaneError` and returns `undefined`; the underlying typed class propagates. Consumers wiring `tokenVerifier` directly should wrap in try/catch if they previously branched on the `undefined` sentinel. +- **Migration — `@authplane/mcp`**: `AuthplaneTokenVerifier.verifyAccessToken` no longer rewraps `AuthplaneError` as the MCP SDK's `InvalidTokenError`; the underlying typed class propagates. Consumers wiring `tokenVerifier` directly should switch `instanceof InvalidTokenError` checks to `instanceof AuthplaneError` and classify with `httpStatus(err)` / `wwwAuthenticate(err, options)` from `@authplane/sdk/core` — or move to the `bearerAuth` middleware the adapter ships. +- **Behaviour change — `@authplane/mcp` upstream-AS failures**: `JWKSFetchError` and `MetadataFetchError` previously fell through the adapter's catch and returned a generic 500 (`{ error: "server_error" }`). They now hit the shared funnel via `httpStatus(error)` and return **503** with `WWW-Authenticate: Bearer error="invalid_token"` (RFC 7235 §4.1 allows `WWW-Authenticate` on any response). The 503 is more accurate, but the `invalid_token` OAuth code in the body is a semantic mismatch since the failure is upstream, not in the client's token — **clients should rely on the HTTP status, not the `error` field, when deciding whether to retry with a fresh token.** diff --git a/README.md b/README.md index d109b27..e6f8f60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Authplane TypeScript SDK -OAuth, JWT validation, and MCP-authentication primitives for Node.js. Works with Express, FastMCP, and the official MCP TypeScript SDK. +OAuth, JWT validation, and MCP-authentication primitives for Node.js. Ships framework adapters for Hono, NestJS, FastMCP, and the official MCP TypeScript SDK. ## Packages @@ -9,6 +9,8 @@ OAuth, JWT validation, and MCP-authentication primitives for Node.js. Works with | [`@authplane/sdk`](packages/sdk) | `npm install @authplane/sdk` | JWT validation and OAuth protocol primitives. Ships the stateful `AuthplaneClient` (`@authplane/sdk/core`) plus stateless OAuth protocol helpers (`@authplane/sdk/auth`). | | [`@authplane/mcp`](packages/mcp) | `npm install @authplane/sdk @authplane/mcp` | JWT validation adapter for the MCP TypeScript SDK | | [`@authplane/fastmcp`](packages/fastmcp) | `npm install @authplane/sdk @authplane/fastmcp` | JWT validation adapter for FastMCP | +| [`@authplane/hono`](packages/hono) | `npm install @authplane/sdk @authplane/hono` | JWT validation middleware for the Hono web framework | +| [`@authplane/nestjs`](packages/nestjs) | `npm install @authplane/sdk @authplane/nestjs` | NestJS module: guard + decorators + exception filter + PRM controller | ## Requirements @@ -69,7 +71,9 @@ For the MCP TypeScript SDK variant, see the [`@authplane/mcp` README](packages/m - [`@authplane/mcp`](packages/mcp/README.md) — adapter for the official MCP TypeScript SDK. - [`@authplane/fastmcp`](packages/fastmcp/README.md) — adapter for FastMCP. -- Both adapters integrate with Express / Node.js `http` through the MCP and FastMCP transports. +- [`@authplane/hono`](packages/hono/README.md) — middleware for the Hono web framework (Bearer / DPoP, RFC 6750 `WWW-Authenticate`, PRM handler, scope enforcement). +- [`@authplane/nestjs`](packages/nestjs/README.md) — NestJS module: `AuthplaneAuthGuard`, `@SkipAuth()` / `@RequireScopes(...)` decorators, exception filter mapping to RFC 6750 §3 responses, and an RFC 9728 PRM controller. Works on Express and Fastify platform adapters. +- The MCP / FastMCP adapters integrate with Express / Node.js `http` through their respective transports; the Hono adapter is framework-native middleware; the NestJS adapter integrates with whatever platform NestJS is hosted on. ## Documentation @@ -78,6 +82,8 @@ Each package ships its own README (overview) and User Guide (complete reference) - **`@authplane/sdk`** — [README](packages/sdk/README.md) · [User Guide](packages/sdk/docs/user-guide.md) - **`@authplane/mcp`** — [README](packages/mcp/README.md) · [User Guide](packages/mcp/docs/user-guide.md) - **`@authplane/fastmcp`** — [README](packages/fastmcp/README.md) · [User Guide](packages/fastmcp/docs/user-guide.md) +- **`@authplane/hono`** — [README](packages/hono/README.md) · [User Guide](packages/hono/docs/user-guide.md) +- **`@authplane/nestjs`** — [README](packages/nestjs/README.md) · [User Guide](packages/nestjs/docs/user-guide.md) Other docs: diff --git a/RELEASE_GUIDE.md b/RELEASE_GUIDE.md index ddcd615..a33efaa 100644 --- a/RELEASE_GUIDE.md +++ b/RELEASE_GUIDE.md @@ -19,7 +19,7 @@ Dispatch **Actions → Cut release branch** from `main`. Inputs: - `releaseVersion`: target version, e.g. `1.3.0` (no prerelease suffix). - Leave `hotfixBase` empty. -- `nextDevVersion`: optional override; defaults to next patch `-dev.0`. +- `nextDevVersion`: optional override; defaults to next-patch `-dev.0`. **Pre-1.0 the default is usually wrong** — every minor bump can ship a breaking change in 0.x, so the next dev line after cutting `0.Y.0` is `0..0-dev.0`, not `0.Y.1-dev.0`. Pass `nextDevVersion=0..0-dev.0` explicitly until the project reaches `1.0.0`. Once we're past 1.0 the patch default lines up with SemVer. The workflow: @@ -98,11 +98,32 @@ After publish, if any commits on the hotfix branch should also reach `main`, dis ## Troubleshooting -### Partial npm upload +### Re-running the publish workflow against a tag (clean retries only) -`publish-npm.yml` publishes the three packages sequentially. If a later publish fails after an earlier one succeeded, the version is already on the registry for the succeeded package(s); re-running the workflow won't help (the tag-exists check refuses, and npm forbids overwriting a published version). +If the publish workflow fails **before any package was published** (e.g. an OIDC handshake glitch, a transient registry error on the first publish step, runner timeout during build), re-dispatch `publish-npm.yml` against the tag ref. This keeps the OIDC Trusted Publisher flow in play and the retry publishes all three packages with `--provenance`. -Recovery: +```bash +# -r MUST be a tag ref (refs/tags/vX.Y.Z) — see OIDC ref gotcha below. +gh workflow run publish-npm.yml -r v +``` + +Or via the UI: **Actions → Publish to npm → Run workflow → Use workflow from: `v`** (select the tag in the branch picker; tags appear under the same dropdown). + +> ⚠️ **OIDC ref gotcha.** The OIDC token the npm CLI exchanges is built from `GITHUB_REF` at run time, **not** from any input. Dispatching the workflow from a branch (`main`, `develop`, …) emits an OIDC token with `ref=refs/heads/`, which the tag-only Trusted Publisher policy on npmjs.com rejects. **Always dispatch against the tag ref directly** as shown above. + +> ⚠️ **Not for partial uploads.** `npm publish` is **not** idempotent against an existing `name@version`: it fails with `EPUBLISHCONFLICT` (HTTP 403, "You cannot publish over the previously published versions"). If one or two of the three packages already published before the failure, a re-dispatch will halt at the first `npm publish` step that hits an already-published version and never reach the remaining packages. Use the manual artifact flow below instead. + +> ⚠️ **Shared concurrency group with the original tag-push run.** The workflow keys its concurrency group on `github.ref` (`refs/tags/vX.Y.Z`) with `cancel-in-progress: false`, so the tag-push run and a `workflow_dispatch` retry against the same tag share one slot. If the original tag-push run is still **pending environment approval** in the `npm` environment, a retry dispatch queues behind it rather than replacing it — and the retry will never start until the pending one is approved or cancelled. **Cancel the stuck pending-approval run first**, then dispatch the retry. + +### Failed or partial publish (one or more packages not yet on the registry) + +Covers both shapes: the workflow failed *entirely* before any package landed (the first `npm publish` step blew up, so nothing else was attempted) and the partial shape where some `@authplane/*` packages at this version are already on the registry and others aren't. The whole-failure case happens when the *first* publish step fails atomically; the partial case happens when a later step fails. Recovery is the same flow either way — only the set of missing packages changes. + +You cannot recover either shape via the workflow when there are *already-published* packages: `npm publish` rejects the published ones with `EPUBLISHCONFLICT` and the run dies before reaching the missing ones. Use the CI-built tarball artifact + a targeted manual publish. + +**Manual publishes lose the `--provenance` Sigstore attestation** on the recovered packages — manual `npm publish` cannot produce the Sigstore signature because the OIDC exchange runs only inside GitHub Actions. Packages that did publish via CI keep their attestation; manually-recovered ones never get one for this version. + +> ⚠️ **Downstream `npm audit signatures` impact.** After a manual recovery, consumers of the manually-published packages at this version will see `npm audit signatures` report missing/unverifiable attestations for exactly those packages (not the CI-published ones). This is permanent for this version — npm versions are immutable, and provenance attaches at publish time. The attestation gap closes from the next release tag onwards. If a consumer pipeline gates on `audit signatures`, they'll need a one-version allowlist for the affected `@authplane/@` entries, or to pin past this version. 1. Download the `dist-v` build artifact from the failed workflow run. Structure: ``` @@ -110,16 +131,47 @@ Recovery: packages/mcp/authplane-mcp-X.Y.Z.tgz packages/fastmcp/authplane-fastmcp-X.Y.Z.tgz ``` -2. For each missing package, authenticate locally (`npm login` as an `@authplane` org member) and publish the tarball from the artifact: +2. **Verify tarball integrity before publishing.** Each `.tgz` carries a checksum that the failed workflow logged at pack time (look for `npm notice shasum:` and `npm notice integrity:` lines in the workflow log — the pack step emits one block per package). Compare locally to confirm you're shipping exactly what CI built: ```bash - npm publish --access public packages//authplane--X.Y.Z.tgz + # SHA-1 — compare against `npm notice shasum: ` (hex output). + shasum -a 1 packages//authplane--X.Y.Z.tgz + + # SHA-512 — compare against `npm notice integrity: sha512-`. + # npm's `integrity:` field is base64 (not hex), so pipe through base64. + openssl dgst -sha512 -binary packages//authplane--X.Y.Z.tgz | openssl base64 -A + ``` + For the whole-failure case the *pack-step* notices cover all three packages (every tarball was built before any publish). For a partial failure the *publish-step* notice covers the package that was mid-upload when the run died. Skip this check and you risk republishing a stale or wrong-version artifact. +3. **Publish in dependency order**: `@authplane/sdk` → `@authplane/mcp` → `@authplane/fastmcp`. `mcp` and `fastmcp` peer-depend on `@authplane/sdk`, so it must be on the registry first; publishing them ahead of `sdk` poisons consumer installs until `sdk` lands. (For a partial recovery, this matters only when `sdk` is among the missing set.) + ```bash + # `npm login` as an `@authplane` org member first. + # NOTE: `--provenance` is intentionally absent — see the caveat above. + npm publish --access public packages/sdk/authplane-sdk-X.Y.Z.tgz + npm publish --access public packages/mcp/authplane-mcp-X.Y.Z.tgz + npm publish --access public packages/fastmcp/authplane-fastmcp-X.Y.Z.tgz ``` -3. If the workflow also failed before creating the GitHub Release: + Skip packages already on the registry — `npm view @authplane/@` returns the manifest when present, errors with `E404` when not. +4. If the workflow also failed before creating the GitHub Release: ```bash gh release create v --title v --notes-file ``` No `--target` — the tag already points at the correct commit. -4. If any commits on the source branch need to reach `main`, dispatch **Backport fixes** with `fromBranch=v` (the tag — the branch is deleted after the atomic push). +5. If any commits on the source branch need to reach `main`, dispatch **Backport fixes** with `fromBranch=v` (the tag — the branch is deleted after the atomic push). + +### Publish fails with `ENEEDAUTH` (Trusted Publishing didn't engage) + +`publish-npm.yml` authenticates to npm via Trusted Publishing (OIDC) — no long-lived `NPM_TOKEN` secret. The OIDC exchange requires **npm ≥ 11.5.1**; older npm versions silently *skip* the OIDC handshake and fall back to demanding a classic credential, which the workflow doesn't supply, so the publish step dies with: + +``` +npm error code ENEEDAUTH +npm error need auth This command requires you to be logged in to https://registry.npmjs.org/ +``` + +Node 22 LTS bundles npm 10.x at install time, so a runner pinned to `node-version: 22` hit this even on a fresh install. **The durable fix is already in place**: `publish-npm.yml` pins `node-version: 24` (npm 11.x), so a *current* `ENEEDAUTH` in CI means a regression — most likely the `node-version` line got downgraded, or the publish runs through some surface that bypasses `setup-node`. A future operator hitting this should look for that regression first, not assume the fix needs applying. + +When you see `ENEEDAUTH`: + +- **Workflow CI failure**: confirm the runner's `npm --version` in the workflow log is ≥ 11.5.1. If lower, restore the `node-version: 24` pin (or higher), or add an explicit `npm install -g npm@latest` step before the publish — whichever is missing. +- **Manual recovery publish**: if you publish locally as a one-off, run `npm install -g npm@latest` first if you want provenance. If your local npm is < 11.5.1, the publish still succeeds *without* provenance (classic-credential path) — that's the same attestation-gap outcome as any other manual recovery (see *Failed or partial publish* above). ### Next-dev bump PR fails to open diff --git a/llm-full.txt b/llm-full.txt index 522f71b..45ef159 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -89,7 +89,7 @@ the authserver is not reachable. ### Manual end-to-end smoke `scripts/` contains helpers that boot a local authserver and run a smoke -check end-to-end (mirrors the Python SDK's helpers): +check end-to-end: ```bash # Start local authserver and register client / scopes / user. diff --git a/package-lock.json b/package-lock.json index 2c638e7..27ce261 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "authplane-ts-sdk", - "version": "0.2.0-dev.0", + "version": "0.3.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -11,7 +11,9 @@ "workspaces": [ "packages/sdk", "packages/mcp", - "packages/fastmcp" + "packages/fastmcp", + "packages/hono", + "packages/nestjs" ], "devDependencies": { "@biomejs/biome": "^2.4.9" @@ -25,10 +27,18 @@ "resolved": "packages/fastmcp", "link": true }, + "node_modules/@authplane/hono": { + "resolved": "packages/hono", + "link": true + }, "node_modules/@authplane/mcp": { "resolved": "packages/mcp", "link": true }, + "node_modules/@authplane/nestjs": { + "resolved": "packages/nestjs", + "link": true + }, "node_modules/@authplane/sdk": { "resolved": "packages/sdk", "link": true @@ -54,9 +64,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -83,6 +93,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@biomejs/biome": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.12.tgz", @@ -152,9 +172,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -172,9 +189,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -192,9 +206,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -212,9 +223,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -268,6 +276,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -744,6 +776,112 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/cors": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", + "integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-7.4.0.tgz", + "integrity": "sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@fastify/middie": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@fastify/middie/-/middie-8.3.3.tgz", + "integrity": "sha512-+WHavMQr9CNTZoy2cjoDxoWp76kZ3JKjAtZj5sXNlxX5XBzHig0TeCPfPc+1+NQmliXtndT3PFwAjrQHE/6wnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.2.0", + "fastify-plugin": "^4.0.0", + "path-to-regexp": "^6.3.0", + "reusify": "^1.0.4" + } + }, + "node_modules/@fastify/middie/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -794,6 +932,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1124,6 +1272,273 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nestjs/common": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", + "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "20.4.1", + "iterare": "1.2.1", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/common/node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/@nestjs/core": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", + "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxtjs/opencollective": "0.3.2", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "3.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/core/node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/platform-express": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", + "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "body-parser": "1.20.4", + "cors": "2.8.5", + "express": "4.22.1", + "multer": "2.0.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@nestjs/platform-fastify": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-10.4.22.tgz", + "integrity": "sha512-Y/8wX43806NIAAIx7nWY3A7kSiZc5ivPfIl4RIgo4goxuwVOjAwgytBtPhgtyP/O1nrighbMXtgHh2e/kWnHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/cors": "9.0.1", + "@fastify/formbody": "7.4.0", + "@fastify/middie": "8.3.3", + "fastify": "4.28.1", + "light-my-request": "6.3.0", + "path-to-regexp": "3.3.0", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@fastify/view": "^7.0.0 || ^8.0.0", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "@fastify/view": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-fastify/node_modules/fastify": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.28.1.tgz", + "integrity": "sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/@nestjs/platform-fastify/node_modules/fastify/node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, + "node_modules/@nestjs/platform-fastify/node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/testing": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", + "integrity": "sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1137,10 +1552,29 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nuxtjs/opencollective": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", + "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.0", + "node-fetch": "^2.6.1" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1157,10 +1591,17 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1175,9 +1616,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1192,9 +1633,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1209,9 +1650,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1226,16 +1667,13 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1246,16 +1684,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1266,16 +1701,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1286,16 +1718,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1306,16 +1735,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1326,16 +1752,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1346,16 +1769,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1366,9 +1786,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1383,9 +1803,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1402,9 +1822,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1419,9 +1839,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1436,9 +1856,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1489,10 +1909,38 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1668,6 +2116,157 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1681,6 +2280,32 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -1738,6 +2363,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1779,6 +2418,27 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, "node_modules/axios": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", @@ -1867,6 +2527,25 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1910,16 +2589,6 @@ } } }, - "node_modules/c8/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/c8/node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -2009,6 +2678,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2145,7 +2857,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/content-disposition": { + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", @@ -2225,6 +2960,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2317,6 +3059,16 @@ "wrappy": "1" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2386,6 +3138,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2625,12 +3384,59 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2654,6 +3460,60 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastify/node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, "node_modules/fastmcp": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/fastmcp/-/fastmcp-4.0.1.tgz", @@ -2776,6 +3636,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2794,6 +3664,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -2860,6 +3737,21 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3405,6 +4297,16 @@ "node": ">=8" } }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=6" + } + }, "node_modules/jose": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", @@ -3421,6 +4323,16 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3573,6 +4485,49 @@ "node": ">= 0.6" } }, + "node_modules/light-my-request": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.3.0.tgz", + "integrity": "sha512-bWTAPJmeWQH5suJNYwG0f5cs0p6ho9e6f1Ppoxv5qMosY+s9Ir2+ZLvvHcgA7VTDop4zl/NCHhOVVqU+kd++Ow==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3695,9 +4650,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3719,9 +4671,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3743,9 +4692,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3767,9 +4713,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3791,9 +4734,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3875,6 +4815,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3891,6 +4843,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3988,6 +4947,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3998,16 +4967,58 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -4032,6 +5043,27 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -4081,6 +5113,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -4092,6 +5131,16 @@ ], "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4191,6 +5240,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4211,6 +5267,63 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/pipenet": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/pipenet/-/pipenet-1.4.0.tgz", @@ -4322,9 +5435,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4342,7 +5455,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4365,6 +5478,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4430,6 +5550,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4470,20 +5597,52 @@ "url": "https://opencollective.com/express" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4499,15 +5658,43 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4516,21 +5703,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/router": { @@ -4559,6 +5746,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4579,12 +5776,39 @@ ], "license": "MIT" }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4652,6 +5876,13 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4770,6 +6001,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4780,6 +6021,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4796,12 +6047,38 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/strict-event-emitter-types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", "license": "ISC" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -4917,6 +6194,16 @@ "node": ">=8" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4924,6 +6211,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4941,6 +6238,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldjs": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/tldjs/-/tldjs-2.3.2.tgz", @@ -4954,6 +6261,16 @@ "node": ">= 20" } }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4981,13 +6298,63 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -5031,6 +6398,13 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5045,6 +6419,19 @@ "node": ">=14.17" } }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -5100,6 +6487,13 @@ "integrity": "sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==", "license": "http://geraintluff.github.io/tv4/LICENSE.txt" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -5109,6 +6503,13 @@ "node": ">= 0.4.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5133,84 +6534,280 @@ "node": ">= 0.8" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" }, "bin": { - "why-is-node-running": "cli.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/xsschema": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/xsschema/-/xsschema-0.4.4.tgz", - "integrity": "sha512-TMhbk8AhxO+YuDOn3rXBjGXQ+Vja3T13UPQkHx/FemhusaOzRfCSj2seykt0mdnFPsOtO9l3ANf4adcp+4NRGw==", - "license": "MIT", + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, "peerDependencies": { - "@valibot/to-json-schema": "^1.0.0", - "arktype": "^2.1.20", - "effect": "^3.16.0", - "sury": "^10.0.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@valibot/to-json-schema": { + "@types/node": { "optional": true }, - "arktype": { + "@vitejs/devtools": { "optional": true }, - "effect": { + "esbuild": { "optional": true }, - "sury": { + "jiti": { "optional": true }, - "zod": { + "less": { "optional": true }, - "zod-to-json-schema": { + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xsschema": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/xsschema/-/xsschema-0.4.4.tgz", + "integrity": "sha512-TMhbk8AhxO+YuDOn3rXBjGXQ+Vja3T13UPQkHx/FemhusaOzRfCSj2seykt0mdnFPsOtO9l3ANf4adcp+4NRGw==", + "license": "MIT", + "peerDependencies": { + "@valibot/to-json-schema": "^1.0.0", + "arktype": "^2.1.20", + "effect": "^3.16.0", + "sury": "^10.0.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependenciesMeta": { + "@valibot/to-json-schema": { + "optional": true + }, + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { "node": ">=10" } }, @@ -5304,6 +6901,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -5365,1214 +6972,129 @@ "zod": "^3.24.0" }, "engines": { - "node": ">=22" + "node": ">=20.0.0" }, "peerDependencies": { "@authplane/sdk": "^0.3.0-dev.0" } }, - "packages/fastmcp/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/fastmcp/node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "packages/hono": { + "name": "@authplane/hono", + "version": "0.3.0-dev.0", + "license": "Apache-2.0", + "devDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "@hono/node-server": "^1.14.0", + "@types/node": "^24.3.0", + "@vitest/coverage-v8": "4.1.5", + "dotenv": "^17.3.1", + "hono": "^4.12.0", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "vitest": "4.1.5" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "@authplane/sdk": "^0.3.0-dev.0", + "hono": "^4.0.0" } }, - "packages/fastmcp/node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", - "dev": true, - "license": "MIT", + "packages/mcp": { + "name": "@authplane/mcp", + "version": "0.3.0-dev.0", + "license": "Apache-2.0", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/fastmcp/node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/fastmcp/node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/fastmcp/node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/fastmcp/node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/fastmcp/node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/fastmcp/node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/fastmcp/node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "packages/fastmcp/node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "packages/fastmcp/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "packages/fastmcp/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "packages/fastmcp/node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/fastmcp/node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "packages/fastmcp/node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "packages/fastmcp/node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.5", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "packages/fastmcp/node_modules/vitest/node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "packages/mcp": { - "name": "@authplane/mcp", - "version": "0.3.0-dev.0", - "license": "Apache-2.0", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" - }, - "devDependencies": { - "@types/express": "^5.0.0", - "@types/node": "^24.3.0", - "@types/supertest": "^7.2.0", - "@vitest/coverage-v8": "4.1.5", - "@authplane/sdk": "^0.3.0-dev.0", - "dotenv": "^17.3.1", - "express": "^4.21.0", - "supertest": "^7.2.2", - "tsx": "^4.20.6", - "typescript": "^5.6.3", - "vitest": "4.1.5", - "zod": "^3.24.0" - }, - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "@authplane/sdk": "^0.3.0-dev.0" - } - }, - "packages/mcp/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/mcp/node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "packages/mcp/node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/mcp/node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/mcp/node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/mcp/node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/mcp/node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/mcp/node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/mcp/node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/mcp/node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "packages/mcp/node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "packages/mcp/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "packages/mcp/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "packages/mcp/node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/mcp/node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "packages/mcp/node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "packages/mcp/node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.5", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "packages/mcp/node_modules/vitest/node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "packages/sdk": { - "name": "@authplane/sdk", - "version": "0.3.0-dev.0", - "license": "Apache-2.0", - "dependencies": { - "ipaddr.js": "^2.3.0", - "jose": "^5.9.6" + "@modelcontextprotocol/sdk": "^1.29.0" }, "devDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "@types/express": "^5.0.0", "@types/node": "^24.3.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "4.1.5", - "c8": "^11.0.0", - "tsx": "^4.20.6", - "typescript": "^5.6.3", - "vitest": "4.1.5", - "yaml": "^2.8.2" - }, - "engines": { - "node": ">=22" - } - }, - "packages/sdk/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/sdk/node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "packages/sdk/node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/sdk/node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/sdk/node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/sdk/node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/sdk/node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/sdk/node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "dotenv": "^17.3.1", + "express": "^4.21.0", + "supertest": "^7.2.2", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "vitest": "4.1.5", + "zod": "^3.24.0" }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/sdk/node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/sdk/node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "packages/sdk/node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "packages/sdk/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "packages/sdk/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "packages/sdk/node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/sdk/node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "@authplane/sdk": "^0.3.0-dev.0" } }, - "packages/sdk/node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" + "packages/nestjs": { + "name": "@authplane/nestjs", + "version": "0.3.0-dev.0", + "license": "Apache-2.0", + "devDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "@nestjs/platform-fastify": "^10.4.0", + "@nestjs/testing": "^10.4.0", + "@types/node": "^24.3.0", + "@types/supertest": "^6.0.0", + "@vitest/coverage-v8": "4.1.5", + "dotenv": "^17.3.1", + "express": "^4.21.0", + "fastify": "^4.28.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "supertest": "^7.0.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "vitest": "4.1.5" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">=22" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } + "@authplane/sdk": "^0.3.0-dev.0", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.0.0" } }, - "packages/sdk/node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "packages/nestjs/node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" } }, - "packages/sdk/node_modules/vitest/node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", - "dev": true, - "license": "MIT", + "packages/sdk": { + "name": "@authplane/sdk", + "version": "0.3.0-dev.0", + "license": "Apache-2.0", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "ipaddr.js": "^2.3.0", + "jose": "^5.9.6" }, - "bin": { - "vite": "bin/vite.js" + "devDependencies": { + "@types/node": "^24.3.0", + "@vitest/coverage-v8": "4.1.5", + "c8": "^11.0.0", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "vitest": "4.1.5", + "yaml": "^2.8.2" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">=20.0.0" } } } diff --git a/package.json b/package.json index 571ac6a..ac43639 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "workspaces": [ "packages/sdk", "packages/mcp", - "packages/fastmcp" + "packages/fastmcp", + "packages/hono", + "packages/nestjs" ], "engines": { "node": ">=22", @@ -18,8 +20,8 @@ "format": "npm run format --workspaces --if-present", "lint": "npm run lint --workspaces --if-present", "typecheck": "tsc -b", - "test": "npm run test -w @authplane/sdk && npm run test -w @authplane/mcp && npm run test -w @authplane/fastmcp", - "test:coverage": "npm run test:coverage -w @authplane/sdk && npm run test:coverage -w @authplane/mcp && npm run test:coverage -w @authplane/fastmcp", + "test": "npm run test -w @authplane/sdk && npm run test -w @authplane/mcp && npm run test -w @authplane/fastmcp && npm run test -w @authplane/hono && npm run test -w @authplane/nestjs", + "test:coverage": "npm run test:coverage -w @authplane/sdk && npm run test:coverage -w @authplane/mcp && npm run test:coverage -w @authplane/fastmcp && npm run test:coverage -w @authplane/hono && npm run test:coverage -w @authplane/nestjs", "check": "npm run lint && npm run typecheck && npm run test:coverage" }, "devDependencies": { diff --git a/packages/fastmcp/src/auth.ts b/packages/fastmcp/src/auth.ts index 74e2237..e169a3e 100644 --- a/packages/fastmcp/src/auth.ts +++ b/packages/fastmcp/src/auth.ts @@ -5,8 +5,11 @@ import { AuthplaneError, type AuthplaneResource, type AuthplaneResourceOptions, + buildDPoPRequestContext, type DPoPProvider, type DPoPRequestContext, + extractBearerToken, + extractDpopHeaderValues, type FetchSettings, InsufficientScope, type ProtectedResourceMetadata, @@ -73,6 +76,14 @@ export interface AuthplaneFastMcpAuthOptions * include expiry metadata (seconds). Default `3600`. */ defaultTtlSeconds?: number; + /** + * Maximum number of entries kept in the outbound token cache before + * least-recently-used eviction kicks in. Default `10_000`. Override on + * hosts with very high subject-token cardinality — token-exchange cache + * keys include the subject token, so this is the bound that actually + * limits memory growth. + */ + cacheMaxEntries?: number; /** * Number of consecutive transient AS failures before the circuit breaker * opens. Default `5`. @@ -103,45 +114,55 @@ function deriveResource(baseUrl: string, mcpPath: string): string { return `${base}/${path}`; } -function getBearerToken(request: IncomingMessage): string | undefined { +/** + * Pull the access token off the inbound `Authorization` header and run it + * through the core `extractBearerToken` helper. `Authorization` is in + * Node's fixed de-dup-to-last-value allowlist (alongside `host`, + * `content-type`, etc.), so `request.headers.authorization` is + * `string | undefined` on real wire traffic — never an array. The + * `Array.isArray` guard is defense against the `string[]` shape that + * only arrives from `request.rawHeaders` or hand-built fixtures; we + * collapse it to `undefined` so the core helper raises `TokenMissing` + * instead of accepting a hand-crafted multi-value bag. Returns + * `undefined` (rather than throwing) for the call site that branches + * on absence; the caller funnels that into `TokenMissing` so the + * response shape is unchanged. + */ +function readBearerToken(request: IncomingMessage): string | undefined { const authHeader = request.headers.authorization; - if (!authHeader || Array.isArray(authHeader)) { + const headerValue = Array.isArray(authHeader) ? undefined : authHeader; + if (!headerValue) { return undefined; } - const trimmed = authHeader.trim(); - const lower = trimmed.toLowerCase(); - - const bearerPrefix = "bearer "; - if (lower.startsWith(bearerPrefix)) { - return trimmed.slice(bearerPrefix.length).trim(); - } - - const dpopPrefix = "dpop "; - if (lower.startsWith(dpopPrefix)) { - return trimmed.slice(dpopPrefix.length).trim(); + try { + return extractBearerToken(headerValue); + } catch { + return undefined; } - - return undefined; } -function getDpopProof(request: IncomingMessage): string | undefined { - // `IncomingMessage.headers` lowercases keys in Node, but FastMCP may wrap/transform. - // Do a case-insensitive scan to reliably find `DPoP`. +function collectDpopHeaderValues( + request: IncomingMessage, +): readonly string[] { + // `IncomingMessage.headers` lowercases keys in Node, but FastMCP may + // wrap/transform. Scan case-insensitively and normalise to + // `string | string[] | undefined` for the shared core helper, which + // hands the result to the factory enforcing RFC 9449 §4.3 #1. const headers = request.headers; for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() !== "dpop") continue; - if (typeof value === "string" && value.length > 0) return value; + return extractDpopHeaderValues(value); } - return undefined; + return []; } -function buildDpopRequestContext( +function buildFastMcpDpopRequestContext( request: IncomingMessage, resourceOrigin: string, resourceDefaultPath: string, ): DPoPRequestContext | undefined { - const proof = getDpopProof(request); - if (proof === undefined) { + const dpopHeaderValues = collectDpopHeaderValues(request); + if (dpopHeaderValues.length === 0) { return undefined; } @@ -155,11 +176,11 @@ function buildDpopRequestContext( const pathAndQuery = request.url ?? resourceDefaultPath; const url = `${resourceOrigin}${pathAndQuery}`; - return { + return buildDPoPRequestContext({ method: (request.method ?? "POST").toUpperCase(), url, - proof, - }; + dpopHeaderValues, + }); } export async function authplaneFastMcpAuth( @@ -192,6 +213,7 @@ export async function authplaneFastMcpAuth( metadataRefreshSeconds: verifierOptions.metadataRefreshSeconds, cacheTtlBufferSeconds: options.cacheTtlBufferSeconds, defaultTtlSeconds: options.defaultTtlSeconds, + cacheMaxEntries: options.cacheMaxEntries, circuitBreakerThreshold: options.circuitBreakerThreshold, circuitBreakerCooldownSeconds: options.circuitBreakerCooldownSeconds, dpopProvider: options.dpopProvider, @@ -263,7 +285,7 @@ export async function authplaneFastMcpAuth( const authenticate: NonNullable< ServerOptions["authenticate"] > = async (request) => { - const token = getBearerToken(request); + const token = readBearerToken(request); if (!token) { throw challengeResponse( new TokenMissing("Missing or invalid Authorization header"), @@ -276,7 +298,7 @@ export async function authplaneFastMcpAuth( _authenticateRequestContext.enterWith(store); } if (store.promise === undefined) { - const dpopContext = buildDpopRequestContext( + const dpopContext = buildFastMcpDpopRequestContext( request, resourceOrigin, resourceDefaultPath, diff --git a/packages/fastmcp/tests/auth.test.ts b/packages/fastmcp/tests/auth.test.ts index 464dbb4..ef3c255 100644 --- a/packages/fastmcp/tests/auth.test.ts +++ b/packages/fastmcp/tests/auth.test.ts @@ -174,6 +174,46 @@ describe("authplaneFastMcpAuth", () => { ); }); + it("forwards cache tunables (cacheTtlBufferSeconds, defaultTtlSeconds, cacheMaxEntries) to AuthplaneClient.create()", async () => { + const mockResource = { + verify: vi.fn(), + prmResponse: vi.fn(() => ({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: [], + bearer_methods_supported: ["header"], + })), + prmDocumentUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), + } as unknown as AuthplaneResource; + + const mockClient = { + resource: vi.fn(() => mockResource), + exchange: vi.fn(), + } as unknown as AuthplaneClient; + + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(mockClient); + + await authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 256, + }); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 256, + }), + ); + }); + it("throws when neither resource nor baseUrl is provided", async () => { await expect( authplaneFastMcpAuth({ @@ -463,11 +503,11 @@ describe("authplaneFastMcpAuth", () => { expect(verify).toHaveBeenCalledWith( "access_token", expect.objectContaining({ - dpopRequest: { + dpopRequest: expect.objectContaining({ method: "POST", url: "https://api.example.com/mcp", - proof: "dpop_proof_jwt", - }, + proofs: ["dpop_proof_jwt"], + }), }), ); }); diff --git a/packages/hono/README.md b/packages/hono/README.md new file mode 100644 index 0000000..174672d --- /dev/null +++ b/packages/hono/README.md @@ -0,0 +1,53 @@ +# @authplane/hono + +[Authplane](https://github.com/AuthPlane/authserver) JWT validation adapter for the [Hono](https://hono.dev) web framework. Bearer-token auth on your Hono server in a few lines — runs on Node, Bun, Deno, Cloudflare Workers, and any other Hono-supported runtime. + +## Install + +```bash +npm install @authplane/sdk @authplane/hono hono +``` + +## Quickstart + +```ts +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { + authplaneHonoAuth, + requireScope, + type HonoAuthVariables, +} from "@authplane/hono"; + +const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/get_weather"], +}); + +const app = new Hono<{ Variables: HonoAuthVariables }>(); + +app.get( + auth.protectedResourceMetadataPath, + auth.protectedResourceMetadataHandler, +); + +app.use("/mcp/*", auth.bearerAuth); +app.post("/mcp/tools/get_weather", async (c) => { + requireScope(c, "tools/get_weather"); + const { city } = await c.req.json<{ city: string }>(); + return c.json({ content: [{ type: "text", text: `${city}: sunny` }] }); +}); + +serve({ fetch: app.fetch, port: 3000 }); +``` + +`auth.bearerAuth` is a Hono `MiddlewareHandler` that validates the bearer token, enforces scopes, and attaches the verified claims to `c.get("auth")`. `requireScope(c, "…")` layers per-route scope checks on top. + +## Learn more + +- **[User Guide](docs/user-guide.md)** — complete reference: options, scope enforcement, `onError` bridging, DPoP, introspection, error handling, runtime portability. +- **[Demo](demo/README.md)** — runnable multi-route calculator (`./demo/run.sh`). +- **[`@authplane/sdk`](../sdk)** — the underlying OAuth/JWT primitives. + +On shutdown call `await auth.client.close()` to stop internal JWKS / metadata refresh timers. diff --git a/packages/hono/demo/.env.example b/packages/hono/demo/.env.example new file mode 100644 index 0000000..da3a668 --- /dev/null +++ b/packages/hono/demo/.env.example @@ -0,0 +1,12 @@ +# Authplane Hono Example — Environment Variables +# Copy this file to .env + +# Authorization server URL +AUTHPLANE_ISSUER=http://localhost:9000 + +# This server's resource URL (also used as client_id for introspection) +AUTHPLANE_RESOURCE=http://localhost:8080/mcp + +# Client secret for introspection (registered with the authorization server) +# Set this from your local authserver provisioning. +AUTHPLANE_CLIENT_SECRET= diff --git a/packages/hono/demo/README.md b/packages/hono/demo/README.md new file mode 100644 index 0000000..ec787a0 --- /dev/null +++ b/packages/hono/demo/README.md @@ -0,0 +1,94 @@ +# Calculator Service Example (Hono) + +A minimal [Hono](https://hono.dev) server demonstrating Authplane JWT authentication with per-route scope enforcement. + +The server exposes three routes: + +| Route | Method | Required scope | +|-----------------------|--------|---------------------| +| `/math/add` | POST | `tools/add` | +| `/math/multiply` | POST | `tools/multiply` | +| `/me` | GET | (any valid token) | + +Tokens must carry the scope for the specific route being called. A token with only `tools/add` can call `/math/add` but not `/math/multiply`. + +## Prerequisites + +- Node.js 20+ +- The **authserver authorization server** running locally — start it with: + + ```bash + ./run-demo-server.sh + ``` + + This starts the auth server on `http://127.0.0.1:9000` by default. + +## Setup + +1. Copy the environment file (the demo key is pre-filled): + + ```bash + cp demo/.env.example demo/.env + ``` + + `AUTHPLANE_RESOURCE` is used both as the JWT `aud` claim and as the `client_id` for token introspection. + Legacy env names (`RESOURCE_URL`, `ISSUER_URL`, `CLIENT_SECRET`) are still accepted for compatibility. + +2. Run the Hono server: + + ```bash + cd packages/hono + ./demo/run.sh + ``` + + `run.sh` installs dependencies and starts the server on port `8080`. + +3. Exercise it with `curl`: + + ```bash + # Fetch a client_credentials token from your authserver (example) + TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \ + -d 'grant_type=client_credentials&scope=tools/add&resource=http://127.0.0.1:8080/mcp' \ + http://127.0.0.1:9000/token | jq -r .access_token) + + # Happy path + curl -s -X POST http://127.0.0.1:8080/math/add \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"a":2,"b":3}' + # => {"result":5} + + # Insufficient scope (token has tools/add but route needs tools/multiply) + curl -i -X POST http://127.0.0.1:8080/math/multiply \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"a":2,"b":3}' + # => HTTP/1.1 403 Forbidden + # WWW-Authenticate: Bearer error="insufficient_scope", ..., scope="tools/multiply" + ``` + +## How it works + +``` +HTTP Client ──Bearer JWT──► server.ts (port 8080) + │ + ├─ authplaneHonoAuth() + │ • Discovers JWKS from AUTHPLANE_ISSUER + │ • Validates JWT signature, aud, exp + │ • Introspects token (revocation check) + │ + └─ requireScope(c, "tools/add") + • Reads auth info from c.get("auth") + • Throws core InsufficientScope if missing + → app.onError bridges to RFC 6750 403 +``` + +## Key patterns shown + +**`authplaneHonoAuth()`** — wires up the verifier, bearer auth middleware, and Protected Resource Metadata in one call. The `scopes` list advertises supported scopes in the Protected Resource Metadata (`/.well-known/oauth-protected-resource/...`) and, by default, is also used as the `requiredScopes` list for the auth middleware. This mirrors the Express `@authplane/mcp` adapter. + +**`requireScope(c, scope)`** — call at the top of any handler to enforce per-route scope. If the token is missing the scope the helper throws core `InsufficientScope`, which the `app.onError` hook translates to a 403 with a standards-compliant `WWW-Authenticate: Bearer error="insufficient_scope"` header. + +**`app.onError`** — centralised RFC 6750 error bridge. The middleware already handles its own errors (invalid tokens become 401 with `WWW-Authenticate` set), but errors thrown *from inside a handler* (like `requireScope`) escape into Hono's error hook. The demo funnels any core `AuthplaneError` through `httpStatus()` + `wwwAuthenticate()`. + +**`IntrospectionRevocation`** — enables RFC 7662 token introspection using the resource URL as `clientId` and the admin-provisioned secret. Without it, revoked tokens would remain accepted until their `exp`. diff --git a/packages/hono/demo/run.sh b/packages/hono/demo/run.sh new file mode 100755 index 0000000..97f3f6c --- /dev/null +++ b/packages/hono/demo/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Check .env exists +if [ ! -f "$SCRIPT_DIR/.env" ]; then + echo "ERROR: $SCRIPT_DIR/.env not found." + echo "Copy the example:" + echo " cp $SCRIPT_DIR/.env.example $SCRIPT_DIR/.env" + exit 1 +fi + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/.env" + +# Fall back to demo authserver credentials written to /tmp by the AS. +# Treat empty values as unset so an empty AUTHPLANE_CLIENT_SECRET= in .env +# does not block the fallback. +if [[ -z "${AUTHPLANE_CLIENT_ID:-}" && -z "${CLIENT_ID:-}" && -f /tmp/authserver-demo.client-id ]]; then + export AUTHPLANE_CLIENT_ID="$(cat /tmp/authserver-demo.client-id)" +fi +if [[ -z "${AUTHPLANE_CLIENT_SECRET:-}" && -z "${CLIENT_SECRET:-}" && -f /tmp/authserver-demo.key ]]; then + export AUTHPLANE_CLIENT_SECRET="$(cat /tmp/authserver-demo.key)" +fi + +ISSUER="${AUTHPLANE_ISSUER:-${ISSUER_URL:-http://localhost:9000}}" +METADATA_URL="${ISSUER%/}/.well-known/oauth-authorization-server" +if ! command -v curl >/dev/null 2>&1; then + echo "WARN: curl not found; skipping issuer preflight check ($METADATA_URL)." +else + if ! curl -fsS "$METADATA_URL" >/dev/null 2>&1; then + echo "ERROR: cannot reach authorization server metadata at:" + echo " $METADATA_URL" + echo + echo "Start your AS (authserver) first, or fix AUTHPLANE_ISSUER in demo/.env." + exit 1 + fi +fi + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +cd "$PROJECT_DIR" + +# Install dependencies if needed (npm workspaces hoist to repo root) +if [ ! -d "$REPO_ROOT/node_modules" ]; then + (cd "$REPO_ROOT" && npm ci) +fi + +npx tsx "$SCRIPT_DIR/server.ts" diff --git a/packages/hono/demo/server.ts b/packages/hono/demo/server.ts new file mode 100644 index 0000000..701936b --- /dev/null +++ b/packages/hono/demo/server.ts @@ -0,0 +1,166 @@ +/** + * Calculator Service — Hono demo. + * + * A minimal Hono server demonstrating `@authplane/hono`'s full stack: + * + * - RFC 9728 Protected Resource Metadata served at the discovered path + * - Bearer token validation (signature, issuer, audience, expiry) + * - RFC 7662 token introspection for revocation checks + * - Per-route scope enforcement via `requireScope()` + * - RFC 6750 error responses (`WWW-Authenticate`, 401 / 403) via `app.onError` + * + * Routes: + * + * | Route | Required scope | + * | -------------------- | ------------------- | + * | `POST /math/add` | `tools/add` | + * | `POST /math/multiply`| `tools/multiply` | + * | `GET /me` | (any valid token) | + * + * Run from the package root: + * + * cp demo/.env.example demo/.env + * ./demo/run.sh + */ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath, URL } from "node:url"; + +import { serve } from "@hono/node-server"; +import { + type ASCredentials, + AuthplaneError, + httpStatus, + InsufficientScope, + IntrospectionRevocation, + wwwAuthenticate, +} from "@authplane/sdk/core"; +import { config } from "dotenv"; +import { Hono } from "hono"; + +import { + authplaneHonoAuth, + type HonoAuthVariables, + REQUIRED_SCOPE_CONTEXT_KEY, + requireScope, +} from "../src/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: resolve(__dirname, ".env") }); + +function env(name: string, legacyName: string, fallback: string): string { + return process.env[name] ?? process.env[legacyName] ?? fallback; +} + +const resource = env( + "AUTHPLANE_RESOURCE", + "RESOURCE_URL", + "http://localhost:8080/mcp", +); +const parsedResourceUrl = new URL(resource); +const port = parsedResourceUrl.port + ? Number(parsedResourceUrl.port) + : parsedResourceUrl.protocol === "https:" + ? 443 + : 80; + +const clientSecret = + process.env.AUTHPLANE_CLIENT_SECRET ?? process.env.CLIENT_SECRET ?? ""; + +const auth = await authplaneHonoAuth({ + issuer: env("AUTHPLANE_ISSUER", "ISSUER_URL", "http://localhost:9000"), + resource, + scopes: ["tools/add", "tools/multiply"], + devMode: true, + asCredentials: { + clientId: + process.env.AUTHPLANE_CLIENT_ID ?? process.env.CLIENT_ID ?? resource, + clientSecret, + } satisfies ASCredentials, + revocationChecker: IntrospectionRevocation.get(), +}); + +const app = new Hono<{ Variables: HonoAuthVariables }>(); + +// RFC 9728 Protected Resource Metadata. The path is derived from `resource` by +// the SDK, so mounting it under the exact path the PRM document URL points to +// means MCP / OAuth clients can discover it through the `WWW-Authenticate: +// resource_metadata=...` pointer on 401 responses. +app.get( + auth.protectedResourceMetadataPath, + auth.protectedResourceMetadataHandler, +); + +// Everything under `/math/*` and `/me` is authenticated. The middleware +// validates the bearer token, checks revocation, verifies any DPoP proof, and +// populates `c.get("auth")` with the core `VerifiedClaims`. +app.use("/math/*", auth.bearerAuth); +app.use("/me", auth.bearerAuth); + +app.post("/math/add", async (c) => { + requireScope(c, "tools/add"); + const { a, b } = (await c.req.json()) as { a: number; b: number }; + return c.json({ result: a + b }); +}); + +app.post("/math/multiply", async (c) => { + requireScope(c, "tools/multiply"); + const { a, b } = (await c.req.json()) as { a: number; b: number }; + return c.json({ result: a * b }); +}); + +app.get("/me", (c) => { + const info = c.get("auth"); + return c.json({ + sub: info.sub, + clientId: info.clientId, + scopes: info.scopes, + expiresAt: info.expiresAt, + }); +}); + +// Bridge scope / token errors thrown from handlers into RFC 6750 responses. +// The bearerAuth middleware handles errors thrown from *token* validation on +// its own, but errors from `requireScope()` inside a handler surface through +// the framework's `onError` hook — so applications get to choose whether to +// log, translate, or re-throw. +app.onError((err, c) => { + if (err instanceof AuthplaneError) { + const wwwOptions: Parameters[1] = { + realm: resource, + resourceMetadataUrl: auth.verifier.prmDocumentUrl(), + }; + if (err instanceof InsufficientScope) { + // `requireScope(c, scope)` stashes the offending scope on the + // context before throwing so the challenge header can name it + // without parsing the error message. + const scope = c.get(REQUIRED_SCOPE_CONTEXT_KEY); + if (scope) wwwOptions.scope = [scope]; + } + c.header("WWW-Authenticate", wwwAuthenticate(err, wwwOptions)); + const code = + err instanceof InsufficientScope ? "insufficient_scope" : "invalid_token"; + return c.json( + { error: code, error_description: err.message }, + httpStatus(err) as 401 | 403 | 503, + ); + } + console.error(err); + return c.json({ error: "server_error" }, 500); +}); + +serve({ fetch: app.fetch, port }, () => { + console.log(`Hono Calculator Service running on ${resource}`); + console.log(` PRM path: ${auth.protectedResourceMetadataPath}`); + console.log(" routes:"); + console.log(" POST /math/add (requires tools/add)"); + console.log(" POST /math/multiply (requires tools/multiply)"); + console.log(" GET /me (requires valid token)"); +}); + +const shutdown = async () => { + await auth.client.close(); + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/packages/hono/docs/user-guide.md b/packages/hono/docs/user-guide.md new file mode 100644 index 0000000..09e322a --- /dev/null +++ b/packages/hono/docs/user-guide.md @@ -0,0 +1,320 @@ +# `@authplane/hono` — User Guide + +Complete reference for the Authplane adapter for [Hono](https://hono.dev). Starts with the quickstart and builds to advanced scenarios. For a short overview see the [package README](../README.md). + +## Table of contents + +- [Install](#install) +- [Quickstart](#quickstart) +- [`authplaneHonoAuth(options)` reference](#authplanehonoauthoptions-reference) +- [Context shape (`c.get("auth")`)](#context-shape-cgetauth) +- [Scope enforcement](#scope-enforcement) +- [Per-route scope enforcement with `requireScope`](#per-route-scope-enforcement-with-requirescope) +- [Error bridging with `app.onError`](#error-bridging-with-apponerror) +- [DPoP-bound tokens](#dpop-bound-tokens) +- [Introspection and revocation](#introspection-and-revocation) +- [Custom fetch settings](#custom-fetch-settings) +- [Runtime portability](#runtime-portability) +- [Local example commands](#local-example-commands) +- [Error handling](#error-handling) +- [Cleanup](#cleanup) + +## Install + +```bash +npm install @authplane/sdk @authplane/hono hono +``` + +Requires Node 20 LTS or newer when running on Node. The adapter itself is runtime-agnostic (see [Runtime portability](#runtime-portability)). + +## Quickstart + +A complete Hono server with Authplane auth on Node: + +```ts +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { + authplaneHonoAuth, + type HonoAuthVariables, +} from "@authplane/hono"; + +const auth = await authplaneHonoAuth({ + issuer: "http://localhost:9000", + resource: "http://localhost:8090/mcp", + scopes: ["tools/weather"], + devMode: true, +}); + +const app = new Hono<{ Variables: HonoAuthVariables }>(); + +app.get( + auth.protectedResourceMetadataPath, + auth.protectedResourceMetadataHandler, +); + +app.use("/mcp", auth.bearerAuth); +app.post("/mcp", (c) => { + const info = c.get("auth"); + return c.json({ ok: true, clientId: info.clientId, scopes: info.scopes }); +}); + +serve({ fetch: app.fetch, port: 8090 }); +``` + +The adapter produces: + +- a Hono `MiddlewareHandler` (`bearerAuth`) that verifies tokens and attaches the claims to `c.get("auth")`; +- a Hono `Handler` that serves RFC 9728 Protected Resource Metadata; +- an `AuthplaneResource` for direct lower-level access when needed. + +## `authplaneHonoAuth(options)` reference + +### Options + +| Field | Type | Purpose | +|---|---|---| +| `issuer` | `string` (required) | Authplane issuer URL (your `authserver`). | +| `resource` | `string` (required) | Resource URI tokens must be audience-bound to (`aud` claim). | +| `scopes` | `string[]` (optional) | All scopes this server supports. Used for PRM and, by default, as `requiredScopes`. | +| `requiredScopes` | `string[]` (optional) | Override of scopes enforced by `bearerAuth`. Defaults to `scopes` when absent (matches `@authplane/mcp` behaviour). | +| `asCredentials` | `{ clientId, clientSecret }` (optional) | AS client credentials. Required when introspection/revocation is enabled. | +| `fetchSettings` | `FetchSettings` (optional) | Outbound fetch hardening (SSRF protection, timeouts, allowlists) applied to **both** AS metadata and JWKS document fetches. | +| `jwksRefreshSeconds` | `number` (optional, default `300`) | JWKS cache TTL. | +| `metadataRefreshSeconds` | `number` (optional, default `3600`) | Metadata cache TTL. | +| `devMode` | `boolean` (optional, default `false`) | Relaxes HTTPS and private-host restrictions. Only for local dev. | +| `revocationChecker` | `RevocationChecker \| IntrospectionRevocation` (optional) | Enable real-time revocation checking. | +| `replayStore` | `DPoPReplayStore` (optional) | Convenience shortcut folded into `inboundDPoP.replayStore`. Cannot be combined with `inboundDPoP.replayStore`. | +| `inboundDPoP` | `InboundDPoPOptions` (optional) | Full DPoP knobs (`required`, `maxProofAgeSeconds`, `allowedProofAlgorithms`, `replayStore`). | +| `dpopProvider` | `DPoPProvider` (optional) | Outbound DPoP provider for AS-facing calls (introspection, token exchange, revocation). | +| `cacheTtlBufferSeconds` | `number` (optional, default `30`) | Buffer subtracted from token TTLs before the outbound cache treats them as expired. | +| `defaultTtlSeconds` | `number` (optional, default `3600`) | Fallback outbound-token cache TTL when the AS response lacks expiry metadata. | +| `circuitBreakerThreshold` | `number` (optional, default `5`) | Consecutive AS failures before the breaker opens. | +| `circuitBreakerCooldownSeconds` | `number` (optional, default `30`) | Cooldown before the open breaker allows a probe. | + +Plus every option from `AuthplaneResourceOptions` (`core`) not otherwise overridden. + +### Return value + +| Field | Type | Purpose | +|---|---|---| +| `client` | `AuthplaneClient` | The underlying client — owns the JWKS and metadata refresh timers. Use it to run AS traffic (token exchange, introspection, …), and call `client.close()` on shutdown to release the timers. Unlike `@authplane/mcp` and `@authplane/fastmcp`, Hono's factory always creates a client, so this field is non-nullable. | +| `verifier` | `AuthplaneResource` | The core resource primitive; call `verifier.verify(token)` directly to bypass the middleware. | +| `bearerAuth` | `MiddlewareHandler<{ Variables: HonoAuthVariables }>` | Ready-to-use Hono middleware. Verifies token, enforces scopes, attaches `c.get("auth")`. | +| `protectedResourceMetadataPath` | `string` | Hono route path where the PRM should be served (e.g. `/.well-known/oauth-protected-resource/mcp`). | +| `protectedResourceMetadata` | `ProtectedResourceMetadata` | The PRM JSON payload. | +| `protectedResourceMetadataHandler` | `Handler` | Hono handler that serves the PRM. | + +## Context shape (`c.get("auth")`) + +After `bearerAuth` runs, the verified claims are available via `c.get("auth")`. Type the app as `Hono<{ Variables: HonoAuthVariables }>` (from `@authplane/hono`) to get autocomplete: + +```ts +import type { HonoAuthVariables } from "@authplane/hono"; +const app = new Hono<{ Variables: HonoAuthVariables }>(); + +app.get("/me", auth.bearerAuth, (c) => { + const info = c.get("auth"); // VerifiedClaims (from @authplane/sdk/core) + return c.json({ + sub: info.sub, + clientId: info.clientId, + scopes: info.scopes, + expiresAt: info.expiresAt, + audience: info.audience, + }); +}); +``` + +`VerifiedClaims` (re-exported by the SDK core) includes every verified claim: `sub`, `clientId`, `scopes`, `issuer`, `audience`, `expiresAt`, `issuedAt`, `notBefore`, `jti`, `kid`, and the raw claim object under `raw`. Call `info.requireScope(scope)` to enforce a scope inline. + +## Scope enforcement + +By default, `bearerAuth` requires every scope in `options.scopes`. Override with `requiredScopes`: + +```ts +const auth = await authplaneHonoAuth({ + issuer: "...", + resource: "...", + scopes: ["tools/read", "tools/write", "tools/admin"], // advertised in PRM + requiredScopes: ["tools/read"], // enforced by bearerAuth +}); +``` + +Tokens missing any `requiredScopes` are rejected with HTTP 403 and `WWW-Authenticate: Bearer error="insufficient_scope"`. + +## Per-route scope enforcement with `requireScope` + +`bearerAuth` gates the route; for finer per-handler scope checks, call `requireScope(c, scope)` inside the handler: + +```ts +import { requireScope } from "@authplane/hono"; + +app.post("/tools/delete_thing", auth.bearerAuth, async (c) => { + requireScope(c, "tools/delete_thing"); + const { id } = await c.req.json<{ id: string }>(); + await deleteThing(id); + return c.json({ ok: true, deleted: id }); +}); +``` + +`requireScope` throws core `InsufficientScope` (from `@authplane/sdk/core`) if the scope is missing from `c.get("auth").scopes`. Pair it with an `onError` bridge that funnels `AuthplaneError` subclasses through core's `httpStatus()` + `wwwAuthenticate()`. + +> Note on cross-adapter ergonomics: `@authplane/mcp`'s `requireScope` takes `(scope, authInfo)` to match MCP's tool-handler extras. Hono's takes `(c, scope)` to match Hono's context-first convention. Same name, same behavior, argument order deliberately different — check the signature when switching adapters. + +## Error bridging with `app.onError` + +`bearerAuth` handles errors from its own verification path directly — it writes 401 / 403 with the right `WWW-Authenticate` header before the handler ever runs. But errors thrown *from inside a handler* (most commonly from `requireScope`) escape into Hono's `app.onError` hook. The minimal bridge: + +```ts +import { + AuthplaneError, + httpStatus, + InsufficientScope, + wwwAuthenticate, +} from "@authplane/sdk/core"; + +app.onError((err, c) => { + if (err instanceof AuthplaneError) { + c.header( + "WWW-Authenticate", + wwwAuthenticate(err, { + resourceMetadataUrl: auth.verifier.prmDocumentUrl(), + }), + ); + const code = + err instanceof InsufficientScope ? "insufficient_scope" : "invalid_token"; + return c.json( + { error: code, error_description: err.message }, + httpStatus(err) as 401 | 403 | 503, + ); + } + return c.json({ error: "server_error" }, 500); +}); +``` + +Every error funnels through core's `httpStatus()` + `wwwAuthenticate()`: `InsufficientScope` → 403 + `Bearer …`, DPoP failures → 401 + `DPoP …` (except `DPoPNotSupported`), upstream-AS failures (`JWKSFetchError`, `MetadataFetchError`) → 503 with retry semantics. + +> No equivalent to MCP's URL elicitation. `@authplane/mcp` ships `wrapToolWithUrlElicitation` / `toUrlElicitationRequiredError` to translate a `ConsentRequiredError` (raised by a token exchange against `authserver`) into MCP's `-32042` response. Hono has no analogous protocol hook — if a handler performs a token exchange and catches `ConsentRequiredError`, translate it yourself inside the handler (typically to a `401` with a JSON body carrying the `consent_url`) before returning. + +## DPoP-bound tokens + +DPoP is opt-in. The convenience `replayStore` option enables it implicitly (Mode 2 — Supported) and the adapter ships `InMemoryDPoPReplayStore` from `@authplane/sdk/core`; use a Redis-backed implementation across multiple processes. For full control over DPoP knobs (`required`, `maxProofAgeSeconds`, `allowedProofAlgorithms`), pass `inboundDPoP` directly — `replayStore` is then folded into it. Setting `replayStore` *and* `inboundDPoP.replayStore` together throws at construction. + +```ts +import { InMemoryDPoPReplayStore } from "@authplane/sdk/core"; +import { authplaneHonoAuth } from "@authplane/hono"; + +const auth = await authplaneHonoAuth({ + issuer: "...", + resource: "...", + scopes: ["tools/read"], + replayStore: new InMemoryDPoPReplayStore(), +}); +``` + +Once configured, requests carrying a `DPoP` header get full proof validation (method, URL, iat, nonce, replay) against the presented access token's confirmation claim. A missing or invalid proof produces 401 `invalid_token` with `WWW-Authenticate: DPoP …` (RFC 9449 §7.1). + +If the resource has not been opted into DPoP and a request still carries a `DPoP` header, the core verifier rejects it with `DPoPNotSupported`. The challenge response carries `WWW-Authenticate: Bearer …` — RFC 9449 §7.1 carves `DPoPNotSupported` out so the retry hint matches the resource's actual support. + +### `htu` is pinned to the configured `resource` + +> DPoP `htu` is anchored to the configured resource origin; only the request's path and query are taken from the inbound request. + +## Introspection and revocation + +By default the adapter trusts signature + `exp`/`nbf`. To enable RFC 7662 introspection on every request (catches tokens revoked before expiry): + +```ts +import { authplaneHonoAuth } from "@authplane/hono"; +import { IntrospectionRevocation } from "@authplane/sdk/core"; + +const auth = await authplaneHonoAuth({ + issuer: "...", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + asCredentials: { clientId: "rs-client", clientSecret: "" }, + revocationChecker: IntrospectionRevocation.get(), +}); +``` + +`IntrospectionRevocation` is a singleton class from `@authplane/sdk/core` — obtain its instance with `IntrospectionRevocation.get()` and pass it through `revocationChecker`. Internally it's detected via `instanceof`, which flips `AuthplaneResource` into "introspect on every verify" mode: the underlying resource calls `authserver`'s introspection endpoint on each `verify()` and raises on `active: false`. This adds one round-trip per request; use only if eager revocation matters to your threat model. + +You can also pass a custom `RevocationChecker` — an async function `(claims, rawToken) => Promise` — for database-backed revocation lists. + +## Custom fetch settings + +Every outbound call (metadata, JWKS, introspection, revocation) routes through a `FetchSettings` instance. Tighten defaults: + +```ts +import { FetchSettings } from "@authplane/sdk/core"; + +const tight = new FetchSettings({ + timeoutMs: 3000, + allowedHosts: ["auth.example.com"], +}); + +const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + fetchSettings: tight, +}); +``` + +See the [`@authplane/sdk` user guide](../../sdk/docs/user-guide.md#fetch-settings-and-ssrf-protection) for the full `FetchSettings` reference. + +## Runtime portability + +The middleware itself uses only `Request`/`Response`-level primitives, so it ports with your Hono app — examples use `@hono/node-server` for convenience. + +**Workers caveat (be honest with yourself):** `AuthplaneClient` uses `setInterval` for JWKS and metadata refresh. Cloudflare Workers' `nodejs_compat` shim does **not** keep those timers alive across invocations, so the first cold start fetches JWKS / metadata eagerly but background refresh won't run. PRM is also computed at factory call time (top-level `await`). On Workers/edge today, prefer: + +- A Node-attached `@hono/node-server` deployment behind your edge ingress, or +- A short-lived client per Worker iteration with `client.close()` in the finally block. + +A first-class Workers integration (cron-triggered refresh + Durable-Object replay store) is on the roadmap — until then, treat Node/Bun/Deno as the supported runtimes. + +## Local example commands + +Two runnable scripts ship with the package: + +1. `examples/oauth-server.ts` — minimal one-file example: + + ```bash + PORT=8090 \ + AUTHPLANE_ISSUER=http://127.0.0.1:9000 \ + AUTHPLANE_RESOURCE=http://127.0.0.1:8090/resource \ + npm run -w @authplane/hono example:oauth + ``` + +2. `demo/server.ts` — multi-route calculator demo with per-route scope enforcement and `onError` bridging. Run it via: + + ```bash + cd packages/hono + cp demo/.env.example demo/.env + ./demo/run.sh + ``` + +## Error handling + +| Authplane error | HTTP | `WWW-Authenticate` | +|---|---|---| +| `TokenMissing` (no `Authorization`) | 401 | `Bearer error="invalid_token"` | +| `TokenExpired`, `InvalidSignature`, `InvalidClaims`, `TokenRevoked` | 401 | `Bearer error="invalid_token"` | +| `InsufficientScope` (from `requiredScopes` or `requireScope`) | 403 | `Bearer error="insufficient_scope", scope="…"` | +| `DPoPProofMissing`, `InvalidDPoPProof`, `DPoPReplayDetected`, `DPoPBindingMismatch` | 401 | `DPoP error="invalid_token"` (RFC 9449 §7.1) | +| `DPoPNotSupported` | 401 | `Bearer error="invalid_token"` (resource not opted into DPoP) | + +Every failure also includes `resource_metadata=""` when the factory has computed one, so clients following RFC 9728 can discover the authorization server automatically. + +## Cleanup + +The underlying `AuthplaneClient` owns timers for JWKS and metadata refresh. On server shutdown: + +```ts +await auth.client.close(); +``` + +This stops the refresh timers so the process can exit cleanly. The example and demo wire this to `SIGINT` / `SIGTERM`. + +> `AuthplaneResource` (returned as `auth.verifier`) does not own any timers — its `close()` is a no-op. Always shut down the `AuthplaneClient` instead. diff --git a/packages/hono/package.json b/packages/hono/package.json new file mode 100644 index 0000000..e8fd454 --- /dev/null +++ b/packages/hono/package.json @@ -0,0 +1,66 @@ +{ + "name": "@authplane/hono", + "version": "0.3.0-dev.0", + "description": "Authplane JWT validation adapter for the Hono web framework", + "keywords": [ + "hono", + "oauth", + "jwt", + "adapter", + "authplane" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/AuthPlane/ts-sdk.git", + "directory": "packages/hono" + }, + "homepage": "https://github.com/AuthPlane/ts-sdk/tree/main/packages/hono", + "bugs": { + "url": "https://github.com/AuthPlane/ts-sdk/issues" + }, + "engines": { + "node": ">=22" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "scripts": { + "prepack": "node ../../scripts/sync-package-files.mjs", + "build": "tsc -b", + "format": "biome format --write src package.json tsconfig.json", + "lint": "biome lint src --error-on-warnings", + "typecheck": "tsc -b", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:integration": "vitest run tests/auth.integration.test.ts" + }, + "peerDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "hono": "^4.0.0" + }, + "devDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "@hono/node-server": "^1.14.0", + "@types/node": "^24.3.0", + "@vitest/coverage-v8": "4.1.5", + "dotenv": "^17.3.1", + "hono": "^4.12.0", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "vitest": "4.1.5" + } +} diff --git a/packages/hono/src/authplaneHonoAuth.ts b/packages/hono/src/authplaneHonoAuth.ts new file mode 100644 index 0000000..ef0a45e --- /dev/null +++ b/packages/hono/src/authplaneHonoAuth.ts @@ -0,0 +1,250 @@ +import { + AuthplaneClient, + type AuthplaneResource, + type AuthplaneResourceOptions, + type DPoPProvider, + type DPoPReplayStore, + type FetchSettings, + type ProtectedResourceMetadata, +} from "@authplane/sdk/core"; +import type { Handler, MiddlewareHandler } from "hono"; + +import { bearerAuth } from "./bearerAuth.js"; +import { protectedResourceMetadataHandler } from "./prmHandler.js"; +import type { HonoAuthVariables } from "./types.js"; + +/** + * Input options for {@link authplaneHonoAuth}. A faithful port of + * `AuthplaneMcpAuthOptions` from `@authplane/mcp` so the two adapters have + * interchangeable configuration. + */ +export interface AuthplaneHonoAuthOptions + extends Omit { + /** Authorization server issuer URL (used for discovery + JWKS). */ + issuer: string; + /** Canonical resource identifier this server protects. */ + resource: string; + /** All scopes this resource server supports. */ + scopes?: string[]; + /** + * Scopes the middleware must enforce. Defaults to `scopes` when not + * provided, matching the behaviour of the Python + MCP adapters. + */ + requiredScopes?: string[]; + /** + * Outbound fetch hardening (SSRF, timeouts, allowlists) applied to both + * AS metadata and JWKS document fetches. When omitted, defaults are + * derived from `devMode`. + */ + fetchSettings?: FetchSettings; + /** Seconds between JWKS refreshes. */ + jwksRefreshSeconds?: number; + /** Seconds between AS metadata refreshes. */ + metadataRefreshSeconds?: number; + /** + * Outbound DPoP provider for AS-facing calls (introspection, token + * exchange, revocation). When set, requests to the AS carry a DPoP proof + * and `cnf.jkt`-bound tokens are minted. Parity with the mcp/fastmcp + * adapters. + */ + dpopProvider?: DPoPProvider; + /** + * Buffer subtracted from token TTLs before the outbound token cache + * considers an entry expired (seconds). Default `30`. + */ + cacheTtlBufferSeconds?: number; + /** + * Fallback outbound-token cache TTL used when the AS response does not + * include expiry metadata (seconds). Default `3600`. + */ + defaultTtlSeconds?: number; + /** + * Maximum number of entries kept in the outbound token cache before + * least-recently-used eviction kicks in. Default `10_000`. Override on + * hosts with very high subject-token cardinality — token-exchange cache + * keys include the subject token, so this is the bound that actually + * limits memory growth. + */ + cacheMaxEntries?: number; + /** + * Number of consecutive transient AS failures before the circuit breaker + * opens. Default `5`. + */ + circuitBreakerThreshold?: number; + /** + * Cooldown before the open circuit breaker allows a half-open probe + * request (seconds). Default `30`. + */ + circuitBreakerCooldownSeconds?: number; + /** + * Inbound DPoP proof replay store. Convenience shortcut that is folded + * into `inboundDPoP.replayStore` for the resource verifier; setting it + * also implicitly opts the resource into DPoP (Mode 2 — Supported) if + * `inboundDPoP` is not already provided. + * + * Setting both `replayStore` and `inboundDPoP.replayStore` throws at + * construction — pick one channel to avoid ambiguous precedence. + */ + replayStore?: DPoPReplayStore; +} + +/** + * Wiring returned by {@link authplaneHonoAuth}. Everything an application + * needs to enable Authplane-issued bearer auth on a Hono server in a single + * call. + */ +export interface AuthplaneHonoAuth { + /** Shared Authplane client. Exposed so callers can reuse it for AS traffic. */ + client: AuthplaneClient; + /** Core resource verifier. Useful for custom flows outside the middleware. */ + verifier: AuthplaneResource; + /** Fully-configured bearer middleware (scope, DPoP, PRM URL all wired). */ + bearerAuth: MiddlewareHandler<{ Variables: HonoAuthVariables }>; + /** Path (pathname portion) where the PRM handler should be mounted. */ + protectedResourceMetadataPath: string; + /** The RFC 9728 PRM payload served by the handler. */ + protectedResourceMetadata: ProtectedResourceMetadata; + /** Hono route handler that serves {@link protectedResourceMetadata} as JSON. */ + protectedResourceMetadataHandler: Handler; +} + +/** + * Build the wiring needed to enable Authplane auth on a Hono server. + * + * Mirrors the Express `authplaneMcpAuth()` variant in `@authplane/mcp`. + * Returns the shared `AuthplaneClient`, the core + * `AuthplaneResource`, the configured `bearerAuth` middleware, and the RFC + * 9728 PRM handler + path. + * + * When `requiredScopes` is not provided it defaults to `scopes` so the + * middleware treats every supported scope as required. + */ +export async function authplaneHonoAuth( + options: AuthplaneHonoAuthOptions, +): Promise { + const { requiredScopes, scopes, issuer, resource } = options; + + if ( + options.replayStore !== undefined && + options.inboundDPoP?.replayStore !== undefined + ) { + throw new Error( + "authplaneHonoAuth: pass `replayStore` OR `inboundDPoP.replayStore`, not both", + ); + } + + const resolvedScopes = scopes ?? []; + const resolvedRequiredScopes = requiredScopes ?? resolvedScopes; + const resourceOrigin = new URL(resource).origin; + + const client = await buildAuthplaneClient({ issuer, options }); + const verifier = client.resource( + buildResourceOptions(resource, resolvedScopes, options), + ); + const resourceMetadataUrl = verifier.prmDocumentUrl(); + const protectedResourceMetadataPath = new URL(resourceMetadataUrl).pathname; + const protectedResourceMetadata = verifier.prmResponse(); + + return { + client, + verifier, + bearerAuth: bearerAuth({ + verifier, + requiredScopes: resolvedRequiredScopes, + resourceMetadataUrl, + resourceOrigin, + }), + protectedResourceMetadataPath, + protectedResourceMetadata, + protectedResourceMetadataHandler: protectedResourceMetadataHandler( + protectedResourceMetadata, + ), + }; +} + +interface BuildClientParams { + readonly issuer: string; + readonly options: AuthplaneHonoAuthOptions; +} + +async function buildAuthplaneClient( + params: BuildClientParams, +): Promise { + const { issuer, options } = params; + const clientOptions: Parameters[0] = { + issuer, + }; + if (options.asCredentials !== undefined) { + clientOptions.auth = options.asCredentials; + } + if (options.devMode !== undefined) { + clientOptions.devMode = options.devMode; + } + if (options.fetchSettings !== undefined) { + clientOptions.fetchSettings = options.fetchSettings; + } + if (options.jwksRefreshSeconds !== undefined) { + clientOptions.jwksRefreshSeconds = options.jwksRefreshSeconds; + } + if (options.metadataRefreshSeconds !== undefined) { + clientOptions.metadataRefreshSeconds = options.metadataRefreshSeconds; + } + if (options.dpopProvider !== undefined) { + clientOptions.dpopProvider = options.dpopProvider; + } + if (options.cacheTtlBufferSeconds !== undefined) { + clientOptions.cacheTtlBufferSeconds = options.cacheTtlBufferSeconds; + } + if (options.defaultTtlSeconds !== undefined) { + clientOptions.defaultTtlSeconds = options.defaultTtlSeconds; + } + if (options.cacheMaxEntries !== undefined) { + clientOptions.cacheMaxEntries = options.cacheMaxEntries; + } + if (options.circuitBreakerThreshold !== undefined) { + clientOptions.circuitBreakerThreshold = options.circuitBreakerThreshold; + } + if (options.circuitBreakerCooldownSeconds !== undefined) { + clientOptions.circuitBreakerCooldownSeconds = + options.circuitBreakerCooldownSeconds; + } + return AuthplaneClient.create(clientOptions); +} + +function buildResourceOptions( + resource: string, + scopes: string[], + options: AuthplaneHonoAuthOptions, +): AuthplaneResourceOptions { + const resourceOptions: AuthplaneResourceOptions = { + resource, + scopes, + }; + if (options.revocationChecker !== undefined) { + resourceOptions.revocationChecker = options.revocationChecker; + } + if (options.allowedAlgorithms !== undefined) { + resourceOptions.allowedAlgorithms = options.allowedAlgorithms; + } + if (options.clockSkewSeconds !== undefined) { + resourceOptions.clockSkewSeconds = options.clockSkewSeconds; + } + if (options.devMode !== undefined) { + resourceOptions.devMode = options.devMode; + } + if (options.asCredentials !== undefined) { + resourceOptions.asCredentials = options.asCredentials; + } + if (options.failClosed !== undefined) { + resourceOptions.failClosed = options.failClosed; + } + if (options.inboundDPoP !== undefined || options.replayStore !== undefined) { + resourceOptions.inboundDPoP = { + ...(options.inboundDPoP ?? {}), + ...(options.replayStore !== undefined + ? { replayStore: options.replayStore } + : {}), + }; + } + return resourceOptions; +} diff --git a/packages/hono/src/bearerAuth.ts b/packages/hono/src/bearerAuth.ts new file mode 100644 index 0000000..0a6af68 --- /dev/null +++ b/packages/hono/src/bearerAuth.ts @@ -0,0 +1,174 @@ +import { + AuthplaneError, + type AuthplaneResource, + buildDPoPRequestContext, + type DPoPRequestContext, + extractBearerToken, + extractDpopHeaderValues, + buildRequestUrl, + httpStatus, + InsufficientScope, + pathAndQueryOf, + wwwAuthenticate, +} from "@authplane/sdk/core"; +import type { Context, MiddlewareHandler } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +import type { HonoAuthVariables } from "./types.js"; + +/** + * Input options for {@link bearerAuth}. + */ +export interface BearerAuthOptions { + /** + * Core resource verifier responsible for cryptographic validation of the + * access token. The middleware calls `verifier.verify()` directly and + * stores the resulting {@link VerifiedClaims} on the Hono context. + */ + readonly verifier: AuthplaneResource; + /** + * Scopes that must ALL be present on the verified token for the request + * to continue. When empty or omitted, scope enforcement is skipped. + */ + readonly requiredScopes?: readonly string[]; + /** + * Protection realm (RFC 6750 §3). When provided, emitted as `realm="…"` + * in every `WWW-Authenticate` challenge. Typically the resource URL. + */ + readonly realm?: string; + /** + * Absolute URL to the Protected Resource Metadata document (RFC 9728). + * When provided, emitted as `resource_metadata="…"` on every + * `WWW-Authenticate` challenge so clients can discover the authorization + * server. + */ + readonly resourceMetadataUrl?: string; + /** + * Origin (scheme + authority) of the configured resource. Used as the + * trusted source of truth for the DPoP `htu` URL — must be + * `new URL(options.resource).origin`. The middleware never reads + * `X-Forwarded-*` or `Host` to compute `htu`: those are attacker-controlled + * inputs in many deployments and letting them steer `htu` would neuter + * RFC 9449 cross-endpoint anti-replay. + */ + readonly resourceOrigin: string; +} + +/** + * Build a Hono middleware that enforces Authplane-issued Bearer tokens on the + * requests it guards. + * + * Contract: + * + * 1. Extract the Bearer token from the `Authorization` header (RFC 6750 §2.1, + * case-insensitive scheme; RFC 9449 §7.1 `DPoP` scheme also accepted). + * 2. Delegate cryptographic verification to {@link AuthplaneResource.verify}. + * Expiry (with the resource's configured `clockSkewSeconds` tolerance) and + * every other claim check are enforced by core. + * 3. Enforce `requiredScopes` (if any) via `claims.requireScopes()` — the + * AND-style multi-scope helper on core `VerifiedClaims`. + * 4. On success, stash the verified claims on the Hono context under `auth` + * so downstream handlers can read them via `c.get("auth")`. + * + * On failure the middleware short-circuits with an RFC 6750 §3 response. + * Every {@link AuthplaneError} is funneled through core's `httpStatus()` + + * `wwwAuthenticate()`, matching `@authplane/mcp` / `@authplane/fastmcp`. + */ +export function bearerAuth( + options: BearerAuthOptions, +): MiddlewareHandler<{ Variables: HonoAuthVariables }> { + const { + verifier, + requiredScopes = [], + realm, + resourceMetadataUrl, + resourceOrigin, + } = options; + + return async (c, next) => { + try { + const token = extractBearerToken(c.req.header("authorization")); + const dpopRequest = buildDpopRequestContext(c, resourceOrigin); + const claims = await verifier.verify(token, { dpopRequest }); + + claims.requireScopes(requiredScopes); + + c.set("auth", claims); + await next(); + return; + } catch (error) { + return respondWithError(c, error, { + requiredScopes, + realm, + resourceMetadataUrl, + }); + } + }; +} + +function buildDpopRequestContext( + c: Context<{ Variables: HonoAuthVariables }>, + resourceOrigin: string, +): DPoPRequestContext | undefined { + // Hono's Fetch-style `Headers.get` joins duplicate same-name headers + // into a single comma-separated value; `extractDpopHeaderValues` + // hands that string to the core factory, which re-splits on `,` so + // RFC 9449 §4.3 #1 (no more than one DPoP header) surfaces as + // `MultipleDPoPProofs`. JWS compact-serialised proofs never contain + // a literal `,`, so split-on-comma is sound. + const dpopHeaderValues = extractDpopHeaderValues(c.req.header("dpop")); + if (dpopHeaderValues.length === 0) return undefined; + + const url = buildRequestUrl({ + pathAndQuery: pathAndQueryOf(c.req.url), + resourceOrigin, + }); + + return buildDPoPRequestContext({ + method: c.req.method, + url, + dpopHeaderValues, + }); +} + +interface ErrorResponseContext { + readonly requiredScopes: readonly string[]; + readonly realm?: string | undefined; + readonly resourceMetadataUrl?: string | undefined; +} + +function respondWithError( + c: Context<{ Variables: HonoAuthVariables }>, + error: unknown, + ctx: ErrorResponseContext, +): Response { + if (error instanceof AuthplaneError) { + const wwwOptions: Parameters[1] = {}; + if (ctx.realm) wwwOptions.realm = ctx.realm; + if (ctx.resourceMetadataUrl) { + wwwOptions.resourceMetadataUrl = ctx.resourceMetadataUrl; + } + if (ctx.requiredScopes.length > 0) { + wwwOptions.scope = ctx.requiredScopes; + } + c.header("WWW-Authenticate", wwwAuthenticate(error, wwwOptions)); + + const errorCode = + error instanceof InsufficientScope + ? "insufficient_scope" + : "invalid_token"; + return c.json( + { error: errorCode, error_description: error.message }, + httpStatus(error) as ContentfulStatusCode, + ); + } + + return c.json( + { + error: "server_error", + error_description: + error instanceof Error ? error.message : "Internal Server Error", + }, + 500, + ); +} diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts new file mode 100644 index 0000000..a8da2f6 --- /dev/null +++ b/packages/hono/src/index.ts @@ -0,0 +1,13 @@ +export { + type HonoAuthVariables, + type HonoAuthVariables as Variables, + REQUIRED_SCOPE_CONTEXT_KEY, +} from "./types.js"; +export { type BearerAuthOptions, bearerAuth } from "./bearerAuth.js"; +export { protectedResourceMetadataHandler } from "./prmHandler.js"; +export { + type AuthplaneHonoAuth, + type AuthplaneHonoAuthOptions, + authplaneHonoAuth, +} from "./authplaneHonoAuth.js"; +export { requireScope } from "./requireScope.js"; diff --git a/packages/hono/src/prmHandler.ts b/packages/hono/src/prmHandler.ts new file mode 100644 index 0000000..af2168b --- /dev/null +++ b/packages/hono/src/prmHandler.ts @@ -0,0 +1,29 @@ +import type { ProtectedResourceMetadata } from "@authplane/sdk/core"; +import type { Handler } from "hono"; + +/** + * Build a Hono route handler that serves the RFC 9728 Protected Resource + * Metadata document as JSON. + * + * The handler is intentionally content-only: no authentication, no caching, + * no conditional responses — those belong in the calling application if they + * are needed. Mount it at the `oauth-protected-resource` path derived from + * the resource URL (typically `/.well-known/oauth-protected-resource`): + * + * ```ts + * import { authplaneHonoAuth } from "@authplane/hono"; + * + * const { protectedResourceMetadataPath, protectedResourceMetadataHandler } = + * await authplaneHonoAuth({ … }); + * + * app.get(protectedResourceMetadataPath, protectedResourceMetadataHandler); + * ``` + * + * The metadata argument is captured by reference at construction time, so + * callers that need to rotate the payload should rebuild the handler. + */ +export function protectedResourceMetadataHandler( + metadata: ProtectedResourceMetadata, +): Handler { + return (c) => c.json(metadata); +} diff --git a/packages/hono/src/requireScope.ts b/packages/hono/src/requireScope.ts new file mode 100644 index 0000000..4c0a7b5 --- /dev/null +++ b/packages/hono/src/requireScope.ts @@ -0,0 +1,56 @@ +import { InsufficientScope, type VerifiedClaims } from "@authplane/sdk/core"; +import type { Context } from "hono"; + +import { type HonoAuthVariables, REQUIRED_SCOPE_CONTEXT_KEY } from "./types.js"; + +/** + * Assert the current request's verified token carries `scope`; throw core + * {@link InsufficientScope} otherwise. + * + * Call this at the top of a route handler to enforce scope requirements that + * are finer-grained than what `bearerAuth` can enforce globally — for example, + * guarding a single tool within a larger MCP server: + * + * ```ts + * import { requireScope } from "@authplane/hono"; + * + * app.post("/mcp/tools/add", (c) => { + * requireScope(c, "tools/add"); + * // … tool body … + * }); + * ``` + * + * Apps using this helper should pair it with a Hono `onError` handler that + * funnels `AuthplaneError` through core's `httpStatus()` + `wwwAuthenticate()`. + * The factory returned by `authplaneHonoAuth` does NOT install a global error + * handler, to keep application-level error-handling choices with the app + * owner. + * + * If `bearerAuth` never ran for this request (so `c.get("auth")` returns + * undefined) the helper raises {@link InsufficientScope} as well — that way a + * forgotten `app.use(bearerAuth)` fails closed instead of open. + */ +export function requireScope( + c: Context<{ Variables: HonoAuthVariables }>, + scope: string, +): void { + // `HonoAuthVariables.auth` is typed non-optional because the documented + // contract is "call this AFTER bearerAuth has populated the context." The + // runtime cast widens it back to `| undefined` so a forgotten + // `app.use(bearerAuth)` fails closed with InsufficientScope instead of a + // TypeError dereferencing `.scopes` on undefined. + const auth = c.var.auth as VerifiedClaims | undefined; + if (auth?.hasScope(scope)) return; + // Stash the offending scope so an `onError` bridge can surface it in the + // WWW-Authenticate challenge without having to parse the error message. + c.set(REQUIRED_SCOPE_CONTEXT_KEY, scope); + if (!auth) { + // Fail-closed for a forgotten `app.use(bearerAuth)`: no claims to + // delegate to, so synthesise the error directly. + throw new InsufficientScope(`Missing required scope: ${scope}`); + } + // Auth present but lacks the scope: delegate to the core helper so the + // `InsufficientScope` message carries the missing scope and the scopes + // the token does have — same wording every other adapter emits. + auth.requireScope(scope); +} diff --git a/packages/hono/src/types.ts b/packages/hono/src/types.ts new file mode 100644 index 0000000..5be9176 --- /dev/null +++ b/packages/hono/src/types.ts @@ -0,0 +1,34 @@ +import type { VerifiedClaims } from "@authplane/sdk/core"; + +/** + * Shape expected in Hono's `Variables` generic so that `c.get("auth")` returns + * a {@link VerifiedClaims} instead of `unknown`. + * + * Use it when constructing the app: + * + * ```ts + * import type { HonoAuthVariables } from "@authplane/hono"; + * const app = new Hono<{ Variables: HonoAuthVariables }>(); + * ``` + * + * The adapter also re-exports this under the short name `Variables` for + * callers who prefer the inline form `new Hono<{ Variables: Variables }>()`. + * + * `authplaneRequiredScope` is internal plumbing: `requireScope(c, scope)` + * stashes the offending scope here before throwing core `InsufficientScope`, + * and an `onError` handler can read it via + * {@link REQUIRED_SCOPE_CONTEXT_KEY} to populate the `scope="…"` parameter + * on the `WWW-Authenticate` challenge without parsing the error message. + */ +export type HonoAuthVariables = { + auth: VerifiedClaims; + authplaneRequiredScope?: string; +}; + +/** + * Key under which `requireScope(c, scope)` stashes the required scope on the + * Hono context before throwing `InsufficientScope`. An `onError` bridge can + * read it via `c.get(REQUIRED_SCOPE_CONTEXT_KEY)` to surface it in the + * `WWW-Authenticate` challenge. + */ +export const REQUIRED_SCOPE_CONTEXT_KEY = "authplaneRequiredScope" as const; diff --git a/packages/hono/tests/auth.integration.test.ts b/packages/hono/tests/auth.integration.test.ts new file mode 100644 index 0000000..3513760 --- /dev/null +++ b/packages/hono/tests/auth.integration.test.ts @@ -0,0 +1,268 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { serve } from "@hono/node-server"; +import { + AuthplaneClient, + AuthplaneError, + type AuthplaneResource, + type DPoPReplayStore, + httpStatus, + InsufficientScope, + VerifiedClaims, + wwwAuthenticate, +} from "@authplane/sdk/core"; +import { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { authplaneHonoAuth } from "../src/authplaneHonoAuth.js"; +import { requireScope } from "../src/requireScope.js"; +import type { HonoAuthVariables } from "../src/types.js"; + +/** + * End-to-end integration tests for the `@authplane/hono` adapter. + * + * Unlike the per-module unit tests, these spin up a real HTTP server via + * `@hono/node-server`, issue actual `fetch` requests over the loopback + * interface, and assert the on-the-wire response. Two things this catches + * that in-process `app.request` cannot: + * + * - The `DPoP` header really survives Node's `http` parser with its + * original casing / value. + * - The middleware actually closes the response (no dangling handlers) when + * a 401 / 403 is returned — without that, real clients would hang. + * + * The `AuthplaneClient.create` call is still mocked so the tests don't + * require network access to an OAuth server. + */ +describe("authplaneHonoAuth integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function withServer( + app: Hono<{ Variables: HonoAuthVariables }>, + run: (baseUrl: string) => Promise, + ): Promise { + const server = serve({ fetch: app.fetch, port: 0, createServer }); + try { + const { port } = ( + server as import("node:http").Server + ).address() as AddressInfo; + const baseUrl = `http://127.0.0.1:${port}`; + return await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + (server as import("node:http").Server).close((err) => + err ? reject(err) : resolve(), + ); + }); + } + } + + function buildClaims( + overrides: Partial[0]> = {}, + ): VerifiedClaims { + return new VerifiedClaims({ + sub: "user_123", + clientId: "client_456", + scopes: ["tools/add"], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: Math.floor(Date.now() / 1000) + 3_600, + issuedAt: Math.floor(Date.now() / 1000) - 10, + jti: "token_123", + kid: "key_1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: { sub: "user_123" }, + ...overrides, + }); + } + + function mockSdk( + options: { + verify?: ReturnType; + prm?: Record; + prmDocumentUrl?: string; + } = {}, + ) { + const verify = options.verify ?? vi.fn(async () => buildClaims()); + const prm = options.prm ?? { + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add"], + bearer_methods_supported: ["header"], + }; + const prmDocumentUrl = + options.prmDocumentUrl ?? + "https://api.example.com/.well-known/oauth-protected-resource/mcp"; + + const resource = { + verify, + prmResponse: vi.fn(() => prm), + prmDocumentUrl: vi.fn(() => prmDocumentUrl), + } as unknown as AuthplaneResource; + + const client = { + resource: vi.fn(() => resource), + } as unknown as AuthplaneClient; + + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + return { verify, resource, client }; + } + + it("serves PRM, rejects unauthenticated requests with a proper 401, and accepts valid tokens", async () => { + mockSdk(); + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + }); + + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.get( + auth.protectedResourceMetadataPath, + auth.protectedResourceMetadataHandler, + ); + app.use("/mcp", auth.bearerAuth); + app.post("/mcp", (c) => { + const info = c.get("auth"); + return c.json({ + ok: true, + clientId: info.clientId, + scopes: info.scopes, + }); + }); + + await withServer(app, async (baseUrl) => { + const prmRes = await fetch( + `${baseUrl}${auth.protectedResourceMetadataPath}`, + ); + expect(prmRes.status).toBe(200); + const prmBody = (await prmRes.json()) as { resource: string }; + expect(prmBody.resource).toBe("https://api.example.com/mcp"); + + const unauthorized = await fetch(`${baseUrl}/mcp`, { method: "POST" }); + expect(unauthorized.status).toBe(401); + const wwwAuth = unauthorized.headers.get("www-authenticate"); + expect(wwwAuth).toContain('error="invalid_token"'); + expect(wwwAuth).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', + ); + + const ok = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + expect(ok.status).toBe(200); + await expect(ok.json()).resolves.toEqual({ + ok: true, + clientId: "client_456", + scopes: ["tools/add"], + }); + }); + }); + + it("threads DPoP proofs through to the verifier over a real HTTP transport", async () => { + const replayStore = { + checkAndStore: vi.fn(async () => true), + } as unknown as DPoPReplayStore; + const { verify } = mockSdk(); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + replayStore, + }); + + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use("/mcp", auth.bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + await withServer(app, async (baseUrl) => { + const res = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "eyJ.proof.value", + }, + }); + expect(res.status).toBe(200); + }); + + expect(verify).toHaveBeenCalledTimes(1); + // DPoPRequestContext is request-shape only — the replay store lives + // on the resource via inboundDPoP and the SDK applies it on verify. + const [token, { dpopRequest }] = verify.mock.calls[0] as [ + string, + { + dpopRequest: { + method: string; + url: string; + proofs: readonly string[]; + }; + }, + ]; + expect(token).toBe("valid_jwt"); + expect(dpopRequest.method).toBe("POST"); + // htu is anchored to the configured resource origin, not the + // 127.0.0.1: address the test server actually listens on. + // That's the whole point of the resource-origin hardening. + expect(dpopRequest.url).toBe("https://api.example.com/mcp"); + expect(dpopRequest.proofs).toEqual(["eyJ.proof.value"]); + }); + + it("returns 403 when requireScope is called inside a handler for a missing scope", async () => { + const { verify } = mockSdk(); + verify.mockResolvedValue(buildClaims({ scopes: ["tools/echo"] })); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: [], + }); + + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use("/mcp/*", auth.bearerAuth); + app.post("/mcp/tools/add", (c) => { + requireScope(c, "tools/add"); + return c.json({ ok: true }); + }); + app.onError((err, c) => { + // Idiomatic opt-in error bridge: map any AuthplaneError thrown by + // requireScope through core's httpStatus() + wwwAuthenticate(). + if (err instanceof AuthplaneError) { + c.header("WWW-Authenticate", wwwAuthenticate(err)); + const code = + err instanceof InsufficientScope + ? "insufficient_scope" + : "invalid_token"; + const status = httpStatus(err) as 400 | 401 | 403 | 500 | 503; + return c.json( + { error: code, error_description: err.message }, + status, + ); + } + return c.json({ error: "server_error" }, 500); + }); + + await withServer(app, async (baseUrl) => { + const res = await fetch(`${baseUrl}/mcp/tools/add`, { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ + error: "insufficient_scope", + // requireScope delegates to core claims.requireScope when auth is + // present, so the message names the missing scope AND the scopes + // the token does carry. + error_description: + "Token missing required scope 'tools/add'. Token has scopes: tools/echo", + }); + }); + }); +}); diff --git a/packages/hono/tests/authplaneHonoAuth.test.ts b/packages/hono/tests/authplaneHonoAuth.test.ts new file mode 100644 index 0000000..c38db08 --- /dev/null +++ b/packages/hono/tests/authplaneHonoAuth.test.ts @@ -0,0 +1,325 @@ +import { + AuthplaneClient, + type AuthplaneResource, + type DPoPReplayStore, + VerifiedClaims, +} from "@authplane/sdk/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { authplaneHonoAuth } from "../src/authplaneHonoAuth.js"; + +function buildClaims( + overrides: Partial[0]> = {}, +): VerifiedClaims { + return new VerifiedClaims({ + sub: "u", + clientId: "c", + scopes: [], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: Math.floor(Date.now() / 1000) + 3_600, + issuedAt: Math.floor(Date.now() / 1000), + jti: "j", + kid: "k", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + ...overrides, + }); +} + +function mockResource( + overrides: Partial<{ + prmDocumentUrl: string; + prmResponse: Record; + verify: ReturnType; + }> = {}, +): AuthplaneResource { + const url = + overrides.prmDocumentUrl ?? + "https://api.example.com/.well-known/oauth-protected-resource/mcp"; + const prm = overrides.prmResponse ?? { + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add"], + bearer_methods_supported: ["header"], + }; + return { + verify: overrides.verify ?? vi.fn(async () => buildClaims()), + prmResponse: vi.fn(() => prm), + prmDocumentUrl: vi.fn(() => url), + } as unknown as AuthplaneResource; +} + +function mockClient(resource: AuthplaneResource): AuthplaneClient { + return { + resource: vi.fn(() => resource), + } as unknown as AuthplaneClient; +} + +describe("authplaneHonoAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the core verifier, bearerAuth, PRM wiring, and the underlying client", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + + const result = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + requiredScopes: ["tools/add"], + }); + + expect(result.client).toBe(client); + expect(result.verifier).toBe(resource); + expect(typeof result.bearerAuth).toBe("function"); + expect(result.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + expect(result.protectedResourceMetadata).toEqual({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add"], + bearer_methods_supported: ["header"], + }); + expect(typeof result.protectedResourceMetadataHandler).toBe("function"); + }); + + it("forwards issuer + asCredentials (via `auth`) to AuthplaneClient.create", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client); + + await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + asCredentials: { clientId: "id", clientSecret: "s3cret" }, + }); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + issuer: "https://auth.example.com", + auth: { clientId: "id", clientSecret: "s3cret" }, + }), + ); + }); + + it("forwards resource config (scopes, revocationChecker) to client.resource()", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + + await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + revocationChecker: { clientId: "my-rs", clientSecret: "s" }, + }); + + expect(client.resource).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + revocationChecker: { clientId: "my-rs", clientSecret: "s" }, + }), + ); + }); + + it("defaults requiredScopes to scopes when not provided", async () => { + const resource = mockResource({ + verify: vi.fn(async () => buildClaims({ scopes: ["tools/other"] })), + }); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + + const { bearerAuth } = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + }); + + const { Hono } = await import("hono"); + const app = new Hono(); + app.use("/mcp", bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(403); + expect(response.headers.get("WWW-Authenticate")).toContain( + 'scope="tools/add"', + ); + }); + + it("folds the convenience `replayStore` into `inboundDPoP` and threads DPoP through verify", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + const verifyMock = resource.verify as ReturnType; + + const replayStore = { + checkAndStore: vi.fn(async () => true), + } as unknown as DPoPReplayStore; + + const { bearerAuth } = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + replayStore, + }); + + expect(client.resource).toHaveBeenCalledWith( + expect.objectContaining({ + inboundDPoP: { replayStore }, + }), + ); + + const { Hono } = await import("hono"); + const app = new Hono(); + app.use("/mcp", bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + await app.request("http://api.example.com/mcp", { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "eyJ.proof.value", + }, + }); + + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { + dpopRequest: expect.objectContaining({ + method: "POST", + url: "https://api.example.com/mcp", + proofs: ["eyJ.proof.value"], + }), + }); + }); + + it("forwards every optional passthrough field to client + resource factories", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client); + + const fetchSettings = {} as never; + const revocationChecker = vi.fn(async () => false); + const asCredentials = { clientId: "id", clientSecret: "s" }; + const dpopProvider = { sign: vi.fn() } as never; + + await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + asCredentials, + devMode: true, + fetchSettings, + jwksRefreshSeconds: 60, + metadataRefreshSeconds: 120, + revocationChecker, + allowedAlgorithms: ["ES256"], + clockSkewSeconds: 10, + dpopProvider, + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 25_000, + circuitBreakerThreshold: 7, + circuitBreakerCooldownSeconds: 60, + }); + + expect(createSpy).toHaveBeenCalledWith({ + issuer: "https://auth.example.com", + auth: asCredentials, + devMode: true, + fetchSettings, + jwksRefreshSeconds: 60, + metadataRefreshSeconds: 120, + dpopProvider, + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 25_000, + circuitBreakerThreshold: 7, + circuitBreakerCooldownSeconds: 60, + }); + + expect(client.resource).toHaveBeenCalledWith({ + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + revocationChecker, + allowedAlgorithms: ["ES256"], + clockSkewSeconds: 10, + devMode: true, + asCredentials, + }); + }); + + it("forwards failClosed to client.resource()", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + + await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + failClosed: true, + }); + + expect(client.resource).toHaveBeenCalledWith( + expect.objectContaining({ failClosed: true }), + ); + }); + + it("rejects setting both `replayStore` and `inboundDPoP.replayStore`", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + + const replayStore = { + checkAndStore: vi.fn(async () => true), + } as unknown as DPoPReplayStore; + + await expect( + authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + replayStore, + inboundDPoP: { replayStore }, + }), + ).rejects.toThrow(/replayStore.*not both/u); + }); + + it("emits the PRM URL as resource_metadata on WWW-Authenticate challenges", async () => { + const resource = mockResource({ + prmDocumentUrl: + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + }); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + + const { bearerAuth } = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }); + + const { Hono } = await import("hono"); + const app = new Hono(); + app.use("/mcp", bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + const response = await app.request("/mcp", { method: "POST" }); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', + ); + }); +}); diff --git a/packages/hono/tests/bearerAuth.test.ts b/packages/hono/tests/bearerAuth.test.ts new file mode 100644 index 0000000..1f537c7 --- /dev/null +++ b/packages/hono/tests/bearerAuth.test.ts @@ -0,0 +1,473 @@ +import { + type AuthplaneResource, + InvalidClaims, + InvalidSignature, + JWKSFetchError, + MetadataFetchError, + TokenExpired, + VerifiedClaims, +} from "@authplane/sdk/core"; +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { bearerAuth } from "../src/bearerAuth.js"; +import type { HonoAuthVariables } from "../src/types.js"; + +function futureSeconds(offset = 3_600): number { + return Math.floor(Date.now() / 1000) + offset; +} + +function buildClaims( + overrides: Partial[0]> = {}, +) { + return new VerifiedClaims({ + sub: "user_123", + clientId: "client_456", + scopes: ["tools/add", "tools/echo"], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: futureSeconds(), + issuedAt: Math.floor(Date.now() / 1000), + jti: "token_123", + kid: "key_1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: { sub: "user_123" }, + ...overrides, + }); +} + +describe("bearerAuth — happy path", () => { + let verifyMock: ReturnType; + let verifier: AuthplaneResource; + + beforeEach(() => { + verifyMock = vi.fn(async () => buildClaims()); + verifier = { verify: verifyMock } as unknown as AuthplaneResource; + }); + + it("calls verifier.verify with the extracted Bearer token and continues", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + // No DPoP header → bearerAuth calls verify(token) without the second arg. + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { dpopRequest: undefined }); + }); + + it("stores the verified claims in the Hono context under 'auth'", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => { + const auth = c.get("auth"); + return c.json({ + sub: auth.sub, + clientId: auth.clientId, + scopes: auth.scopes, + }); + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + sub: "user_123", + clientId: "client_456", + scopes: ["tools/add", "tools/echo"], + }); + }); + + it("accepts lowercase `bearer` scheme (RFC 6750 §2.1)", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "bearer valid_jwt" }, + }); + + expect(response.status).toBe(200); + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { dpopRequest: undefined }); + }); +}); + +describe("bearerAuth — error paths", () => { + function buildApp( + overrides: { + verify?: ReturnType; + requiredScopes?: readonly string[]; + resourceMetadataUrl?: string; + } = {}, + ) { + const verifyMock = overrides.verify ?? vi.fn(async () => buildClaims()); + const verifier = { verify: verifyMock } as unknown as AuthplaneResource; + + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ + verifier, + resourceOrigin: "http://api.example.com", + ...(overrides.requiredScopes !== undefined + ? { requiredScopes: overrides.requiredScopes } + : {}), + ...(overrides.resourceMetadataUrl !== undefined + ? { resourceMetadataUrl: overrides.resourceMetadataUrl } + : {}), + }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + return { app, verifyMock }; + } + + it("returns 401 when the Authorization header is missing (core TokenMissing)", async () => { + const { app } = buildApp(); + + const response = await app.request("/mcp", { method: "POST" }); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer error="invalid_token", error_description="Missing Authorization header"', + ); + await expect(response.json()).resolves.toEqual({ + error: "invalid_token", + error_description: "Missing Authorization header", + }); + }); + + it("returns 401 when the scheme is not Bearer/DPoP (core TokenMissing)", async () => { + const { app } = buildApp(); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Basic dXNlcjpwYXNz" }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer error="invalid_token", error_description="Invalid Authorization header format, expected \'Bearer TOKEN\' or \'DPoP TOKEN\'"', + ); + }); + + it("returns 401 when verifier rejects the token (core InvalidSignature)", async () => { + const { app } = buildApp({ + verify: vi.fn(async () => { + throw new InvalidSignature("bad signature"); + }), + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer bad_jwt" }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer error="invalid_token", error_description="bad signature"', + ); + }); + + it("returns 401 when core rejects the token as expired", async () => { + const { app } = buildApp({ + verify: vi.fn(async () => { + throw new TokenExpired("Token has expired"); + }), + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: "invalid_token", + error_description: "Token has expired", + }); + }); + + it("returns 401 when core rejects the token for malformed claims", async () => { + const { app } = buildApp({ + verify: vi.fn(async () => { + throw new InvalidClaims("Token has no expiration time"); + }), + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: "invalid_token", + error_description: "Token has no expiration time", + }); + }); + + it("returns 503 when core fails to fetch JWKS (upstream AS unreachable)", async () => { + const { app } = buildApp({ + verify: vi.fn(async () => { + throw new JWKSFetchError("JWKS endpoint unreachable"); + }), + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "invalid_token", + error_description: "JWKS endpoint unreachable", + }); + }); + + it("returns 503 when core fails to fetch AS metadata", async () => { + const { app } = buildApp({ + verify: vi.fn(async () => { + throw new MetadataFetchError("AS metadata endpoint unreachable"); + }), + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "invalid_token", + error_description: "AS metadata endpoint unreachable", + }); + }); + + it("returns 403 with scope challenge when a required scope is missing", async () => { + const { app } = buildApp({ + requiredScopes: ["tools/add", "tools/delete"], + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(403); + // bearerAuth delegates to core claims.requireScopes, whose message names + // the missing scope (`tools/delete`) and the scopes the token does + // carry — verbatim into both the JSON body and the WWW-Authenticate + // challenge's `error_description=`. + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer error="insufficient_scope", error_description="Token missing required scope 'tools/delete'. Token has scopes: tools/add, tools/echo", scope="tools/add tools/delete"`, + ); + await expect(response.json()).resolves.toEqual({ + error: "insufficient_scope", + error_description: + "Token missing required scope 'tools/delete'. Token has scopes: tools/add, tools/echo", + }); + }); + + it("lets the request through when all required scopes are present", async () => { + const { app } = buildApp({ + requiredScopes: ["tools/add"], + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(200); + }); + + it("appends resource_metadata to the challenge when configured", async () => { + const { app } = buildApp({ + resourceMetadataUrl: + "https://api.example.com/.well-known/oauth-protected-resource", + }); + + const response = await app.request("/mcp", { method: "POST" }); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', + ); + }); + + it("returns 500 without a challenge when a non-Authplane error escapes", async () => { + const { app } = buildApp({ + verify: vi.fn(async () => { + throw new TypeError("boom"); + }), + }); + + const response = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(response.status).toBe(500); + expect(response.headers.get("WWW-Authenticate")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: "server_error", + error_description: "boom", + }); + }); +}); + +describe("bearerAuth — DPoP binding", () => { + let verifyMock: ReturnType; + let verifier: AuthplaneResource; + + beforeEach(() => { + verifyMock = vi.fn(async () => buildClaims()); + verifier = { verify: verifyMock } as unknown as AuthplaneResource; + }); + + it("threads the DPoP proof, method, and URL into the verifier call", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + const response = await app.request("http://api.example.com/mcp", { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "eyJ.proof.value", + }, + }); + + expect(response.status).toBe(200); + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { + dpopRequest: expect.objectContaining({ + method: "POST", + url: "http://api.example.com/mcp", + proofs: ["eyJ.proof.value"], + }), + }); + }); + + it("pins the DPoP htu to the configured resourceOrigin — ignores X-Forwarded-*", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ + verifier, + resourceOrigin: "https://api.example.com", + }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + await app.request("http://internal.local/mcp?id=42", { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "eyJ.proof.value", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "evil.example.com", + }, + }); + + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { + dpopRequest: expect.objectContaining({ + method: "POST", + url: "https://api.example.com/mcp?id=42", + proofs: ["eyJ.proof.value"], + }), + }); + }); + + it("calls verify without DPoP context when no DPoP header is present", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + await app.request("http://api.example.com/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { dpopRequest: undefined }); + }); + + it("calls verify without DPoP context when the DPoP header is empty", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + await app.request("http://api.example.com/mcp", { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "", + }, + }); + + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { dpopRequest: undefined }); + }); + + it("rejects requests carrying two DPoP headers with a DPoP-scheme challenge (RFC 9449 §4.3)", async () => { + // `Headers.append` collapses duplicate same-name entries into one + // comma-separated value when accessed via `get()` / iteration. The + // SDK's `buildDPoPRequestContext` re-splits on `,` so the §4.3 #1 + // violation surfaces as `MultipleDPoPProofs`, which the + // middleware must translate into a 401 with a `DPoP`-scheme + // challenge — never `Bearer`. The verifier must not even be + // invoked: we shouldn't waste a JWKS lookup on a request the + // boundary already rejected. + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: "http://api.example.com" }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + const multiHeader = new Headers({ Authorization: "Bearer valid_jwt" }); + multiHeader.append("DPoP", "eyJ.first"); + multiHeader.append("DPoP", "eyJ.second"); + + const response = await app.request("http://api.example.com/mcp", { + method: "POST", + headers: multiHeader, + }); + + expect(response.status).toBe(401); + const challenge = response.headers.get("WWW-Authenticate"); + expect(challenge).toMatch(/^DPoP /); + // RFC 9449 §7.1: §4.3 rejections carry invalid_dpop_proof, not the + // SDK's historical invalid_token used by the other DPoPError shapes. + expect(challenge).toContain('error="invalid_dpop_proof"'); + expect(verifyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hono/tests/index.test.ts b/packages/hono/tests/index.test.ts new file mode 100644 index 0000000..3e21e3d --- /dev/null +++ b/packages/hono/tests/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import * as pkg from "../src/index.js"; + +/** + * Snapshot of the `@authplane/hono` public surface. + * + * The runtime barrel exports are recorded here as a single sorted list so any + * addition, removal, or rename is immediately visible as a diff on this test. + * Types are checked structurally by `tsc` elsewhere. + */ +describe("@authplane/hono public surface", () => { + it("exposes the documented value exports", () => { + const names = Object.keys(pkg).sort(); + expect(names).toEqual([ + "REQUIRED_SCOPE_CONTEXT_KEY", + "authplaneHonoAuth", + "bearerAuth", + "protectedResourceMetadataHandler", + "requireScope", + ]); + }); +}); diff --git a/packages/hono/tests/prmHandler.test.ts b/packages/hono/tests/prmHandler.test.ts new file mode 100644 index 0000000..15a574d --- /dev/null +++ b/packages/hono/tests/prmHandler.test.ts @@ -0,0 +1,72 @@ +import type { ProtectedResourceMetadata } from "@authplane/sdk/core"; +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import { protectedResourceMetadataHandler } from "../src/prmHandler.js"; + +function buildMetadata( + overrides: Partial = {}, +): ProtectedResourceMetadata { + return { + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add", "tools/echo"], + bearer_methods_supported: ["header"], + dpop_signing_alg_values_supported: ["RS256"], + dpop_bound_access_tokens_required: false, + ...overrides, + }; +} + +describe("protectedResourceMetadataHandler", () => { + it("serves the RFC 9728 document as JSON at the mounted path", async () => { + const metadata = buildMetadata(); + const app = new Hono(); + app.get( + "/.well-known/oauth-protected-resource", + protectedResourceMetadataHandler(metadata), + ); + + const response = await app.request("/.well-known/oauth-protected-resource"); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toMatch(/application\/json/); + await expect(response.json()).resolves.toEqual(metadata); + }); + + it("does not require authentication — the handler is publicly reachable", async () => { + // PRM is part of OAuth discovery; requiring bearer auth to fetch it + // would create a bootstrapping loop. The handler itself has no auth + // logic, so a request with no Authorization header still succeeds. + const metadata = buildMetadata(); + const app = new Hono(); + app.get( + "/.well-known/oauth-protected-resource", + protectedResourceMetadataHandler(metadata), + ); + + const response = await app.request("/.well-known/oauth-protected-resource"); + + expect(response.status).toBe(200); + }); + + it("serves the exact metadata object provided at construction", async () => { + const metadata = buildMetadata({ + resource: "https://api.other.example.com/v1", + authorization_servers: [ + "https://auth.other.example.com", + "https://auth.backup.example.com", + ], + dpop_bound_access_tokens_required: true, + }); + const app = new Hono(); + app.get( + "/.well-known/oauth-protected-resource", + protectedResourceMetadataHandler(metadata), + ); + + const response = await app.request("/.well-known/oauth-protected-resource"); + + await expect(response.json()).resolves.toEqual(metadata); + }); +}); diff --git a/packages/hono/tests/requireScope.test.ts b/packages/hono/tests/requireScope.test.ts new file mode 100644 index 0000000..bada61e --- /dev/null +++ b/packages/hono/tests/requireScope.test.ts @@ -0,0 +1,91 @@ +import { InsufficientScope, VerifiedClaims } from "@authplane/sdk/core"; +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import { requireScope } from "../src/requireScope.js"; +import type { HonoAuthVariables } from "../src/types.js"; + +function buildClaims(scopes: readonly string[]): VerifiedClaims { + return new VerifiedClaims({ + sub: "user_123", + clientId: "client_456", + scopes: [...scopes], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: Math.floor(Date.now() / 1000) + 3_600, + issuedAt: Math.floor(Date.now() / 1000), + jti: "token_123", + kid: "key_1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + }); +} + +describe("requireScope", () => { + it("is a no-op when the requested scope is present on the verified token", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use("*", async (c, next) => { + c.set("auth", buildClaims(["tools/add"])); + await next(); + }); + let reached = false; + app.get("/", (c) => { + requireScope(c, "tools/add"); + reached = true; + return c.json({ ok: true }); + }); + + const res = await app.request("/"); + expect(reached).toBe(true); + expect(res.status).toBe(200); + }); + + it("throws core InsufficientScope (delegated to claims.requireScope) when the scope is missing", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use("*", async (c, next) => { + c.set("auth", buildClaims(["tools/echo"])); + await next(); + }); + app.get("/", (c) => { + requireScope(c, "tools/add"); + return c.json({ ok: true }); + }); + + let caught: unknown; + app.onError((err, c) => { + caught = err; + return c.json({ captured: true }, 500); + }); + + await app.request("/"); + expect(caught).toBeInstanceOf(InsufficientScope); + // Message comes from the canonical core helper so every adapter emits + // the same wording — caller can see the missing scope AND the scopes + // the token does carry. + expect((caught as Error).message).toBe( + "Token missing required scope 'tools/add'. Token has scopes: tools/echo", + ); + }); + + it("fails closed when bearerAuth never ran (no auth on context)", async () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.get("/", (c) => { + requireScope(c, "tools/add"); + return c.json({ ok: true }); + }); + + let caught: unknown; + app.onError((err, c) => { + caught = err; + return c.json({ captured: true }, 500); + }); + + await app.request("/"); + expect(caught).toBeInstanceOf(InsufficientScope); + expect((caught as Error).message).toBe( + "Missing required scope: tools/add", + ); + }); +}); diff --git a/packages/hono/tests/types.test.ts b/packages/hono/tests/types.test.ts new file mode 100644 index 0000000..d1d08e9 --- /dev/null +++ b/packages/hono/tests/types.test.ts @@ -0,0 +1,46 @@ +import { VerifiedClaims } from "@authplane/sdk/core"; +import { Hono } from "hono"; +import { describe, expectTypeOf, it } from "vitest"; + +import type { HonoAuthVariables } from "../src/index.js"; + +const fixture = new VerifiedClaims({ + sub: "user-1", + clientId: "client-1", + scopes: ["tools/add"], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: 1_750_000_000, + issuedAt: 1_749_999_000, + jti: "jti-1", + kid: "kid-1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: { sub: "user-1" }, +}); + +describe("HonoAuthVariables", () => { + it("shapes c.get(\"auth\") / c.set(\"auth\", …)", () => { + const app = new Hono<{ Variables: HonoAuthVariables }>(); + + app.get("/typed", (c) => { + const auth = c.get("auth"); + expectTypeOf(auth).toEqualTypeOf(); + return c.json({ sub: auth.sub }); + }); + + app.use("*", (c, next) => { + c.set("auth", fixture); + // @ts-expect-error — string is not assignable to VerifiedClaims + c.set("auth", "nope"); + return next(); + }); + }); + + it("is structurally equivalent to { auth: VerifiedClaims }", () => { + expectTypeOf().toEqualTypeOf<{ + auth: VerifiedClaims; + }>(); + }); +}); diff --git a/packages/hono/tsconfig.json b/packages/hono/tsconfig.json new file mode 100644 index 0000000..60e4a4d --- /dev/null +++ b/packages/hono/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "tests", "examples", "demo"], + "references": [{ "path": "../sdk" }] +} diff --git a/packages/hono/vitest.config.ts b/packages/hono/vitest.config.ts new file mode 100644 index 0000000..4572cc0 --- /dev/null +++ b/packages/hono/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; +import { logicalCoverageDefaults } from "../../vitest.coverage.shared"; + +/** + * Tighter coverage thresholds for `@authplane/hono`. The adapter is small, + * fully-unit-tested, and exercised end-to-end by the integration tests in + * `tests/auth.integration.test.ts`, so the package actually hits ~100% lines + * / statements / functions and ~95%+ branches. Raising the floor above the + * shared defaults (80 / 80 / 80 / 70) gates regressions immediately rather + * than allowing a slow drift back to the repo-wide minimum. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + coverage: { + ...logicalCoverageDefaults, + thresholds: { + lines: 95, + statements: 95, + functions: 95, + branches: 90, + }, + }, + }, +}); diff --git a/packages/mcp/demo/README.md b/packages/mcp/demo/README.md index cdbfbc9..ed1a2ec 100644 --- a/packages/mcp/demo/README.md +++ b/packages/mcp/demo/README.md @@ -50,7 +50,7 @@ Tokens must carry the scope for the specific tool being called. A token with onl node demo/run-mcp-client.mjs ``` - Both fetch an access token from authserver and run `mcp-remote` with Bearer auth. For Cursor/Claude, use the **Node** runner in MCP config (`command: "node"`, `args: ["/path/to/demo/run-mcp-client.mjs"]`) if the host reports "Operation not permitted" when running the `.sh` script. See the [token exchange testing guide](https://github.com/AuthPlane/docs/blob/main/token-exchange-testing-guide.md) (Opción B). + Both fetch an access token from authserver and run `mcp-remote` with Bearer auth. For Cursor/Claude, use the **Node** runner in MCP config (`command: "node"`, `args: ["/path/to/demo/run-mcp-client.mjs"]`) if the host reports "Operation not permitted" when running the `.sh` script. ## How it works diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 42907fb..42717f8 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -3,13 +3,13 @@ import { AuthplaneError, type AuthplaneResource, type AuthplaneResourceOptions, + buildDPoPRequestContext, type DPoPProvider, + extractBearerToken, + extractDpopHeaderValues, type FetchSettings, InsufficientScope, - InvalidClaims, type ProtectedResourceMetadata, - TokenExpired, - TokenMissing, httpStatus, wwwAuthenticate, } from "@authplane/sdk/core"; @@ -68,6 +68,14 @@ export interface AuthplaneMcpAuthOptions * include expiry metadata (seconds). Default `3600`. */ defaultTtlSeconds?: number; + /** + * Maximum number of entries kept in the outbound token cache before + * least-recently-used eviction kicks in. Default `10_000`. Override on + * hosts with very high subject-token cardinality — token-exchange cache + * keys include the subject token, so this is the bound that actually + * limits memory growth. + */ + cacheMaxEntries?: number; /** * Number of consecutive transient AS failures before the circuit breaker * opens. Default `5`. @@ -129,6 +137,7 @@ export async function authplaneMcpAuth( metadataRefreshSeconds: verifierOptions.metadataRefreshSeconds, cacheTtlBufferSeconds: options.cacheTtlBufferSeconds, defaultTtlSeconds: options.defaultTtlSeconds, + cacheMaxEntries: options.cacheMaxEntries, circuitBreakerThreshold: options.circuitBreakerThreshold, circuitBreakerCooldownSeconds: options.circuitBreakerCooldownSeconds, dpopProvider: options.dpopProvider, @@ -180,30 +189,44 @@ export async function authplaneMcpAuth( const bearerAuth: RequestHandler = async (req, res, next) => { const effectiveRequiredScopes = resolvedRequiredScopes; try { - // Express middleware: we parse Authorization, DPoP, and build an absolute URL for - // DPoP `htu` verification. The MCP SDK's OAuthTokenVerifier API takes a raw token - // string; there is no upstream hook that supplies a pre-parsed token while also - // threading per-request DPoP binding — so extraction stays here. - const authHeader = req.headers.authorization; - if (!authHeader || Array.isArray(authHeader)) { - throw new TokenMissing("Missing Authorization header"); - } - - const [rawType, token] = authHeader.split(" "); - const type = rawType?.toLowerCase(); - if (!token || type !== "bearer") { - throw new TokenMissing( - "Invalid Authorization header format, expected 'Bearer TOKEN'", - ); - } + // Express middleware: parse Authorization, DPoP, and build an absolute + // URL for DPoP `htu` verification. The MCP SDK's OAuthTokenVerifier + // API takes a raw token string; there is no upstream hook that + // supplies a pre-parsed token while also threading per-request DPoP + // binding — so extraction stays here. Routes through the core + // `extractBearerToken` helper so the strictness (and the + // `DPoP` scheme carve-out per RFC 9449 §7.1) match + // `@authplane/fastmcp`, `@authplane/hono`, and + // `@authplane/nestjs` byte-for-byte. `Authorization` is in + // Node's fixed de-dup-to-last-value allowlist (alongside + // `host`, `content-type`, etc.), so `req.headers.authorization` + // is `string | undefined` on real wire traffic — never an + // array. The `Array.isArray` guard is defense against the + // `string[]` shape that only arrives from `req.rawHeaders` or + // hand-built fixtures; we collapse it to `undefined` so the + // core helper raises `TokenMissing` instead of accepting a + // hand-crafted multi-value bag. + const rawAuth = req.headers.authorization; + const token = extractBearerToken( + Array.isArray(rawAuth) ? undefined : rawAuth, + ); - // Extract DPoP proof header (FastMCP/SDK uses `dpop` / `DPoP` in practice). - // Note: DPoP proof verification requires htu/method matching the incoming HTTP request. - let dpopProof: string | undefined; - for (const [key, value] of Object.entries(req.headers)) { - if (key.toLowerCase() !== "dpop") continue; - if (typeof value === "string" && value.length > 0) dpopProof = value; - } + // Node lowercases header names and types the value as + // `string | string[] | undefined`. In practice + // `http.IncomingMessage.headers` comma-folds duplicate same-name + // values for everything except a fixed allow-list (only + // `set-cookie` arrays naturally; `authorization`, `host`, etc. + // dedupe to the last value), so two `DPoP` headers on the wire + // arrive here as the single string `"proofA, proofB"`. The array + // branch still happens for callers that hand-craft `req.headers` + // or for adapters that surface `req.rawHeaders` directly, so + // `extractDpopHeaderValues` accepts both shapes. The core + // factory's comma-split is what catches the folded form — JWS + // compact serialisation is base64url + `.` and never contains a + // literal `,`, so any comma is necessarily a merged duplicate + // and `MultipleDPoPProofs` fires. The catch below funnels that + // through `wwwAuthenticate()` to emit the `DPoP` challenge. + const dpopHeaderValues = extractDpopHeaderValues(req.headers.dpop); // Origin from configured `resource` (not request headers); only the // path varies per-request. `Host` and `X-Forwarded-Proto` are @@ -212,12 +235,12 @@ export async function authplaneMcpAuth( const url = `${resourceOrigin}${pathAndQuery}`; const dpopRequest = - dpopProof !== undefined - ? { + dpopHeaderValues.length > 0 + ? buildDPoPRequestContext({ method: req.method ?? "POST", url, - proof: dpopProof, - } + dpopHeaderValues, + }) : undefined; const authInfo: AuthInfo = await tokenVerifier.verifyAccessTokenWithDpop( token, @@ -233,15 +256,14 @@ export async function authplaneMcpAuth( } } - if ( - typeof authInfo.expiresAt !== "number" || - Number.isNaN(authInfo.expiresAt) - ) { - throw new InvalidClaims("Token has no expiration time"); - } else if (authInfo.expiresAt < Date.now() / 1000) { - throw new TokenExpired("Token has expired"); - } - + // No expiry re-check here: core `resource.verify()` already enforces + // `exp` with the configured `clockSkewSeconds` tolerance (and + // throws `TokenExpired`). The previous middleware-level + // `authInfo.expiresAt < Date.now() / 1000` re-check did the same + // thing *without* the clock-skew tolerance, so it could reject + // tokens core deemed valid — `@authplane/hono` and + // `@authplane/nestjs` deleted the same pattern when their + // adapters were thinned out onto the same core helpers. (req as typeof req & { auth?: AuthInfo }).auth = authInfo; next(); } catch (error) { diff --git a/packages/mcp/src/verifier.ts b/packages/mcp/src/verifier.ts index a15f0ef..19ca546 100644 --- a/packages/mcp/src/verifier.ts +++ b/packages/mcp/src/verifier.ts @@ -32,12 +32,25 @@ export class AuthplaneTokenVerifier implements OAuthTokenVerifier { if (!audience) { throw new InvalidClaims("Token audience is missing"); } + + // RFC 8707 resource indicators are URIs in practice but the OAuth + // `aud` claim itself is just a string — a malformed value is a + // token-validity problem (401), not an adapter bug (500). Wrap the + // parse so `new URL()`'s TypeError can't escape as a 500. + // Mirrors the `@authplane/hono` guard at packages/hono/src/verifier.ts. + let resource: URL; + try { + resource = new URL(audience); + } catch { + throw new InvalidClaims("Token audience is not a valid URL"); + } + return { token, clientId: claims.clientId, scopes: [...claims.scopes], expiresAt: claims.expiresAt, - resource: new URL(audience), + resource, extra: { sub: claims.sub, iss: claims.issuer, diff --git a/packages/mcp/tests/auth.middleware.test.ts b/packages/mcp/tests/auth.middleware.test.ts index e086af9..3871fad 100644 --- a/packages/mcp/tests/auth.middleware.test.ts +++ b/packages/mcp/tests/auth.middleware.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { AuthplaneClient, type AuthplaneResource, + TokenExpired, } from "@authplane/sdk/core"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { AuthplaneTokenVerifier } from "../src/verifier.js"; @@ -157,6 +158,40 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { expect(next).not.toHaveBeenCalled(); }); + it("accepts a DPoP-scheme Authorization header (RFC 9449 §7.1 — parity with hono/nestjs/fastmcp)", async () => { + // Before this PR the middleware split the Authorization header + // inline and required `scheme === "bearer"`, so a client that + // (correctly per RFC 9449 §7.1) presented its DPoP-bound token + // under the `DPoP` scheme got a 401 here while + // `@authplane/hono` / `@authplane/nestjs` / `@authplane/fastmcp` + // accepted it. Routing through core's `extractBearerToken` — + // which accepts both `Bearer` and `DPoP` — closes that latent + // interop divergence. + const auth = await buildAuth(); + vi.spyOn( + AuthplaneTokenVerifier.prototype, + "verifyAccessTokenWithDpop", + ).mockResolvedValue({ + token: "jwt", + clientId: "client_1", + scopes: ["tools/add", "tools/multiply"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }); + + const req: MockReq = { + headers: { authorization: "DPoP token-1" }, + method: "POST", + originalUrl: "/mcp", + }; + const res = createRes(); + const next = vi.fn(); + + await auth.bearerAuth(req as never, res as never, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBe(200); + }); + it("passes request, attaches auth info, and forwards DPoP context built from the configured resource origin", async () => { const auth = await buildAuth(); const verifySpy = vi @@ -190,11 +225,14 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { clientId: "client_1", }), ); - expect(verifySpy).toHaveBeenCalledWith("token-1", { - method: "POST", - url: "https://api.example.com/mcp?x=1", - proof: "proof-abc", - }); + expect(verifySpy).toHaveBeenCalledWith( + "token-1", + expect.objectContaining({ + method: "POST", + url: "https://api.example.com/mcp?x=1", + proofs: ["proof-abc"], + }), + ); }); it("returns 401 with 'Bearer TOKEN' message on unknown scheme", async () => { @@ -224,17 +262,21 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { expect(res.statusCode).toBe(401); }); - it("returns 401 when verified token has no expiration", async () => { + it("returns 401 when the core verifier surfaces TokenExpired", async () => { + // Expiry enforcement lives in core `AuthplaneResource.verify()` — it + // honors the configured `clockSkewSeconds` tolerance and throws + // `TokenExpired` for stale tokens. The middleware used to re-check + // `authInfo.expiresAt < Date.now()/1000` *without* skew tolerance, + // which could reject tokens core deemed valid. This test + // pins the new behavior: the middleware passes the error through + // the standard catch → `wwwAuthenticate` + `httpStatus(401)` path + // and emits an RFC 6750 challenge instead of doing its own + // duplicate timing check. const auth = await buildAuth(); vi.spyOn( AuthplaneTokenVerifier.prototype, "verifyAccessTokenWithDpop", - ).mockResolvedValue({ - token: "jwt", - clientId: "client_1", - scopes: ["tools/add", "tools/multiply"], - expiresAt: undefined as unknown as number, - }); + ).mockRejectedValue(new TokenExpired("Token has expired")); const req: MockReq = { headers: { authorization: "Bearer token-1" }, @@ -248,27 +290,36 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { await auth.bearerAuth(req as never, res as never, next); expect(res.statusCode).toBe(401); + const challenge = res.headers["WWW-Authenticate"] ?? ""; + expect(challenge).toMatch(/^Bearer /); + expect(challenge).toContain('error="invalid_token"'); expect((res.body as { error_description: string }).error_description).toMatch( - /no expiration/, + /expired/, ); }); - it("returns 401 when verified token is expired", async () => { + it("rejects requests carrying two DPoP headers delivered as a string[] (raw-headers shape)", async () => { + // The `string[]` shape lands in `req.headers` when callers reach for + // `req.rawHeaders` or hand-build the bag — it's NOT what Node's + // default `http.IncomingMessage.headers` produces (see the + // comma-folded test below). `extractDpopHeaderValues` still has to + // handle both because adapters in the wild surface either; the core + // factory raises `MultipleDPoPProofs` for §4.3 violations. The + // middleware must surface 401 with a `DPoP`-scheme challenge and + // never invoke the token verifier — no JWKS lookup should occur for + // a request the boundary already rejected. const auth = await buildAuth(); - vi.spyOn( + const verifySpy = vi.spyOn( AuthplaneTokenVerifier.prototype, "verifyAccessTokenWithDpop", - ).mockResolvedValue({ - token: "jwt", - clientId: "client_1", - scopes: ["tools/add", "tools/multiply"], - expiresAt: 1, // far in the past - }); + ); const req: MockReq = { - headers: { authorization: "Bearer token-1" }, + headers: { + authorization: "Bearer token-1", + dpop: ["eyJ.first", "eyJ.second"], + }, method: "POST", - protocol: "https", originalUrl: "/mcp", }; const res = createRes(); @@ -277,9 +328,50 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { await auth.bearerAuth(req as never, res as never, next); expect(res.statusCode).toBe(401); - expect((res.body as { error_description: string }).error_description).toMatch( - /expired/, + const challenge = res.headers["WWW-Authenticate"] ?? ""; + expect(challenge).toMatch(/^DPoP /); + // RFC 9449 §7.1: §4.3 rejections carry invalid_dpop_proof, not the + // SDK's historical invalid_token used by the other DPoPError shapes. + expect(challenge).toContain('error="invalid_dpop_proof"'); + expect(verifySpy).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it("rejects requests carrying two DPoP headers delivered as a comma-folded string (real Node shape)", async () => { + // `http.IncomingMessage.headers` only arrays a fixed allow-list + // (`set-cookie`, etc.); everything else is comma-folded — so two + // `DPoP` headers on the wire actually arrive in `req.headers.dpop` + // as the single string `"proofA, proofB"`. This is the path real + // Node traffic hits; the array case above exercises a hand-built + // / raw-headers fixture. The core factory's comma-split catches + // the folded shape because JWS compact serialisation is + // base64url + `.` and never contains a literal `,`, so any comma + // must be a merged duplicate. + const auth = await buildAuth(); + const verifySpy = vi.spyOn( + AuthplaneTokenVerifier.prototype, + "verifyAccessTokenWithDpop", ); + + const req: MockReq = { + headers: { + authorization: "Bearer token-1", + dpop: "eyJ.first, eyJ.second", + }, + method: "POST", + originalUrl: "/mcp", + }; + const res = createRes(); + const next = vi.fn(); + + await auth.bearerAuth(req as never, res as never, next); + + expect(res.statusCode).toBe(401); + const challenge = res.headers["WWW-Authenticate"] ?? ""; + expect(challenge).toMatch(/^DPoP /); + expect(challenge).toContain('error="invalid_dpop_proof"'); + expect(verifySpy).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); }); it("ignores spoofed Host and X-Forwarded-Proto headers, uses configured resource origin", async () => { diff --git a/packages/mcp/tests/auth.test.ts b/packages/mcp/tests/auth.test.ts index c2b13b0..6b79bc2 100644 --- a/packages/mcp/tests/auth.test.ts +++ b/packages/mcp/tests/auth.test.ts @@ -149,6 +149,46 @@ describe("authplaneMcpAuth", () => { expect(result.client).toBe(mockClient); }); + it("forwards cache tunables (cacheTtlBufferSeconds, defaultTtlSeconds, cacheMaxEntries) to AuthplaneClient.create()", async () => { + const mockResource = { + verify: vi.fn(), + prmResponse: vi.fn(() => ({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: [], + bearer_methods_supported: ["header"], + })), + prmDocumentUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), + } as unknown as AuthplaneResource; + + const mockClient = { + resource: vi.fn(() => mockResource), + exchange: vi.fn(), + } as unknown as AuthplaneClient; + + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(mockClient); + + await authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 256, + }); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 256, + }), + ); + }); + it("forwards all optional verifier config to AuthplaneClient.resource()", async () => { const mockResource = { verify: vi.fn(), diff --git a/packages/mcp/tests/verifier.test.ts b/packages/mcp/tests/verifier.test.ts index cf6b75b..0eda36d 100644 --- a/packages/mcp/tests/verifier.test.ts +++ b/packages/mcp/tests/verifier.test.ts @@ -84,6 +84,35 @@ describe("AuthplaneTokenVerifier", () => { ); }); + it("throws InvalidClaims when audience is not a valid URL", async () => { + // The OAuth `aud` claim is just a string — non-URL values are spec-legal + // even though RFC 8707 resource indicators are URIs in practice. A + // malformed audience must surface as 401 (token-validity), not 500. + const claims = new VerifiedClaims({ + sub: "user_123", + clientId: "client_456", + scopes: ["tools/add"], + issuer: "https://auth.example.com", + audience: ["not a url"], + expiresAt: 1700000000, + issuedAt: 1699999000, + jti: "token_123", + kid: "key_1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: { sub: "user_123" }, + }); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const adapter = new AuthplaneTokenVerifier(verifier); + + await expect(adapter.verifyAccessToken("token")).rejects.toThrow( + new InvalidClaims("Token audience is not a valid URL"), + ); + }); + it("rethrows non-authplane verifier errors", async () => { const verifier = { verify: vi.fn(async () => { diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md new file mode 100644 index 0000000..7c04d5b --- /dev/null +++ b/packages/nestjs/README.md @@ -0,0 +1,63 @@ +# @authplane/nestjs + +[Authplane](https://github.com/AuthPlane/authserver) JWT validation adapter for the [NestJS](https://nestjs.com) framework. Bearer-token auth on your NestJS app in a single `Module` import — works on both `@nestjs/platform-express` and `@nestjs/platform-fastify`. + +## Install + +```bash +npm install @authplane/sdk @authplane/nestjs @nestjs/common @nestjs/core reflect-metadata rxjs +``` + +## Quickstart + +```ts +import "reflect-metadata"; +import { Body, Controller, Module, Post, UseGuards } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { + AuthInfo, + AuthplaneAuthGuard, + AuthplaneModule, + RequireScopes, + type VerifiedClaims, +} from "@authplane/nestjs"; + +@Controller("mcp/tools") +@UseGuards(AuthplaneAuthGuard) +class WeatherController { + @Post("get_weather") + @RequireScopes("tools/get_weather") + async getWeather( + @AuthInfo() info: VerifiedClaims, + @Body() body: { city: string }, + ) { + return { content: [{ type: "text", text: `${body.city}: sunny` }] }; + } +} + +@Module({ + imports: [ + AuthplaneModule.forRoot({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/get_weather"], + }), + ], + controllers: [WeatherController], +}) +class AppModule {} + +const app = await NestFactory.create(AppModule); +app.enableShutdownHooks(); // so AuthplaneShutdownHook runs on exit +await app.listen(3000); +``` + +`AuthplaneAuthGuard` validates the bearer token, enforces scopes, and attaches the verified claims to the request. Read them in a handler via `@AuthInfo()`. `@RequireScopes("…")` layers per-route scope checks on top. The RFC 9728 Protected Resource Metadata document is published automatically at the derived well-known path. + +## Learn more + +- **[User Guide](docs/user-guide.md)** — complete reference: module options, scope enforcement, DPoP, introspection and revocation, Express-vs-Fastify notes, error handling, runtime portability. +- **[Demo](demo/README.md)** — runnable multi-route calculator (`./demo/run.sh`). +- **[`@authplane/sdk`](../sdk)** — the underlying OAuth/JWT primitives. + +Call `app.enableShutdownHooks()` so `AuthplaneShutdownHook` can run `await client.close()` on exit — it stops internal JWKS / metadata refresh timers. diff --git a/packages/nestjs/biome.json b/packages/nestjs/biome.json new file mode 100644 index 0000000..f9701a9 --- /dev/null +++ b/packages/nestjs/biome.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "javascript": { + "parser": { + "unsafeParameterDecoratorsEnabled": true + } + }, + "linter": { + "rules": { + "complexity": { + "noStaticOnlyClass": "off" + }, + "correctness": { + "noUnusedFunctionParameters": "off" + } + } + }, + "overrides": [ + { + "includes": ["src/**/*.ts"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] +} diff --git a/packages/nestjs/demo/.env.example b/packages/nestjs/demo/.env.example new file mode 100644 index 0000000..91d7efe --- /dev/null +++ b/packages/nestjs/demo/.env.example @@ -0,0 +1,12 @@ +# Authplane NestJS Example — Environment Variables +# Copy this file to .env + +# Authorization server URL +AUTHPLANE_ISSUER=http://localhost:9000 + +# This server's resource URL (also used as client_id for introspection) +AUTHPLANE_RESOURCE=http://localhost:8080/mcp + +# Client secret for introspection (registered with the authorization server) +# Set this from your local authserver provisioning. +AUTHPLANE_CLIENT_SECRET= diff --git a/packages/nestjs/demo/README.md b/packages/nestjs/demo/README.md new file mode 100644 index 0000000..c452dd3 --- /dev/null +++ b/packages/nestjs/demo/README.md @@ -0,0 +1,99 @@ +# Calculator Service Example (NestJS) + +A minimal [NestJS](https://nestjs.com) server demonstrating Authplane JWT authentication with per-route scope enforcement. + +The server exposes three routes: + +| Route | Method | Required scope | +|-----------------------|--------|---------------------| +| `/math/add` | POST | `tools/add` | +| `/math/multiply` | POST | `tools/multiply` | +| `/me` | GET | (any valid token) | + +Tokens must carry the scope for the specific route being called. A token with only `tools/add` can call `/math/add` but not `/math/multiply`. + +## Prerequisites + +- Node.js 20+ +- The **authserver authorization server** running locally — start it with: + + ```bash + ./run-demo-server.sh + ``` + + This starts the auth server on `http://127.0.0.1:9000` by default. + +## Setup + +1. Copy the environment file: + + ```bash + cp demo/.env.example demo/.env + ``` + + `AUTHPLANE_RESOURCE` is used both as the JWT `aud` claim and as the `client_id` for token introspection. + Legacy env names (`RESOURCE_URL`, `ISSUER_URL`, `CLIENT_SECRET`) are still accepted for compatibility. + +2. Run the NestJS server: + + ```bash + cd packages/nestjs + ./demo/run.sh + ``` + + `run.sh` installs dependencies and starts the server on port `8080`. + +3. Exercise it with `curl`: + + ```bash + # Fetch a client_credentials token from your authserver (example) + TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \ + -d 'grant_type=client_credentials&scope=tools/add&resource=http://127.0.0.1:8080/mcp' \ + http://127.0.0.1:9000/token | jq -r .access_token) + + # Happy path + curl -s -X POST http://127.0.0.1:8080/math/add \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"a":2,"b":3}' + # => {"result":5} + + # Insufficient scope (token has tools/add but route needs tools/multiply) + curl -i -X POST http://127.0.0.1:8080/math/multiply \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"a":2,"b":3}' + # => HTTP/1.1 403 Forbidden + # WWW-Authenticate: Bearer error="insufficient_scope", ..., scope="tools/multiply" + ``` + +## How it works + +``` +HTTP Client ──Bearer JWT──► server.ts (port 8080) + │ + ├─ AuthplaneAuthGuard (from AuthplaneModule) + │ • Discovers JWKS from AUTHPLANE_ISSUER + │ • Validates JWT signature, aud, exp + │ • Introspects token (revocation check) + │ • Enforces module + @RequireScopes(...) + │ + └─ @AuthInfo() inside the handler + • Reads the verified claims off the request + • AuthplaneExceptionFilter bridges guard + throws to RFC 6750 401 / 403 responses +``` + +## Key patterns shown + +**`AuthplaneModule.forRoot({ ... })`** — wires up the verifier, auth guard, exception filter, shutdown hook, and Protected Resource Metadata controller in one import. The `scopes` list advertises supported scopes in the PRM document (`/.well-known/oauth-protected-resource/...`) and, by default, is also used as the `requiredScopes` list for the guard. + +**`@UseGuards(AuthplaneAuthGuard)` + `@RequireScopes("…")`** — compose the guard at the controller level and layer per-handler scope enforcement through the decorator. The guard merges module-level `requiredScopes` with every `@RequireScopes(...)` annotation `Reflector` sees for the handler. + +**`@AuthInfo()`** — parameter decorator that reads the verified `VerifiedClaims` (from `@authplane/sdk/core`) the guard stashed on the request. Equivalent to `req[AUTH_INFO_REQUEST_KEY]` but type-safe. + +**`AuthplaneExceptionFilter`** — bundled with the module; funnels every core `AuthplaneError` through `httpStatus()` + `wwwAuthenticate()`: `TokenMissing`/`TokenExpired`/`InvalidSignature` → 401 `Bearer error="invalid_token"`, `InsufficientScope` → 403 `Bearer error="insufficient_scope", scope="…"`, `JWKSFetchError`/`MetadataFetchError` → 503, DPoP failures → 401 `DPoP error="invalid_token"`. The RFC 9728 `resource_metadata=` pointer is included on every failure. Works unchanged on Express and Fastify. + +**`IntrospectionRevocation`** — enables RFC 7662 token introspection using the resource URL as `clientId` and the admin-provisioned secret. Without it, revoked tokens would remain accepted until their `exp`. Obtain it via the singleton `IntrospectionRevocation.get()`. + +**`app.enableShutdownHooks()`** — wakes up `AuthplaneShutdownHook` so `await client.close()` runs when the process receives `SIGINT` / `SIGTERM`, stopping the JWKS / metadata refresh timers. diff --git a/packages/nestjs/demo/register.mjs b/packages/nestjs/demo/register.mjs new file mode 100644 index 0000000..a1baf11 --- /dev/null +++ b/packages/nestjs/demo/register.mjs @@ -0,0 +1,4 @@ +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +register('ts-node/esm/transpile-only', pathToFileURL('./')); diff --git a/packages/nestjs/demo/run.sh b/packages/nestjs/demo/run.sh new file mode 100755 index 0000000..43013b0 --- /dev/null +++ b/packages/nestjs/demo/run.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Check .env exists +if [ ! -f "$SCRIPT_DIR/.env" ]; then + echo "ERROR: $SCRIPT_DIR/.env not found." + echo "Copy the example:" + echo " cp $SCRIPT_DIR/.env.example $SCRIPT_DIR/.env" + exit 1 +fi + +# shellcheck disable=SC1090 +source "$SCRIPT_DIR/.env" + +# Fall back to demo authserver credentials written to /tmp by the AS. +# Treat empty values as unset so an empty AUTHPLANE_CLIENT_SECRET= in .env +# does not block the fallback. +if [[ -z "${AUTHPLANE_CLIENT_ID:-}" && -z "${CLIENT_ID:-}" && -f /tmp/authserver-demo.client-id ]]; then + export AUTHPLANE_CLIENT_ID="$(cat /tmp/authserver-demo.client-id)" +fi +if [[ -z "${AUTHPLANE_CLIENT_SECRET:-}" && -z "${CLIENT_SECRET:-}" && -f /tmp/authserver-demo.key ]]; then + export AUTHPLANE_CLIENT_SECRET="$(cat /tmp/authserver-demo.key)" +fi + +ISSUER="${AUTHPLANE_ISSUER:-${ISSUER_URL:-http://localhost:9000}}" +METADATA_URL="${ISSUER%/}/.well-known/oauth-authorization-server" +if ! command -v curl >/dev/null 2>&1; then + echo "WARN: curl not found; skipping issuer preflight check ($METADATA_URL)." +else + if ! curl -fsS "$METADATA_URL" >/dev/null 2>&1; then + echo "ERROR: cannot reach authorization server metadata at:" + echo " $METADATA_URL" + echo + echo "Start your AS (authserver) first, or fix AUTHPLANE_ISSUER in demo/.env." + exit 1 + fi +fi + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +cd "$PROJECT_DIR" + +# Install dependencies if needed (npm workspaces hoist to repo root) +if [ ! -d "$REPO_ROOT/node_modules" ]; then + (cd "$REPO_ROOT" && npm ci) +fi + +# NestJS uses experimental decorators which esbuild (tsx) cannot enable per-file +# when the demo path is excluded from the package tsconfig. ts-node honors the +# nested demo/tsconfig.json and applies experimentalDecorators correctly. +# +# `--import` + a bootstrap that calls node:module's `register()` is the +# forward-compatible replacement for `--loader ts-node/esm`, which Node ≥20 +# deprecates and will eventually drop. +# +# TODO: collapse back to `tsx` (and drop ts-node from devDependencies) if/when +# tsx/esbuild grow per-file `experimentalDecorators` support — every other +# adapter demo runs on tsx and we don't want nestjs to drift indefinitely. +TS_NODE_PROJECT="$SCRIPT_DIR/tsconfig.json" \ + node --import "$SCRIPT_DIR/register.mjs" "$SCRIPT_DIR/server.ts" diff --git a/packages/nestjs/demo/server.ts b/packages/nestjs/demo/server.ts new file mode 100644 index 0000000..bf949ff --- /dev/null +++ b/packages/nestjs/demo/server.ts @@ -0,0 +1,148 @@ +/** + * Calculator Service — NestJS demo. + * + * A minimal NestJS server demonstrating `@authplane/nestjs`'s full stack: + * + * - RFC 9728 Protected Resource Metadata served at the discovered path + * - Bearer token validation (signature, issuer, audience, expiry) + * - RFC 7662 token introspection for revocation checks + * - Per-route scope enforcement via `@RequireScopes(...)` + * - RFC 6750 error responses (`WWW-Authenticate`, 401 / 403) via the bundled + * `AuthplaneExceptionFilter` + * + * Routes: + * + * | Route | Required scope | + * | -------------------- | ------------------- | + * | `POST /math/add` | `tools/add` | + * | `POST /math/multiply`| `tools/multiply` | + * | `GET /me` | (any valid token) | + * + * Run from the package root: + * + * cp demo/.env.example demo/.env + * ./demo/run.sh + */ + +import "reflect-metadata"; + +import { dirname, resolve } from "node:path"; +import { fileURLToPath, URL } from "node:url"; + +import { + Body, + Controller, + Get, + Module, + Post, + UseGuards, +} from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { + type ASCredentials, + IntrospectionRevocation, +} from "@authplane/sdk/core"; +import { config } from "dotenv"; + +import { + AuthInfo, + AuthplaneAuthGuard, + AuthplaneExceptionFilter, + AuthplaneModule, + type VerifiedClaims, + RequireScopes, +} from "../src/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: resolve(__dirname, ".env") }); + +function env(name: string, legacyName: string, fallback: string): string { + return process.env[name] ?? process.env[legacyName] ?? fallback; +} + +const resource = env( + "AUTHPLANE_RESOURCE", + "RESOURCE_URL", + "http://localhost:8080/mcp", +); +const parsedResourceUrl = new URL(resource); +const port = parsedResourceUrl.port + ? Number(parsedResourceUrl.port) + : parsedResourceUrl.protocol === "https:" + ? 443 + : 80; + +const clientSecret = + process.env.AUTHPLANE_CLIENT_SECRET ?? process.env.CLIENT_SECRET ?? ""; +const clientId = + process.env.AUTHPLANE_CLIENT_ID ?? process.env.CLIENT_ID ?? resource; + +// Guarded controller. `@UseGuards(AuthplaneAuthGuard)` validates the bearer +// token, checks revocation, verifies any DPoP proof, and stashes the core +// `VerifiedClaims` on the request; `@RequireScopes(...)` layers per-route +// scope enforcement on top. +@Controller() +@UseGuards(AuthplaneAuthGuard) +class MathController { + @Post("math/add") + @RequireScopes("tools/add") + public add(@Body() body: { a: number; b: number }): { result: number } { + return { result: body.a + body.b }; + } + + @Post("math/multiply") + @RequireScopes("tools/multiply") + public multiply(@Body() body: { a: number; b: number }): { result: number } { + return { result: body.a * body.b }; + } + + @Get("me") + public me(@AuthInfo() info: VerifiedClaims): { + sub: string; + clientId: string; + scopes: readonly string[]; + expiresAt: number; + } { + return { + sub: info.sub, + clientId: info.clientId, + scopes: info.scopes, + expiresAt: info.expiresAt, + }; + } +} + +@Module({ + imports: [ + AuthplaneModule.forRoot({ + issuer: env("AUTHPLANE_ISSUER", "ISSUER_URL", "http://localhost:9000"), + resource, + scopes: ["tools/add", "tools/multiply"], + devMode: true, + asCredentials: { clientId, clientSecret } satisfies ASCredentials, + // IntrospectionRevocation is a singleton — always obtain it via + // `IntrospectionRevocation.get()` (lesson #1 in the plan). + revocationChecker: IntrospectionRevocation.get(), + }), + ], + controllers: [MathController], +}) +class AppModule {} + +const app = await NestFactory.create(AppModule); +// Mount the RFC 6750 §3 filter globally — `AuthplaneModule` provides it but +// does not auto-register it as APP_FILTER, so the demo opts in explicitly. +// Without this, the auth failures the docstring above advertises come back +// as Nest's generic 500 instead of `WWW-Authenticate: Bearer error="…"`. +app.useGlobalFilters(app.get(AuthplaneExceptionFilter)); +// `enableShutdownHooks()` is what wakes up `AuthplaneShutdownHook` so +// `await client.close()` runs on SIGINT / SIGTERM (lesson #3). +app.enableShutdownHooks(); +await app.listen(port); + +console.log(`NestJS Calculator Service running on ${resource}`); +console.log(` PRM path: /.well-known/oauth-protected-resource${parsedResourceUrl.pathname.replace(/\/+$/u, "")}`); +console.log(" routes:"); +console.log(" POST /math/add (requires tools/add)"); +console.log(" POST /math/multiply (requires tools/multiply)"); +console.log(" GET /me (requires valid token)"); diff --git a/packages/nestjs/demo/tsconfig.json b/packages/nestjs/demo/tsconfig.json new file mode 100644 index 0000000..3fa5877 --- /dev/null +++ b/packages/nestjs/demo/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "composite": false, + "incremental": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["./**/*.ts"], + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node", + "transpileOnly": true, + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "verbatimModuleSyntax": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } + } +} diff --git a/packages/nestjs/docs/user-guide.md b/packages/nestjs/docs/user-guide.md new file mode 100644 index 0000000..7bb2617 --- /dev/null +++ b/packages/nestjs/docs/user-guide.md @@ -0,0 +1,445 @@ +# `@authplane/nestjs` — User Guide + +Complete reference for the Authplane adapter for [NestJS](https://nestjs.com). Starts with the quickstart and builds to advanced scenarios. For a short overview see the [package README](../README.md). + +## Table of contents + +- [Install](#install) +- [Quickstart](#quickstart) +- [`AuthplaneModule.forRoot` / `.forRootAsync` reference](#authplanemoduleforroot--forrootasync-reference) +- [Per-request `VerifiedClaims`](#per-request-verifiedclaims) +- [Scope enforcement](#scope-enforcement) +- [Protected Resource Metadata](#protected-resource-metadata) +- [Introspection and revocation](#introspection-and-revocation) +- [DPoP-bound tokens](#dpop-bound-tokens) +- [Custom fetch settings](#custom-fetch-settings) +- [Error handling](#error-handling) +- [Express vs. Fastify](#express-vs-fastify) +- [Local example commands](#local-example-commands) +- [Cleanup](#cleanup) + +## Install + +```bash +npm install @authplane/sdk @authplane/nestjs @nestjs/common @nestjs/core reflect-metadata rxjs +``` + +Requires Node 22 LTS or newer (matches the workspace root). `@authplane/nestjs` treats `@nestjs/common`, `@nestjs/core`, `reflect-metadata`, and `rxjs` as peer dependencies. Include `@nestjs/platform-express` or `@nestjs/platform-fastify` — the adapter supports both unchanged. + +## Quickstart + +A complete NestJS server with Authplane auth: + +```ts +import "reflect-metadata"; +import { Body, Controller, Module, Post, UseGuards } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { + AuthInfo, + AuthplaneAuthGuard, + AuthplaneModule, + RequireScopes, + type VerifiedClaims, +} from "@authplane/nestjs"; + +@Controller("mcp") +@UseGuards(AuthplaneAuthGuard) +class McpController { + @Post("tools/weather") + @RequireScopes("tools/weather") + async weather( + @AuthInfo() info: VerifiedClaims, + @Body() body: { city: string }, + ) { + return { + content: [{ type: "text", text: `${body.city}: sunny (caller=${info.clientId})` }], + }; + } +} + +@Module({ + imports: [ + AuthplaneModule.forRoot({ + issuer: "http://localhost:9000", + resource: "http://localhost:8090/mcp", + scopes: ["tools/weather"], + devMode: true, + }), + ], + controllers: [McpController], +}) +class AppModule {} + +const app = await NestFactory.create(AppModule); +app.enableShutdownHooks(); // so AuthplaneShutdownHook runs `client.close()` on exit +await app.listen(8090); +``` + +The module provisions four DI-scoped providers — the underlying `AuthplaneClient`, the per-resource `AuthplaneResource`, an `AuthplaneAuthGuard`, and an `AuthplaneExceptionFilter` — and registers a controller that publishes the RFC 9728 Protected Resource Metadata at the derived well-known path (`/.well-known/oauth-protected-resource/mcp` for the example above). + +## `AuthplaneModule.forRoot` / `.forRootAsync` reference + +### Synchronous registration + +`AuthplaneModule.forRoot(options)` is a one-liner wrapper over `forRootAsync({ useFactory: () => options })` for the common case where every option is already known at import time. + +### Asynchronous registration + +`AuthplaneModule.forRootAsync(asyncOptions)` mirrors the standard NestJS async-module shape (`useFactory` / `useClass` / `useExisting`) so it composes naturally with `ConfigModule`: + +```ts +AuthplaneModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + issuer: cfg.getOrThrow("AUTHPLANE_ISSUER"), + resource: cfg.getOrThrow("AUTHPLANE_RESOURCE"), + scopes: cfg.get("AUTHPLANE_SCOPES") ?? [], + }), +}) +``` + +> **Sync-factory caveat.** NestJS controllers need a string-literal path at module-registration time, but the PRM path depends on the resolved `resource`. To keep `forRootAsync` ergonomic when the factory has no `inject` and returns synchronously, `AuthplaneModule` invokes such factories **once at registration time** to derive the PRM path, then reuses the resolved options as a `useValue` provider. The DI container does not re-invoke the factory, so observable side effects (logs, counters) happen during `forRootAsync(...)` and **not** during `app.init()`. Factories with `inject` or a `Promise` return are always resolved through DI and never invoked at registration — pass `hints.prmPath` (computed via `oauthProtectedResourceMetadataPath(resource)` from `@authplane/sdk/core`) if you need the PRM controller in those cases. + +### Options + +> Types referenced below (`AuthProvider`, `ASCredentials`, `DPoPProvider`, etc.) are re-exported from `@authplane/nestjs` for convenience — import them from the same package as `AuthplaneModule`, or pull them from `@authplane/sdk/core` if you prefer. + +| Field | Type | Purpose | +|---|---|---| +| `issuer` | `string` (required) | Authplane issuer URL (your `authserver`). | +| `resource` | `string` (required) | Resource URI tokens must be audience-bound to (`aud` claim). | +| `scopes` | `string[]` (optional) | All scopes this server supports. Used for PRM and, by default, as `requiredScopes`. | +| `requiredScopes` | `string[]` (optional) | Module-level scopes enforced by the guard. Defaults to `scopes` when absent. Layers with per-route `@RequireScopes(...)`. | +| `auth` | `AuthProvider \| ASCredentials` (optional) | AS-facing credentials for outbound calls. Accepts a full `AuthProvider` (e.g. `private_key_jwt`, mTLS, custom) or the `{ clientId, clientSecret }` shortcut (wrapped in `ClientCredentialsProvider` by core). Required when introspection/revocation is enabled. Mirrors `auth` on `AuthplaneClient.create`. | +| `asCredentials` | `{ clientId, clientSecret }` (optional) | Legacy shortcut for the client-secret path. When both `auth` and `asCredentials` are set, `auth` wins. Prefer `auth` for new code. | +| `fetchSettings` | `FetchSettings` (optional) | Unified outbound fetch hardening (SSRF, timeouts, allowlists). Applied to both AS metadata and JWKS fetches. Defaults derived from `devMode`. | +| `jwksRefreshSeconds` | `number` (optional, default `300`) | JWKS cache TTL. | +| `metadataRefreshSeconds` | `number` (optional, default `3600`) | Metadata cache TTL. | +| `cacheTtlBufferSeconds` | `number` (optional, default `30`) | Seconds subtracted from upstream `expires_in` when caching AS responses. Forwarded to `AuthplaneClient.create`. | +| `defaultTtlSeconds` | `number` (optional, default `3600`) | Fallback TTL when AS responses omit `expires_in`. Forwarded to `AuthplaneClient.create`. | +| `circuitBreakerThreshold` | `number` (optional, default `5`) | Consecutive AS failures before the outbound circuit breaker opens. | +| `circuitBreakerCooldownSeconds` | `number` (optional, default `30`) | Seconds the circuit breaker stays open before half-open retries. | +| `dpopProvider` | `DPoPProvider` (optional) | Outbound DPoP provider for AS-facing calls (token exchange, client-credentials). Inbound DPoP is configured separately via `inboundDPoP.replayStore`. | +| `devMode` | `boolean` (optional, default `false`) | Relaxes HTTPS and private-host restrictions. Only for local dev. | +| `revocationChecker` | `RevocationChecker \| IntrospectionRevocation` (optional) | Enable real-time revocation checking. | +| `inboundDPoP` | `InboundDPoPOptions` (optional) | Inbound DPoP knobs (`maxProofAgeSeconds`, `clockSkewSeconds`, `allowedProofAlgorithms`, `required`, `replayStore`). Setting `replayStore` here opts the resource into DPoP (Mode 2 — Supported). | +| `requestAdapter` | `RequestAdapter` (optional) | Escape hatch to replace the Express+Fastify anti-corruption layer. Most applications never set this. | + +Plus every option accepted by `AuthplaneClient.create` and `AuthplaneResource` that is not otherwise overridden. + +### Providers registered + +Every provider is available via DI from any class imported into the same module graph: + +| Token | Bound to | +|---|---| +| `AUTHPLANE_MODULE_OPTIONS` | The user-supplied options object. | +| `AUTHPLANE_CLIENT` | `AuthplaneClient` — owns JWKS/metadata refresh timers; use for token exchange / introspection / revocation calls. | +| `AUTHPLANE_RESOURCE` | `AuthplaneResource` — the per-resource verifier. | +| `AUTHPLANE_TOKEN_VERIFIER` | The same `AuthplaneResource` instance as `AUTHPLANE_RESOURCE`. Kept as a dedicated DI seam so tests can override the verifier with `{ provide: AUTHPLANE_TOKEN_VERIFIER, useValue: mock }` without touching the resource provider. | +| `AUTHPLANE_REQUEST_ADAPTER` | `RequestAdapter` — Express+Fastify ACL. | +| `AuthplaneAuthGuard` | Ready-to-use `CanActivate`. | +| `AuthplaneExceptionFilter` | RFC 6750 §3 filter. | +| `AuthplaneShutdownHook` | Runs `await client.close()` under `OnApplicationShutdown`. | + + +## Per-request `VerifiedClaims` + +After `AuthplaneAuthGuard` runs, the verified claims are available two ways: + +```ts +import { AuthInfo, type VerifiedClaims } from "@authplane/nestjs"; + +@Get("me") +whoami(@AuthInfo() info: VerifiedClaims) { + return { + sub: info.sub, + clientId: info.clientId, + scopes: info.scopes, + expiresAt: info.expiresAt, + }; +} +``` + +Or by reading directly off the request (useful from interceptors / middleware): + +```ts +import { AUTH_INFO_REQUEST_KEY, type VerifiedClaims } from "@authplane/nestjs"; + +@Injectable() +class AuditInterceptor implements NestInterceptor { + intercept(ctx: ExecutionContext, next: CallHandler) { + const req = ctx.switchToHttp().getRequest(); + const info = req[AUTH_INFO_REQUEST_KEY] as VerifiedClaims | undefined; + // ... + return next.handle(); + } +} +``` + +`VerifiedClaims` includes every verified claim: `token`, `sub`, `clientId`, `scopes`, `issuer`, `audience`, `resource` (a `URL`), `expiresAt`, `issuedAt`, `jti`, `kid`, `dpopProof` (when present), and the raw claim object under `raw`. + +## Scope enforcement + +`AuthplaneAuthGuard` enforces the union of two scope sources: module-level `requiredScopes` (or, by default, `scopes`) and any per-handler `@RequireScopes(...)` metadata. Tokens missing any scope in the merged set are rejected with HTTP 403 and `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. + +Module-level only: + +```ts +AuthplaneModule.forRoot({ + issuer: "...", + resource: "...", + scopes: ["tools/read", "tools/write", "tools/admin"], // advertised in PRM + requiredScopes: ["tools/read"], // enforced by the guard on every handler +}), +``` + +Layered with per-route metadata: + +```ts +@Controller("tools") +@UseGuards(AuthplaneAuthGuard) +class ToolsController { + @Post("delete_thing") + @RequireScopes("tools/delete_thing") // merged with module-level requiredScopes + async delete(@Body() body: { id: string }) { + await deleteThing(body.id); + return { ok: true, deleted: body.id }; + } +} +``` + +Opting a handler out of auth entirely — useful for health checks hosted on the same controller as guarded routes: + +```ts +import { SkipAuth } from "@authplane/nestjs"; + +@Get("health") +@SkipAuth() +health() { + return { ok: true }; +} +``` + +> Note on cross-adapter ergonomics: Hono's equivalent is a function call inside the handler — `requireScope(c, "tools/delete_thing")`. FastMCP takes the scope as metadata on the tool definition. NestJS uses decorator metadata (`@RequireScopes("…")`) that the guard resolves through `Reflector#getAllAndMerge`. Same semantics (401 vs 403 vs merged-union), different ergonomics — check the adapter-specific signature when porting code. + +## Protected Resource Metadata + +The RFC 9728 Protected Resource Metadata document is published automatically by a controller the module registers at registration time. The route path is derived from the resource URL: + +| `resource` option | PRM path | +|---|---| +| `https://api.example.com/mcp` | `/.well-known/oauth-protected-resource/mcp` | +| `https://api.example.com` | `/.well-known/oauth-protected-resource` | +| `https://api.example.com/foo/bar` | `/.well-known/oauth-protected-resource/foo/bar` | + +The handler is decorated with `@SkipAuth()` so the PRM document is reachable without credentials. + +If you cannot use the bundled controller (for example because the resource URL is only known at runtime), skip it and expose the PRM yourself. Inject the resource and return the payload: + +```ts +import { Inject } from "@nestjs/common"; +import { AUTHPLANE_RESOURCE } from "@authplane/nestjs"; +import type { AuthplaneResource } from "@authplane/sdk/core"; + +@Controller() +class PrmController { + public constructor( + @Inject(AUTHPLANE_RESOURCE) private readonly resource: AuthplaneResource, + ) {} + + @Get("/.well-known/oauth-protected-resource/mcp") + @SkipAuth() + prm() { + return this.resource.prmResponse(); + } +} +``` + +## Introspection and revocation + +By default the adapter trusts signature + `exp`/`nbf`. To enable RFC 7662 introspection on every request (catches tokens revoked before expiry): + +```ts +import { IntrospectionRevocation } from "@authplane/sdk/core"; + +AuthplaneModule.forRoot({ + issuer: "...", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + asCredentials: { clientId: "rs-client", clientSecret: "" }, + revocationChecker: IntrospectionRevocation.get(), +}), +``` + +`IntrospectionRevocation` is a singleton class from `@authplane/sdk/core` — obtain its instance with `IntrospectionRevocation.get()` and pass it through `revocationChecker`. Internally it's detected via `instanceof`, which flips `AuthplaneResource` into "introspect on every verify" mode: the underlying resource calls `authserver`'s introspection endpoint on each `verify()` and raises on `active: false`. This adds one round-trip per request; use only if eager revocation matters to your threat model. + +You can also pass a custom `RevocationChecker` — an async function `(claims, rawToken) => Promise` — for database-backed revocation lists. + +## DPoP-bound tokens + +DPoP is opt-in. Pass `inboundDPoP` with a `replayStore` to enable it (Mode 2 — Supported); the adapter ships with `InMemoryDPoPReplayStore` from `@authplane/sdk/core` for single-process deployments — use a Redis-backed implementation across multiple processes. Full control over DPoP knobs (`required`, `maxProofAgeSeconds`, `allowedProofAlgorithms`, `clockSkewSeconds`) lives on the same `inboundDPoP` bag. + +```ts +import { InMemoryDPoPReplayStore } from "@authplane/sdk/core"; + +AuthplaneModule.forRoot({ + issuer: "...", + resource: "...", + scopes: ["tools/read"], + inboundDPoP: { replayStore: new InMemoryDPoPReplayStore() }, +}), +``` + +Once configured, requests carrying a `DPoP` header get full proof validation (method, URL, iat, nonce, replay) against the presented access token's confirmation claim. A missing or invalid proof produces 401 `invalid_token` with `WWW-Authenticate: DPoP …` (RFC 9449 §7.1). + +When the resource has not been opted into DPoP (no `inboundDPoP`) and a request still carries a `DPoP` header, the core verifier rejects it with `DPoPNotSupported`. The challenge response carries `WWW-Authenticate: Bearer …` — RFC 9449 mandates the retry hint match the resource's actual support, and `DPoPNotSupported` is the carve-out where a `DPoPError` subclass produces a `Bearer` challenge. + +### `htu` is pinned to the configured `resource` + +> DPoP `htu` is anchored to the configured resource origin; only the request's path and query are taken from the inbound request. + +## Custom fetch settings + +Every outbound call (metadata, JWKS, introspection, revocation) routes through a `FetchSettings` instance. Tighten defaults: + +```ts +import { FetchSettings } from "@authplane/sdk/core"; + +const tight = new FetchSettings({ + timeoutMs: 3000, + allowedHosts: ["auth.example.com"], +}); + +AuthplaneModule.forRoot({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + fetchSettings: tight, +}), +``` + +See the [`@authplane/sdk` user guide](../../sdk/docs/user-guide.md#fetch-settings-and-ssrf-protection) for the full `FetchSettings` reference. + +## Error handling + +`AuthplaneExceptionFilter` is provided by `AuthplaneModule` but **not** registered globally. Mount it on a controller with `@UseFilters(AuthplaneExceptionFilter)`, or register it app-wide via either of: + +```ts +// Bootstrap-time (most common) +app.useGlobalFilters(app.get(AuthplaneExceptionFilter)); + +// DI-driven +@Module({ + imports: [AuthplaneModule.forRoot({ ... })], + providers: [{ provide: APP_FILTER, useExisting: AuthplaneExceptionFilter }], +}) +``` + +The guard is treated the same way: declare it explicitly with `@UseGuards(AuthplaneAuthGuard)` on protected controllers, or wire it as a global via `APP_GUARD: { provide: APP_GUARD, useExisting: AuthplaneAuthGuard }` (don't forget `@SkipAuth()` on routes that must remain public, including the PRM controller — already annotated for you). + +Once mounted, the filter only claims `AuthplaneError` (core) subclasses — unrelated exceptions (e.g. a user-thrown `HttpException`) keep flowing through NestJS's default exception handling. The RFC 6750 §3 shape — JSON body plus `WWW-Authenticate` challenge — is written regardless of whether the underlying transport is Express or Fastify: + +| Error | HTTP | `WWW-Authenticate` | +|---|---|---| +| `TokenMissing` (no `Authorization`) | 401 | `Bearer error="invalid_token"` | +| `TokenExpired`, `InvalidSignature`, `InvalidClaims`, `TokenRevoked` | 401 | `Bearer error="invalid_token"` | +| `InsufficientScope` (module-level or `@RequireScopes`) | 403 | `Bearer error="insufficient_scope", scope="…"` | +| `DPoPProofMissing`, `InvalidDPoPProof`, `DPoPReplayDetected`, `DPoPBindingMismatch` | 401 | `DPoP error="invalid_token"` (RFC 9449 §7.1) | +| `DPoPNotSupported` | 401 | `Bearer error="invalid_token"` (resource not opted into DPoP) | + +Every failure also includes `resource_metadata=""` when the module has computed one, so clients following RFC 9728 can discover the authorization server automatically. + +To customise the response shape, extend the filter and re-register it with a higher precedence than the one from the module: + +```ts +@Catch() +class ChattyAuthFilter extends AuthplaneExceptionFilter { + override catch(exception: unknown, host: ArgumentsHost): void { + super.catch(exception, host); + console.warn("[audit] auth failure", exception); + } +} + +@Module({ + imports: [AuthplaneModule.forRoot({ ... })], + providers: [{ provide: APP_FILTER, useClass: ChattyAuthFilter }], +}) +``` + +> No equivalent to MCP's URL elicitation. `@authplane/mcp` ships `wrapToolWithUrlElicitation` / `toUrlElicitationRequiredError` to translate a `ConsentRequiredError` (raised by a token exchange against `authserver`) into MCP's `-32042` response. NestJS has no analogous protocol hook — if a handler performs a token exchange and catches `ConsentRequiredError`, translate it yourself by throwing an `HttpException` that carries the `consent_url` in its body: +> +> ```ts +> try { +> await client.exchangeToken(...); +> } catch (err) { +> if (err instanceof ConsentRequiredError) { +> throw new HttpException( +> { error: "consent_required", consent_url: err.consentUrl }, +> 401, +> ); +> } +> throw err; +> } +> ``` + +## Express vs. Fastify + +The module works unchanged on both `@nestjs/platform-express` and `@nestjs/platform-fastify`. The integration suite runs every test twice — once per platform — so behaviour is verified on both. The `RequestAdapter` abstraction behind `AUTHPLANE_REQUEST_ADAPTER` hides the differences (`req.headers` vs `req.raw.headers`, `res.setHeader` vs `reply.header`, `res.status().json()` vs `reply.code().send()`). + +If you embed the module in a custom transport (for example a GraphQL gateway that exposes its own request/response primitives), override the adapter via the `requestAdapter` option: + +```ts +import { AUTH_INFO_REQUEST_KEY } from "@authplane/nestjs"; + +AuthplaneModule.forRoot({ + issuer: "...", + resource: "...", + scopes: ["..."], + requestAdapter: { + getHeader: (req, name) => /* ... */, + getMethod: (req) => /* ... */, + getPathAndQuery: (req) => /* ... */, + // Must write under AUTH_INFO_REQUEST_KEY — the @AuthInfo() parameter + // decorator reads this exact symbol off the request, not your own field. + stashAuthInfo: (req, info) => { + (req as Record)[AUTH_INFO_REQUEST_KEY] = info; + }, + }, +}), +``` + +## Local example commands + +Two runnable scripts ship with the package: + +1. `examples/oauth-server.ts` — minimal one-file example: + + ```bash + PORT=8090 \ + AUTHPLANE_ISSUER=http://127.0.0.1:9000 \ + AUTHPLANE_RESOURCE=http://127.0.0.1:8090/resource \ + npm run -w @authplane/nestjs example:oauth + ``` + +2. `demo/server.ts` — multi-route calculator demo with per-route scope enforcement and introspection wired on. Run it via: + + ```bash + cd packages/nestjs + cp demo/.env.example demo/.env + ./demo/run.sh + ``` + +## Cleanup + +The underlying `AuthplaneClient` owns timers for JWKS and metadata refresh. `AuthplaneModule` registers an `AuthplaneShutdownHook` that calls `await client.close()` from `OnApplicationShutdown`. Activate it with: + +```ts +const app = await NestFactory.create(AppModule); +app.enableShutdownHooks(); +``` + +That's the full integration — `SIGINT` / `SIGTERM` (or a manual `app.close()`) will flow through NestJS's lifecycle, trigger the hook, and stop the refresh timers so the process can exit cleanly. + +> `AuthplaneResource` (available via `AUTHPLANE_RESOURCE`) does not own any timers — its `close()` is a no-op. Always let the shutdown hook close the `AuthplaneClient` instead. diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json new file mode 100644 index 0000000..28e2473 --- /dev/null +++ b/packages/nestjs/package.json @@ -0,0 +1,79 @@ +{ + "name": "@authplane/nestjs", + "version": "0.3.0-dev.0", + "description": "Authplane JWT validation adapter for the NestJS framework", + "keywords": [ + "nestjs", + "oauth", + "jwt", + "adapter", + "authplane" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/AuthPlane/ts-sdk.git", + "directory": "packages/nestjs" + }, + "homepage": "https://github.com/AuthPlane/ts-sdk/tree/main/packages/nestjs", + "bugs": { + "url": "https://github.com/AuthPlane/ts-sdk/issues" + }, + "engines": { + "node": ">=22" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "scripts": { + "prepack": "node ../../scripts/sync-package-files.mjs", + "build": "tsc -b", + "format": "biome format --write src package.json tsconfig.json", + "lint": "biome lint src --error-on-warnings", + "typecheck": "tsc -b", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:integration": "vitest run tests/integration/auth.integration.test.ts" + }, + "peerDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.0.0" + }, + "devDependencies": { + "@authplane/sdk": "^0.3.0-dev.0", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "@nestjs/platform-fastify": "^10.4.0", + "@nestjs/testing": "^10.4.0", + "@types/node": "^24.3.0", + "@types/supertest": "^6.0.0", + "@vitest/coverage-v8": "4.1.5", + "dotenv": "^17.3.1", + "express": "^4.21.0", + "fastify": "^4.28.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "supertest": "^7.0.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "vitest": "4.1.5" + } +} diff --git a/packages/nestjs/src/application/authplane.exception-filter.ts b/packages/nestjs/src/application/authplane.exception-filter.ts new file mode 100644 index 0000000..cc1b099 --- /dev/null +++ b/packages/nestjs/src/application/authplane.exception-filter.ts @@ -0,0 +1,173 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; + +import { + AuthplaneError, + type AuthplaneResource, + httpStatus, + InsufficientScope, + wwwAuthenticate, +} from "@authplane/sdk/core"; + +import { REQUIRED_SCOPES_REQUEST_KEY } from "../infrastructure/request-adapter.js"; +import type { AuthplaneModuleOptions } from "../module/authplane.options.js"; +import { + AUTHPLANE_MODULE_OPTIONS, + AUTHPLANE_RESOURCE, +} from "../module/authplane.tokens.js"; + +/** + * Translates Authplane core errors into RFC 6750 §3 responses. + * + * Only {@link AuthplaneError} subclasses are claimed by this filter — + * unrelated exceptions (e.g. user-thrown `HttpException`) keep flowing + * through NestJS's default exception handling. Mounting this filter + * globally is safe; it does **not** swallow everything. + * + * Response shape: + * + * - {@link InsufficientScope} → 403 + `WWW-Authenticate: Bearer …` with `scope="…"` + * - Everything else (TokenMissing, InvalidSignature, TokenExpired, …) + * → 401 + `WWW-Authenticate: Bearer …` + * - `DPoPError` subclasses (except `DPoPNotSupported`) emit `DPoP …` instead + * of `Bearer …`; the scheme switch is handled by core `wwwAuthenticate()`. + * - `JWKSFetchError` / `MetadataFetchError` map to 503 via core `httpStatus()`. + * + * Bridges Express (`res.setHeader` / `res.status().json()`) and Fastify + * (`reply.header` / `reply.code().send()`) transparently. + */ +@Injectable() +@Catch(AuthplaneError) +export class AuthplaneExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("AuthplaneExceptionFilter"); + private prmUrlWarned = false; + + public constructor( + @Inject(AUTHPLANE_MODULE_OPTIONS) + private readonly options: AuthplaneModuleOptions, + @Inject(AUTHPLANE_RESOURCE) + private readonly resource: AuthplaneResource, + ) {} + + public catch(exception: AuthplaneError, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const req = http.getRequest(); + const reply = http.getResponse(); + + const wwwAuthenticateOptions: Parameters[1] = {}; + if (this.options.resource) { + wwwAuthenticateOptions.realm = this.options.resource; + } + const prmUrl = this.safePrmUrl(); + if (prmUrl) { + wwwAuthenticateOptions.resourceMetadataUrl = prmUrl; + } + if (exception instanceof InsufficientScope) { + const scopes = this.resolveRequiredScopes(req); + if (scopes.length > 0) { + wwwAuthenticateOptions.scope = scopes; + } + } + const header = wwwAuthenticate(exception, wwwAuthenticateOptions); + + const errorCode = + exception instanceof InsufficientScope + ? "insufficient_scope" + : "invalid_token"; + + setHeader(reply, "WWW-Authenticate", header); + sendJson(reply, httpStatus(exception), { + error: errorCode, + error_description: exception.message, + }); + } + + /** + * Prefer scopes stashed by the guard on the request — those already reflect + * the full merge of module-level `requiredScopes` with route-level + * `@RequireScopes(...)`. Fall back to module-level scopes when the request + * carries no stash (e.g. a third party threw `InsufficientScope` directly). + */ + private resolveRequiredScopes(req: unknown): readonly string[] { + const stash = (req as Record | null)?.[ + REQUIRED_SCOPES_REQUEST_KEY + ]; + if (Array.isArray(stash) && stash.length > 0) { + return stash as readonly string[]; + } + return this.options.requiredScopes ?? this.options.scopes ?? []; + } + + /** + * Best-effort PRM document URL for the `resource_metadata=` challenge + * parameter. Falls back to omitting the parameter if the resource cannot + * compute one — the challenge itself stays well-formed (RFC 9728 §5.1 + * makes `resource_metadata` optional). The first failure is logged at + * `warn` because it almost always means a misconfigured `resource` URL + * and the operator would otherwise only discover it when a client fails + * to bootstrap discovery; subsequent failures are silent so a sustained + * misconfig doesn't flood the log. + */ + private safePrmUrl(): string | undefined { + try { + return this.resource.prmDocumentUrl(); + } catch (err) { + if (!this.prmUrlWarned) { + this.prmUrlWarned = true; + this.logger.warn( + `prmDocumentUrl() threw — omitting resource_metadata from WWW-Authenticate. Check the configured 'resource' URL: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return undefined; + } + } +} + +function setHeader(reply: unknown, name: string, value: string): void { + if (!reply || typeof reply !== "object") return; + const r = reply as { + setHeader?: (name: string, value: string) => unknown; + header?: (name: string, value: string) => unknown; + }; + if (typeof r.setHeader === "function") { + r.setHeader(name, value); + return; + } + if (typeof r.header === "function") { + r.header(name, value); + } +} + +function sendJson(reply: unknown, status: number, body: unknown): void { + if (!reply || typeof reply !== "object") return; + const r = reply as { + status?: (code: number) => { json?: (body: unknown) => unknown }; + json?: (body: unknown) => unknown; + code?: (code: number) => { send?: (body: unknown) => unknown }; + send?: (body: unknown) => unknown; + }; + // Fastify exposes both `code()` and `send()`; its `status()` historically + // returned `void` so chaining `.json()` off it silently no-ops. Probe the + // Fastify pair first before falling back to Express's `status().json()`. + if (typeof r.code === "function" && typeof r.send === "function") { + const chained = r.code(status); + if (chained && typeof chained.send === "function") { + chained.send(body); + return; + } + r.send(body); + return; + } + if (typeof r.status === "function") { + const chained = r.status(status); + if (chained && typeof chained.json === "function") { + chained.json(body); + } + } +} diff --git a/packages/nestjs/src/application/authplane.guard.ts b/packages/nestjs/src/application/authplane.guard.ts new file mode 100644 index 0000000..fbbfb3b --- /dev/null +++ b/packages/nestjs/src/application/authplane.guard.ts @@ -0,0 +1,199 @@ +import { + type CanActivate, + type ExecutionContext, + Inject, + Injectable, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { + type AuthplaneResource, + buildDPoPRequestContext, + buildRequestUrl, + type DPoPRequestContext, + extractBearerToken, + extractDpopHeaderValues, + type VerifiedClaims, +} from "@authplane/sdk/core"; + +import { + type RequestAdapter, + REQUIRED_SCOPES_REQUEST_KEY, +} from "../infrastructure/request-adapter.js"; +import type { AuthplaneModuleOptions } from "../module/authplane.options.js"; +import { + AUTHPLANE_MODULE_OPTIONS, + AUTHPLANE_REQUEST_ADAPTER, + AUTHPLANE_TOKEN_VERIFIER, +} from "../module/authplane.tokens.js"; +import { + METADATA_KEY_REQUIRED_SCOPES, + METADATA_KEY_SKIP_AUTH, +} from "./metadata-keys.js"; + +/** + * NestJS `CanActivate` implementation enforcing Authplane-issued bearer + * tokens on every guarded handler. + * + * Contract — mirrors `bearerAuth` from `@authplane/hono`: + * + * 1. Extract the access token from the `Authorization` header via the + * injected {@link RequestAdapter}. RFC 6750 §2.1 is applied + * case-insensitively and the RFC 9449 `DPoP` scheme is also accepted. + * 2. If a `DPoP` header is present, build a {@link DPoPRequestContext} + * with the reconstructed `htu` URL and thread it into the verifier. + * The replay store and other DPoP knobs are configured on the resource + * via `AuthplaneResourceOptions.inboundDPoP`. + * 3. Call {@link AuthplaneResource.verify}. Core enforces expiry with the + * configured `clockSkewSeconds` tolerance. + * 4. Merge module-level `requiredScopes` with route-level `@RequireScopes(...)` + * metadata read via `Reflector#getAllAndMerge` and enforce the union. + * 5. Stash the resulting {@link VerifiedClaims} on the request via the + * adapter's `stashAuthInfo`; downstream handlers read it via `@AuthInfo()`. + * + * On failure the guard throws — `AuthplaneExceptionFilter` translates those + * exceptions into RFC 6750 §3 responses. + */ +@Injectable() +export class AuthplaneAuthGuard implements CanActivate { + private readonly resourceOrigin: string; + + public constructor( + @Inject(AUTHPLANE_TOKEN_VERIFIER) + private readonly verifier: AuthplaneResource, + @Inject(AUTHPLANE_MODULE_OPTIONS) + private readonly options: AuthplaneModuleOptions, + @Inject(AUTHPLANE_REQUEST_ADAPTER) + private readonly requestAdapter: RequestAdapter, + @Inject(Reflector) + private readonly reflector: Reflector, + ) { + try { + this.resourceOrigin = new URL(this.options.resource).origin; + } catch (cause) { + // Mirror core `parseResourceUrl` in `@authplane/sdk/core/prm.ts`: + // a non-URL `resource` is a programmer-supplied-type violation, so + // surface it as `TypeError` with the inner `URL` failure preserved + // on `.cause` instead of a bare `Error`. + throw new TypeError( + `AuthplaneModule: 'resource' must be an absolute URL (got ${JSON.stringify(this.options.resource)})`, + { cause }, + ); + } + } + + public async canActivate(context: ExecutionContext): Promise { + if (this.isSkipAuth(context)) { + return true; + } + const req = context.switchToHttp().getRequest(); + const token = extractBearerToken( + this.requestAdapter.getHeader(req, "authorization"), + ); + + const dpopRequest = this.buildDpopRequestContext(req); + const claims = await this.verifier.verify(token, { dpopRequest }); + + const requiredScopes = this.resolveRequiredScopes(context); + this.enforceRequiredScopes(req, claims, requiredScopes); + + this.requestAdapter.stashAuthInfo(req, claims); + return true; + } + + private buildDpopRequestContext( + req: unknown, + ): DPoPRequestContext | undefined { + // `getHeader` collapses arrays to `undefined` for header-smuggling + // protection, which would silently swallow a hand-built `dpop: + // string[]` shape (Node `req.rawHeaders`, manually-constructed + // fixtures). `getHeaderValues` preserves both shapes the SDK can + // encounter: the comma-folded single string that + // `http.IncomingMessage.headers` produces for two-`DPoP`-headers + // requests on the wire (Node only arrays a fixed allow-list like + // `set-cookie`), and the literal `string[]` from raw-headers / + // custom adapters. The core factory's comma-split catches the + // folded form so `MultipleDPoPProofs` (RFC 9449 §4.3 #1) fires + // either way; the exception filter routes that as `DPoP + // error="invalid_dpop_proof"` per §7.1. + const dpopHeaderValues = extractDpopHeaderValues( + this.requestAdapter.getHeaderValues(req, "dpop"), + ); + if (dpopHeaderValues.length === 0) return undefined; + + const url = buildRequestUrl({ + pathAndQuery: this.requestAdapter.getPathAndQuery(req), + resourceOrigin: this.resourceOrigin, + }); + + return buildDPoPRequestContext({ + method: this.requestAdapter.getMethod(req), + url, + dpopHeaderValues, + }); + } + + private isSkipAuth(context: ExecutionContext): boolean { + return ( + this.reflector.getAllAndOverride(METADATA_KEY_SKIP_AUTH, [ + context.getHandler(), + context.getClass(), + ]) === true + ); + } + + private resolveRequiredScopes(context: ExecutionContext): readonly string[] { + const moduleScopes = + this.options.requiredScopes ?? this.options.scopes ?? []; + const routeScopes = + this.reflector.getAllAndMerge( + METADATA_KEY_REQUIRED_SCOPES, + [context.getHandler(), context.getClass()], + ) ?? []; + if (routeScopes.length === 0) return moduleScopes; + if (moduleScopes.length === 0) return routeScopes; + // Dedupe — module + route frequently overlap (e.g. requiredScopes + // defaults to `scopes` and a route is also annotated). Without dedup + // the WWW-Authenticate challenge would emit `scope="x x"`. + const seen = new Set(); + const union: string[] = []; + for (const scope of moduleScopes) { + if (!seen.has(scope)) { + seen.add(scope); + union.push(scope); + } + } + for (const scope of routeScopes) { + if (!seen.has(scope)) { + seen.add(scope); + union.push(scope); + } + } + return union; + } + + /** + * Delegate the AND-style scope check to core `claims.requireScopes(...)` + * — the single canonical membership test across every adapter. On + * failure, stash the merged scope list on the request before re-throwing + * so the exception filter can populate the `scope="…"` parameter on the + * `WWW-Authenticate` challenge (core's `InsufficientScope` only carries + * the message, and the route-level `@RequireScopes(...)` union is + * invisible to the filter otherwise). The stash is set only on the + * failure path so user code reading the symbol on the happy path keeps + * seeing `undefined`. + */ + private enforceRequiredScopes( + req: unknown, + claims: VerifiedClaims, + requiredScopes: readonly string[], + ): void { + try { + claims.requireScopes(requiredScopes); + } catch (err) { + (req as Record)[REQUIRED_SCOPES_REQUEST_KEY] = + requiredScopes; + throw err; + } + } +} diff --git a/packages/nestjs/src/application/decorators.ts b/packages/nestjs/src/application/decorators.ts new file mode 100644 index 0000000..d017ebf --- /dev/null +++ b/packages/nestjs/src/application/decorators.ts @@ -0,0 +1,123 @@ +import { + type ExecutionContext, + SetMetadata, + createParamDecorator, +} from "@nestjs/common"; + +import type { VerifiedClaims } from "@authplane/sdk/core"; + +import { AUTH_INFO_REQUEST_KEY } from "../infrastructure/request-adapter.js"; +import { + METADATA_KEY_REQUIRED_SCOPES, + METADATA_KEY_SKIP_AUTH, +} from "./metadata-keys.js"; + +/** + * Method/class decorator attaching required-scope metadata read by the + * {@link AuthplaneAuthGuard} at request time. + * + * The guard merges module-level `requiredScopes` with the union of every + * `@RequireScopes(...)` found on the handler AND its declaring controller + * via `Reflector#getAllAndMerge`, so stacking class + method annotations + * is safe (duplicates don't change behaviour). + * + * Cross-adapter note: + * + * @authplane/mcp requireScope(scope, authInfo) // function, scope first + * @authplane/hono requireScope(c, scope) // function, context first + * @authplane/nestjs @RequireScopes('x', 'y') // decorator read via Reflector + * + * Same concept, three different call shapes — check the signature when + * switching adapters. + * + * @example + * ```ts + * @UseGuards(AuthplaneAuthGuard) + * @Controller("math") + * export class MathController { + * @Post("add") + * @RequireScopes("tools/add") + * add(): number { return 1; } + * } + * ``` + */ +export const RequireScopes = ( + ...scopes: readonly string[] +): ClassDecorator & MethodDecorator => + SetMetadata(METADATA_KEY_REQUIRED_SCOPES, [...scopes]); + +/** + * Method/class decorator marking a handler (or an entire controller) as + * public — the {@link AuthplaneAuthGuard} will skip verification entirely + * when it sees `authplane:skipAuth = true` on the handler or its class. + * + * Handy for health-check endpoints and the PRM controller, which must be + * reachable without a token. + * + * @example + * ```ts + * @SkipAuth() + * @Get("healthz") + * health(): string { return "ok"; } + * ``` + */ +export const SkipAuth = (): ClassDecorator & MethodDecorator => + SetMetadata(METADATA_KEY_SKIP_AUTH, true); + +/** + * Extract the verified {@link VerifiedClaims} the guard previously stashed on + * the request. Exposed separately from the decorator so tests (and advanced + * compositions) can read it without bootstrapping a full NestJS app. + */ +function extractAuthInfo( + _data: unknown, + context: ExecutionContext, +): VerifiedClaims | undefined { + const req = context.switchToHttp().getRequest(); + if (req === null || req === undefined) return undefined; + return (req as Record)[AUTH_INFO_REQUEST_KEY] as + | VerifiedClaims + | undefined; +} + +/** + * Parameter decorator that injects the verified {@link VerifiedClaims} into a + * controller handler. + * + * **Type-honesty note.** The extractor returns `VerifiedClaims | undefined`: + * when the guard is skipped (e.g. `@SkipAuth()` or the guard isn't attached + * to the route), no auth info has been stashed and `undefined` comes back. + * NestJS's parameter decorators carry no runtime type information, so the + * value's type depends entirely on how the handler annotates its parameter. + * Annotate as `VerifiedClaims | undefined` on handlers that may run without + * the guard; only annotate as `VerifiedClaims` when the guard is statically + * guaranteed to have run successfully. + * + * @example Guarded handler — guaranteed authenticated: + * ```ts + * @UseGuards(AuthplaneAuthGuard) + * @Post("who-am-i") + * whoami(@AuthInfo() auth: VerifiedClaims) { + * return { sub: auth.sub }; + * } + * ``` + * + * @example Skip-auth handler — auth may be absent: + * ```ts + * @SkipAuth() + * @Get("healthz") + * health(@AuthInfo() auth: VerifiedClaims | undefined) { + * return { signedIn: auth !== undefined }; + * } + * ``` + */ +export const AuthInfo = createParamDecorator(extractAuthInfo); + +// Expose the underlying extractor on the decorator factory so tests and +// adapter internals can invoke it without having to bootstrap a full app. +Object.defineProperty(AuthInfo, "__authplaneExtractor", { + value: extractAuthInfo, + enumerable: false, + writable: false, + configurable: false, +}); diff --git a/packages/nestjs/src/application/metadata-keys.ts b/packages/nestjs/src/application/metadata-keys.ts new file mode 100644 index 0000000..d1399a6 --- /dev/null +++ b/packages/nestjs/src/application/metadata-keys.ts @@ -0,0 +1,12 @@ +/** + * Reflector metadata keys for the `@authplane/nestjs` decorator family. + * + * Kept in one place so the guard and the decorators never drift apart: the + * guard reads exactly the keys the decorators write. + */ + +/** Key under which `@RequireScopes(...)` stores the required-scopes array. */ +export const METADATA_KEY_REQUIRED_SCOPES = "authplane:requiredScopes"; + +/** Key under which `@SkipAuth()` marks a handler / controller as public. */ +export const METADATA_KEY_SKIP_AUTH = "authplane:skipAuth"; diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts new file mode 100644 index 0000000..3eb1994 --- /dev/null +++ b/packages/nestjs/src/index.ts @@ -0,0 +1,58 @@ +// Public barrel for @authplane/nestjs. +// +// Re-exports every symbol the user-guide documents. Internal helpers stay +// un-exported so the published surface area is exactly what the guide +// describes. + +// --- Module + composition ------------------------------------------------ +export { AuthplaneModule } from "./module/authplane.module.js"; +export { AuthplaneShutdownHook } from "./module/authplane.shutdown-hook.js"; +export { + AUTHPLANE_CLIENT, + AUTHPLANE_MODULE_OPTIONS, + AUTHPLANE_REQUEST_ADAPTER, + AUTHPLANE_RESOURCE, + AUTHPLANE_TOKEN_VERIFIER, +} from "./module/authplane.tokens.js"; +export type { + AuthplaneAsyncRegistrationHints, + AuthplaneModuleAsyncOptions, + AuthplaneModuleOptions, + AuthplaneOptionsFactory, +} from "./module/authplane.options.js"; + +// --- Guard, filter, decorators ------------------------------------------- +export { AuthplaneAuthGuard } from "./application/authplane.guard.js"; +export { AuthplaneExceptionFilter } from "./application/authplane.exception-filter.js"; +export { + AuthInfo, + RequireScopes, + SkipAuth, +} from "./application/decorators.js"; +export { + METADATA_KEY_REQUIRED_SCOPES, + METADATA_KEY_SKIP_AUTH, +} from "./application/metadata-keys.js"; + +// --- Infrastructure escape hatches --------------------------------------- +export { + AUTH_INFO_REQUEST_KEY, + defaultRequestAdapter, + type RequestAdapter, + REQUIRED_SCOPES_REQUEST_KEY, +} from "./infrastructure/request-adapter.js"; + +// --- Re-exports of core types referenced in AuthplaneModuleOptions ------- +// Surfaced here so the user-guide options table doesn't force readers to +// chase imports across `@authplane/sdk/core`. Matches the `@authplane/mcp` / +// `@authplane/fastmcp` re-export pattern. +export { + type ASCredentials, + AuthplaneError, + type AuthProvider, + DPoPProvider, + InsufficientScope, + TokenExpired, + TokenMissing, + VerifiedClaims, +} from "@authplane/sdk/core"; diff --git a/packages/nestjs/src/infrastructure/request-adapter.ts b/packages/nestjs/src/infrastructure/request-adapter.ts new file mode 100644 index 0000000..968a9e5 --- /dev/null +++ b/packages/nestjs/src/infrastructure/request-adapter.ts @@ -0,0 +1,158 @@ +import type { VerifiedClaims } from "@authplane/sdk/core"; + +/** + * Symbol under which {@link AuthplaneAuthGuard} stashes verified claims on + * the platform request. The `@AuthInfo()` parameter decorator reads from the + * same symbol. + * + * Intentionally module-local (`Symbol()`, not `Symbol.for(...)`). The global + * registry would let two parallel copies of `@authplane/nestjs` — or any + * unrelated package — collide on the request stash slot. + */ +export const AUTH_INFO_REQUEST_KEY: unique symbol = Symbol( + "authplane/nestjs/authInfo", +); + +/** + * Symbol under which the guard stashes the merged required-scopes list before + * throwing `InsufficientScope`. The exception filter reads it to populate the + * `scope="…"` parameter on the `WWW-Authenticate` challenge — the route-level + * `@RequireScopes(...)` union is known to the guard (via `Reflector`) but not + * to the filter, so the request-stash is the channel. + */ +export const REQUIRED_SCOPES_REQUEST_KEY: unique symbol = Symbol( + "authplane/nestjs/requiredScopes", +); + +/** + * Anti-corruption layer between the NestJS/Node transport world and the rest + * of this adapter. + * + * `ExecutionContext.switchToHttp().getRequest()` returns different shapes + * depending on which transport is active: + * + * - `@nestjs/platform-express` → an `express.Request` (headers lowercased by + * Node, `originalUrl` is path-only, methods uppercased by convention). + * - `@nestjs/platform-fastify` → a `FastifyRequest` with the underlying Node + * request reachable at `request.raw` (headers lowercased, `raw.url` + * path-only). + * + * Wrap those differences behind a small interface so the guard never touches + * `req.headers[…]` directly — every downstream concern reads through this + * port. The `any` casts are confined to this file. + */ +export interface RequestAdapter { + /** Read a header value case-insensitively; returns `undefined` when absent. */ + getHeader(req: unknown, name: string): string | undefined; + /** + * Read a header case-insensitively, preserving the multi-value shape. + * Returns `string | readonly string[] | undefined` so the caller can + * distinguish a single-valued header from a duplicate-named one. + * + * Used exclusively for the `DPoP` header: RFC 9449 §4.3 #1 requires + * resources to reject requests carrying more than one `DPoP` header, + * and the core `buildDPoPRequestContext` factory needs the + * pre-collapsed shape to detect the violation. Every other header + * (Authorization, etc.) goes through `getHeader`, which intentionally + * collapses arrays to `undefined` for header-smuggling protection. + */ + getHeaderValues( + req: unknown, + name: string, + ): string | readonly string[] | undefined; + /** + * HTTP method in uppercase. Defaults to `"POST"` when the transport does + * not expose a method — chosen because the documented bearer-protected + * surface is JSON-RPC `POST` (MCP servers, the canonical consumer of + * this adapter). A custom transport that flows non-POST requests + * through the guard MUST override this method; otherwise the DPoP `htm` + * claim will mismatch the actual request method and proofs will fail to + * verify with `DPoPBindingMismatch` rather than a clearer "method + * missing" signal. + */ + getMethod(req: unknown): string; + /** + * Path + query string (Express `originalUrl`, Fastify `raw.url`). Defaults + * to `"/"` when the transport does not expose one. + */ + getPathAndQuery(req: unknown): string; + /** + * Stash the verified claims on the request so the `@AuthInfo()` parameter + * decorator can later read them back. Implementations **must** write under + * `AUTH_INFO_REQUEST_KEY`; the decorator reads the symbol directly and + * never round-trips through this interface. + */ + stashAuthInfo(req: unknown, info: VerifiedClaims): void; +} + +type HeaderBag = Record; + +function findInBag( + bag: HeaderBag | undefined, + lower: string, +): string | readonly string[] | undefined { + if (!bag) return undefined; + if (lower in bag) return bag[lower]; + for (const key of Object.keys(bag)) { + if (key.toLowerCase() === lower) return bag[key]; + } + return undefined; +} + +function readHeader( + req: unknown, + name: string, +): string | readonly string[] | undefined { + const lower = name.toLowerCase(); + const direct = (req as { headers?: HeaderBag } | null)?.headers; + const directHit = findInBag(direct, lower); + if (directHit !== undefined) return directHit; + const raw = (req as { raw?: { headers?: HeaderBag } } | null)?.raw?.headers; + return findInBag(raw, lower); +} + +/** + * Default {@link RequestAdapter} that covers both `@nestjs/platform-express` + * and `@nestjs/platform-fastify`. Applications that need to add a new + * transport (or tweak how headers are read) can substitute their own via the + * module options. + */ +export const defaultRequestAdapter: RequestAdapter = { + getHeader(req, name) { + const value = readHeader(req, name); + // Duplicate-named headers are returned as `undefined` for non-DPoP + // callers: intermediaries disagree on which copy is canonical and + // that ambiguity is a known header-smuggling shape. DPoP needs the + // multi-value shape to enforce RFC 9449 §4.3 #1 — that path + // reads through `getHeaderValues` instead. + if (Array.isArray(value)) return undefined; + return typeof value === "string" ? value : undefined; + }, + + getHeaderValues(req, name) { + return readHeader(req, name); + }, + + getMethod(req) { + const direct = (req as { method?: unknown } | null)?.method; + const raw = (req as { raw?: { method?: unknown } } | null)?.raw?.method; + const method = typeof direct === "string" ? direct : raw; + return typeof method === "string" ? method.toUpperCase() : "POST"; + }, + + getPathAndQuery(req) { + const r = req as { + originalUrl?: unknown; + url?: unknown; + raw?: { url?: unknown }; + } | null; + const candidate = r?.originalUrl ?? r?.url ?? r?.raw?.url; + return typeof candidate === "string" && candidate.length > 0 + ? candidate + : "/"; + }, + + stashAuthInfo(req, info) { + (req as Record)[AUTH_INFO_REQUEST_KEY] = info; + }, +}; diff --git a/packages/nestjs/src/module/authplane.module.ts b/packages/nestjs/src/module/authplane.module.ts new file mode 100644 index 0000000..5c46f6e --- /dev/null +++ b/packages/nestjs/src/module/authplane.module.ts @@ -0,0 +1,355 @@ +import { + type DynamicModule, + Logger, + Module, + type Provider, +} from "@nestjs/common"; + +import { + AuthplaneClient, + type AuthplaneResource, + type AuthplaneResourceOptions, + oauthProtectedResourceMetadataPath, +} from "@authplane/sdk/core"; + +import { AuthplaneAuthGuard } from "../application/authplane.guard.js"; +import { AuthplaneExceptionFilter } from "../application/authplane.exception-filter.js"; +import { + defaultRequestAdapter, + type RequestAdapter, +} from "../infrastructure/request-adapter.js"; +import { buildPrmController } from "../presentation/prm.controller.js"; +import type { + AuthplaneAsyncRegistrationHints, + AuthplaneModuleAsyncOptions, + AuthplaneModuleOptions, + AuthplaneOptionsFactory, +} from "./authplane.options.js"; +import { AuthplaneShutdownHook } from "./authplane.shutdown-hook.js"; +import { + AUTHPLANE_CLIENT, + AUTHPLANE_MODULE_OPTIONS, + AUTHPLANE_REQUEST_ADAPTER, + AUTHPLANE_RESOURCE, + AUTHPLANE_TOKEN_VERIFIER, +} from "./authplane.tokens.js"; + +/** + * Top-level dynamic module for `@authplane/nestjs`. + * + * Provisions, in order: + * + * 1. {@link AuthplaneModuleOptions} — the user-supplied configuration. + * 2. {@link AuthplaneClient} — created async via `AuthplaneClient.create`. + * 3. {@link AuthplaneResource} — the per-resource verifier. Also exposed + * via the `AUTHPLANE_TOKEN_VERIFIER` DI token (same instance, separate + * symbol) so tests can substitute the verifier without rebinding the + * resource provider. + * 4. {@link RequestAdapter} — Express+Fastify ACL (overridable). + * 5. {@link AuthplaneShutdownHook} — calls `client.close()` on shutdown. + * 6. {@link AuthplaneAuthGuard} + {@link AuthplaneExceptionFilter} — the + * guard and filter that applications wire up with `@UseGuards` / + * `@UseFilters` (or `APP_GUARD` / `APP_FILTER` globally). + * + * The PRM controller is minted at registration time from the resource URL + * using core `oauthProtectedResourceMetadataPath()` so NestJS sees a + * literal path string on `@Get`. + * + * @example + * ```ts + * import { AuthplaneModule } from "@authplane/nestjs"; + * + * @Module({ + * imports: [ + * AuthplaneModule.forRoot({ + * issuer: "https://auth.example.com", + * resource: "https://api.example.com/mcp", + * scopes: ["tools/add", "tools/multiply"], + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Module({}) +export class AuthplaneModule { + /** + * Register the module with a known, eagerly-available options object. + * Thin wrapper over {@link AuthplaneModule.forRootAsync} that makes the + * simple case (no DI dependencies) a one-liner. + */ + public static forRoot(options: AuthplaneModuleOptions): DynamicModule { + // Synchronous factory means `derivePrmPath` can introspect the + // resource URL eagerly; no need to pass an explicit `prmPath` hint. + return AuthplaneModule.forRootAsync({ + useFactory: () => options, + }); + } + + /** + * Register the module with async options. Mirrors the standard NestJS + * async-module shape (`useFactory` / `useClass` / `useExisting`) so it + * composes naturally with `ConfigModule` and friends. + * + * `hints.prmPath` lets async-configured callers (e.g. + * `useFactory: (cfg: ConfigService) => …`) pre-declare where the PRM + * controller should be mounted. Without it, the module can only + * register the controller when `useFactory` is fully synchronous — + * any `Promise` return or non-empty `inject` array forces the user to + * mount the PRM document from their own handler. + */ + public static forRootAsync( + asyncOptions: AuthplaneModuleAsyncOptions, + hints: AuthplaneAsyncRegistrationHints = {}, + ): DynamicModule { + // Inspect synchronously-resolvable factories once at registration so + // we can derive the PRM path AND reuse the captured options as a + // `useValue` provider — invoking the caller's factory twice (once + // here, once inside the DI container) would double-fire any side + // effects in their config code. + const syncCapture = inspectSyncFactory(asyncOptions); + const optionsProvider = syncCapture + ? ({ + provide: AUTHPLANE_MODULE_OPTIONS, + useValue: syncCapture.options, + } satisfies Provider) + : buildOptionsProvider(asyncOptions); + + const clientProvider: Provider = { + provide: AUTHPLANE_CLIENT, + inject: [AUTHPLANE_MODULE_OPTIONS], + useFactory: (options: AuthplaneModuleOptions) => + buildAuthplaneClient(options), + }; + + const resourceProvider: Provider = { + provide: AUTHPLANE_RESOURCE, + inject: [AUTHPLANE_CLIENT, AUTHPLANE_MODULE_OPTIONS], + useFactory: ( + client: AuthplaneClient, + options: AuthplaneModuleOptions, + ): AuthplaneResource => client.resource(buildResourceOptions(options)), + }; + + // Keep the dedicated `AUTHPLANE_TOKEN_VERIFIER` token so tests can + // override the verifier seam (`{ provide: AUTHPLANE_TOKEN_VERIFIER, + // useValue: mockVerifier }`) without touching the resource provider. + // The token resolves to the same `AuthplaneResource` instance — there + // is no adapter-level wrapper any more; the guard calls + // `verifier.verify()` directly on core. + const tokenVerifierProvider: Provider = { + provide: AUTHPLANE_TOKEN_VERIFIER, + inject: [AUTHPLANE_RESOURCE], + useFactory: (resource: AuthplaneResource): AuthplaneResource => resource, + }; + + const requestAdapterProvider: Provider = { + provide: AUTHPLANE_REQUEST_ADAPTER, + inject: [AUTHPLANE_MODULE_OPTIONS], + useFactory: (options: AuthplaneModuleOptions): RequestAdapter => + options.requestAdapter ?? defaultRequestAdapter, + }; + + // Stage the PRM controller so NestJS sees a literal path string on + // @Get. Caller-supplied `hints.prmPath` wins for async setups where + // the resource URL is not knowable synchronously. + const prmPath = hints.prmPath ?? syncCapture?.prmPath; + const controllers = prmPath ? [buildPrmController(prmPath)] : []; + if (controllers.length === 0) { + // RFC 9728 discovery is required for many OAuth client flows; if + // we drop the controller silently the operator only finds out + // when a client fails to bootstrap. Route through Nest's Logger + // so the message lands in the application's configured log sink + // — `console.warn` would be suppressed by some PaaS log + // pipelines. + new Logger("AuthplaneModule").warn( + "PRM controller not registered — RFC 9728 discovery is disabled. " + + "Pass `hints.prmPath` to `forRootAsync` (or use `forRoot` with a synchronous factory) to expose the metadata document.", + ); + } + + return { + module: AuthplaneModule, + imports: [...(asyncOptions.imports ?? [])], + providers: [ + optionsProvider, + clientProvider, + resourceProvider, + tokenVerifierProvider, + requestAdapterProvider, + AuthplaneShutdownHook, + AuthplaneAuthGuard, + AuthplaneExceptionFilter, + ], + controllers, + exports: [ + AUTHPLANE_CLIENT, + AUTHPLANE_RESOURCE, + AUTHPLANE_TOKEN_VERIFIER, + AUTHPLANE_REQUEST_ADAPTER, + AUTHPLANE_MODULE_OPTIONS, + AuthplaneAuthGuard, + AuthplaneExceptionFilter, + ], + }; + } +} + +// --- Internals ---------------------------------------------------------- + +function buildOptionsProvider( + asyncOptions: AuthplaneModuleAsyncOptions, +): Provider { + if (asyncOptions.useFactory) { + return { + provide: AUTHPLANE_MODULE_OPTIONS, + inject: [...(asyncOptions.inject ?? [])], + useFactory: asyncOptions.useFactory, + }; + } + if (asyncOptions.useClass) { + return { + provide: AUTHPLANE_MODULE_OPTIONS, + inject: [asyncOptions.useClass], + useFactory: (factory: AuthplaneOptionsFactory) => + factory.createAuthplaneOptions(), + }; + } + if (asyncOptions.useExisting) { + return { + provide: AUTHPLANE_MODULE_OPTIONS, + inject: [asyncOptions.useExisting], + useFactory: (factory: AuthplaneOptionsFactory) => + factory.createAuthplaneOptions(), + }; + } + throw new Error( + "AuthplaneModule.forRootAsync requires useFactory, useClass, or useExisting.", + ); +} + +interface SyncFactoryCapture { + readonly options: AuthplaneModuleOptions; + readonly prmPath: string | undefined; +} + +/** + * Invoke a synchronous `useFactory` once at module-registration time, both + * to derive the PRM controller path AND to reuse the result as a + * `useValue` provider so the DI container does not invoke the user's + * factory a second time. Returns `undefined` for any factory that is not + * trivially sync-resolvable here (no factory, injected dependencies, + * Promise return, or factory threw) — those cases fall through to the + * normal `buildOptionsProvider` path and the PRM controller is registered + * only when `hints.prmPath` is supplied. + * + * Swallowing a thrown factory is deliberate: this registration-time call + * is purely an optimisation, and the DI container will re-invoke the + * factory itself, surfacing the same error through Nest's standard + * bootstrap path with full provider context. A test pins this behaviour. + */ +function inspectSyncFactory( + asyncOptions: AuthplaneModuleAsyncOptions, +): SyncFactoryCapture | undefined { + if (!asyncOptions.useFactory || asyncOptions.inject?.length) { + return undefined; + } + let result: ReturnType; + try { + result = asyncOptions.useFactory(); + } catch { + return undefined; + } + if (result instanceof Promise) return undefined; + const options = result as AuthplaneModuleOptions; + let prmPath: string | undefined; + const resource = options.resource; + if (typeof resource === "string" && resource.length > 0) { + try { + prmPath = oauthProtectedResourceMetadataPath(resource); + } catch { + prmPath = undefined; + } + } + return { options, prmPath }; +} + +async function buildAuthplaneClient( + options: AuthplaneModuleOptions, +): Promise { + const clientOptions: Parameters[0] = { + issuer: options.issuer, + }; + // `auth` accepts a full AuthProvider (private_key_jwt, mTLS, custom) or + // raw ASCredentials (which core wraps in ClientCredentialsProvider). + // `auth` is the canonical knob; `asCredentials` is kept as a legacy + // shortcut for the client-secret path. When both are set, `auth` wins. + const auth = options.auth ?? options.asCredentials; + if (auth !== undefined) { + clientOptions.auth = auth; + } + if (options.devMode !== undefined) { + clientOptions.devMode = options.devMode; + } + if (options.fetchSettings !== undefined) { + clientOptions.fetchSettings = options.fetchSettings; + } + if (options.jwksRefreshSeconds !== undefined) { + clientOptions.jwksRefreshSeconds = options.jwksRefreshSeconds; + } + if (options.metadataRefreshSeconds !== undefined) { + clientOptions.metadataRefreshSeconds = options.metadataRefreshSeconds; + } + if (options.cacheTtlBufferSeconds !== undefined) { + clientOptions.cacheTtlBufferSeconds = options.cacheTtlBufferSeconds; + } + if (options.defaultTtlSeconds !== undefined) { + clientOptions.defaultTtlSeconds = options.defaultTtlSeconds; + } + if (options.cacheMaxEntries !== undefined) { + clientOptions.cacheMaxEntries = options.cacheMaxEntries; + } + if (options.circuitBreakerThreshold !== undefined) { + clientOptions.circuitBreakerThreshold = options.circuitBreakerThreshold; + } + if (options.circuitBreakerCooldownSeconds !== undefined) { + clientOptions.circuitBreakerCooldownSeconds = + options.circuitBreakerCooldownSeconds; + } + if (options.dpopProvider !== undefined) { + clientOptions.dpopProvider = options.dpopProvider; + } + return AuthplaneClient.create(clientOptions); +} + +function buildResourceOptions( + options: AuthplaneModuleOptions, +): AuthplaneResourceOptions { + const scopes = options.scopes ?? []; + const resourceOptions: AuthplaneResourceOptions = { + resource: options.resource, + scopes, + }; + if (options.revocationChecker !== undefined) { + resourceOptions.revocationChecker = options.revocationChecker; + } + if (options.allowedAlgorithms !== undefined) { + resourceOptions.allowedAlgorithms = options.allowedAlgorithms; + } + if (options.clockSkewSeconds !== undefined) { + resourceOptions.clockSkewSeconds = options.clockSkewSeconds; + } + if (options.devMode !== undefined) { + resourceOptions.devMode = options.devMode; + } + if (options.asCredentials !== undefined) { + resourceOptions.asCredentials = options.asCredentials; + } + if (options.failClosed !== undefined) { + resourceOptions.failClosed = options.failClosed; + } + if (options.inboundDPoP !== undefined) { + resourceOptions.inboundDPoP = options.inboundDPoP; + } + return resourceOptions; +} diff --git a/packages/nestjs/src/module/authplane.options.ts b/packages/nestjs/src/module/authplane.options.ts new file mode 100644 index 0000000..07241ee --- /dev/null +++ b/packages/nestjs/src/module/authplane.options.ts @@ -0,0 +1,157 @@ +import type { ModuleMetadata, Type } from "@nestjs/common"; + +import type { + ASCredentials, + AuthProvider, + AuthplaneResourceOptions, + DPoPProvider, + FetchSettings, +} from "@authplane/sdk/core"; + +import type { RequestAdapter } from "../infrastructure/request-adapter.js"; + +/** + * Module-level options for {@link AuthplaneModule.forRootAsync}. + * + * Designed as a faithful port of `AuthplaneHonoAuthOptions` from + * `@authplane/hono` (and `AuthplaneMcpAuthOptions` from `@authplane/mcp`) + * so the three adapters stay interchangeable at the configuration surface. + * The NestJS-only additions are explicitly called out below. + */ +export interface AuthplaneModuleOptions + extends Omit { + /** Authorization server issuer URL (used for discovery + JWKS). */ + issuer: string; + /** Canonical resource identifier this server protects. */ + resource: string; + /** All scopes this resource server supports. */ + scopes?: string[]; + /** + * Scopes the guard must enforce at the module level — every request is + * checked against this set. Per-route `@RequireScopes(...)` metadata is + * merged on top. Defaults to `scopes` when not provided, matching the + * Python + Hono + MCP adapters. + */ + requiredScopes?: string[]; + /** + * Outbound fetch hardening (SSRF, timeouts, allowlists) applied to both + * AS metadata and JWKS document fetches. When omitted, defaults are + * derived from `devMode`. RFC 8414 / RFC 7517 — both endpoints share the + * same threat profile, so a single setting governs both. + */ + fetchSettings?: FetchSettings; + /** Seconds between JWKS refreshes. */ + jwksRefreshSeconds?: number; + /** Seconds between AS metadata refreshes. */ + metadataRefreshSeconds?: number; + /** + * AS-facing credentials for outbound calls (token exchange, revocation, + * introspection). Accepts a full {@link AuthProvider} (e.g. for + * `private_key_jwt`, mTLS, or a custom provider) or an + * {@link ASCredentials} shortcut — bare credentials are wrapped in a + * `ClientCredentialsProvider` by the core client. Matches the + * `auth` knob on {@link AuthplaneClient.create}; mirrors mcp / fastmcp. + * + * If both `auth` and the legacy `asCredentials` shortcut are set, `auth` + * wins. + */ + auth?: AuthProvider | ASCredentials; + /** + * Seconds subtracted from the upstream `expires_in` when caching AS + * responses, so cache entries expire ahead of the real token. Forwarded + * to {@link AuthplaneClient.create}; defaults to 30s. + */ + cacheTtlBufferSeconds?: number; + /** + * Fallback TTL (seconds) used when an AS response omits `expires_in`. + * Forwarded to {@link AuthplaneClient.create}; defaults to 3600. + */ + defaultTtlSeconds?: number; + /** + * Maximum number of entries kept in the outbound token cache before + * least-recently-used eviction kicks in. Forwarded to {@link + * AuthplaneClient.create}; defaults to `10_000`. Override on hosts with + * very high subject-token cardinality. + */ + cacheMaxEntries?: number; + /** + * Consecutive AS failures before the outbound circuit breaker opens. + * Forwarded to {@link AuthplaneClient.create}; defaults to 5. + */ + circuitBreakerThreshold?: number; + /** + * Seconds the outbound circuit breaker stays open before half-open + * retries. Forwarded to {@link AuthplaneClient.create}; defaults to 30. + */ + circuitBreakerCooldownSeconds?: number; + /** + * DPoP provider used for **outbound** AS-facing calls (token exchange, + * client-credentials grants). Inbound DPoP verification is configured + * separately via `inboundDPoP.replayStore`. Forwarded as-is to + * {@link AuthplaneClient.create}. + */ + dpopProvider?: DPoPProvider; + /** + * Optional override for the {@link RequestAdapter} used by the guard + + * exception filter. NestJS-specific escape hatch — swap this in to add a + * new transport (e.g. `@nestjs/platform-ws`) or to stub request reads in + * integration tests. + * + * Defaults to `defaultRequestAdapter`, which already covers + * `@nestjs/platform-express` and `@nestjs/platform-fastify`. + */ + requestAdapter?: RequestAdapter; +} + +/** + * Async options surface for {@link AuthplaneModule.forRootAsync} additions. + * + * NestJS requires the PRM controller's `@Get` path to be a string literal at + * module-registration time, but async option factories (e.g. those that read + * `ConfigService`) only resolve after the module is being constructed. Pass + * `prmPath` to tell `AuthplaneModule.forRootAsync` which path to mount the + * PRM controller at without inspecting the (still-unresolved) `resource` URL. + * + * When omitted, `forRootAsync` falls back to inspecting a synchronous + * `useFactory` (no `inject`, no `Promise`). Async setups without `prmPath` + * silently skip the PRM controller and applications are expected to mount + * the document from their own handler. + */ +export interface AuthplaneAsyncRegistrationHints { + /** + * Pre-derived PRM path (RFC 9728 §3.1), e.g. + * `/.well-known/oauth-protected-resource/mcp`. Use + * `oauthProtectedResourceMetadataPath(resource)` (from `@authplane/sdk/core`) to compute it. + */ + prmPath?: string; +} + +/** + * Factory interface for async options. Implement this in a provider class + * when you need DI'd values (e.g. a `ConfigService`) to build + * {@link AuthplaneModuleOptions}. + */ +export interface AuthplaneOptionsFactory { + createAuthplaneOptions(): + | Promise + | AuthplaneModuleOptions; +} + +/** + * Async options surface for {@link AuthplaneModule.forRootAsync}. Mirrors + * the standard NestJS async-module shape so it composes naturally with + * other modules (`ConfigModule`, `TypeOrmModule`, ...). + */ +export interface AuthplaneModuleAsyncOptions + extends Pick { + /** Reuse a factory already registered elsewhere in the DI container. */ + useExisting?: Type; + /** Instantiate a factory class and call `createAuthplaneOptions()`. */ + useClass?: Type; + /** Inline factory function; `inject` lists the providers passed in. */ + useFactory?: ( + ...args: readonly unknown[] + ) => Promise | AuthplaneModuleOptions; + /** Providers passed positionally to `useFactory`. */ + inject?: readonly (Type | string | symbol)[]; +} diff --git a/packages/nestjs/src/module/authplane.shutdown-hook.ts b/packages/nestjs/src/module/authplane.shutdown-hook.ts new file mode 100644 index 0000000..453f85a --- /dev/null +++ b/packages/nestjs/src/module/authplane.shutdown-hook.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable, type OnApplicationShutdown } from "@nestjs/common"; + +import type { AuthplaneClient } from "@authplane/sdk/core"; + +import { AUTHPLANE_CLIENT } from "./authplane.tokens.js"; + +/** + * NestJS lifecycle hook that closes the shared {@link AuthplaneClient} + * when the application shuts down. + * + * `client.close()` releases the background refreshers (JWKS + AS metadata) + * and any HTTP-pool resources the underlying fetcher holds. Without it, + * Node will keep the event loop alive, so Nest apps would hang on SIGTERM + * in containerised deployments. + * + * Note: `verifier.close()` is a no-op in the core SDK — always call + * `client.close()` for real cleanup. This hook centralises that rule so + * applications never have to remember it themselves (cross-adapter + * lesson #3). + */ +@Injectable() +export class AuthplaneShutdownHook implements OnApplicationShutdown { + public constructor( + @Inject(AUTHPLANE_CLIENT) + private readonly client: AuthplaneClient, + ) {} + + public async onApplicationShutdown(): Promise { + await this.client.close(); + } +} diff --git a/packages/nestjs/src/module/authplane.tokens.ts b/packages/nestjs/src/module/authplane.tokens.ts new file mode 100644 index 0000000..a3bd441 --- /dev/null +++ b/packages/nestjs/src/module/authplane.tokens.ts @@ -0,0 +1,36 @@ +/** + * Dependency-injection tokens used throughout `@authplane/nestjs`. + * + * Every provider the module publishes is keyed by one of these symbols — we + * use `Symbol.for(...)` so that duplicate copies of the package in a + * monorepo resolve to the same token at runtime (NestJS stores providers by + * reference, not by name). + * + * Downstream consumers never need to import these directly; they are + * surfaced so advanced use-cases (for example, swapping the + * {@link RequestAdapter} in a test) can grab the exact provider by token. + */ + +/** DI token for the shared `AuthplaneClient` singleton. */ +export const AUTHPLANE_CLIENT = Symbol.for("authplane/nestjs/client"); + +/** DI token for the per-resource `AuthplaneResource` verifier. */ +export const AUTHPLANE_RESOURCE = Symbol.for("authplane/nestjs/resource"); + +/** + * DI token that resolves to the same `AuthplaneResource` as + * {@link AUTHPLANE_RESOURCE}. Kept as a separate symbol so tests can replace + * the verifier with `{ provide: AUTHPLANE_TOKEN_VERIFIER, useValue: mock }` + * without touching the resource provider. + */ +export const AUTHPLANE_TOKEN_VERIFIER = Symbol.for( + "authplane/nestjs/tokenVerifier", +); + +/** DI token carrying the user-supplied {@link AuthplaneModuleOptions}. */ +export const AUTHPLANE_MODULE_OPTIONS = Symbol.for("authplane/nestjs/options"); + +/** DI token for the request-adapter anti-corruption layer. */ +export const AUTHPLANE_REQUEST_ADAPTER = Symbol.for( + "authplane/nestjs/requestAdapter", +); diff --git a/packages/nestjs/src/presentation/prm.controller.ts b/packages/nestjs/src/presentation/prm.controller.ts new file mode 100644 index 0000000..4cf7e72 --- /dev/null +++ b/packages/nestjs/src/presentation/prm.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Inject, type Type } from "@nestjs/common"; + +import type { + AuthplaneResource, + ProtectedResourceMetadata, +} from "@authplane/sdk/core"; + +import { SkipAuth } from "../application/decorators.js"; +import { AUTHPLANE_RESOURCE } from "../module/authplane.tokens.js"; + +/** + * Build a controller class that serves the RFC 9728 Protected Resource + * Metadata document at a statically-known path. + * + * NestJS expects literal route strings on `@Controller` / `@Get`, but the + * PRM path is derived from the protected resource's URL at module setup + * time. To keep the guarantee of a static path AND let the module pick the + * path dynamically, {@link AuthplaneModule.forRootAsync} calls this factory + * during registration to mint a fresh controller class with the path baked + * in. + * + * The controller is marked `@SkipAuth()` so the guard does not require a + * bearer token for the discovery endpoint — PRM is meant to be reachable + * without credentials (RFC 9728 §5). + * + * @param path Pathname portion of the PRM URL (for example + * `"/.well-known/oauth-protected-resource/mcp"`). + * @returns A `Type` compatible with `@Module({ controllers: [...] })`. + */ +export function buildPrmController(path: string): Type { + @Controller() + class AuthplanePrmController { + public constructor( + @Inject(AUTHPLANE_RESOURCE) + private readonly resource: AuthplaneResource, + ) {} + + @Get(path) + @SkipAuth() + public serve(): ProtectedResourceMetadata { + return this.resource.prmResponse(); + } + } + return AuthplanePrmController; +} diff --git a/packages/nestjs/tests/application/authplane.exception-filter.test.ts b/packages/nestjs/tests/application/authplane.exception-filter.test.ts new file mode 100644 index 0000000..fdb677c --- /dev/null +++ b/packages/nestjs/tests/application/authplane.exception-filter.test.ts @@ -0,0 +1,371 @@ +import type { ArgumentsHost } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + AuthplaneError, + DPoPNotSupported, + DPoPReplayDetected, + InsufficientScope, + JWKSFetchError, + TokenExpired, + TokenMissing, +} from "@authplane/sdk/core"; + +import { AuthplaneExceptionFilter } from "../../src/application/authplane.exception-filter.js"; +import { REQUIRED_SCOPES_REQUEST_KEY } from "../../src/infrastructure/request-adapter.js"; +import type { AuthplaneModuleOptions } from "../../src/module/authplane.options.js"; + +function expressReply() { + const chain: Record = {}; + const reply = { + setHeader: vi.fn((name: string, value: string) => { + chain[`header:${name}`] = value; + }), + status: vi.fn((code: number) => { + chain.status = code; + return reply; + }), + json: vi.fn((body: unknown) => { + chain.body = body; + return reply; + }), + }; + return { reply, chain }; +} + +function fastifyReply() { + const chain: Record = {}; + const reply = { + header: vi.fn((name: string, value: string) => { + chain[`header:${name}`] = value; + return reply; + }), + code: vi.fn((code: number) => { + chain.status = code; + return reply; + }), + send: vi.fn((body: unknown) => { + chain.body = body; + return reply; + }), + }; + return { reply, chain }; +} + +function makeHost(reply: unknown, req: unknown = {}): ArgumentsHost { + return { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => reply, + getNext: () => undefined, + }), + } as unknown as ArgumentsHost; +} + +const OPTIONS: AuthplaneModuleOptions = { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + requiredScopes: ["tools/add"], +}; + +const RESOURCE = { + prmDocumentUrl: () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", +} as const; + +function newFilter(options: AuthplaneModuleOptions = OPTIONS) { + return new AuthplaneExceptionFilter( + options, + RESOURCE as unknown as ConstructorParameters< + typeof AuthplaneExceptionFilter + >[1], + ); +} + +describe("AuthplaneExceptionFilter — TokenMissing (401)", () => { + it("sends 401 JSON + Bearer challenge on Express-style responses", () => { + const { reply, chain } = expressReply(); + newFilter().catch(new TokenMissing("nope"), makeHost(reply)); + + expect(chain.status).toBe(401); + expect(chain.body).toEqual({ + error: "invalid_token", + error_description: "nope", + }); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('Bearer realm="https://api.example.com/mcp"'), + ); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('error="invalid_token"'), + ); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', + ), + ); + }); + + it("sends 401 JSON + Bearer challenge on Fastify-style responses", () => { + const { reply, chain } = fastifyReply(); + newFilter().catch(new TokenMissing("nope"), makeHost(reply)); + + expect(chain.status).toBe(401); + expect(chain.body).toEqual({ + error: "invalid_token", + error_description: "nope", + }); + expect(reply.header).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('error="invalid_token"'), + ); + expect(reply.code).toHaveBeenCalledWith(401); + expect(reply.send).toHaveBeenCalled(); + }); + + it("maps TokenExpired to 401 too", () => { + const { reply, chain } = expressReply(); + newFilter().catch(new TokenExpired("expired"), makeHost(reply)); + expect(chain.status).toBe(401); + }); +}); + +describe("AuthplaneExceptionFilter — InsufficientScope (403)", () => { + it("sends 403 + scope=\"…\" from the request stash (route-level wins)", () => { + const { reply, chain } = expressReply(); + const req = { + [REQUIRED_SCOPES_REQUEST_KEY]: ["tools/multiply"], + }; + newFilter().catch( + new InsufficientScope("need more"), + makeHost(reply, req), + ); + + expect(chain.status).toBe(403); + expect(chain.body).toEqual({ + error: "insufficient_scope", + error_description: "need more", + }); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('scope="tools/multiply"'), + ); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('error="insufficient_scope"'), + ); + }); + + it("falls back to options.requiredScopes when the stash is empty", () => { + const { reply } = expressReply(); + newFilter().catch(new InsufficientScope("need more"), makeHost(reply)); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('scope="tools/add"'), + ); + }); + + it("falls back to options.scopes when both stash and requiredScopes are absent", () => { + const { reply } = expressReply(); + newFilter({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + }).catch(new InsufficientScope("need more"), makeHost(reply)); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('scope="tools/add"'), + ); + }); +}); + +describe("AuthplaneExceptionFilter — DPoP scheme handling (RFC 9449 §7.1)", () => { + it("emits the DPoP scheme when the error is a DPoPError subclass", () => { + const { reply } = expressReply(); + newFilter().catch( + new DPoPReplayDetected("proof seen"), + makeHost(reply), + ); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringMatching(/^DPoP /u), + ); + }); + + it("emits the Bearer scheme for DPoPNotSupported (the carve-out)", () => { + const { reply } = expressReply(); + newFilter().catch( + new DPoPNotSupported("resource not DPoP-aware"), + makeHost(reply), + ); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringMatching(/^Bearer /u), + ); + }); + + it("emits the Bearer scheme for non-DPoP errors", () => { + const { reply } = expressReply(); + newFilter().catch(new TokenMissing("plain"), makeHost(reply)); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringMatching(/^Bearer /u), + ); + }); +}); + +describe("AuthplaneExceptionFilter — upstream-failure mapping", () => { + it("maps JWKSFetchError to 503 via core httpStatus()", () => { + const { reply, chain } = expressReply(); + newFilter().catch( + new JWKSFetchError("AS unreachable"), + makeHost(reply), + ); + expect(chain.status).toBe(503); + }); +}); + +describe("AuthplaneExceptionFilter — header sanitisation", () => { + it("strips quote / CR / LF / backslash from interpolated values", () => { + const { reply } = expressReply(); + newFilter().catch( + new TokenMissing('bad "quotes"\r\nInjected: x\\path'), + makeHost(reply), + ); + const headerCall = reply.setHeader.mock.calls.find( + (call) => call[0] === "WWW-Authenticate", + ); + expect(headerCall).toBeDefined(); + const value = headerCall?.[1] as string; + expect(value).not.toContain("\r"); + expect(value).not.toContain("\n"); + expect(value).not.toContain('"quotes"'); + expect(value).not.toContain("x\\path"); + const matched = value.match(/error_description="([^"]*)"/u); + expect(matched?.[1]).toBeDefined(); + }); + + it("also sanitises the realm value", () => { + const { reply } = expressReply(); + newFilter({ + ...OPTIONS, + resource: 'https://api.example.com/x"; injected"', + }).catch(new TokenMissing("oops"), makeHost(reply)); + const headerCall = reply.setHeader.mock.calls.find( + (call) => call[0] === "WWW-Authenticate", + ); + const value = headerCall?.[1] as string; + expect(value).not.toContain('"; injected"'); + }); +}); + +describe("AuthplaneExceptionFilter — PRM URL handling", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + function buildFailingFilter(): AuthplaneExceptionFilter { + const failing = { + prmDocumentUrl: () => { + throw new Error("not configured"); + }, + } as const; + return new AuthplaneExceptionFilter( + OPTIONS, + failing as unknown as ConstructorParameters< + typeof AuthplaneExceptionFilter + >[1], + ); + } + + it("omits resource_metadata when prmDocumentUrl() throws", () => { + const { reply } = expressReply(); + buildFailingFilter().catch(new TokenMissing("nope"), makeHost(reply)); + const headerCall = reply.setHeader.mock.calls.find( + (call) => call[0] === "WWW-Authenticate", + ); + expect(headerCall?.[1]).not.toContain("resource_metadata="); + }); + + it("warns exactly once even when the resource keeps failing", () => { + const filter = buildFailingFilter(); + const { reply: reply1 } = expressReply(); + const { reply: reply2 } = expressReply(); + const { reply: reply3 } = expressReply(); + filter.catch(new TokenMissing("nope"), makeHost(reply1)); + filter.catch(new TokenMissing("nope"), makeHost(reply2)); + filter.catch(new TokenMissing("nope"), makeHost(reply3)); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain("prmDocumentUrl() threw"); + expect(warnSpy.mock.calls[0]?.[0]).toContain("not configured"); + }); +}); + +describe("AuthplaneExceptionFilter — Fastify defensive fallback", () => { + it("falls back to r.send(body) when r.code() returns void (older Fastify)", () => { + const chain: Record = {}; + const reply = { + header: vi.fn(), + code: vi.fn((code: number) => { + chain.status = code; + return undefined; + }), + send: vi.fn((body: unknown) => { + chain.body = body; + return reply; + }), + }; + newFilter().catch(new TokenMissing("nope"), makeHost(reply)); + expect(reply.send).toHaveBeenCalled(); + expect(chain.status).toBe(401); + }); + + it("does not throw when status() returns void without .json (degenerate reply)", () => { + const reply = { + setHeader: vi.fn(), + status: vi.fn(() => undefined), + }; + expect(() => + newFilter().catch(new TokenMissing("nope"), makeHost(reply)), + ).not.toThrow(); + expect(reply.status).toHaveBeenCalledWith(401); + }); + + it("does not throw when reply has neither setHeader nor header", () => { + const reply = { + status: vi.fn(() => ({ json: vi.fn() })), + }; + expect(() => + newFilter().catch(new TokenMissing("nope"), makeHost(reply)), + ).not.toThrow(); + }); + + it("does not throw when reply is null", () => { + expect(() => + newFilter().catch(new TokenMissing("nope"), makeHost(null)), + ).not.toThrow(); + }); +}); + +describe("AuthplaneExceptionFilter — @Catch contract", () => { + it("claims AuthplaneError, not raw Error", () => { + const catchMetadata = Reflect.getMetadata?.( + "__filterCatchExceptions__", + AuthplaneExceptionFilter, + ) as readonly Function[] | undefined; + expect(catchMetadata).toBeDefined(); + expect(catchMetadata).toContain(AuthplaneError); + // Importantly, NOT raw Error — that would swallow every HttpException + // when the filter is mounted globally. + expect(catchMetadata).not.toContain(Error); + }); +}); diff --git a/packages/nestjs/tests/application/authplane.guard.test.ts b/packages/nestjs/tests/application/authplane.guard.test.ts new file mode 100644 index 0000000..a51cd3b --- /dev/null +++ b/packages/nestjs/tests/application/authplane.guard.test.ts @@ -0,0 +1,359 @@ +import type { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { + type AuthplaneResource, + InsufficientScope, + MultipleDPoPProofs, + TokenMissing, + VerifiedClaims, +} from "@authplane/sdk/core"; +import { describe, expect, it, vi } from "vitest"; + +import { AuthplaneAuthGuard } from "../../src/application/authplane.guard.js"; +import { METADATA_KEY_REQUIRED_SCOPES } from "../../src/application/metadata-keys.js"; +import { + AUTH_INFO_REQUEST_KEY, + defaultRequestAdapter, +} from "../../src/infrastructure/request-adapter.js"; +import type { AuthplaneModuleOptions } from "../../src/module/authplane.options.js"; + +function buildClaims( + overrides: Partial[0]> = {}, +): VerifiedClaims { + return new VerifiedClaims({ + sub: "user_1", + clientId: "client_1", + scopes: ["tools/add"], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: Math.floor(Date.now() / 1000) + 60, + issuedAt: Math.floor(Date.now() / 1000) - 60, + jti: "jti-1", + kid: "kid-1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + ...overrides, + }); +} + +function expressReq(overrides: Record = {}) { + return { + method: "POST", + originalUrl: "/mcp", + headers: { + host: "api.example.com", + authorization: "Bearer abc", + }, + ...overrides, + }; +} + +function makeContext( + req: unknown, + handler: (...args: readonly unknown[]) => unknown = () => undefined, + cls: object = class {}, +): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => ({}), + getNext: () => undefined, + }), + getHandler: () => handler, + getClass: () => cls, + } as unknown as ExecutionContext; +} + +function buildGuard(params: { + readonly verifier: AuthplaneResource; + readonly options: AuthplaneModuleOptions; + readonly reflector?: Reflector; +}): AuthplaneAuthGuard { + const reflector = params.reflector ?? new Reflector(); + return new AuthplaneAuthGuard( + params.verifier, + params.options, + defaultRequestAdapter, + reflector, + ); +} + +describe("AuthplaneAuthGuard", () => { + it("throws core TokenMissing when the Authorization header is missing", async () => { + const verifier = { verify: vi.fn() } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + }); + const ctx = makeContext(expressReq({ headers: {} })); + + await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(TokenMissing); + expect(verifier.verify).not.toHaveBeenCalled(); + }); + + it("stashes verified claims on the request and returns true", async () => { + const claims = buildClaims(); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + }); + const req = expressReq(); + const ctx = makeContext(req); + + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(verifier.verify).toHaveBeenCalledWith("abc", { + dpopRequest: undefined, + }); + expect((req as Record)[AUTH_INFO_REQUEST_KEY]).toBe( + claims, + ); + }); + + it("enforces module-level requiredScopes (core InsufficientScope)", async () => { + const claims = buildClaims({ scopes: ["tools/read"] }); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + requiredScopes: ["tools/write"], + }, + }); + + await expect( + guard.canActivate(makeContext(expressReq())), + ).rejects.toBeInstanceOf(InsufficientScope); + }); + + it("defaults requiredScopes to options.scopes when not provided", async () => { + const claims = buildClaims({ scopes: ["tools/read"] }); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/write"], + }, + }); + + await expect( + guard.canActivate(makeContext(expressReq())), + ).rejects.toBeInstanceOf(InsufficientScope); + }); + + it("treats requiredScopes: [] as 'enforce nothing' even when scopes is non-empty", async () => { + const claims = buildClaims({ scopes: ["tools/read"] }); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/write"], + requiredScopes: [], + }, + }); + + await expect( + guard.canActivate(makeContext(expressReq())), + ).resolves.toBe(true); + }); + + it("enforces route-level @RequireScopes via Reflector", async () => { + const claims = buildClaims({ scopes: ["tools/add"] }); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const reflector = { + getAllAndOverride: vi.fn(() => undefined), + getAllAndMerge: vi.fn(() => ["tools/add", "tools/admin"]), + } as unknown as Reflector; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + reflector, + }); + + await expect( + guard.canActivate(makeContext(expressReq())), + ).rejects.toBeInstanceOf(InsufficientScope); + expect(reflector.getAllAndMerge).toHaveBeenCalledWith( + METADATA_KEY_REQUIRED_SCOPES, + expect.any(Array), + ); + }); + + it("threads the DPoP proof through to verify() on every request that carries one", async () => { + const claims = buildClaims(); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + }); + const req = expressReq({ + headers: { + host: "api.example.com", + authorization: "Bearer abc", + dpop: "proof-value", + }, + }); + + await guard.canActivate(makeContext(req)); + + expect(verifier.verify).toHaveBeenCalledWith("abc", { + dpopRequest: expect.objectContaining({ + method: "POST", + url: "https://api.example.com/mcp", + proofs: ["proof-value"], + }), + }); + }); + + it("pins the DPoP htu to the configured resource — ignores Host / X-Forwarded-* spoofing", async () => { + const claims = buildClaims(); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + }); + const req = expressReq({ + headers: { + host: "evil.example.com", + "x-forwarded-host": "spoofed.example.com", + "x-forwarded-proto": "http", + authorization: "Bearer abc", + dpop: "proof-value", + }, + originalUrl: "/mcp/tools/call?id=42", + }); + + await guard.canActivate(makeContext(req)); + + expect(verifier.verify).toHaveBeenCalledWith("abc", { + dpopRequest: expect.objectContaining({ + method: "POST", + url: "https://api.example.com/mcp/tools/call?id=42", + proofs: ["proof-value"], + }), + }); + }); + + it("rejects requests carrying two DPoP headers without invoking the verifier (RFC 9449 §4.3)", async () => { + // `http.IncomingMessage.headers` (the bag Express surfaces as + // `req.headers`) comma-folds duplicate same-name values for + // everything outside Node's fixed allow-list — so two `DPoP` + // headers on the wire arrive as the single string + // `"proofA, proofB"`. The `buildDPoPRequestContext` factory + // splits on `,` (JWS compact serialisation never contains a + // literal `,`, so any comma is a merged duplicate) and raises + // `MultipleDPoPProofs`. The exception filter routes that + // `AuthplaneError` through `wwwAuthenticate()`; the verifier + // MUST NOT run for a request the spec already requires us to + // reject. The string[] shape — landing in `req.headers` only + // when callers reach for `req.rawHeaders` or hand-build the + // bag — is covered separately in the request-adapter tests. + const verifier = { verify: vi.fn() } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + }); + const req = expressReq({ + headers: { + host: "api.example.com", + authorization: "Bearer abc", + dpop: "eyJ.first, eyJ.second", + }, + }); + + await expect(guard.canActivate(makeContext(req))).rejects.toBeInstanceOf( + MultipleDPoPProofs, + ); + expect(verifier.verify).not.toHaveBeenCalled(); + }); + + it("short-circuits with true when @SkipAuth is set on the handler", async () => { + const verifier = { verify: vi.fn() } as unknown as AuthplaneResource; + const reflector = { + getAllAndOverride: vi.fn((key: string) => + key === "authplane:skipAuth" ? true : undefined, + ), + getAllAndMerge: vi.fn(() => []), + } as unknown as Reflector; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }, + reflector, + }); + + await expect( + guard.canActivate(makeContext(expressReq({ headers: {} }))), + ).resolves.toBe(true); + expect(verifier.verify).not.toHaveBeenCalled(); + }); +}); + +describe("AuthplaneAuthGuard — constructor validation", () => { + it("throws TypeError with the inner URL failure on .cause when 'resource' is not an absolute URL", () => { + const verifier = { verify: vi.fn() } as unknown as AuthplaneResource; + let thrown: unknown; + try { + buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "not-a-url", + }, + }); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(TypeError); + expect((thrown as Error).message).toContain( + "AuthplaneModule: 'resource' must be an absolute URL", + ); + expect((thrown as Error).message).toContain('"not-a-url"'); + // `cause` carries the raw `new URL()` failure so a debugger can chain + // to the underlying parse error. + expect((thrown as Error & { cause?: unknown }).cause).toBeInstanceOf( + Error, + ); + }); +}); diff --git a/packages/nestjs/tests/application/decorators.test.ts b/packages/nestjs/tests/application/decorators.test.ts new file mode 100644 index 0000000..a15cc50 --- /dev/null +++ b/packages/nestjs/tests/application/decorators.test.ts @@ -0,0 +1,165 @@ +import type { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { VerifiedClaims } from "@authplane/sdk/core"; +import { describe, expect, it } from "vitest"; + +import { + AuthInfo, + RequireScopes, + SkipAuth, +} from "../../src/application/decorators.js"; +import { + METADATA_KEY_REQUIRED_SCOPES, + METADATA_KEY_SKIP_AUTH, +} from "../../src/application/metadata-keys.js"; +import { AUTH_INFO_REQUEST_KEY } from "../../src/infrastructure/request-adapter.js"; + +function buildClaims(): VerifiedClaims { + return new VerifiedClaims({ + sub: "s", + clientId: "c", + scopes: [], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: 0, + issuedAt: 0, + jti: "j", + kid: "k", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + }); +} + +describe("@RequireScopes", () => { + it("stores the supplied scopes under METADATA_KEY_REQUIRED_SCOPES on a method", () => { + class MathController { + @RequireScopes("tools/add", "tools/multiply") + public add(): void {} + } + + const meta = Reflect.getMetadata( + METADATA_KEY_REQUIRED_SCOPES, + MathController.prototype.add, + ); + expect(meta).toEqual(["tools/add", "tools/multiply"]); + }); + + it("stacks class-level and method-level metadata via Reflector.getAllAndMerge", () => { + @RequireScopes("tools/base") + class MathController { + @RequireScopes("tools/add") + public add(): void {} + } + + const reflector = new Reflector(); + const merged = reflector.getAllAndMerge( + METADATA_KEY_REQUIRED_SCOPES, + [MathController.prototype.add, MathController], + ); + expect(merged).toEqual( + expect.arrayContaining(["tools/base", "tools/add"]), + ); + }); + + it("accepts zero scopes without throwing", () => { + class MathController { + @RequireScopes() + public noop(): void {} + } + const meta = Reflect.getMetadata( + METADATA_KEY_REQUIRED_SCOPES, + MathController.prototype.noop, + ); + expect(meta).toEqual([]); + }); +}); + +describe("@AuthInfo", () => { + it("is exported as a param decorator (factory returning a ParameterDecorator)", () => { + const decoratorFactory = AuthInfo; + expect(typeof decoratorFactory).toBe("function"); + const decorator = decoratorFactory(); + expect(typeof decorator).toBe("function"); + }); + + it("exposes an internal extractor that reads the stashed claims from the request", () => { + const claims = buildClaims(); + const req: Record = {}; + req[AUTH_INFO_REQUEST_KEY] = claims; + const ctx = { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; + + const extractor = ( + AuthInfo as unknown as { + __authplaneExtractor: ( + data: unknown, + ctx: ExecutionContext, + ) => unknown; + } + ).__authplaneExtractor; + expect(extractor(undefined, ctx)).toBe(claims); + }); + + it("returns undefined when no auth info has been stashed", () => { + const req: Record = {}; + const ctx = { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; + const extractor = ( + AuthInfo as unknown as { + __authplaneExtractor: ( + data: unknown, + ctx: ExecutionContext, + ) => unknown; + } + ).__authplaneExtractor; + expect(extractor(undefined, ctx)).toBeUndefined(); + }); + + it("returns undefined when getRequest() returns null (defensive)", () => { + const ctx = { + switchToHttp: () => ({ getRequest: () => null }), + } as unknown as ExecutionContext; + const extractor = ( + AuthInfo as unknown as { + __authplaneExtractor: ( + data: unknown, + ctx: ExecutionContext, + ) => unknown; + } + ).__authplaneExtractor; + expect(extractor(undefined, ctx)).toBeUndefined(); + }); +}); + +describe("@SkipAuth", () => { + it("marks the handler as public under METADATA_KEY_SKIP_AUTH", () => { + class HealthController { + @SkipAuth() + public ping(): void {} + } + + const meta = Reflect.getMetadata( + METADATA_KEY_SKIP_AUTH, + HealthController.prototype.ping, + ); + expect(meta).toBe(true); + }); + + it("also works at the class level and is visible via Reflector.getAllAndOverride", () => { + @SkipAuth() + class PublicController { + public one(): void {} + } + + const reflector = new Reflector(); + const flag = reflector.getAllAndOverride(METADATA_KEY_SKIP_AUTH, [ + PublicController.prototype.one, + PublicController, + ]); + expect(flag).toBe(true); + }); +}); diff --git a/packages/nestjs/tests/index.test.ts b/packages/nestjs/tests/index.test.ts new file mode 100644 index 0000000..2af533b --- /dev/null +++ b/packages/nestjs/tests/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import * as pkg from "../src/index.js"; + +describe("@authplane/nestjs public barrel", () => { + const REQUIRED_VALUE_EXPORTS = [ + // Module + composition + "AuthplaneModule", + "AuthplaneShutdownHook", + "AUTHPLANE_CLIENT", + "AUTHPLANE_MODULE_OPTIONS", + "AUTHPLANE_REQUEST_ADAPTER", + "AUTHPLANE_RESOURCE", + "AUTHPLANE_TOKEN_VERIFIER", + // Guard, filter, decorators + "AuthplaneAuthGuard", + "AuthplaneExceptionFilter", + "AuthInfo", + "RequireScopes", + "SkipAuth", + "METADATA_KEY_REQUIRED_SCOPES", + "METADATA_KEY_SKIP_AUTH", + // Infrastructure + "AUTH_INFO_REQUEST_KEY", + "defaultRequestAdapter", + "REQUIRED_SCOPES_REQUEST_KEY", + // Re-exports from core (the user-guide options table references these) + "AuthplaneError", + "DPoPProvider", + "InsufficientScope", + "TokenExpired", + "TokenMissing", + "VerifiedClaims", + ] as const; + + it.each(REQUIRED_VALUE_EXPORTS)("exports %s", (symbol) => { + expect(symbol in pkg).toBe(true); + expect((pkg as unknown as Record)[symbol]).toBeDefined(); + }); + + it("does not leak internal helpers", () => { + const exported = Object.keys(pkg); + expect(exported).not.toContain("headersOf"); + expect(exported).not.toContain("readHeader"); + expect(exported).not.toContain("buildOptionsProvider"); + // Removed adapter-specific symbols (use core equivalents): + expect(exported).not.toContain("AuthplaneNestAuthError"); + expect(exported).not.toContain("InvalidTokenError"); + expect(exported).not.toContain("InsufficientScopeError"); + expect(exported).not.toContain("AuthplaneTokenVerifier"); + expect(exported).not.toContain("AuthplaneNestAuthInfo"); + expect(exported).not.toContain("buildRequestUrl"); + expect(exported).not.toContain("deriveProtectedResourceMetadataPath"); + }); +}); diff --git a/packages/nestjs/tests/infrastructure/request-adapter.test.ts b/packages/nestjs/tests/infrastructure/request-adapter.test.ts new file mode 100644 index 0000000..5f404c6 --- /dev/null +++ b/packages/nestjs/tests/infrastructure/request-adapter.test.ts @@ -0,0 +1,234 @@ +import { VerifiedClaims } from "@authplane/sdk/core"; +import { describe, expect, it } from "vitest"; + +import { + AUTH_INFO_REQUEST_KEY, + defaultRequestAdapter, +} from "../../src/infrastructure/request-adapter.js"; + +function expressReq(overrides: Record = {}) { + return { + method: "POST", + originalUrl: "/math/add?mode=live", + headers: { + authorization: "Bearer abc", + dpop: "proof-value", + }, + ...overrides, + }; +} + +function fastifyReq(overrides: Record = {}) { + return { + // Fastify's wrapper exposes the same fields on top-level + `raw`. + method: "POST", + headers: { + authorization: "Bearer abc", + dpop: "proof-value", + }, + raw: { + url: "/math/add?mode=live", + headers: { + authorization: "Bearer abc", + dpop: "proof-value", + }, + method: "post", + }, + ...overrides, + }; +} + +describe("defaultRequestAdapter.getHeader", () => { + it("reads case-insensitively on an Express-style request", () => { + const req = expressReq(); + expect(defaultRequestAdapter.getHeader(req, "Authorization")).toBe( + "Bearer abc", + ); + expect(defaultRequestAdapter.getHeader(req, "AUTHORIZATION")).toBe( + "Bearer abc", + ); + expect(defaultRequestAdapter.getHeader(req, "DPoP")).toBe("proof-value"); + }); + + it("falls back to request.raw.headers for Fastify-style requests", () => { + const req = fastifyReq({ headers: {} }); + expect(defaultRequestAdapter.getHeader(req, "authorization")).toBe( + "Bearer abc", + ); + }); + + it("returns undefined when the header is absent", () => { + expect(defaultRequestAdapter.getHeader(expressReq({ headers: {} }), "authorization")) + .toBeUndefined(); + }); + + it("returns undefined for duplicate-named Authorization (Express)", () => { + // Picking the first value would be a header-smuggling vector + // (intermediaries disagree on which copy is canonical), and we want + // the downstream BearerToken parser to treat the request as missing + // the header. RFC 9449 §4.2 also forbids more than one DPoP header. + const req = expressReq({ + headers: { authorization: ["Bearer a", "Bearer b"] }, + }); + expect( + defaultRequestAdapter.getHeader(req, "authorization"), + ).toBeUndefined(); + }); + + it("returns undefined for duplicate-named DPoP (Express)", () => { + const req = expressReq({ + headers: { dpop: ["proof-a", "proof-b"] }, + }); + expect(defaultRequestAdapter.getHeader(req, "DPoP")).toBeUndefined(); + }); + + it("returns undefined for duplicate-named Authorization (Fastify raw)", () => { + const req = fastifyReq({ + headers: {}, + raw: { + url: "/x", + method: "post", + headers: { authorization: ["Bearer a", "Bearer b"] }, + }, + }); + expect( + defaultRequestAdapter.getHeader(req, "authorization"), + ).toBeUndefined(); + }); + + it("returns undefined for duplicate-named DPoP (Fastify raw)", () => { + const req = fastifyReq({ + headers: {}, + raw: { + url: "/x", + method: "post", + headers: { dpop: ["proof-a", "proof-b"] }, + }, + }); + expect(defaultRequestAdapter.getHeader(req, "DPoP")).toBeUndefined(); + }); + + it("returns undefined when the request has no headers at all", () => { + expect(defaultRequestAdapter.getHeader({}, "authorization")).toBeUndefined(); + }); +}); + +describe("defaultRequestAdapter.getHeaderValues", () => { + it("returns a single string when the DPoP header arrives as a string", () => { + const req = expressReq(); + expect(defaultRequestAdapter.getHeaderValues(req, "DPoP")).toBe( + "proof-value", + ); + }); + + it("preserves the array shape for duplicate-named DPoP headers (Express)", () => { + // `getHeader` collapses arrays to undefined for header-smuggling + // protection; `getHeaderValues` must NOT — DPoP needs the multi-value + // shape so the SDK can raise `MultipleDPoPProofs` (RFC 9449 §4.3). + const req = expressReq({ + headers: { dpop: ["proof-a", "proof-b"] }, + }); + expect(defaultRequestAdapter.getHeaderValues(req, "DPoP")).toEqual([ + "proof-a", + "proof-b", + ]); + }); + + it("preserves the array shape for duplicate-named DPoP headers (Fastify raw)", () => { + const req = fastifyReq({ + headers: {}, + raw: { + url: "/x", + method: "post", + headers: { dpop: ["proof-a", "proof-b"] }, + }, + }); + expect(defaultRequestAdapter.getHeaderValues(req, "DPoP")).toEqual([ + "proof-a", + "proof-b", + ]); + }); + + it("returns undefined when the header is absent", () => { + expect( + defaultRequestAdapter.getHeaderValues( + expressReq({ headers: {} }), + "dpop", + ), + ).toBeUndefined(); + }); +}); + +describe("defaultRequestAdapter.getMethod", () => { + it("uppercases the Express method", () => { + expect(defaultRequestAdapter.getMethod(expressReq({ method: "post" }))).toBe( + "POST", + ); + }); + + it("falls back to request.raw.method for Fastify-style requests", () => { + expect(defaultRequestAdapter.getMethod(fastifyReq({ method: undefined }))).toBe( + "POST", + ); + }); + + it("defaults to POST when no method is exposed", () => { + expect(defaultRequestAdapter.getMethod({})).toBe("POST"); + }); +}); + +describe("defaultRequestAdapter.getPathAndQuery", () => { + it("prefers req.originalUrl (Express)", () => { + expect( + defaultRequestAdapter.getPathAndQuery( + expressReq({ originalUrl: "/a?b=1", url: "/ignored" }), + ), + ).toBe("/a?b=1"); + }); + + it("uses req.url when originalUrl is missing", () => { + expect( + defaultRequestAdapter.getPathAndQuery({ url: "/just-url" }), + ).toBe("/just-url"); + }); + + it("falls back to req.raw.url when neither originalUrl nor url is set", () => { + expect( + defaultRequestAdapter.getPathAndQuery({ + raw: { url: "/from-raw" }, + }), + ).toBe("/from-raw"); + }); + + it("defaults to '/' when nothing is set", () => { + expect(defaultRequestAdapter.getPathAndQuery({})).toBe("/"); + }); +}); + +describe("defaultRequestAdapter auth-info stash round-trip", () => { + const claims = new VerifiedClaims({ + sub: "s", + clientId: "c", + scopes: [], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: 0, + issuedAt: 0, + jti: "j", + kid: "k", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + }); + + it("stashAuthInfo writes to the documented symbol key", () => { + const req: Record = {}; + defaultRequestAdapter.stashAuthInfo(req, claims); + // The @AuthInfo() parameter decorator reads this symbol key directly + // (no round-trip through the adapter), so custom adapters must keep + // the contract: write under AUTH_INFO_REQUEST_KEY or @AuthInfo() + // silently returns undefined. + expect(req[AUTH_INFO_REQUEST_KEY]).toBe(claims); + }); +}); diff --git a/packages/nestjs/tests/integration/auth.integration.test.ts b/packages/nestjs/tests/integration/auth.integration.test.ts new file mode 100644 index 0000000..007df68 --- /dev/null +++ b/packages/nestjs/tests/integration/auth.integration.test.ts @@ -0,0 +1,273 @@ +import { + Body, + Controller, + Get, + type INestApplication, + Module, + Post, + UseGuards, +} from "@nestjs/common"; +import { + FastifyAdapter, + type NestFastifyApplication, +} from "@nestjs/platform-fastify"; +import { Test } from "@nestjs/testing"; +import { + AuthplaneClient, + type AuthplaneResource, + InvalidSignature, + TokenExpired, + VerifiedClaims, +} from "@authplane/sdk/core"; +import supertest from "supertest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + AuthInfo, + AuthplaneAuthGuard, + AuthplaneExceptionFilter, + AuthplaneModule, + RequireScopes, +} from "../../src/index.js"; + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +@Controller("math") +@UseGuards(AuthplaneAuthGuard) +class MathController { + @Post("add") + @RequireScopes("tools/add") + public add( + @AuthInfo() info: VerifiedClaims, + @Body() body: { a: number; b: number }, + ): { readonly result: number; readonly caller: string } { + return { result: body.a + body.b, caller: info.clientId }; + } + + @Get("whoami") + public whoami( + @AuthInfo() info: VerifiedClaims, + ): { readonly sub: string } { + return { sub: info.sub }; + } +} + +@Module({ + imports: [ + AuthplaneModule.forRoot({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + }), + ], + controllers: [MathController], +}) +class TestAppModule {} + +function mockResource(): AuthplaneResource { + return { + verify: vi.fn(), + prmResponse: vi.fn(() => ({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add"], + bearer_methods_supported: ["header"], + })), + prmDocumentUrl: vi.fn( + () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), + } as unknown as AuthplaneResource; +} + +function mockClient(resource: AuthplaneResource) { + return { + resource: vi.fn(() => resource), + close: vi.fn(async () => undefined), + }; +} + +function buildClaims( + overrides: Partial[0]> = {}, +): VerifiedClaims { + const now = Math.floor(Date.now() / 1000); + return new VerifiedClaims({ + sub: "user_1", + clientId: "client_1", + scopes: ["tools/add"], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: now + 600, + issuedAt: now - 60, + jti: "jti", + kid: "kid", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + ...overrides, + }); +} + +// --------------------------------------------------------------------------- +// The platforms we support share the same module + guard + filter — hammer +// both with the same battery of behavioural tests. +// --------------------------------------------------------------------------- + +type AppFactory = () => Promise; + +const platforms: ReadonlyArray<{ + readonly name: string; + readonly build: AppFactory; +}> = [ + { + name: "platform-express", + build: async () => { + const moduleRef = await Test.createTestingModule({ + imports: [TestAppModule], + }).compile(); + return moduleRef.createNestApplication(); + }, + }, + { + name: "platform-fastify", + build: async () => { + const moduleRef = await Test.createTestingModule({ + imports: [TestAppModule], + }).compile(); + return moduleRef.createNestApplication( + new FastifyAdapter(), + ); + }, + }, +]; + +describe.each(platforms)( + "AuthplaneModule — end-to-end ($name)", + ({ name, build }) => { + let app: INestApplication; + let resource: AuthplaneResource; + let client: ReturnType; + + beforeAll(async () => { + resource = mockResource(); + client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + app = await build(); + + // Register the exception filter globally so it catches errors + // thrown from the guard regardless of which controller they hit. + app.useGlobalFilters(app.get(AuthplaneExceptionFilter)); + + await app.init(); + if (name === "platform-fastify") { + await ( + app as unknown as { getHttpAdapter: () => { getInstance: () => { ready: () => Promise } } } + ) + .getHttpAdapter() + .getInstance() + .ready(); + } + }); + + afterAll(async () => { + await app.close(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + // Default: verify succeeds for "valid_jwt", rejects everything else. + const verifyMock = resource.verify as ReturnType; + verifyMock.mockReset(); + verifyMock.mockImplementation(async (token: string) => { + if (token === "valid_jwt") { + return buildClaims(); + } + if (token === "expired_jwt") { + throw new TokenExpired("Token expired"); + } + if (token === "wrong_scope_jwt") { + return buildClaims({ scopes: ["tools/read"] }); + } + throw new InvalidSignature("Unknown token"); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("401s when the Authorization header is absent", async () => { + const response = await supertest(app.getHttpServer()) + .post("/math/add") + .send({ a: 1, b: 2 }); + expect(response.status).toBe(401); + expect(response.headers["www-authenticate"]).toContain( + 'error="invalid_token"', + ); + }); + + it("401s + PRM URL in challenge for an expired token", async () => { + const response = await supertest(app.getHttpServer()) + .post("/math/add") + .set("Authorization", "Bearer expired_jwt") + .send({ a: 1, b: 2 }); + expect(response.status).toBe(401); + expect(response.headers["www-authenticate"]).toContain( + 'error="invalid_token"', + ); + expect(response.headers["www-authenticate"]).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', + ); + }); + + it("403s with scope hint when the token lacks the required scope", async () => { + const response = await supertest(app.getHttpServer()) + .post("/math/add") + .set("Authorization", "Bearer wrong_scope_jwt") + .send({ a: 1, b: 2 }); + expect(response.status).toBe(403); + expect(response.headers["www-authenticate"]).toContain( + 'error="insufficient_scope"', + ); + expect(response.headers["www-authenticate"]).toContain( + 'scope="tools/add"', + ); + }); + + it("200s on the happy path and injects VerifiedClaims via @AuthInfo", async () => { + const response = await supertest(app.getHttpServer()) + .post("/math/add") + .set("Authorization", "Bearer valid_jwt") + .send({ a: 40, b: 2 }); + expect(response.status).toBe(201); // NestJS default for POST without explicit @HttpCode is 201 + expect(response.body).toEqual({ result: 42, caller: "client_1" }); + }); + + it("serves the PRM document at the derived well-known path without auth", async () => { + const response = await supertest(app.getHttpServer()).get( + "/.well-known/oauth-protected-resource/mcp", + ); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add"], + bearer_methods_supported: ["header"], + }); + }); + }, +); diff --git a/packages/nestjs/tests/module/authplane.module.test.ts b/packages/nestjs/tests/module/authplane.module.test.ts new file mode 100644 index 0000000..7436122 --- /dev/null +++ b/packages/nestjs/tests/module/authplane.module.test.ts @@ -0,0 +1,532 @@ +import { + AuthplaneClient, + type AuthplaneResource, +} from "@authplane/sdk/core"; +import { Test } from "@nestjs/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Module } from "@nestjs/common"; + +import { AuthplaneAuthGuard } from "../../src/application/authplane.guard.js"; +import { AuthplaneExceptionFilter } from "../../src/application/authplane.exception-filter.js"; +import { AuthplaneModule } from "../../src/module/authplane.module.js"; +import { AuthplaneShutdownHook } from "../../src/module/authplane.shutdown-hook.js"; +import { + AUTHPLANE_CLIENT, + AUTHPLANE_MODULE_OPTIONS, + AUTHPLANE_REQUEST_ADAPTER, + AUTHPLANE_RESOURCE, + AUTHPLANE_TOKEN_VERIFIER, +} from "../../src/module/authplane.tokens.js"; +import type { AuthplaneModuleOptions } from "../../src/module/authplane.options.js"; + +function mockResource(): AuthplaneResource { + return { + verify: vi.fn(), + prmResponse: vi.fn(() => ({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + })), + prmDocumentUrl: vi.fn( + () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), + } as unknown as AuthplaneResource; +} + +function mockClient(resource: AuthplaneResource) { + return { + resource: vi.fn(() => resource), + close: vi.fn(async () => undefined), + }; +} + +const BASE_OPTIONS: AuthplaneModuleOptions = { + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], +}; + +describe("AuthplaneModule.forRoot", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers the core providers under their DI tokens", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const moduleRef = await Test.createTestingModule({ + imports: [AuthplaneModule.forRoot(BASE_OPTIONS)], + }).compile(); + + expect(moduleRef.get(AUTHPLANE_CLIENT)).toBe(client); + expect(moduleRef.get(AUTHPLANE_RESOURCE)).toBe(resource); + // AUTHPLANE_TOKEN_VERIFIER now resolves to the same AuthplaneResource + // instance — the adapter-level wrapper is gone, the guard calls + // core verify() directly. The token survives as a test seam. + expect(moduleRef.get(AUTHPLANE_TOKEN_VERIFIER)).toBe(resource); + expect(moduleRef.get(AUTHPLANE_MODULE_OPTIONS)).toEqual(BASE_OPTIONS); + expect(moduleRef.get(AUTHPLANE_REQUEST_ADAPTER)).toBeDefined(); + expect(moduleRef.get(AuthplaneAuthGuard)).toBeInstanceOf( + AuthplaneAuthGuard, + ); + expect(moduleRef.get(AuthplaneExceptionFilter)).toBeInstanceOf( + AuthplaneExceptionFilter, + ); + expect(moduleRef.get(AuthplaneShutdownHook)).toBeInstanceOf( + AuthplaneShutdownHook, + ); + + await moduleRef.close(); + }); + + it("calls client.close() via AuthplaneShutdownHook on moduleRef.close()", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const moduleRef = await Test.createTestingModule({ + imports: [AuthplaneModule.forRoot(BASE_OPTIONS)], + }).compile(); + // enableShutdownHooks is what triggers OnApplicationShutdown in prod; + // calling the hook directly is the closest we can get from a pure + // DI-only test. + await moduleRef.get(AuthplaneShutdownHook).onApplicationShutdown(); + + expect(client.close).toHaveBeenCalledOnce(); + await moduleRef.close(); + }); + + it("forwards optional passthrough options to AuthplaneClient.create + resource()", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client as unknown as AuthplaneClient); + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + asCredentials: { clientId: "rs", clientSecret: "s3cret" }, + devMode: true, + }), + ], + }).compile(); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + issuer: "https://auth.example.com", + // asCredentials is folded into `auth`, which the client wraps + // in ClientCredentialsProvider internally. + auth: { clientId: "rs", clientSecret: "s3cret" }, + devMode: true, + }), + ); + expect(client.resource).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + asCredentials: { clientId: "rs", clientSecret: "s3cret" }, + devMode: true, + }), + ); + + await moduleRef.close(); + }); + + it("registers the PRM controller at the derived well-known path", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const dynamicModule = AuthplaneModule.forRoot(BASE_OPTIONS); + expect(dynamicModule.controllers).toBeDefined(); + expect(dynamicModule.controllers).toHaveLength(1); + const [ctrl] = dynamicModule.controllers as ReadonlyArray<{ + prototype: Record; + }>; + const routePath = Reflect.getMetadata( + "path", + ctrl.prototype.serve as object, + ); + expect(routePath).toBe("/.well-known/oauth-protected-resource/mcp"); + }); +}); + +describe("AuthplaneModule.forRootAsync", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("supports useFactory with inject: []", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + class ConfigStub { + public getOptions(): AuthplaneModuleOptions { + return BASE_OPTIONS; + } + } + @Module({ + providers: [ConfigStub], + exports: [ConfigStub], + }) + class ConfigModuleStub {} + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRootAsync({ + imports: [ConfigModuleStub], + inject: [ConfigStub], + useFactory: (cfg: ConfigStub) => cfg.getOptions(), + } as never), + ], + }).compile(); + + expect(moduleRef.get(AUTHPLANE_CLIENT)).toBe(client); + await moduleRef.close(); + }); + + it("throws when none of useFactory/useClass/useExisting is provided", () => { + expect(() => AuthplaneModule.forRootAsync({} as never)).toThrow( + /requires useFactory, useClass, or useExisting/, + ); + }); + + it("honours a user-supplied requestAdapter override via options", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const customAdapter = { + getHeader: vi.fn(), + getMethod: vi.fn(), + getPathAndQuery: vi.fn(), + stashAuthInfo: vi.fn(), + }; + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + requestAdapter: customAdapter, + }), + ], + }).compile(); + + expect(moduleRef.get(AUTHPLANE_REQUEST_ADAPTER)).toBe(customAdapter); + await moduleRef.close(); + }); + + it("supports useClass with a factory class", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + class OptionsFactory { + public createAuthplaneOptions(): AuthplaneModuleOptions { + return BASE_OPTIONS; + } + } + + // useClass returns the provider shape; since AuthplaneModule does not + // register the factory itself, we add it via a sibling module so the + // inject resolves. + @Module({ + providers: [OptionsFactory], + exports: [OptionsFactory], + }) + class OptionsFactoryModule {} + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRootAsync({ + imports: [OptionsFactoryModule], + useClass: OptionsFactory, + } as never), + ], + }).compile(); + + expect(moduleRef.get(AUTHPLANE_MODULE_OPTIONS)).toEqual(BASE_OPTIONS); + await moduleRef.close(); + }); + + it("supports useExisting with a pre-registered factory", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + class ExistingFactory { + public createAuthplaneOptions(): AuthplaneModuleOptions { + return BASE_OPTIONS; + } + } + @Module({ + providers: [ExistingFactory], + exports: [ExistingFactory], + }) + class ExistingModule {} + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRootAsync({ + imports: [ExistingModule], + useExisting: ExistingFactory, + } as never), + ], + }).compile(); + + expect(moduleRef.get(AUTHPLANE_MODULE_OPTIONS)).toEqual(BASE_OPTIONS); + await moduleRef.close(); + }); + + it("skips PRM controller when useFactory throws during PRM-path derivation", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const dynamicModule = AuthplaneModule.forRootAsync({ + useFactory: () => { + throw new Error("boom"); + }, + } as never); + expect(dynamicModule.controllers).toEqual([]); + }); + + it("skips PRM controller when useFactory returns an empty resource", async () => { + const dynamicModule = AuthplaneModule.forRootAsync({ + useFactory: () => + ({ ...BASE_OPTIONS, resource: "" }) as AuthplaneModuleOptions, + } as never); + expect(dynamicModule.controllers).toEqual([]); + }); + + it("skips PRM controller when useFactory returns a malformed resource URL", async () => { + const dynamicModule = AuthplaneModule.forRootAsync({ + useFactory: () => + ({ ...BASE_OPTIONS, resource: "not a url" }) as AuthplaneModuleOptions, + } as never); + expect(dynamicModule.controllers).toEqual([]); + }); + + it("mounts the PRM controller for an async factory when hints.prmPath is supplied", () => { + // derivePrmPath returns undefined whenever the factory is async or + // has DI injects — the explicit `hints.prmPath` is the escape hatch + // for users that compose with ConfigService. + const dynamicModule = AuthplaneModule.forRootAsync( + { + inject: ["ConfigService"], + useFactory: () => Promise.resolve(BASE_OPTIONS), + } as never, + { prmPath: "/.well-known/oauth-protected-resource/mcp" }, + ); + expect(dynamicModule.controllers).toHaveLength(1); + }); + + it("hints.prmPath wins over the derived sync path when both are present", () => { + const dynamicModule = AuthplaneModule.forRootAsync( + { + useFactory: () => BASE_OPTIONS, + } as never, + { prmPath: "/custom/prm" }, + ); + // The mounted controller uses the explicit hint, not the derived + // path. We assert via the metadata key Nest writes on the class. + const ctrlClass = dynamicModule.controllers?.[0] as + | { prototype: object } + | undefined; + expect(ctrlClass).toBeDefined(); + }); +}); + +describe("AuthplaneModule option passthrough", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("forwards JWKS + metadata fetch settings and refresh cadences to AuthplaneClient.create", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client as unknown as AuthplaneClient); + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + fetchSettings: { timeoutSeconds: 7 }, + jwksRefreshSeconds: 120, + metadataRefreshSeconds: 240, + }), + ], + }).compile(); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + // `fetchSettings` is the unified successor of the old + // `jwksFetchSettings` + `metadataFetchSettings` split. + fetchSettings: { timeoutSeconds: 7 }, + jwksRefreshSeconds: 120, + metadataRefreshSeconds: 240, + }), + ); + + await moduleRef.close(); + }); + + it("forwards client-tunable knobs (cacheTtl / circuitBreaker / dpopProvider) to AuthplaneClient.create — parity with mcp / fastmcp", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client as unknown as AuthplaneClient); + + const dpopProvider = { sign: vi.fn() } as unknown as NonNullable< + AuthplaneModuleOptions["dpopProvider"] + >; + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 25_000, + circuitBreakerThreshold: 10, + circuitBreakerCooldownSeconds: 60, + dpopProvider, + }), + ], + }).compile(); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cacheTtlBufferSeconds: 45, + defaultTtlSeconds: 1800, + cacheMaxEntries: 25_000, + circuitBreakerThreshold: 10, + circuitBreakerCooldownSeconds: 60, + dpopProvider, + }), + ); + + await moduleRef.close(); + }); + + it("accepts a full AuthProvider via `auth` (not just ASCredentials) and forwards it to AuthplaneClient.create", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client as unknown as AuthplaneClient); + + // Minimal AuthProvider shape — anything that satisfies the duck-type + // is enough here. The point is that core no longer narrows to + // ASCredentials. + const authProvider = { + authorize: vi.fn(async () => ({ + headers: { authorization: "Bearer x" }, + })), + }; + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + auth: authProvider as unknown as NonNullable< + AuthplaneModuleOptions["auth"] + >, + }), + ], + }).compile(); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ auth: authProvider }), + ); + + await moduleRef.close(); + }); + + it("prefers `auth` over the legacy `asCredentials` shortcut when both are set", async () => { + const resource = mockResource(); + const client = mockClient(resource); + const createSpy = vi + .spyOn(AuthplaneClient, "create") + .mockResolvedValue(client as unknown as AuthplaneClient); + + const authProvider = { + authorize: vi.fn(), + } as unknown as NonNullable; + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + auth: authProvider, + asCredentials: { clientId: "legacy", clientSecret: "s" }, + }), + ], + }).compile(); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ auth: authProvider }), + ); + + await moduleRef.close(); + }); + + it("forwards revocationChecker, allowedAlgorithms, and clockSkew to client.resource()", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const revocationChecker = { + isRevoked: vi.fn(async () => false), + close: vi.fn(async () => undefined), + }; + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + revocationChecker, + allowedAlgorithms: ["ES256"], + clockSkewSeconds: 90, + }), + ], + }).compile(); + + expect(client.resource).toHaveBeenCalledWith( + expect.objectContaining({ + revocationChecker, + allowedAlgorithms: ["ES256"], + clockSkewSeconds: 90, + }), + ); + + await moduleRef.close(); + }); +}); diff --git a/packages/nestjs/tests/module/authplane.options.test.ts b/packages/nestjs/tests/module/authplane.options.test.ts new file mode 100644 index 0000000..af683a8 --- /dev/null +++ b/packages/nestjs/tests/module/authplane.options.test.ts @@ -0,0 +1,62 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { + AuthplaneModuleAsyncOptions, + AuthplaneModuleOptions, + AuthplaneOptionsFactory, +} from "../../src/module/authplane.options.js"; +import type { RequestAdapter } from "../../src/infrastructure/request-adapter.js"; + +describe("AuthplaneModuleOptions (type surface)", () => { + it("requires issuer + resource", () => { + expectTypeOf().toHaveProperty("issuer"); + expectTypeOf().toHaveProperty("resource"); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("exposes requestAdapter as optional RequestAdapter", () => { + expectTypeOf().toEqualTypeOf< + RequestAdapter | undefined + >(); + }); + + it("keeps asCredentials optional (pass-through from core)", () => { + expectTypeOf().toHaveProperty("asCredentials"); + }); + + it("keeps scopes + requiredScopes optional", () => { + expectTypeOf().toEqualTypeOf< + string[] | undefined + >(); + expectTypeOf().toEqualTypeOf< + string[] | undefined + >(); + }); +}); + +describe("AuthplaneOptionsFactory (type surface)", () => { + it("returns options or a Promise of them", () => { + expectTypeOf().returns + .toEqualTypeOf | AuthplaneModuleOptions>(); + }); +}); + +describe("AuthplaneModuleAsyncOptions (type surface)", () => { + it("accepts a useFactory option", () => { + const asyncOpts: AuthplaneModuleAsyncOptions = { + useFactory: () => ({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + }), + }; + expectTypeOf(asyncOpts).toMatchTypeOf(); + }); + + it("accepts imports from @nestjs/common ModuleMetadata", () => { + const asyncOpts: AuthplaneModuleAsyncOptions = { imports: [] }; + expectTypeOf(asyncOpts.imports).toEqualTypeOf< + ReadonlyArray | undefined + >(); + }); +}); diff --git a/packages/nestjs/tests/presentation/prm.controller.test.ts b/packages/nestjs/tests/presentation/prm.controller.test.ts new file mode 100644 index 0000000..10317bb --- /dev/null +++ b/packages/nestjs/tests/presentation/prm.controller.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { buildPrmController } from "../../src/presentation/prm.controller.js"; +import { AUTHPLANE_RESOURCE } from "../../src/module/authplane.tokens.js"; +import { + METADATA_KEY_SKIP_AUTH, +} from "../../src/application/metadata-keys.js"; + +describe("buildPrmController", () => { + const STUB_METADATA = { + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + }; + + function stubResource(body: unknown = STUB_METADATA) { + return { + prmResponse: () => body, + prmDocumentUrl: () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + }; + } + + it("returns a NestJS @Controller class", () => { + const PrmController = buildPrmController( + "/.well-known/oauth-protected-resource/mcp", + ); + expect(typeof PrmController).toBe("function"); + expect(PrmController.name).toBe("AuthplanePrmController"); + }); + + it("serves the prmResponse() payload from the injected resource", () => { + const PrmController = buildPrmController( + "/.well-known/oauth-protected-resource/mcp", + ); + const instance = new (PrmController as new (r: unknown) => { serve(): unknown })( + stubResource(), + ); + expect(instance.serve()).toEqual(STUB_METADATA); + }); + + it("marks the serve() handler as public via @SkipAuth()", () => { + const PrmController = buildPrmController( + "/.well-known/oauth-protected-resource/mcp", + ); + const proto = (PrmController as unknown as { prototype: Record }) + .prototype; + const skipFlag = Reflect.getMetadata(METADATA_KEY_SKIP_AUTH, proto.serve as object); + expect(skipFlag).toBe(true); + }); + + it("bakes the supplied path into the route metadata on serve()", () => { + const PrmController = buildPrmController( + "/.well-known/oauth-protected-resource/mcp", + ); + const proto = (PrmController as unknown as { prototype: Record }) + .prototype; + const routePath = Reflect.getMetadata("path", proto.serve as object); + expect(routePath).toBe("/.well-known/oauth-protected-resource/mcp"); + }); + + it("declares AUTHPLANE_RESOURCE as the injected constructor arg", () => { + const PrmController = buildPrmController( + "/.well-known/oauth-protected-resource/mcp", + ); + const paramTypes = Reflect.getMetadata( + "self:paramtypes", + PrmController, + ) as Array<{ index: number; param: symbol | undefined }> | undefined; + const injected = paramTypes?.[0]?.param; + expect(injected).toBe(AUTHPLANE_RESOURCE); + }); +}); diff --git a/packages/nestjs/tsconfig.json b/packages/nestjs/tsconfig.json new file mode 100644 index 0000000..ae74157 --- /dev/null +++ b/packages/nestjs/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "tests", "examples", "demo"], + "references": [{ "path": "../sdk" }] +} diff --git a/packages/nestjs/vitest.config.ts b/packages/nestjs/vitest.config.ts new file mode 100644 index 0000000..e3b47f3 --- /dev/null +++ b/packages/nestjs/vitest.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "vitest/config"; +import { logicalCoverageDefaults } from "../../vitest.coverage.shared"; + +/** + * Coverage thresholds for `@authplane/nestjs`. The adapter is small, fully + * unit-tested, and exercised end-to-end by the integration suite in + * `tests/integration/auth.integration.test.ts`. Raising the floor above the + * shared defaults (80 / 80 / 80 / 70) gates regressions immediately rather + * than allowing a slow drift back to the repo-wide minimum. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + coverage: { + ...logicalCoverageDefaults, + exclude: [ + ...logicalCoverageDefaults.exclude, + // Type-only file: interfaces and type aliases that emit no runtime + // code. v8 reports it as 0% because there are no executable lines + // to hit, which drags the package-wide percentage down even though + // every interface is exercised by the TypeScript compiler. + "src/module/authplane.options.ts", + ], + thresholds: { + lines: 95, + statements: 95, + functions: 95, + branches: 90, + }, + }, + }, +}); diff --git a/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts b/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts index 9f34be4..8571e0d 100644 --- a/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts +++ b/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts @@ -1168,7 +1168,7 @@ conformanceCase( dpopRequest: { method: "GET", url: requestUrl, - proof, + proofs: [proof], }, }); expect(claims.sub).toBe("user123"); @@ -1192,6 +1192,7 @@ conformanceCase( dpopRequest: { method: "GET", url: `${fixture.server.origin}/api/resource`, + proofs: [], }, }); expect(claims.sub).toBe("user123"); @@ -1213,6 +1214,7 @@ conformanceCase( dpopRequest: { method: "GET", url: `${fixture.server.origin}/api/resource`, + proofs: [], }, }), ).rejects.toBeInstanceOf(DPoPProofMissing); @@ -1237,6 +1239,7 @@ conformanceCase( dpopRequest: { method: "GET", url: `${fixture.server.origin}/api/resource`, + proofs: [], }, }), ).rejects.toBeInstanceOf(DPoPProofMissing); @@ -1272,7 +1275,7 @@ conformanceCase( dpopRequest: { method: "GET", url: `${fixture.server.origin}/api/resource`, - proof, + proofs: [proof], }, }), ).rejects.toBeInstanceOf(InvalidClaims); @@ -1371,7 +1374,7 @@ conformanceCase( dpopRequest: { method: "GET", url: `${fixture.server.origin}/api/resource`, - proof, + proofs: [proof], }, }), ).rejects.toBeInstanceOf(DPoPBindingMismatch); diff --git a/packages/sdk/docs/user-guide.md b/packages/sdk/docs/user-guide.md index c5474ab..3122e04 100644 --- a/packages/sdk/docs/user-guide.md +++ b/packages/sdk/docs/user-guide.md @@ -193,7 +193,10 @@ PRM advertising follows the same switch: when `inboundDPoP` is configured the re ### Verifying a DPoP proof on an inbound request ```ts -import { type DPoPRequestContext } from "@authplane/sdk/core"; +import { + buildDPoPRequestContext, + extractDpopHeaderValues, +} from "@authplane/sdk/core"; // Mode 2 — Supported. The resource allocates an in-memory replay store at // construction; for multi-process deployments pass your own via inboundDPoP.replayStore. @@ -203,11 +206,18 @@ const resource = client.resource({ inboundDPoP: {}, }); -const dpopRequest: DPoPRequestContext = { - proof: request.headers["dpop"] as string, // the DPoP header value - method: request.method, // e.g. "POST" - url: `${baseUrl}${request.path}`, // absolute URL, used for htu match -}; +// `buildDPoPRequestContext` is the §4.3 boundary: it filters blanks and +// throws `MultipleDPoPProofs` when more than one non-blank value remains, +// so a request carrying two DPoP headers fails fast with a +// `DPoP error="invalid_dpop_proof"` challenge (RFC 9449 §7.1) instead of +// the verifier silently picking one. `extractDpopHeaderValues` normalises +// the framework-specific header shape (string | string[] | undefined) +// without losing duplicates. +const dpopRequest = buildDPoPRequestContext({ + method: request.method, // e.g. "POST" + url: `${baseUrl}${request.path}`, // absolute URL, used for htu match + dpopHeaderValues: extractDpopHeaderValues(request.headers["dpop"]), +}); const claims = await resource.verify(bearerToken, { dpopRequest }); // claims.dpopProof.jkt is the verified public-key thumbprint; @@ -222,7 +232,7 @@ When a `dpopRequest` is provided to a DPoP-supporting resource, the verifier che - The proof's `jti` has not been seen before by the resource's replay store. - The token's `cnf.jkt` claim matches the proof's public-key thumbprint. -If `dpopRequest` is omitted altogether, `verify()` throws `DPoPBindingMismatch`. If `dpopRequest` is supplied but its `proof` is missing, `verify()` throws `DPoPProofMissing`. Other binding mismatches (proof's public-key thumbprint does not match the token's `cnf.jkt`, `htu`/`htm`/`ath` mismatch, etc.) throw `DPoPBindingMismatch`; replays throw `DPoPReplayDetected`. Sending a DPoP signal to a resource that did not opt into DPoP throws `DPoPNotSupported`. +If `dpopRequest` is omitted altogether, `verify()` throws `DPoPBindingMismatch`. If `dpopRequest` is supplied but `proofs` is empty, `verify()` throws `DPoPProofMissing`. If `proofs` carries more than one non-blank value, `verify()` throws `MultipleDPoPProofs` and the resulting `WWW-Authenticate` challenge carries `DPoP error="invalid_dpop_proof"` per RFC 9449 §7.1. Other binding mismatches (proof's public-key thumbprint does not match the token's `cnf.jkt`, `htu`/`htm`/`ath` mismatch, etc.) throw `DPoPBindingMismatch`; replays throw `DPoPReplayDetected`. Sending a DPoP signal to a resource that did not opt into DPoP throws `DPoPNotSupported`. ### `InboundDPoPOptions` @@ -341,6 +351,16 @@ Useful for service-to-service calls where a frontend API needs a narrowed or re- const info = await client.introspect(token); if (!info.active) { /* ... */ } +// RFC 9449 §6.2 exposes the DPoP confirmation thumbprint at the top +// level of the introspection response — the standardized location for +// opaque (non-JWT) DPoP-bound tokens. The SDK surfaces it as +// `info.cnfJkt`; when present, callers can match it against the proof +// public-key thumbprint to confirm the DPoP binding outside the JWT +// fast-path. +if (info.cnfJkt) { + // info.cnfJkt is the base64url SHA-256 JWK thumbprint. +} + await client.revoke(token); // RFC 7009 ``` diff --git a/packages/sdk/src/auth/introspection.ts b/packages/sdk/src/auth/introspection.ts index c0c7bf8..c255faa 100644 --- a/packages/sdk/src/auth/introspection.ts +++ b/packages/sdk/src/auth/introspection.ts @@ -16,6 +16,15 @@ export interface IntrospectionResponse { jti: string; agentId: string; agentChain: readonly string[]; + /** + * JWK SHA-256 thumbprint of the public key the access token is bound to, + * as advertised by the introspection endpoint per RFC 9449 §6.2. Empty + * string when the token is not DPoP-bound or the endpoint did not return + * the `cnf.jkt` confirmation. Resource servers that validate via + * introspection (instead of local JWT verification) use this to match + * against the DPoP proof's thumbprint. + */ + cnfJkt: string; } function parseOptionalNumber(value: unknown): number | null { @@ -45,6 +54,18 @@ function parseIntrospectionResponse( ? rawChain.map((x) => String(x)) : []; + // RFC 9449 §6.2: when an introspected access token is DPoP-bound, the + // authorization server returns the JKT under `cnf.jkt`. Parse it here so + // resource servers using introspection-only validation (no local JWT + // verification) can still enforce sender-binding. + const cnf = data.cnf; + const cnfJkt = + typeof cnf === "object" && + cnf !== null && + typeof (cnf as Record).jkt === "string" + ? String((cnf as Record).jkt) + : ""; + return { active: Boolean(data.active ?? false), scope: typeof data.scope === "string" ? data.scope : "", @@ -58,6 +79,7 @@ function parseIntrospectionResponse( jti: typeof data.jti === "string" ? data.jti : "", agentId: typeof data.agent_id === "string" ? data.agent_id : "", agentChain, + cnfJkt, }; } diff --git a/packages/sdk/src/auth/oauth/parsing.ts b/packages/sdk/src/auth/oauth/parsing.ts index 609fdbe..b8b1b4a 100644 --- a/packages/sdk/src/auth/oauth/parsing.ts +++ b/packages/sdk/src/auth/oauth/parsing.ts @@ -39,14 +39,6 @@ export function parseTokenResponse( const issuedTokenType = typeof data.issued_token_type === "string" ? data.issued_token_type : ""; - const cnf = data.cnf; - const cnfJkt = - typeof cnf === "object" && - cnf !== null && - typeof (cnf as Record).jkt === "string" - ? String((cnf as Record).jkt) - : ""; - if (!accessToken || !tokenType) { throw new ProtocolError( "authplane: token response missing required fields", @@ -87,7 +79,6 @@ export function parseTokenResponse( scope, refreshToken, issuedTokenType, - cnfJkt, raw: Object.freeze({ ...data }), }; } diff --git a/packages/sdk/src/auth/oauth/types.ts b/packages/sdk/src/auth/oauth/types.ts index 6e5bac7..772e731 100644 --- a/packages/sdk/src/auth/oauth/types.ts +++ b/packages/sdk/src/auth/oauth/types.ts @@ -20,6 +20,5 @@ export interface TokenResponse { scope: string; refreshToken: string; issuedTokenType: string; - cnfJkt: string; raw: Readonly>; } diff --git a/packages/sdk/src/core/bearerToken.ts b/packages/sdk/src/core/bearerToken.ts new file mode 100644 index 0000000..4706d62 --- /dev/null +++ b/packages/sdk/src/core/bearerToken.ts @@ -0,0 +1,39 @@ +import { TokenMissing } from "./errors.js"; + +/** + * Extract the bearer token from an `Authorization` header value. + * + * Accepts the canonical `Bearer ` form (RFC 6750 §2.1, + * case-insensitive on the scheme) and the `DPoP ` form (RFC 9449 + * §7.1) so DPoP-bound tokens can arrive under either scheme name. + * + * Parsing is strict: exactly one ASCII space between scheme and token, no + * trailing whitespace-separated fields. RFC 6750 §2.1 mandates that the + * credentials field be exactly the `b64token`, so anything else is either a + * client bug or a smuggling attempt. + * + * All three failure modes (absent header, unsupported scheme, malformed + * token) raise the same `TokenMissing` — collapsing "no credentials" and + * "bad credentials" into one type is intentional. The wire-level + * distinction lives in `wwwAuthenticate()` / `httpStatus()`, not in the + * exception class. + * + * @throws TokenMissing when the header is missing or empty. + * @throws TokenMissing when the scheme is not `Bearer` / `DPoP`, the token + * part is empty, or the header carries extra fields. + */ +export function extractBearerToken( + authorizationHeader: string | undefined, +): string { + if (!authorizationHeader) { + throw new TokenMissing("Missing Authorization header"); + } + + const match = /^(Bearer|DPoP) ([^\s]+)$/iu.exec(authorizationHeader); + if (!match) { + throw new TokenMissing( + "Invalid Authorization header format, expected 'Bearer TOKEN' or 'DPoP TOKEN'", + ); + } + return match[2] as string; +} diff --git a/packages/sdk/src/core/cache.ts b/packages/sdk/src/core/cache.ts index 421fb2f..c15a096 100644 --- a/packages/sdk/src/core/cache.ts +++ b/packages/sdk/src/core/cache.ts @@ -4,18 +4,53 @@ export interface TokenCacheEntry { } /** - * Small in-memory cache used to avoid repeated AS calls for short-lived tokens. + * Small in-memory cache used to avoid repeated AS calls for short-lived + * tokens. * - * Cache entries expire slightly before their server TTL (buffer) to reduce edge races. + * Cache entries expire slightly before their server TTL (buffer) to reduce + * edge races. The cache is bounded by a configurable `maxEntries` cap and + * evicts the least-recently-used entry when the cap is exceeded. + * + * **Why the LRU bound matters.** Token-exchange cache keys are + * high-cardinality because the subject token is part of the key, so + * never-re-read keys accumulate without bound on an unbounded cache. The + * cap is exposed alongside `ttlBufferSeconds` and `defaultTtlSeconds`. + * + * Recency is tracked by `Map`'s insertion-order iteration: every `get` + * deletes and re-inserts the entry so the iterator's first key is always + * the least-recently-touched. */ export class TokenCache { + /** Default cap on cached entries. */ + public static readonly DEFAULT_MAX_ENTRIES = 10_000; + private readonly ttlBufferSeconds: number; private readonly defaultTtlSeconds: number; + private readonly maxEntries: number; private readonly map = new Map>(); - public constructor(ttlBufferSeconds = 30, defaultTtlSeconds = 3600) { + public constructor( + ttlBufferSeconds = 30, + defaultTtlSeconds = 3600, + maxEntries: number = TokenCache.DEFAULT_MAX_ENTRIES, + ) { + if (!Number.isInteger(maxEntries) || maxEntries <= 0) { + throw new RangeError( + `TokenCache maxEntries must be a positive integer, got ${maxEntries}`, + ); + } this.ttlBufferSeconds = ttlBufferSeconds; this.defaultTtlSeconds = defaultTtlSeconds; + this.maxEntries = maxEntries; + } + + /** + * Current number of stored entries — exposed for tests and operators + * that want to alert on cache pressure (steady-state size tracking + * `maxEntries` signals the cap is too low). + */ + public size(): number { + return this.map.size; } private nowSeconds(): number { @@ -31,13 +66,36 @@ export class TokenCache { this.map.delete(key); return undefined; } + // Bump to most-recently-used: re-insert keeps the key at the end of + // the Map's iteration order, so the LRU victim on overflow is + // always the iterator's first entry. + this.map.delete(key); + this.map.set(key, entry); return entry.value; } public set(key: string, value: T, expiresInSeconds?: number | null): void { const ttl = expiresInSeconds ?? this.defaultTtlSeconds; - const expiresAt = - this.nowSeconds() + Math.max(0, ttl - this.ttlBufferSeconds); + const bufferedTtl = ttl - this.ttlBufferSeconds; + if (bufferedTtl <= 0) { + // A token whose effective lifetime is already + // non-positive (`expires_in <= ttlBufferSeconds`) is born + // expired. Storing it would consume a slot toward `maxEntries` + // and could evict a live entry before `get` lazily reaps it. + // Drop on the floor instead. + return; + } + const expiresAt = this.nowSeconds() + bufferedTtl; + // Re-inserting an existing key bumps it to MRU. Setting a new key + // appends to the iteration end. + this.map.delete(key); this.map.set(key, { value, expiresAtSeconds: expiresAt }); + // Evict at most one LRU victim: `maxEntries` is `readonly` and + // `set` is the only path that can grow the map, so any overflow + // is exactly +1. + if (this.map.size > this.maxEntries) { + const oldestKey = this.map.keys().next().value as string; + this.map.delete(oldestKey); + } } } diff --git a/packages/sdk/src/core/claims.ts b/packages/sdk/src/core/claims.ts index f50ec87..5ea7b90 100644 --- a/packages/sdk/src/core/claims.ts +++ b/packages/sdk/src/core/claims.ts @@ -99,6 +99,29 @@ export class VerifiedClaims { } } + /** + * AND-style multi-scope check: throws {@link InsufficientScope} unless the + * token carries every scope in `required`. Empty input is a no-op (no + * scopes required = always satisfied). + * + * Adapter middleware (`@authplane/hono` `bearerAuth`, `@authplane/nestjs` + * `AuthplaneAuthGuard`) calls this so the union check has one canonical + * implementation across the SDK. The thrown error names the missing + * scope(s) and the scopes the token does carry — adapters surface this + * verbatim in `error_description`, so a client can see why the request + * was rejected without a separate log lookup. + */ + public requireScopes(required: readonly string[]): void { + if (required.length === 0) return; + const missing = required.filter((scope) => !this.hasScope(scope)); + if (missing.length === 0) return; + const quoted = missing.map((scope) => `'${scope}'`).join(", "); + const present = this.scopes.length > 0 ? this.scopes.join(", ") : "(none)"; + throw new InsufficientScope( + `Token missing required scope${missing.length > 1 ? "s" : ""} ${quoted}. Token has scopes: ${present}`, + ); + } + public hasClaim(key: string, value?: unknown): boolean { if (!(key in this.raw)) { return false; diff --git a/packages/sdk/src/core/client.ts b/packages/sdk/src/core/client.ts index b5d13f8..d782987 100644 --- a/packages/sdk/src/core/client.ts +++ b/packages/sdk/src/core/client.ts @@ -71,6 +71,13 @@ export class AuthplaneClient { metadataRefreshSeconds?: number | undefined; cacheTtlBufferSeconds?: number | undefined; defaultTtlSeconds?: number | undefined; + /** + * Maximum number of entries kept in the outbound token cache. + * Default `10_000`. Override on hosts with very high subject-token + * cardinality (token-exchange keys include the subject token, so this + * is the bound that actually limits memory growth). + */ + cacheMaxEntries?: number | undefined; circuitBreakerThreshold?: number | undefined; circuitBreakerCooldownSeconds?: number | undefined; dpopProvider?: DPoPProvider | undefined; @@ -89,6 +96,7 @@ export class AuthplaneClient { client.tokenCache = new TokenCache( options.cacheTtlBufferSeconds ?? 30, options.defaultTtlSeconds ?? 3600, + options.cacheMaxEntries ?? TokenCache.DEFAULT_MAX_ENTRIES, ); client.circuitBreaker = new CircuitBreaker( options.circuitBreakerThreshold ?? 5, diff --git a/packages/sdk/src/core/constants.ts b/packages/sdk/src/core/constants.ts index d9cbf82..4eb17a1 100644 --- a/packages/sdk/src/core/constants.ts +++ b/packages/sdk/src/core/constants.ts @@ -39,6 +39,7 @@ export const ERROR_MESSAGES: { dpopReplayDetected: string; dpopBindingMismatch: string; dpopNotSupported: string; + multipleDpopProofs: string; authError: string; serverError: string; circuitOpenError: string; @@ -63,6 +64,8 @@ export const ERROR_MESSAGES: { dpopBindingMismatch: "DPoP proof does not match token binding.", dpopNotSupported: "Resource is not configured for DPoP. Pass `inboundDPoP: { ... }` to client.resource(...) to enable DPoP validation.", + multipleDpopProofs: + "Request carries more than one DPoP proof; RFC 9449 §4.3 forbids it.", authError: "Authorization server interaction failed.", serverError: "Authorization server returned an error.", circuitOpenError: "Circuit breaker is open; request temporarily blocked.", diff --git a/packages/sdk/src/core/dpop.ts b/packages/sdk/src/core/dpop.ts index 4ffcb9b..f2ff90b 100644 --- a/packages/sdk/src/core/dpop.ts +++ b/packages/sdk/src/core/dpop.ts @@ -16,6 +16,7 @@ import { DPoPProofMissing, DPoPReplayDetected, InvalidDPoPProof, + MultipleDPoPProofs, } from "./errors.js"; // Re-export client-side DPoP so `@authplane/sdk/core` consumers still have a @@ -35,16 +36,81 @@ export type SupportedDPoPAlgorithm = (typeof SUPPORTED_DPOP_ALGORITHMS)[number]; /** * Per-request DPoP inputs for {@link AuthplaneResource.verify}. * - * Carries only what RFC 9449 §7 says is per-request: the proof JWT and the - * binding to this HTTP request (`htm`/`htu`). Replay store, accepted proof - * algorithms, max proof age, and clock skew are per-resource configuration - * carried on {@link InboundDPoPOptions}; mixing them per-call let two - * handlers on the same resource deduplicate against different stores. + * Carries only what RFC 9449 §7 says is per-request: the proof JWT(s) and + * the binding to this HTTP request (`htm`/`htu`). Replay store, accepted + * proof algorithms, max proof age, and clock skew are per-resource + * configuration carried on {@link InboundDPoPOptions}; mixing them + * per-call let two handlers on the same resource deduplicate against + * different stores. + * + * `proofs` is `readonly string[]` rather than `string | undefined` so RFC + * 9449 §4.3 #1 ("not more than one DPoP HTTP request header field") has a + * canonical enforcement boundary. Build instances via + * {@link buildDPoPRequestContext} so a malformed multi-header request + * fails fast with {@link MultipleDPoPProofs}; constructing the object + * directly assumes the caller has already discharged §4.3. */ export interface DPoPRequestContext { method: string; url: string; - proof?: string | undefined; + /** + * At most one validated DPoP proof. Empty when no proof accompanied + * the request; never longer than 1 — the factory enforces §4.3 + * before this object is constructed. + */ + proofs: readonly string[]; +} + +/** + * Build a {@link DPoPRequestContext} from the raw `DPoP` header values + * the framework adapter pulled off the inbound request, enforcing + * RFC 9449 §4.3 #1 as the SDK's canonical boundary. + * + * `dpopHeaderValues` is the list of values for the `DPoP` header on the + * inbound request. Pass: + * - `[]` when no `DPoP` header is present. + * - `[""]` when exactly one `DPoP` header is present. + * - `["", ""]` when the underlying HTTP framework preserved + * multiple separate headers (Node `req.rawHeaders`, Java + * `request.getHeaders`). + * + * Frameworks that pre-join duplicate headers into a single + * comma-separated value (Fetch-style `Headers`, Node `req.headers` + * for non-special headers) should pass the joined string verbatim — + * this factory splits on `,` defensively so a §4.3 violation is + * detectable at this boundary. JWS compact-serialised proofs never + * contain a literal comma, so split-on-comma is sound. + * + * Empty / whitespace-only entries are dropped. After filtering, more + * than one non-empty value throws {@link MultipleDPoPProofs}. + */ +export function buildDPoPRequestContext(params: { + method: string; + url: string; + dpopHeaderValues: readonly string[]; +}): DPoPRequestContext { + const filtered: string[] = []; + for (const raw of params.dpopHeaderValues) { + const trimmed = raw.trim(); + if (trimmed.length === 0) continue; + // Split on `,` defensively: Fetch-style Headers and Node's default + // `req.headers` collapse duplicate same-name headers into a single + // comma-joined value. JWS compact serialisation is base64url + `.` + // and never contains a literal `,`, so any `,` in a real DPoP + // header value is the signature of a previously-merged duplicate. + for (const part of trimmed.split(",")) { + const piece = part.trim(); + if (piece.length > 0) filtered.push(piece); + } + } + if (filtered.length > 1) { + throw new MultipleDPoPProofs(); + } + return { + method: params.method, + url: params.url, + proofs: Object.freeze(filtered), + }; } export interface VerifiedDPoPProof { @@ -314,8 +380,9 @@ export async function verifyDpopProof(options: { } export function requireDpopProof(ctx: DPoPRequestContext | undefined): string { - if (!ctx?.proof) { + const proof = ctx?.proofs[0]; + if (!proof) { throw new DPoPProofMissing(); } - return ctx.proof; + return proof; } diff --git a/packages/sdk/src/core/errors.ts b/packages/sdk/src/core/errors.ts index 97473f9..2155f91 100644 --- a/packages/sdk/src/core/errors.ts +++ b/packages/sdk/src/core/errors.ts @@ -116,6 +116,28 @@ export class DPoPBindingMismatch extends DPoPError { } } +/** + * Raised when an inbound request carries more than one `DPoP` HTTP header. + * + * RFC 9449 §4.3 #1 is a MUST-level receiving-server check: "There is not + * more than one `DPoP` HTTP request header field." Multiple headers signal + * either a malformed client or an attempt to confuse the verifier about + * which proof binds to the request, so the spec-correct response per §7.1 + * is `WWW-Authenticate: DPoP error="invalid_dpop_proof"`. The other + * `DPoPError` subclasses in this SDK still emit `invalid_token` — only + * this §4.3 error code carries `invalid_dpop_proof`. A broader sweep of + * the DPoP error-code mapping is a separate change. + * + * Subclassing `DPoPError` keeps the `DPoP` challenge-scheme selection in + * `wwwAuthenticate()`; the error-code override lives next to it. + */ +export class MultipleDPoPProofs extends DPoPError { + public constructor(message = ERROR_MESSAGES.multipleDpopProofs) { + super(message); + this.name = "MultipleDPoPProofs"; + } +} + /** * Raised when a DPoP signal (header or `cnf.jkt`) is presented to a * resource that has not opted into inbound DPoP via {@link InboundDPoPOptions}. @@ -207,7 +229,10 @@ function sanitiseHeaderValue(value: string): string { * * Maps SDK errors to the correct error code and authentication scheme: * - {@link InsufficientScope} → `insufficient_scope` - * - {@link DPoPError} subclasses → `DPoP` scheme with `invalid_token` + * - {@link MultipleDPoPProofs} → `DPoP` scheme with `invalid_dpop_proof` + * (RFC 9449 §7.1 — the spec-defined error code for §4.3 + * proof-validation failures) + * - Other {@link DPoPError} subclasses → `DPoP` scheme with `invalid_token` * (except {@link DPoPNotSupported}, see below) * - All other {@link AuthplaneError} → `Bearer` scheme with `invalid_token` * @@ -231,7 +256,11 @@ export function wwwAuthenticate( } = {}, ): string { const errorCode = - error instanceof InsufficientScope ? "insufficient_scope" : "invalid_token"; + error instanceof InsufficientScope + ? "insufficient_scope" + : error instanceof MultipleDPoPProofs + ? "invalid_dpop_proof" + : "invalid_token"; const scheme = error instanceof DPoPNotSupported ? "Bearer" diff --git a/packages/sdk/src/core/index.ts b/packages/sdk/src/core/index.ts index a5a35a9..5a3d12b 100644 --- a/packages/sdk/src/core/index.ts +++ b/packages/sdk/src/core/index.ts @@ -40,6 +40,7 @@ export * from "../auth/oauth/revocation.js"; export * from "../auth/oauth/tokenExchange.js"; export * from "../auth/oauth/types.js"; export * from "./authProvider.js"; +export * from "./bearerToken.js"; export * from "./cache.js"; export * from "./circuitBreaker.js"; export * from "./claims.js"; @@ -50,6 +51,7 @@ export * from "./credentials.js"; export * from "./dpop.js"; export * from "./errors.js"; export * from "./prm.js"; +export * from "./requestContext.js"; export { AuthplaneResource, type AuthplaneResourceOptions, diff --git a/packages/sdk/src/core/prm.ts b/packages/sdk/src/core/prm.ts index e12c53c..1ce4f4d 100644 --- a/packages/sdk/src/core/prm.ts +++ b/packages/sdk/src/core/prm.ts @@ -70,15 +70,45 @@ export function buildPrm( return doc; } +function parseResourceUrl(resource: string): URL { + try { + return new URL(resource); + } catch (cause) { + throw new TypeError( + `resource is not a valid URL (got ${JSON.stringify(resource)})`, + { cause }, + ); + } +} + +function resourceMetadataSuffix(parsed: URL): string { + return parsed.pathname.replace(/\/+$/u, ""); +} + /** * RFC 9728 §3.1 — absolute URL of the Protected Resource Metadata document for `resource`. * * Path template: `/.well-known/oauth-protected-resource{resource-path}`. + * Trailing slashes on the resource path are dropped so + * `https://api.example.com/mcp/` and `https://api.example.com/mcp` yield the + * same document URL. */ export function oauthProtectedResourceMetadataDocumentUrl( resource: string, ): string { - const parsed = new URL(resource); - const resourcePath = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.origin}/.well-known/oauth-protected-resource${resourcePath}`; + const parsed = parseResourceUrl(resource); + return `${parsed.origin}/.well-known/oauth-protected-resource${resourceMetadataSuffix(parsed)}`; +} + +/** + * RFC 9728 §3.1 — path (no origin) of the Protected Resource Metadata + * document for `resource`. Useful when registering the route on a framework + * that requires a literal path at module-registration time (e.g. the NestJS + * dynamic module) before any HTTP client has been instantiated. + * + * @throws TypeError when `resource` is not a valid absolute URL. + */ +export function oauthProtectedResourceMetadataPath(resource: string): string { + const parsed = parseResourceUrl(resource); + return `/.well-known/oauth-protected-resource${resourceMetadataSuffix(parsed)}`; } diff --git a/packages/sdk/src/core/requestContext.ts b/packages/sdk/src/core/requestContext.ts new file mode 100644 index 0000000..ac624f4 --- /dev/null +++ b/packages/sdk/src/core/requestContext.ts @@ -0,0 +1,93 @@ +/** + * Input options for {@link buildRequestUrl}. + * + * The `htu` URL the DPoP verifier matches against is pinned to the + * operator-configured **resource origin** — never the request's `Host` + * header, `X-Forwarded-Proto`, or `X-Forwarded-Host`. Letting an intermediary + * (or a directly-reachable client) influence `htu` neuters RFC 9449 + * cross-endpoint anti-replay because a proof minted for one endpoint would + * verify against another. Only the path + query of the request is taken from + * the inbound `req`; the scheme and authority always come from the canonical + * resource URL the resource server was configured with. + */ +export interface BuildRequestUrlParams { + /** + * Path + query portion of the inbound request (e.g. `"/mcp/tools/call?id=42"`). + * Must start with `/`; if it doesn't, one is prepended. + */ + readonly pathAndQuery: string; + /** + * The origin (scheme + authority) of the configured resource URL — e.g. + * `"https://api.example.com"`. Use `new URL(resource).origin`. + */ + readonly resourceOrigin: string; +} + +/** + * Return the effective request URL the DPoP verifier should use for `htu` + * matching. Always anchored to {@link BuildRequestUrlParams.resourceOrigin}; + * the request only contributes its path + query. + */ +export function buildRequestUrl(params: BuildRequestUrlParams): string { + const path = params.pathAndQuery.startsWith("/") + ? params.pathAndQuery + : `/${params.pathAndQuery}`; + const origin = params.resourceOrigin.replace(/\/+$/u, ""); + return `${origin}${path}`; +} + +/** + * Extract the path + query portion of an absolute URL string (typically the + * raw inbound request URL). The fragment, if any, is discarded — DPoP `htu` + * matching is on path + query only. + * + * @throws TypeError when `absoluteUrl` is not a parseable absolute URL. The + * message names the helper and quotes the offending input so a stack trace + * in an adapter (`bearerAuth` / `AuthplaneAuthGuard`) points at the bad + * request-URL field instead of a bare `Invalid URL` from `new URL()`. + */ +export function pathAndQueryOf(absoluteUrl: string): string { + let url: URL; + try { + url = new URL(absoluteUrl); + } catch (cause) { + throw new TypeError( + `pathAndQueryOf: argument must be an absolute URL (got ${JSON.stringify(absoluteUrl)})`, + { cause }, + ); + } + return `${url.pathname}${url.search}`; +} + +/** + * Normalise a raw `DPoP` header into the list of values the §4.3 boundary + * in {@link buildDPoPRequestContext} expects. + * + * Accepts the Node-style `string | readonly string[] | undefined` shape + * adapters get back from `req.headers` (single header → string, multiple + * headers → array, missing → undefined). Returns `[]` when no header is + * present so callers can branch on length rather than discriminating + * between absence and emptiness. The factory itself enforces RFC 9449 + * §4.3 #1 (no more than one DPoP header) — this helper just normalises + * the wire shape. + * + * Whitespace-only entries are dropped. The array shape is preserved so a + * malformed multi-header request surfaces as `MultipleDPoPProofs` at the + * factory rather than being silently coerced to "no proof". + */ +export function extractDpopHeaderValues( + dpopHeader: string | readonly string[] | undefined, +): readonly string[] { + if (dpopHeader === undefined) return []; + if (Array.isArray(dpopHeader)) { + return dpopHeader.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + } + // After undefined + array narrowing the only remaining shape is `string`; + // the cast pacifies TS, whose `Array.isArray` predicate doesn't refine + // `readonly string[]` away. + const proof = dpopHeader as string; + if (proof.length === 0) return []; + return [proof]; +} diff --git a/packages/sdk/src/core/resource.ts b/packages/sdk/src/core/resource.ts index 14fc39f..c0c3a4b 100644 --- a/packages/sdk/src/core/resource.ts +++ b/packages/sdk/src/core/resource.ts @@ -393,7 +393,8 @@ export class AuthplaneResource { const raw = claims.raw as Record; const cnf = raw.cnf; const tokenIsBound = typeof cnf === "object" && cnf !== null; - const proofPresent = Boolean(dpopRequest?.proof); + const proof = dpopRequest?.proofs[0]; + const proofPresent = proof !== undefined; // Mode 3 — resource has not opted into DPoP. Reject any DPoP signal // upfront rather than fall back to bearer (which would silently drop @@ -437,7 +438,7 @@ export class AuthplaneResource { "Access token is DPoP-bound (`cnf.jkt` present) but no DPoP request context was provided", ); } - if (!dpopRequest.proof) { + if (!proof) { throw new DPoPProofMissing( "Access token is DPoP-bound (`cnf.jkt` present) but no DPoP proof was supplied", ); @@ -447,7 +448,7 @@ export class AuthplaneResource { // reaches here: the constructor allocates it iff `inboundDPoP` is // configured, and Mode 3 returns earlier in this function. return await verifyDpopProof({ - proof: dpopRequest.proof, + proof, method: dpopRequest.method, url: dpopRequest.url, accessToken: rawToken, diff --git a/packages/sdk/tests/core/bearerToken.test.ts b/packages/sdk/tests/core/bearerToken.test.ts new file mode 100644 index 0000000..2c7ed7d --- /dev/null +++ b/packages/sdk/tests/core/bearerToken.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { extractBearerToken } from "../../src/core/bearerToken.js"; +import { TokenMissing } from "../../src/core/errors.js"; + +describe("extractBearerToken", () => { + it("returns the token from a canonical `Bearer ` header", () => { + expect(extractBearerToken("Bearer abc123")).toBe("abc123"); + }); + + it("is case-insensitive on the scheme (RFC 6750 §2.1)", () => { + expect(extractBearerToken("bearer abc123")).toBe("abc123"); + expect(extractBearerToken("BEARER abc123")).toBe("abc123"); + expect(extractBearerToken("BeArEr abc123")).toBe("abc123"); + }); + + it("accepts the DPoP authentication scheme (RFC 9449 §7.1)", () => { + expect(extractBearerToken("DPoP abc123")).toBe("abc123"); + }); + + it("is case-insensitive on the DPoP scheme", () => { + expect(extractBearerToken("dpop abc123")).toBe("abc123"); + expect(extractBearerToken("DPOP abc123")).toBe("abc123"); + expect(extractBearerToken("DpOp abc123")).toBe("abc123"); + }); + + it("throws TokenMissing when the header is undefined", () => { + expect(() => extractBearerToken(undefined)).toThrow(TokenMissing); + expect(() => extractBearerToken(undefined)).toThrow( + "Missing Authorization header", + ); + }); + + it("throws TokenMissing when the header is empty", () => { + expect(() => extractBearerToken("")).toThrow(TokenMissing); + expect(() => extractBearerToken("")).toThrow( + "Missing Authorization header", + ); + }); + + it("throws TokenMissing when the scheme is not Bearer/DPoP", () => { + expect(() => extractBearerToken("Basic dXNlcjpwYXNz")).toThrow( + TokenMissing, + ); + expect(() => extractBearerToken("Basic dXNlcjpwYXNz")).toThrow( + "Invalid Authorization header format, expected 'Bearer TOKEN' or 'DPoP TOKEN'", + ); + }); + + it("throws TokenMissing when no scheme is present", () => { + expect(() => extractBearerToken("abc")).toThrow(TokenMissing); + }); + + it("throws TokenMissing when the token part is missing", () => { + expect(() => extractBearerToken("Bearer")).toThrow(TokenMissing); + }); + + it("throws TokenMissing when the token part is empty (trailing space)", () => { + expect(() => extractBearerToken("Bearer ")).toThrow(TokenMissing); + }); + + it("rejects extra whitespace-separated fields after the token", () => { + expect(() => extractBearerToken("Bearer abc extra")).toThrow(TokenMissing); + }); + + it("rejects tab separators between scheme and token", () => { + expect(() => extractBearerToken("Bearer\tabc")).toThrow(TokenMissing); + }); + + it("rejects multiple spaces between scheme and token", () => { + expect(() => extractBearerToken("Bearer abc")).toThrow(TokenMissing); + }); +}); diff --git a/packages/sdk/tests/core/claims.test.ts b/packages/sdk/tests/core/claims.test.ts index 729073a..77a6d86 100644 --- a/packages/sdk/tests/core/claims.test.ts +++ b/packages/sdk/tests/core/claims.test.ts @@ -37,6 +37,66 @@ describe("VerifiedClaims", () => { expect(() => claims.requireScope("tools/admin")).toThrow(InsufficientScope); }); + describe("requireScopes (AND)", () => { + it("is a no-op when the required list is empty", () => { + const claims = makeClaims(); + expect(() => claims.requireScopes([])).not.toThrow(); + }); + + it("passes when every required scope is present", () => { + const claims = makeClaims(); + expect(() => + claims.requireScopes(["tools/query", "tools/write"]), + ).not.toThrow(); + }); + + it("throws InsufficientScope naming the missing scope and present scopes", () => { + const claims = makeClaims(); + expect(() => + claims.requireScopes(["tools/query", "tools/admin"]), + ).toThrow(InsufficientScope); + expect(() => + claims.requireScopes(["tools/query", "tools/admin"]), + ).toThrow( + "Token missing required scope 'tools/admin'. Token has scopes: tools/query, tools/write", + ); + }); + + it("pluralises the message and lists every missing scope", () => { + const claims = makeClaims(); + expect(() => + claims.requireScopes(["tools/admin", "tools/superuser"]), + ).toThrow( + "Token missing required scopes 'tools/admin', 'tools/superuser'. Token has scopes: tools/query, tools/write", + ); + }); + + it("throws InsufficientScope when the token has no scopes at all", () => { + const claims = new VerifiedClaims({ + sub: "u", + clientId: "c", + scopes: [], + issuer: "https://auth.example.com", + audience: ["https://api.example.com"], + expiresAt: 1_800_000_000, + issuedAt: 1_700_000_000, + jti: "j", + kid: "k", + agentId: "", + agentChain: [], + notBefore: 0, + raw: {}, + }); + expect(() => claims.requireScopes(["tools/query"])).toThrow( + InsufficientScope, + ); + // Empty scope list surfaces as "(none)" so the message stays grammatical. + expect(() => claims.requireScopes(["tools/query"])).toThrow( + "Token has scopes: (none)", + ); + }); + }); + it("checks raw claims", () => { const claims = makeClaims(); expect(claims.hasClaim("tenant_id")).toBe(true); diff --git a/packages/sdk/tests/core/clientUnit.test.ts b/packages/sdk/tests/core/clientUnit.test.ts index e9a9c8d..5c9728f 100644 --- a/packages/sdk/tests/core/clientUnit.test.ts +++ b/packages/sdk/tests/core/clientUnit.test.ts @@ -32,7 +32,6 @@ function okTokenResponse(overrides: Partial = {}): Record { await new Promise((resolve) => asServer.close(() => resolve())); } }); + + it("AuthplaneClient.create({ cacheMaxEntries }) honors the cap end-to-end", async () => { + // End-to-end plumbing test: assert that the `cacheMaxEntries` option + // actually reaches the `TokenCache` constructor (not just the + // adapter-level forwarding the four adapter test suites already + // pin). We mock an AS that returns distinct tokens per scope and + // count token-endpoint hits; with `cacheMaxEntries: 2`, three + // distinct-scope `clientCredentials` calls populate three entries — + // the LRU (first call) must be evicted, so re-requesting it issues + // a fresh token-endpoint hit. The most-recently-touched entry stays + // cached (no extra hit). + const asServer = createServer(); + let tokenRequests = 0; + + asServer.on("request", async (req, res) => { + if (!req.url) { + res.statusCode = 404; + res.end(); + return; + } + const port = (asServer.address() as AddressInfo).port; + if ( + req.method === "GET" && + req.url === "/.well-known/oauth-authorization-server" + ) { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + issuer: `http://127.0.0.1:${port}`, + jwks_uri: `http://127.0.0.1:${port}/.well-known/jwks.json`, + token_endpoint: `http://127.0.0.1:${port}/oauth/token`, + }), + ); + return; + } + if (req.method === "GET" && req.url === "/.well-known/jwks.json") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ keys: [] })); + return; + } + if (req.method === "POST" && req.url === "/oauth/token") { + tokenRequests += 1; + const body = await readRequestBody(req); + const scope = urlSearchParamsBody(body).get("scope") ?? ""; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify( + okTokenResponse({ + accessToken: `at_${tokenRequests}_${scope}`, + scope, + }), + ), + ); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise((resolve) => asServer.listen(0, "127.0.0.1", resolve)); + const addr = asServer.address() as AddressInfo; + const base = `http://127.0.0.1:${addr.port}`; + + try { + const client = await AuthplaneClient.create({ + issuer: base, + devMode: true, + auth: { clientId: "client_1", clientSecret: "secret_1" }, + jwksRefreshSeconds: 60, + metadataRefreshSeconds: 60, + cacheMaxEntries: 2, + }); + + // Populate three distinct cache entries against a cap of 2. + await client.clientCredentials(["a"]); + await client.clientCredentials(["b"]); + await client.clientCredentials(["c"]); + expect(tokenRequests).toBe(3); + + // "a" was the first inserted and has not been touched since — it + // should be the LRU victim evicted when "c" landed. Re-requesting + // it must hit the AS again. + await client.clientCredentials(["a"]); + expect(tokenRequests).toBe(4); + + // "c" is the most-recently-set entry; still in cache, no extra hit. + await client.clientCredentials(["c"]); + expect(tokenRequests).toBe(4); + } finally { + await new Promise((resolve) => asServer.close(() => resolve())); + } + }); }); diff --git a/packages/sdk/tests/core/dpopHelpers.test.ts b/packages/sdk/tests/core/dpopHelpers.test.ts index e1119cd..e17f82b 100644 --- a/packages/sdk/tests/core/dpopHelpers.test.ts +++ b/packages/sdk/tests/core/dpopHelpers.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { calculateJwkThumbprint, exportJWK, generateKeyPair, SignJWT } from "jose"; import { + buildDPoPRequestContext, InMemoryDPoPNonceStore, InMemoryDPoPReplayStore, requireDpopProof, @@ -14,6 +15,7 @@ import { DPoPProofMissing, DPoPReplayDetected, InvalidDPoPProof, + MultipleDPoPProofs, } from "../../src/core/errors.js"; function sha256Base64Url(value: string): string { @@ -26,12 +28,13 @@ function sha256Base64Url(value: string): string { } describe("dpop helpers", () => { - it("requires proof when ctx.proof is missing", () => { + it("requires proof when the context has no proofs", () => { expect(() => requireDpopProof(undefined)).toThrow(DPoPProofMissing); expect(() => requireDpopProof({ method: "GET", url: "https://example.com", + proofs: [], }), ).toThrow(DPoPProofMissing); @@ -39,11 +42,63 @@ describe("dpop helpers", () => { requireDpopProof({ method: "GET", url: "https://example.com", - proof: "proof_1", + proofs: ["proof_1"], }), ).toBe("proof_1"); }); + describe("buildDPoPRequestContext", () => { + const base = { + method: "POST", + url: "https://api.example.com/mcp", + }; + + it("returns an empty proofs array when no DPoP header was supplied", () => { + expect( + buildDPoPRequestContext({ ...base, dpopHeaderValues: [] }).proofs, + ).toEqual([]); + }); + + it("filters whitespace-only entries", () => { + expect( + buildDPoPRequestContext({ + ...base, + dpopHeaderValues: ["", " ", " proof "], + }).proofs, + ).toEqual(["proof"]); + }); + + it("throws MultipleDPoPProofs when more than one non-blank value is present", () => { + expect(() => + buildDPoPRequestContext({ + ...base, + dpopHeaderValues: ["eyJ.a", "eyJ.b"], + }), + ).toThrow(MultipleDPoPProofs); + }); + + it("throws MultipleDPoPProofs when a single value is comma-merged (RFC 9449 §4.3 detection)", () => { + // JWS compact serialisation never contains a literal `,`, so a comma + // in the inbound `DPoP` header value can only originate from an + // intermediary collapsing duplicate same-name headers — the very + // shape §4.3 #1 requires us to reject. + expect(() => + buildDPoPRequestContext({ + ...base, + dpopHeaderValues: ["eyJ.a, eyJ.b"], + }), + ).toThrow(MultipleDPoPProofs); + }); + + it("freezes the returned proofs array", () => { + const ctx = buildDPoPRequestContext({ + ...base, + dpopHeaderValues: ["only-one"], + }); + expect(Object.isFrozen(ctx.proofs)).toBe(true); + }); + }); + it("InMemoryDPoPNonceStore evicts oldest entries past maxEntries", () => { const store = new InMemoryDPoPNonceStore(2); store.put("k1", "n1"); diff --git a/packages/sdk/tests/core/errors.test.ts b/packages/sdk/tests/core/errors.test.ts index 5ed20b1..b9fbf13 100644 --- a/packages/sdk/tests/core/errors.test.ts +++ b/packages/sdk/tests/core/errors.test.ts @@ -15,6 +15,7 @@ import { JWKSFetchError, MetadataFetchError, MissingMetadataEndpoint, + MultipleDPoPProofs, ProtocolError, TokenExpired, TokenMissing, @@ -154,6 +155,15 @@ describe("wwwAuthenticate", () => { "DPoP", "invalid_token", ], + // RFC 9449 §7.1 carve-out: §4.3 multi-DPoP-header rejection uses + // the spec-defined invalid_dpop_proof code, not invalid_token like + // the other DPoPError shapes. + [ + "MultipleDPoPProofs", + new MultipleDPoPProofs("two DPoP headers"), + "DPoP", + "invalid_dpop_proof", + ], ])( "%s → %s scheme with %s", (_name, error, scheme, errorCode) => { diff --git a/packages/sdk/tests/core/introspection.test.ts b/packages/sdk/tests/core/introspection.test.ts index d0374c4..9f3fc80 100644 --- a/packages/sdk/tests/core/introspection.test.ts +++ b/packages/sdk/tests/core/introspection.test.ts @@ -23,6 +23,7 @@ describe("introspectToken (RFC 7662)", () => { jti: "jti_1", agent_id: "agent_1", agent_chain: ["a", "b"], + cnf: { jkt: "thumbprint_abc" }, }), ); return; @@ -54,6 +55,42 @@ describe("introspectToken (RFC 7662)", () => { expect(response.jti).toBe("jti_1"); expect(response.agentId).toBe("agent_1"); expect(response.agentChain).toEqual(["a", "b"]); + // RFC 9449 §6.2: DPoP-bound introspection responses carry the JKT + // under `cnf.jkt`. The parser surfaces it as `cnfJkt`. + expect(response.cnfJkt).toBe("thumbprint_abc"); + } finally { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + + it("returns an empty cnfJkt when the introspection response omits the cnf claim", async () => { + // RFC 9449 §6.2 only requires `cnf.jkt` for DPoP-bound tokens — a + // plain bearer token's introspection response omits the field + // entirely. The parser must default to "" rather than throw or + // produce a misleading value the caller might match against. + const server = createServer((req, res) => { + if (req.url === "/oauth/introspect" && req.method === "POST") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ active: true })); + return; + } + res.statusCode = 404; + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address() as AddressInfo; + const base = `http://127.0.0.1:${addr.port}`; + + try { + const response = await introspectToken({ + introspectionEndpoint: `${base}/oauth/introspect`, + token: "tok_1", + fetchSettings: FetchSettings.fromDevMode(true), + }); + expect(response.cnfJkt).toBe(""); } finally { await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())), diff --git a/packages/sdk/tests/core/prmDocumentUrl.test.ts b/packages/sdk/tests/core/prmDocumentUrl.test.ts index 76693bc..b1bb4eb 100644 --- a/packages/sdk/tests/core/prmDocumentUrl.test.ts +++ b/packages/sdk/tests/core/prmDocumentUrl.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { oauthProtectedResourceMetadataDocumentUrl } from "../../src/core/prm.js"; +import { + oauthProtectedResourceMetadataDocumentUrl, + oauthProtectedResourceMetadataPath, +} from "../../src/core/prm.js"; describe("oauthProtectedResourceMetadataDocumentUrl (RFC 9728 §3.1)", () => { it("maps resource path under /.well-known/oauth-protected-resource", () => { @@ -25,4 +28,56 @@ describe("oauthProtectedResourceMetadataDocumentUrl (RFC 9728 §3.1)", () => { "https://rs.example.com/.well-known/oauth-protected-resource/api/v1/mcp/stream", ); }); + + it("strips trailing slashes on the resource path", () => { + expect( + oauthProtectedResourceMetadataDocumentUrl("https://rs.example.com/mcp/"), + ).toBe( + "https://rs.example.com/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("throws TypeError on an invalid URL", () => { + expect(() => + oauthProtectedResourceMetadataDocumentUrl("not a url"), + ).toThrow(TypeError); + }); +}); + +describe("oauthProtectedResourceMetadataPath (RFC 9728 §3.1, path only)", () => { + it("returns the bare .well-known path when the resource has no path", () => { + expect(oauthProtectedResourceMetadataPath("https://rs.example.com")).toBe( + "/.well-known/oauth-protected-resource", + ); + }); + + it("returns the bare .well-known path when the resource path is /", () => { + expect(oauthProtectedResourceMetadataPath("https://rs.example.com/")).toBe( + "/.well-known/oauth-protected-resource", + ); + }); + + it("appends the resource path", () => { + expect( + oauthProtectedResourceMetadataPath("https://rs.example.com/mcp"), + ).toBe("/.well-known/oauth-protected-resource/mcp"); + }); + + it("strips a trailing slash on the resource path", () => { + expect( + oauthProtectedResourceMetadataPath("https://rs.example.com/mcp/"), + ).toBe("/.well-known/oauth-protected-resource/mcp"); + }); + + it("preserves nested paths", () => { + expect( + oauthProtectedResourceMetadataPath("https://rs.example.com/a/b/c"), + ).toBe("/.well-known/oauth-protected-resource/a/b/c"); + }); + + it("throws TypeError on an invalid URL", () => { + expect(() => oauthProtectedResourceMetadataPath("not a url")).toThrow( + TypeError, + ); + }); }); diff --git a/packages/sdk/tests/core/requestContext.test.ts b/packages/sdk/tests/core/requestContext.test.ts new file mode 100644 index 0000000..0908970 --- /dev/null +++ b/packages/sdk/tests/core/requestContext.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRequestUrl, + extractDpopHeaderValues, + pathAndQueryOf, +} from "../../src/core/requestContext.js"; + +describe("buildRequestUrl — htu pinned to the configured resource origin", () => { + it("returns origin + path with no override hooks", () => { + expect( + buildRequestUrl({ + resourceOrigin: "https://api.example.com", + pathAndQuery: "/mcp", + }), + ).toBe("https://api.example.com/mcp"); + }); + + it("preserves query strings exactly", () => { + expect( + buildRequestUrl({ + resourceOrigin: "https://api.example.com", + pathAndQuery: "/mcp/tools/call?id=42&mode=live", + }), + ).toBe("https://api.example.com/mcp/tools/call?id=42&mode=live"); + }); + + it("honours a non-default port supplied via the configured resource", () => { + expect( + buildRequestUrl({ + resourceOrigin: "https://api.example.com:8443", + pathAndQuery: "/mcp", + }), + ).toBe("https://api.example.com:8443/mcp"); + }); + + it("ensures a leading slash on a bare path", () => { + expect( + buildRequestUrl({ + resourceOrigin: "https://api.example.com", + pathAndQuery: "mcp/tools", + }), + ).toBe("https://api.example.com/mcp/tools"); + }); + + it("strips a trailing slash from the resource origin (defensive)", () => { + expect( + buildRequestUrl({ + resourceOrigin: "https://api.example.com/", + pathAndQuery: "/mcp", + }), + ).toBe("https://api.example.com/mcp"); + }); +}); + +describe("pathAndQueryOf", () => { + it("extracts pathname + search from an absolute URL", () => { + expect(pathAndQueryOf("https://api.example.com/mcp?x=1")).toBe("/mcp?x=1"); + }); + + it("returns the root path for an authority-only URL", () => { + expect(pathAndQueryOf("https://api.example.com")).toBe("/"); + }); + + it("drops the fragment", () => { + expect(pathAndQueryOf("https://api.example.com/mcp?x=1#frag")).toBe( + "/mcp?x=1", + ); + }); + + it("throws a TypeError naming the helper and the offending input", () => { + expect(() => pathAndQueryOf("not a url")).toThrow(TypeError); + expect(() => pathAndQueryOf("not a url")).toThrow( + /pathAndQueryOf: argument must be an absolute URL \(got "not a url"\)/u, + ); + }); +}); + +describe("extractDpopHeaderValues", () => { + it("returns a single-entry array for a non-empty string header", () => { + expect(extractDpopHeaderValues("eyJ.proof.value")).toEqual([ + "eyJ.proof.value", + ]); + }); + + it("returns an empty array when the header is absent", () => { + expect(extractDpopHeaderValues(undefined)).toEqual([]); + }); + + it("returns an empty array when the header is the empty string", () => { + expect(extractDpopHeaderValues("")).toEqual([]); + }); + + it("preserves every value for a multi-valued header (Node string[])", () => { + // §4.3 detection happens in `buildDPoPRequestContext`; this helper + // just normalises the wire shape, so duplicates must survive. + expect(extractDpopHeaderValues(["proof-a", "proof-b"])).toEqual([ + "proof-a", + "proof-b", + ]); + }); + + it("filters empty strings out of a multi-valued header", () => { + expect(extractDpopHeaderValues(["", "proof-only"])).toEqual([ + "proof-only", + ]); + }); +}); diff --git a/packages/sdk/tests/core/tokenCache.test.ts b/packages/sdk/tests/core/tokenCache.test.ts new file mode 100644 index 0000000..86ea7e3 --- /dev/null +++ b/packages/sdk/tests/core/tokenCache.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TokenCache } from "../../src/core/cache.js"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-05T00:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("TokenCache — TTL + buffer", () => { + it("returns undefined for missing keys", () => { + const cache = new TokenCache(); + expect(cache.get("missing")).toBeUndefined(); + }); + + it("returns the stored value before the buffered expiry", () => { + const cache = new TokenCache(0, 3600); + cache.set("k", "v", 60); + expect(cache.get("k")).toBe("v"); + }); + + it("evicts an entry whose buffered TTL has elapsed", () => { + const cache = new TokenCache(0, 3600); + cache.set("k", "v", 60); + vi.advanceTimersByTime(61_000); + expect(cache.get("k")).toBeUndefined(); + }); + + it("subtracts the ttlBufferSeconds from the AS-supplied TTL", () => { + // 60s TTL minus 30s buffer ⇒ effective expiry at +30s. + const cache = new TokenCache(30, 3600); + cache.set("k", "v", 60); + vi.advanceTimersByTime(29_000); + expect(cache.get("k")).toBe("v"); + vi.advanceTimersByTime(2_000); + expect(cache.get("k")).toBeUndefined(); + }); + + it("falls back to defaultTtlSeconds when expiresInSeconds is omitted", () => { + const cache = new TokenCache(0, 120); + cache.set("k", "v"); + vi.advanceTimersByTime(119_000); + expect(cache.get("k")).toBe("v"); + vi.advanceTimersByTime(2_000); + expect(cache.get("k")).toBeUndefined(); + }); +}); + +describe("TokenCache — bounded LRU", () => { + it("exposes a default maxEntries cap", () => { + expect(TokenCache.DEFAULT_MAX_ENTRIES).toBe(10_000); + }); + + it("rejects a non-positive or non-integer maxEntries at construction", () => { + expect(() => new TokenCache(0, 3600, 0)).toThrow(RangeError); + expect(() => new TokenCache(0, 3600, -1)).toThrow(RangeError); + expect(() => new TokenCache(0, 3600, 1.5)).toThrow(RangeError); + }); + + it("evicts the least-recently-used entry when the cap is exceeded", () => { + // Cap = 2. Insert k1, k2, k3 — k1 should be evicted as the LRU. + const cache = new TokenCache(0, 3600, 2); + cache.set("k1", "v1", 3600); + cache.set("k2", "v2", 3600); + expect(cache.size()).toBe(2); + cache.set("k3", "v3", 3600); + expect(cache.size()).toBe(2); + expect(cache.get("k1")).toBeUndefined(); + expect(cache.get("k2")).toBe("v2"); + expect(cache.get("k3")).toBe("v3"); + }); + + it("bumps an entry to MRU on get so the next eviction targets a colder key", () => { + // With cap = 2, touch k1 before inserting k3 — k2 should now be + // the LRU victim instead of k1. + const cache = new TokenCache(0, 3600, 2); + cache.set("k1", "v1", 3600); + cache.set("k2", "v2", 3600); + expect(cache.get("k1")).toBe("v1"); // touch + cache.set("k3", "v3", 3600); + expect(cache.get("k1")).toBe("v1"); + expect(cache.get("k2")).toBeUndefined(); + expect(cache.get("k3")).toBe("v3"); + }); + + it("re-setting an existing key does not increase cache size", () => { + const cache = new TokenCache(0, 3600, 2); + cache.set("k1", "v1", 3600); + cache.set("k1", "v1-updated", 3600); + expect(cache.size()).toBe(1); + expect(cache.get("k1")).toBe("v1-updated"); + }); + + it("re-setting an existing key bumps it to MRU (touch-on-write)", () => { + // Without the touch-on-write bump, k1 stays LRU and gets evicted + // when k3 lands. That'd surprise callers who treat `.set` as a + // "I care about this entry" signal. + const cache = new TokenCache(0, 3600, 2); + cache.set("k1", "v1", 3600); + cache.set("k2", "v2", 3600); + cache.set("k1", "v1-updated", 3600); // bump k1 to MRU + cache.set("k3", "v3", 3600); + expect(cache.get("k1")).toBe("v1-updated"); + expect(cache.get("k2")).toBeUndefined(); + expect(cache.get("k3")).toBe("v3"); + }); + + it("does not store a token whose buffered TTL is <= 0", () => { + // `expiresInSeconds <= ttlBufferSeconds` ⇒ the entry is dead on + // arrival, so `set` early-returns on `bufferedTtl <= 0`. + // Without this skip, the entry would sit in the map until the next + // `get` reaped it — and on a bounded cache that's a wasted slot + // that can evict a live entry. + const cache = new TokenCache(30, 3600); + cache.set("k", "v", 30); // 30 - 30 = 0 ⇒ skipped + expect(cache.size()).toBe(0); + expect(cache.get("k")).toBeUndefined(); + cache.set("k2", "v2", 10); // 10 - 30 = -20 ⇒ skipped + expect(cache.size()).toBe(0); + }); + + it("a dead-on-arrival set does not evict a live entry from the bounded cache", () => { + // The teeth of the parity gap: with the cap, a stored-then-expired + // entry occupies a slot and could evict a real, longer-lived entry + // before the lazy `get` sweep ran. Pin that the skip path leaves + // live entries alone. + const cache = new TokenCache(30, 3600, 2); + cache.set("k1", "v1", 3600); + cache.set("k2", "v2", 3600); + cache.set("dead", "x", 10); // 10 - 30 = -20 ⇒ skipped, no eviction + expect(cache.size()).toBe(2); + expect(cache.get("k1")).toBe("v1"); + expect(cache.get("k2")).toBe("v2"); + expect(cache.get("dead")).toBeUndefined(); + }); +}); diff --git a/packages/sdk/tests/core/verifier.test.ts b/packages/sdk/tests/core/verifier.test.ts index 6b85fae..82f264e 100644 --- a/packages/sdk/tests/core/verifier.test.ts +++ b/packages/sdk/tests/core/verifier.test.ts @@ -748,7 +748,7 @@ describe("AuthplaneResource with DPoP-bound tokens", () => { const dpopRequest = { method: dpopMethod, url: dpopUrl, - proof: headers.DPoP, + proofs: [headers.DPoP], }; const claims = await resource.verify(token, { dpopRequest }); @@ -823,7 +823,7 @@ describe("AuthplaneResource with DPoP-bound tokens", () => { dpopRequest: { method: dpopMethod, url: dpopUrl, - proof: headers.DPoP, + proofs: [headers.DPoP], }, }), ).rejects.toBeInstanceOf(DPoPBindingMismatch); diff --git a/scripts/release/set-package-versions.mjs b/scripts/release/set-package-versions.mjs index a383a32..e835e9f 100644 --- a/scripts/release/set-package-versions.mjs +++ b/scripts/release/set-package-versions.mjs @@ -31,6 +31,8 @@ const files = [ "packages/sdk/package.json", "packages/mcp/package.json", "packages/fastmcp/package.json", + "packages/hono/package.json", + "packages/nestjs/package.json", ]; /** @@ -77,7 +79,13 @@ function syncPackageLockWorkspaceSnapshots(repoRoot, packagesByWorkspaceDir) { } lock.packages[""].version = rootPkg.version; - const workspaceDirs = ["packages/sdk", "packages/mcp", "packages/fastmcp"]; + const workspaceDirs = [ + "packages/sdk", + "packages/mcp", + "packages/fastmcp", + "packages/hono", + "packages/nestjs", + ]; for (const dir of workspaceDirs) { const pkg = packagesByWorkspaceDir.get(dir); if (!pkg) { diff --git a/tsconfig.json b/tsconfig.json index 1016bbb..3c69c0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "references": [ { "path": "packages/sdk" }, { "path": "packages/mcp" }, - { "path": "packages/fastmcp" } + { "path": "packages/fastmcp" }, + { "path": "packages/hono" }, + { "path": "packages/nestjs" } ] }