Skip to content

feat(ax): config-declared entry auth — tell agents where to authenticate - #25

Merged
arielshulman29 merged 12 commits into
mainfrom
feat/declared-entry-auth
Aug 27, 2026
Merged

arielshulman29 merged 12 commits into
mainfrom
feat/declared-entry-auth

Conversation

@arielshulman29

@arielshulman29 arielshulman29 commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

What

Site owners can declare where agents authenticate, ax init asks for it (prefilled when the source tree already states it), and the build report gets a structured auth section. Designed around the realistic maturity ladder: most apps' agent lane is an API key behind their existing login; OAuth is the spec-track upgrade for those whose auth provider already offers it.

1. Config-declared entry auth

An entry override in ax.config can declare a first-class auth descriptor (exactly Ora's secret-free EntryAuth shape):

entries: [{
  identifier: 'urn:air:example.com:mcp-server',
  auth: { status: 'api_key', docsUrl: 'https://example.com/docs/api-access' },
  // or status: 'oauth2' with oauth.authorizationEndpoint / tokenEndpoint
}]

Declared once, it flows to the catalog entry, the MCP server card's authentication block, and the generated /auth.md. For withMcpAuth-wrapped MCP mounts — which detection can only describe as "requires auth, scheme unknown" — the declaration is routed into the mount itself, so all three surfaces agree.

  • config-schema: auth typed + validated by a closed sub-schema (status enum, absolute http(s) URLs ≤256 chars, capped lists, unknown keys fail loudly).
  • auth: sanitizeDeclaredAuth() applies the same secret-guard as detected descriptors (safeHttpUrl, caps, whitelist-only fields).
  • detect-mcp: applyDeclaredMountAuth() routes declarations into mounts pre-gating (declaring auth marks the mount gated + reviewed); a declared "none" can't un-gate a detected wrapper.
  • entries: overrides sanitized once up front; declared-vs-detected status conflicts warn (declared wins).

2. ax init asks how agents authenticate

For each gated MCP server, a scheme select then targeted follow-ups:

  • API key / bearer (the realistic common case): declares status "api_key", asks only for the docs URL where a human obtains a key.
  • OAuth 2.0: asks authorization + token endpoints — prefilled when a committed RFC 8414 oauth-authorization-server document states them (new detect-auth-metadata detector; also surfaces authServerUrls literals from protectedResourceHandler routes — including under app/.well-known/, which the route walker skips as hidden — and RFC 9728 documents as context).
  • Skip: asks nothing.

The default is OAuth only when the source tree shows OAuth evidence; otherwise skip — Enter-through never steers a basic-auth user into questions they can't answer, and never writes a declaration that wasn't an active choice. Answers are validated with the schema's own URL rule, so init can't write a config the next build rejects.

3. Report auth section

.ora/report.json gains a structured auth block: one entry per published gated surface (path, scheme, declared-vs-derived, endpoints present, docsUrl, and an actionable note when something's missing) plus the detected auth provider (@clerk/nextjs, better-auth, @auth0/nextjs-auth0, next-auth/@auth/core) with a durable note on what it means for agent auth. Deliberately no wiring snippets — provider APIs drift; the notes state stable facts and name the provider's own docs. next-auth's note steers to the api_key lane, since it signs humans in but exposes no authorization server agents can use.

Testing

  • 48 new tests (712 total, all passing): sanitizer, override merge + conflict warnings, mount routing, schema loud-failures, end-to-end generateCatalog (catalog/card/auth.md/report.auth), auth-metadata detector (incl. comment scrubbing + hidden-dir route), provider detector, config rendering round-tripped through the real schema gate, and scripted-prompter init flows (oauth collect, prefill-approve, api_key, skip, docs-only, invalid-URL re-prompt, nothing-gated, evidence-based default).
  • Verified with real CLI runs against a copy of the demo app: the wizard defaults to skip (no OAuth evidence), an active api_key choice + docs URL lands in ax.config, and the next build publishes it to the catalog, the server card, auth.md, and report.auth.

🤖 Generated with Claude Code

arielshulman29 and others added 12 commits August 26, 2026 12:59
An entry override in ax.config can now declare a first-class `auth`
descriptor (exactly Ora's secret-free EntryAuth shape: status, OAuth
endpoint URLs, scope keys, docsUrl). Declared once, it flows to the
catalog entry, the MCP server card, and the generated /auth.md — most
usefully for withMcpAuth-wrapped MCP mounts, which detection can only
ever describe as "requires auth, scheme unknown".

- config-schema: `auth` typed on AxEntryOverride and validated by a
  closed sub-schema — status enum required, URL fields must be absolute
  http(s) within 256 chars, lists capped, unknown keys fail loudly.
- auth: sanitizeDeclaredAuth() gives declared descriptors the same
  secret-guard discipline as detected ones (safeHttpUrl, caps, only
  EntryAuth fields cross), reporting every dropped field.
- detect-mcp: applyDeclaredMountAuth() routes a declared descriptor into
  the mount itself (matched by the entry URN), so the server card and
  auth.md read it too; runs before gating, so declaring auth marks the
  mount gated and reviewed. A declared "none" can't un-gate a detected
  wrapper — warned and ignored.
- entries: overrides sanitized once up front; a declared status that
  contradicts a detected one wins but warns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed declarations

For each MCP server the wizard just gated, `ax init` now asks the optional
follow-ups gating never covered: the OAuth authorization/token endpoints
and a docs URL where a human obtains access. Answers land as a declared
`auth` entry override in the generated ax.config — the same block a user
would hand-write — so the next build publishes them to the catalog entry,
the server card, and /auth.md. Enter skips any question; skipping all
writes no entries key.

Same prefill posture as siteUrl: a new detect-auth-metadata detector reads
the app's committed declarations (never probes, never guesses provider
paths) —
- a static RFC 8414 public/.well-known/oauth-authorization-server document
  states endpoints verbatim → editable defaults, named with their source;
- mcp-handler's protectedResourceHandler `authServerUrls` literals and a
  static RFC 9728 protected-resource document name the authorization
  server → printed as context above the questions (its endpoints live
  behind its own /.well-known, which ax won't fetch). The conventional
  app/.well-known/oauth-protected-resource/route.* is checked directly,
  since the route walker skips hidden directories.

Answers are validated with the same safeHttpUrl guard the config schema
enforces, so the wizard can't write a config the very next build rejects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…auth section

Most real apps' agent lane is an API key behind their existing login, not
OAuth — the wizard and the report now reflect that.

- init: a scheme select precedes the endpoint questions — OAuth 2.0 (asks
  the two endpoints), API key/bearer (declares status "api_key", asks only
  the docs URL), or skip. OAuth is the default only when the source tree
  shows OAuth evidence (committed metadata / declared authorization
  server); otherwise the default is skip, so Enter-through never steers a
  basic-auth user into OAuth questions they can't answer and never writes
  a declaration they didn't actively choose. An active api_key choice is a
  declaration by itself, docs URL or not.
- report: a structured `auth` section — one entry per published gated
  surface (scheme, declared-vs-derived, endpoints present, docsUrl, and an
  actionable note when something's missing) plus the detected auth
  provider (@clerk/nextjs, better-auth, @auth0/nextjs-auth0, next-auth /
  @auth/core) with a durable note on what it's worth for agent auth —
  deliberately no wiring snippets, which rot as provider APIs change;
  next-auth's note steers to the api_key lane since it signs humans in
  but exposes no authorization server agents can use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s never carry secrets

The declared-auth surface only ever collects URLs, and safeHttpUrl already
keeps a pasted bare token out (not a URL). This closes the remaining leak
vector: a real token embedded *inside* an otherwise-valid URL
(?api_key=sk-live-…), which would have shipped verbatim in the public
catalog and auth.md.

credentialQueryParam() flags credential-named query parameters; the wizard
re-asks with an explanation (the page itself can require login — the
published URL must be clean), and sanitizeDeclaredAuth drops the field
with a named reason for config-declared values, so both entry paths are
covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Provider-flavored RFC 9728 handlers (@clerk/mcp-tools'
protectedResourceHandlerClerk) resolve the authorization server from env
at runtime, so there is no committed issuer literal to read — but the
wiring itself is committed evidence the gated surface speaks OAuth. The
detector now records the route (resourceMetadataRoute); the wizard uses
it to default the scheme to OAuth, prints it as context ("endpoints are
agent-discoverable at runtime"), and lets an evidence-backed bare
status "oauth2" stand instead of downgrading to "unknown" — endpoint
questions stay optional because agents walk the metadata chain
themselves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…auth checks

- Interactive select/multi-select markers are ASCII-only (> (*) [x],
  plain hint text): the previous glyphs (❯ • · ↑↓) are ambiguous-width
  Unicode that some terminal/font configs render two columns wide,
  silently wrapping rows the redraw arithmetic counted as one physical
  row — the reported first-row-disappears corruption.
- The scheme question defaults to API key when no OAuth evidence exists
  (skip is never the default): a gated surface can nearly always
  truthfully declare api_key, so an Enter-through now declares it
  instead of writing nothing.
- Tighter intro copy, "(the MCP-spec path)" dropped from the OAuth
  label, and the docs-URL question now says what the URL is for (agents
  send their user there when they hit the auth wall).
- report.ora.checks now speaks to Ora's auth checks: mcp-auth-mechanism
  (actionable while any gated surface ships status "unknown", with the
  declare-it note) and oauth-protected-resource (only when OAuth is
  declared — an api_key site doesn't get the OAuth ceiling held against
  it; addressed once RFC 9728 metadata is wired/committed/declared).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live Clerk-path testing surfaced the miss: the wizard asked for OAuth
endpoint URLs even when a wired RFC 9728 protected-resource route proved
agents discover them at runtime. Endpoint questions now run only when
the user chose OAuth and the source tree has no OAuth to read — the one
case a declaration is the sole way agents learn the endpoints. Committed
RFC 8414 metadata is adopted verbatim (announced with its source, never
asked); a wired protected-resource route or declared authorization
server asks nothing, and the issuer context line now says "agents
discover its endpoints from the metadata chain at runtime" instead of
"paste below".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…OAuth access line

More live-testing feedback:
- Dropped the RFC 9728 context line and the per-mount "will declare
  auth" recap — the config write confirmation covers it.
- Every CLI y/N now carries the [ax] prefix (added once, in
  defaultConfirm, so injected test confirms see the bare question).
- The docs-URL question prefills the site origin so the user only types
  the path; an unedited prefill means skip — a bare homepage is never a
  docs URL someone chose on purpose.
- auth.md's "Get access" line for an OAuth surface without a docsUrl now
  says the truth ("sign in through your MCP client via OAuth") instead
  of nagging for a docs page that has no job in a self-service flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OAuth is self-service — the agent's client runs the sign-in flow, so
there is no credential page to ask about; the Clerk-path wizard now ends
at the scheme choice. The docs question remains only for api_key, where
a human genuinely fetches a credential somewhere, and its wording now
matches its behavior: "complete the path; submitting it unchanged skips"
(the prefill is a typing head start, never a value Enter would save).
An OAuth answer with no backing (no evidence, no endpoints) declares
nothing — the docs-only "unknown" path is gone with the question that
fed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The BuildReport gained the structured auth block (gated surfaces +
detected provider) and new ora-check entries, so every fixture's
committed golden was stale — CI's fixtures-build job diffs them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d gating

Field-tested failure: a site wired withAx around a User-Agent bot gate
whose matcher covered everything — so the catalog, server cards,
llms.txt, auth.md, openapi.json, and the twins all returned the gate's
403 to script UAs (including Ora's own journey engine, Go-http-client),
and the agent concluded the site published nothing. The build published;
the middleware un-published.

withAx now short-circuits requests for the manifest-listed discovery
artifacts (and the twins' own paths) before the wrapped middleware runs
— these files exist precisely to be read by agents the site has never
heard of, so a reachable publication is part of withAx's contract, not
the wrapped gate's jurisdiction. Routes and gated paths are untouched
(page gating stays the app's policy), and protectDiscovery: false opts
out for sites that genuinely gate their artifacts. The wiring
recommendation now mentions the guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous regen ran against a stale dist/ (built before the wiring-
recommendation text changed), so the goldens verified locally against
old code while CI, building fresh, saw the new string in 16 fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arielshulman29
arielshulman29 merged commit 8cc01b4 into main Aug 27, 2026
2 checks passed
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.

1 participant