fix: getCapabilityLocation now consults resolveCapabilities - #40
Conversation
getCapabilityLocation() only ever read the static `capabilities` array, never opts.resolveCapabilities -- unlike every other capability-list consumer in the plugin (listCapabilities, registration validation, capability-request validation, execute-capability's own lookup), which all check resolveCapabilities first when configured. Practical effect: a capability served only via resolveCapabilities was discoverable (GET /capability/list) and grantable (/agent/request-capability, /agent/approve-capability), but its `location` was silently never checked at request time -- both in the JWT audience-verification before-hook (every authenticated request) and in POST /agent/introspect. No error, just a quietly-skipped check. Fix mirrors execute-capability.ts's existing static-then-resolve fallback pattern: check the static array first (no behavior change for the common case), fall back to resolveCapabilities only if the capability isn't found there. Both real call sites were already inside async handlers, so this is a small, contained change plus an `await` at each call site. Updated the function's own existing unit tests for the new async (opts, name) signature, and added coverage for the new resolveCapabilities fallback path, including that the static list still wins when a capability is defined in both. Fixes better-auth#39 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
|
Correction on impact framing in the PR description above, after tracing the client SDK (`packages/sdk`, `@auth/agent`) as well (see full detail on #39) -- the failure mode this fixes is a false rejection, not a silently-skipped check that lets something wrong through. The client SDK already resolves capability `location` correctly and dynamically (caches it from `GET /capability/list`, which already threads `resolveCapabilities`), so it was already signing execute JWTs with the correct `aud`. Pre-fix, the server-side bug meant `getCapabilityLocation` couldn't produce a matching `expectedLocation` for a `resolveCapabilities`-only capability, so `verifyAudience` rejected an otherwise-correct request with 401 `INVALID_JWT`. This PR brings server-side audience verification back in sync with what the client SDK was already doing correctly. |
Fixes #39.
Bug
getCapabilityLocation()only ever read the staticcapabilitiesarray, neveropts.resolveCapabilities-- unlike every other capability-list consumer in the plugin (listCapabilities, registration validation, capability-request validation,execute-capability's own lookup), which all checkresolveCapabilitiesfirst when it's configured.Effect: a capability served only via
resolveCapabilities(a DB-backed catalog, a per-tenant capability set, etc.) is discoverable viaGET /capability/listand grantable via/agent/request-capability//agent/approve-capability, but itslocationis silently never checked the moment a JWT actually carries it -- in both:POST /agent/introspectNo error, no warning -- the check just quietly doesn't apply.
Fix
Mirrors the static-then-resolve fallback pattern
execute-capability.tsalready uses for single-capability lookup: check the static array first (identical behavior to today for the common case, no dynamic resolution needed), fall back toresolveCapabilitiesonly if the capability isn't found there.Both real call sites (
middleware.ts's before-hook,routes/introspect.ts) were already insideasynchandlers, so this is a small, contained change -- signature change plusawaitat each call site.Note:
src/server/*has an identical, separately-duplicated copy of this function (server/helpers.ts) with the same bug, but pertsup.config.tsthat subtree isn't part of any built entry point (src/index.ts,src/client.ts,src/openapi.tsare the only ones), so it's not part of what actually ships today. Left it alone here since fixing genuinely-dead/unshipped code felt out of scope for this PR -- happy to fix it too if that's actually live/upcoming code and a maintainer wants it in the same pass.Tests
getCapabilityLocation's existing unit tests insecurity.test.tsfor the new async(opts, name)signature (no behavior change to what they assert).resolveCapabilitieswhen the capability isn't in the static list, the static list winning when a capability is defined in both, and stayingundefinedwhen neither resolves it.pnpm test).pnpm typecheckandpnpm buildboth clean.oxfmt --check .clean.🤖 Generated with Claude Code
via Happy