Skip to content

fix: getCapabilityLocation now consults resolveCapabilities - #40

Open
envisean wants to merge 1 commit into
better-auth:mainfrom
envisean:fix/capability-location-resolve-capabilities
Open

fix: getCapabilityLocation now consults resolveCapabilities#40
envisean wants to merge 1 commit into
better-auth:mainfrom
envisean:fix/capability-location-resolve-capabilities

Conversation

@envisean

Copy link
Copy Markdown

Fixes #39.

Bug

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 it's configured.

Effect: a capability served only via resolveCapabilities (a DB-backed catalog, a per-tenant capability set, etc.) is discoverable via GET /capability/list and grantable via /agent/request-capability / /agent/approve-capability, but its location is silently never checked the moment a JWT actually carries it -- in both:

  • the JWT audience-verification before-hook (runs on every authenticated request)
  • POST /agent/introspect

No error, no warning -- the check just quietly doesn't apply.

Fix

Mirrors the static-then-resolve fallback pattern execute-capability.ts already uses for single-capability lookup: check the static array first (identical behavior to today for the common case, no dynamic resolution needed), fall back to resolveCapabilities only if the capability isn't found there.

export async function getCapabilityLocation(
  opts: Pick<ResolvedAgentAuthOptions, "capabilities" | "resolveCapabilities">,
  capabilityName: string,
): Promise<string | undefined> {
  const staticCap = opts.capabilities?.find((c) => c.name === capabilityName);
  if (staticCap) return staticCap.location;
  if (!opts.resolveCapabilities) return undefined;
  const resolved = await opts.resolveCapabilities({
    capabilities: opts.capabilities ?? [],
    query: null,
    agentSession: null,
    hostSession: null,
  });
  return resolved.find((c) => c.name === capabilityName)?.location;
}

Both real call sites (middleware.ts's before-hook, routes/introspect.ts) were already inside async handlers, so this is a small, contained change -- signature change plus await at each call site.

Note: src/server/* has an identical, separately-duplicated copy of this function (server/helpers.ts) with the same bug, but per tsup.config.ts that subtree isn't part of any built entry point (src/index.ts, src/client.ts, src/openapi.ts are 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

  • Updated getCapabilityLocation's existing unit tests in security.test.ts for the new async (opts, name) signature (no behavior change to what they assert).
  • Added coverage for: falling back to resolveCapabilities when the capability isn't in the static list, the static list winning when a capability is defined in both, and staying undefined when neither resolves it.
  • Full suite: 259/259 passing (pnpm test).
  • pnpm typecheck and pnpm build both clean.
  • oxfmt --check . clean.

🤖 Generated with Claude Code
via Happy

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>
@envisean

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getCapabilityLocation() ignores resolveCapabilities -- location enforcement silently doesn't apply to dynamically-resolved capabilities

1 participant