diff --git a/MODULE.md b/MODULE.md index 666d299b6..f5757d539 100644 --- a/MODULE.md +++ b/MODULE.md @@ -47,6 +47,8 @@ A codefly **module** is a collection of **services**; each service owns its own - Generated REST inventory: `module/services/accounts/generated/rest-surface.json` - REST/OpenAPI contract and extension boundary: `module/REST_SURFACE.md` - Resource follow subscriptions over events and notifications: `module/FOLLOWS.md` +- The host as an OAuth 2.1 authorization server, its two client sources, and + resource indicators: [The host as an OAuth 2.1 authorization server](#the-host-as-an-oauth-21-authorization-server) ## Architecture @@ -154,6 +156,209 @@ which runtime behavior is rolled out. A flag may disable entitled behavior but must never grant an entitlement, raise a quota, or replace authorization. Each product path checks its entitlement independently from its flag evaluation. +## The host as an OAuth 2.1 authorization server + +The host signs people in for its own public clients — an add-in, a CLI, a mobile +app, an MCP client — and it is the **only** sign-in UI: a client never talks to +an identity provider. Two spellings of the same authorization server exist, over +one registry, one authorization-code table, one minter and one session kind. + +| Surface | Path | Owner | +|---|---|---| +| RFC 8414 authorization-server metadata | `GET /.well-known/oauth-authorization-server` | accounts (`pkg/business/oauth_authorization_server.go`), presented at the edge by the frontend proxy | +| Authorization endpoint | `GET /oauth2/authorize` | frontend (`src/app/(auth)/oauth2/authorize/route.ts`) → the login page → the consent page | +| Token endpoint (RFC 6749 form-encoded) | `POST /oauth2/token` | accounts (`pkg/adapters/oauth_http.go`) | +| The first registered client's own RPCs | `/v1/auth/clients/validate`, `/v1/auth/clients/authorize`, `/v1/auth/token` | accounts (`pkg/business/client_authorization.go`) | +| RFC 9728 protected-resource metadata for a solution's MCP endpoint | `GET /api/solutions//proxy/.well-known/oauth-protected-resource` | the solution's **runtime**, reached through the frontend's solution proxy (the gateway serves a solution's `.well-known` GET unauthenticated) | +| The MCP endpoint itself | `POST /api/solutions//proxy/mcp` | the solution's runtime, same route | + +**The issuer is `APP_BASE_URL`.** RFC 8414 §2 requires an authorization +server's issuer to be an https URL, and a client that discovers the metadata +then verifies `iss` against it — so the published `issuer` and the `iss` of +every minted token are **one configured value**, resolved once at startup, never +from the request's `Host`. A deployment with no `APP_BASE_URL` publishes no +metadata and keeps the pre-metadata literal issuer, so the two cannot disagree — +and the registered-client browser handoff keeps working there, because issuing a +code never depends on a published issuer. One that sets it mints the URL and +keeps **accepting** the literal until tokens carrying it expire, so the change +signs nobody out. + +**Registration credentials keep their own issuer.** The module- and +solution-registration credentials are internal, with their own audiences and +their own verifiers in the gateway and the frontend. They are not OAuth and +publish nothing, so they stay on the fixed `saas-starter` issuer rather than +following `APP_BASE_URL`: moving them would have refused a solution's frontend +half while its gateway half registered, and an active registration needs both. + +**The resource is the path a client can reach, which is the solution proxy.** +`/api/solutions//proxy/*` is the **only** public route to a solution's +backend: it forwards the caller's bearer to the gateway and already serves the +solution's `.well-known` anonymously. `/solutions//*` is the gateway's own +internal surface — on the public origin that prefix is a page of the frontend +that redirects to login — so the RFC 8707 resource is +`/api/solutions//proxy/mcp` and nothing else. An audience naming the +internal path is one no client could ever present at the resource it describes. + +**A denied solution request carries the discovery challenge.** The gateway is +where it is refused — it strips identity, runs ext_authz and answers itself, +never proxying — so a challenge the runtime would have sent cannot reach anyone +through it. Every protected `/solutions//*` answers 401 with +`WWW-Authenticate: Bearer resource_metadata="/api/solutions//proxy/.well-known/oauth-protected-resource"`, +and the proxy route **passes that header through**: it is the only public way +out, so a challenge it dropped was one nobody could read. The host serves no +second copy of that document — two for one resource are two places to disagree, +and a client follows whichever URL the challenge names. + +**Every `/.well-known/` path is public, served or not, matched as a path +SEGMENT.** A well-known URI is a reserved metadata namespace (RFC 8615) and every +discovery chain begins by fetching one with no credential, so the page +middleware's login redirect must never reach them — including the ones this host +does not answer, which must get the 404 that says so. A segment test rather than +a root prefix, because these documents are not all at the root: a solution's +protected-resource metadata sits beneath its own route, and `/solutions//` is +a page of the frontend. A browser follows a 307 and looks fine; a non-browser +client follows it, parses a login page as JSON, and concludes that this host is +not an authorization server, which is a report pointing nowhere near the +middleware that caused it. The predicate is `isPublic` in `frontend/src/proxy.ts` +and it is tested directly for exactly that reason. + +**Consent is enforced by the host, not advertised to the page.** A grant request +for a client whose authorization requires consent is refused unless it states +that the person approved. That does not defend against a hostile browser — one +holding the session can claim anything — but it is what stops a browser-side +defect from issuing credentials nobody approved: the host does not rely on the +page having read `requires_consent` correctly. + +**The wire names are the contract.** Accounts serialises OAuth snake_case and +the frontend **decodes** it (`features/auth/model/oauth-authorization.ts`), +failing closed on anything it cannot read. A cast would type-check while +producing `undefined` for every field, and a unit test on either side cannot +establish the agreement because each side's test fixes the shape that side +expects — so the seam is held from both ends, each spelled against the other's +own serialisation (`accounts/pkg/adapters/oauth_wire_contract_test.go` and +`frontend/.../model/__tests__/wire-contract.test.ts`). + +The OAuth surface is **raw HTTP, not transcoded RPCs**, because its shape is the +contract: RFC 6749 §3.2 requires a form-encoded token request and §5.2 a +specific JSON error object, neither of which grpc-gateway produces. A client +written against a standard OAuth library could not use a transcoded RPC. + +Two client sources, which cannot collide — a registry slug can never spell +`https://`: + +- **Operator-registered.** `IDENTITY_REGISTERED_CLIENTS`, a JSON array in the + `identity` configuration group. Unset registers none, which refuses the flow. +- **Client ID Metadata Documents.** A `client_id` that is an https URL is + fetched (bounded size and time, **no redirect at all** per CIMD draft-02 §5.1, + and guarded at the dial against every non-public destination — the IANA + special-purpose registries for both families, not just loopback and RFC 1918), + validated, and used as a public client for that one flow. Failures and invalid + documents are **not** cached (§5.2), so a publisher who fixes one is not made + to wait out a window; the abuse a negative cache would have bounded is bounded + by the authentication rate-limit class on the routes that reach it. + **Nothing durable is written.** Gated by + `IDENTITY_CLIENT_METADATA_DOCUMENTS`, unset meaning refused. There is **no** + dynamic client registration (RFC 7591) and no `registration_endpoint` in the + published metadata. + +**Resource indicators (RFC 8707).** An authorization request may name one +resource: a solution's MCP endpoint **as a client can reach it**, which on a +deployed cell is the host frontend's solution proxy — +`https:///api/solutions//proxy/mcp`. The gateway's own +`/solutions//*` is an internal surface, and on the public origin that prefix +is a frontend page that redirects to login, so a resource spelled that way names +a document no client can fetch. + +The resource is recorded on the authorization code, persisted on the session, and +carried as a second `aud` value beside the host's own — so every existing +verifier still accepts the token. A rotation reissues the binding from the locked +session row, never from the request. + +**A solution's MCP endpoint admits only a token issued for itself** (the security +posture's SP-SOL-07). A token carrying just the host audience is a credential for +the host's own API, and the whole purpose of a resource indicator is that one +audience is not the other: it is refused there, with a 401 and a challenge saying +what to authorize for. A deployment that has not been told its own public address +cannot state which resource its tool endpoint requires, so it refuses by name +rather than admitting what arrived. + +That line is drawn at the tool endpoint, not across every solution route. A +solution's other routes are the product's own API surface, reached by a signed-in +person whose session token names no resource; a resource-bound token is still +confined there to the solution it names, so one solution's token never reads +another's data. + +**The resource identifier is compared EXACTLY, by code-point equality, at both +issuance and admission.** The resource URL is itself the audience value this host +mints; a solution's runtime publishes that same string as its `resource`; and RFC +9728 §3.3 has the client require the document's `resource` to equal the URL it +dialled. No step in that chain folds case or re-renders the URL, so a host that +accepted several spellings of one resource would have several resources — and a +variant it minted would be rejected by the very client it was issued for. Both +sides compare against `auth.SolutionMCPResource`, the single place the string is +composed. A deployment with no configured public address cannot state what the +expected identifier is, so it refuses to issue one and refuses to admit one. + +**A token's audience set has three readings, not two.** Bound to nothing, bound +to one valid resource, or unreadable — and the third is refused on every path. It +cannot be carried as an empty resource, because empty means "bound to nothing", +which is the reading admitted most widely. Two resource audiences, or one that is +not shaped like a resource identifier (userinfo, a query, a fragment, a +non-loopback `http` origin), are refused. The verifier's shape rule is the +issuer's own `auth.ParseResourceIndicator`, so verification cannot drift looser +than issuance; the gateway spells the identical rule on its own side of the +module boundary because nothing crosses it. + +**A sign-in state that cannot be recorded as used is refused** (handbook +SP-IDENT-04). While the consumption store is unavailable, "has this state been +used before?" has no answer, and admitting on no answer means the single-use +property does not hold for the duration of the outage. The refusal carries its +own error so an operator can tell a failing dependency from a replayed state; the +caller maps both to one sentinel before answering. + +**RFC 8252 §7.3 is unconditional for loopback.** A native client takes an +ephemeral port from the operating system at the moment of the request, so it +cannot have registered the port it will listen on, and any port is allowed at +request time whether or not the registration named one. The port is the only +component relaxed — scheme, host, path and query must match, and userinfo and a +fragment are refused. + +**This changes what an existing registration means.** The rule lives on +`RegisteredClient.AllowsRedirect`, which the operator-declared registry uses too, +so a client already declared with `http://localhost:3000/auth/callback` now +matches that path on **any** port. An operator who wrote the port expecting it to +pin should read it as no longer pinning: on loopback the port is not the thing +that identifies the recipient, which is why §7.3 requires this and why PKCE is +what protects the exchange. + +**A session cookie is not a credential at this perimeter.** The gateway verifies +a bearer — an access token or an API key — and reads no cookie anywhere; a cookie +is how the frontend holds a session for its own pages. A caller presenting one is +told that, rather than reading "authentication required" while holding what looks +to them like a credential. + +**Consent.** The host asks the person to approve a client by name when the +client registered itself by publishing a document (nobody but the person can +vouch for it) or when the request narrows the credential to a named resource. +An operator-declared client asking for nothing in particular keeps the silent +handoff it has today. + +The consent screen always shows the origin the host **verified** — the one it +fetched the document from. A document's own `client_uri` is read by nothing: a +document at `https://client.example.com/doc` may claim any `client_uri`, and +displaying that claim would name a publisher the host never reached (CIMD +draft-02 §8.5). The client's `client_name` is shown as untrusted presentation +beside the verified origin, never instead of it. + +A redemption is checked against the client's policy **as it stands then**, not +only against the code: a metadata client withdraws a callback by removing it +from its document, since there is no row to delete. + +An MCP token is a **session** credential: the gateway stamps +`x-credential-kind: session` with a session id, so a solution's SDK mints the +viewer's Work Context from it exactly as from a browser session. Lifetime, +refresh rotation and revocation are the registered-client tokens'. + ## Three layers of authorization | Layer | Where | What it asserts | diff --git a/configurations/local-dogfood/identity.env.example b/configurations/local-dogfood/identity.env.example index 87d2dedc8..2994fb2bd 100644 --- a/configurations/local-dogfood/identity.env.example +++ b/configurations/local-dogfood/identity.env.example @@ -13,3 +13,8 @@ IDENTITY_ALLOWED_REDIRECT_URIS= # Gate signup only (login and invite are never gated): open | invite | waitlist. # Defaults to open when unset; unrecognised values fail startup closed. IDENTITY_SIGNUP_MODE=open +# Admit Client ID Metadata Document clients — how an MCP client (Claude Code, +# Claude Desktop, a Claude.ai connector) registers itself. Unset refuses them; +# `any` admits any document that validates; a comma-separated list admits only +# those exact client_id URLs. +IDENTITY_CLIENT_METADATA_DOCUMENTS= diff --git a/module/configurations/local/application.env b/module/configurations/local/application.env index 9a3fc55f7..e21f0a51f 100644 --- a/module/configurations/local/application.env +++ b/module/configurations/local/application.env @@ -2,3 +2,18 @@ # SDK-discovered endpoint. A background-only production origin may be supplied # by deployment configuration; local development never pins a runtime port. BOOTSTRAP_ADMIN_EMAIL= + +# APP_BASE_URL is this host's own public origin, and since issue #1003 it is +# also its OAuth 2.1 issuer: the `iss` of every access token AND the `issuer` of +# the RFC 8414 document published at +# /.well-known/oauth-authorization-server. RFC 8414 §2 requires +# the issuer to be an https URL, so the two can only be equal if this is set — +# a client that discovers the metadata and then verifies `iss` against it +# refuses a token naming anything else. +# +# Unset (the default here) means no metadata is published and tokens keep the +# pre-metadata literal issuer, so the two cannot disagree. A deployment that +# wants an MCP client to discover it MUST set this; the gateway reads the same +# value to publish a solution's protected-resource metadata and to require a +# token's resource audience to name this host. +APP_BASE_URL= diff --git a/module/configurations/local/identity.env b/module/configurations/local/identity.env index 0d493ccb4..c2980660f 100644 --- a/module/configurations/local/identity.env +++ b/module/configurations/local/identity.env @@ -21,3 +21,20 @@ IDENTITY_SIGNUP_MODE=open # Unset means no client is registered, which refuses the flow. This module names # no specific client; a deployment declares its own. IDENTITY_REGISTERED_CLIENTS= + +# IDENTITY_CLIENT_METADATA_DOCUMENTS admits clients that register themselves by +# publishing a Client ID Metadata Document at an https client_id URL — the +# mechanism the MCP specification prefers, and the one an MCP client (Claude +# Code, Claude Desktop, a Claude.ai connector) uses. The host fetches the +# document, validates it, and writes nothing durable: there is no row, no admin +# surface, and no state outliving the authorization request. +# +# (unset) — refused. Only IDENTITY_REGISTERED_CLIENTS may sign anyone in. +# any — any client_id URL whose document validates. +# , — only these exact client_id URLs. +# +# Unset is the default for the same reason an unset client registry registers +# nobody: a deployment that configured nothing must not be admitting clients it +# never decided to admit. Whether to enable it is a deployment decision, not a +# per-organisation one; see the handbook's decisions/registered-clients.md. +IDENTITY_CLIENT_METADATA_DOCUMENTS= diff --git a/module/services/accounts/code/pkg/adapters/billing_authorization_test.go b/module/services/accounts/code/pkg/adapters/billing_authorization_test.go index d056c591b..e3601e67e 100644 --- a/module/services/accounts/code/pkg/adapters/billing_authorization_test.go +++ b/module/services/accounts/code/pkg/adapters/billing_authorization_test.go @@ -121,12 +121,12 @@ func (*fixedAccessMinter) VerifyRefresh(context.Context, string) (*auth.TokenPai return nil, nil } -func (*fixedAccessMinter) VerifyClientRefresh(context.Context, string, string) (*auth.TokenPair, error) { +func (*fixedAccessMinter) VerifyClientRefresh(context.Context, string, string, string) (*auth.TokenPair, error) { return nil, nil } func (*fixedAccessMinter) MintForClient( - context.Context, uuid.UUID, uuid.UUID, string, + context.Context, uuid.UUID, uuid.UUID, string, string, ) (*auth.TokenPair, error) { return nil, nil } diff --git a/module/services/accounts/code/pkg/adapters/billing_http.go b/module/services/accounts/code/pkg/adapters/billing_http.go index b20dd30e5..4df579126 100644 --- a/module/services/accounts/code/pkg/adapters/billing_http.go +++ b/module/services/accounts/code/pkg/adapters/billing_http.go @@ -185,8 +185,30 @@ var errAPIKeyNotAccepted = errors.New("this route does not accept API keys") // (enforceAPIKeyScopePolicy). A route opened to keys must first declare its // scope here. func authenticateHTTPRequest(svc *business.Service, r *http.Request) (context.Context, string, string, error) { + ctx, err := authenticateHTTPSession(svc, r) + if err != nil { + return ctx, "", "", err + } + tenantID, userID, ok := auth.VerifiedDatabaseIdentity(ctx) + if !ok { + return ctx, "", "", auth.ErrVerifiedDatabaseIdentityRequired + } + return ctx, userID, tenantID, nil +} + +// authenticateHTTPSession is authenticateHTTPRequest without the tenant: it +// establishes the same private identity and refuses an API key the same way, +// but does not require the caller to be acting in an organization. +// +// Split out for the routes whose RPC equivalents declare +// TENANT_REQUIREMENT_NONE. Authorizing a client is one: the code names the +// person, their current authorization is resolved through their session at +// redemption, and an orgless person signing in to a client is an ordinary +// state the RPC has always allowed. Requiring a tenant here would refuse them +// on the HTTP spelling of a flow the proto spelling serves. +func authenticateHTTPSession(svc *business.Service, r *http.Request) (context.Context, error) { if svc == nil || r == nil { - return nil, "", "", errors.New("authentication is unavailable") + return nil, errors.New("authentication is unavailable") } ctx := r.Context() // The same trust test as the Connect and gRPC interceptors: exactly one @@ -194,22 +216,22 @@ func authenticateHTTPRequest(svc *business.Service, r *http.Request) (context.Co // twice is refused rather than read by index. trustedForwarded := singleValidGatewayToken(r.Header.Values("X-Codefly-Gateway-Token")) if trustedForwarded && forwardedIdentityAmbiguous(r.Header.Values) { - return ctx, "", "", errors.New("forwarded identity is ambiguous") + return ctx, errors.New("forwarded identity is ambiguous") } if trustedForwarded && r.Header.Get("X-User-Id") != "" { forwarded, err := stampForwardedHTTPIdentity(ctx, r.Header) if err != nil { - return ctx, "", "", err + return ctx, err } ctx = forwarded } else { minter := svc.JWTMinter() if minter == nil { - return ctx, "", "", errors.New("access-token verifier is unavailable") + return ctx, errors.New("access-token verifier is unavailable") } token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || token == "" { - return ctx, "", "", errors.New("bearer token is required") + return ctx, errors.New("bearer token is required") } identity, err := minter.VerifyAccess(token) if err != nil { @@ -217,23 +239,19 @@ func authenticateHTTPRequest(svc *business.Service, r *http.Request) (context.Co // answer 503 (retryable) instead of collapsing it into a 401; every // other verify failure stays a generic invalid-credentials answer. if errors.Is(err, auth.ErrRevocationUnavailable) { - return ctx, "", "", err + return ctx, err } - return ctx, "", "", errors.New("access token is invalid") + return ctx, errors.New("access token is invalid") } if identity == nil { - return ctx, "", "", errors.New("access token is invalid") + return ctx, errors.New("access token is invalid") } ctx = stampRequestIdentity(ctx, auth.RequestIdentityOf(identity), identity.Assurance()) } if credentialKindFromContext(ctx) == credentialKindAPIKey { - return ctx, "", "", errAPIKeyNotAccepted + return ctx, errAPIKeyNotAccepted } - tenantID, userID, ok := auth.VerifiedDatabaseIdentity(ctx) - if !ok { - return ctx, "", "", auth.ErrVerifiedDatabaseIdentityRequired - } - return ctx, userID, tenantID, nil + return ctx, nil } // writeBillingAuthnError answers a billing authentication failure. A revocation diff --git a/module/services/accounts/code/pkg/adapters/oauth_http.go b/module/services/accounts/code/pkg/adapters/oauth_http.go new file mode 100644 index 000000000..a5954ce13 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/oauth_http.go @@ -0,0 +1,513 @@ +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "accounts/pkg/auth" + "accounts/pkg/business" + + "github.com/codefly-dev/core/wool" +) + +// The OAuth 2.1 / MCP authorization-server surface. +// +// These are raw HTTP handlers rather than proto RPCs on purpose, and not for +// convenience: RFC 6749 §3.2 requires the token endpoint to accept +// `application/x-www-form-urlencoded`, and §5.2 requires a specific JSON error +// body with a specific status. grpc-gateway produces neither, so an MCP client +// written against a standard OAuth library could not use a transcoded RPC — the +// shape IS the contract here. The business logic underneath is the same +// registered-client flow pkg/business/client_authorization.go serves. +// +// Public paths at the host's edge, which the frontend maps onto these: +// +// GET /.well-known/oauth-authorization-server -> /v1/oauth2/authorization-server +// POST /oauth2/token -> /v1/oauth2/token +// GET /oauth2/authorize -> the frontend's own page, which +// drives the two authorize calls +// +// See module/services/frontend/code/src/proxy.ts for that mapping and +// MODULE.md for why the public shape lives at the edge. + +// OAuthRoutePrefix is the namespace these paths live in. It is NOT what the +// handler is mounted at: RegisterHTTPRoute matches by prefix, so mounting the +// namespace would claim every path under it, including ones nothing declares at +// the gateway — and the correspondence gate in module/tools reads a registered +// prefix as a promise that the gateway routes something below it. +// +// The namespace must stay disjoint from every other registered route in BOTH +// directions, because combineHandlers iterates a map and an overlap would +// resolve nondeterministically. `/v1/auth/oauth/begin` — the host's own hop to +// an identity provider — is the near miss, which is why this is `/v1/oauth2/` +// rather than anything under `/v1/auth/oauth/`. +const OAuthRoutePrefix = "/v1/oauth2/" + +// The exact paths. work.go registers each one on its own, so the surface the +// service registers is exactly the surface the gateway declares. Every request +// is still matched against this set in the handler, because a prefix match +// would otherwise serve `/v1/oauth2/token/anything` from the token endpoint. +const ( + OAuthMetadataPath = "/v1/oauth2/authorization-server" + OAuthAuthorizeValidatePath = "/v1/oauth2/authorize/validate" + OAuthAuthorizeGrantPath = "/v1/oauth2/authorize/grant" + OAuthTokenPath = "/v1/oauth2/token" +) + +// NewOAuthHTTPHandler serves the authorization-server surface. +func NewOAuthHTTPHandler(svc *business.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(withTrustedPublicOrigin(r)) + switch r.URL.Path { + case OAuthMetadataPath: + serveAuthorizationServerMetadata(svc, w, r) + case OAuthAuthorizeValidatePath: + serveOAuthAuthorizeValidate(svc, w, r) + case OAuthAuthorizeGrantPath: + serveOAuthAuthorizeGrant(svc, w, r) + case OAuthTokenPath: + serveOAuthToken(svc, w, r) + default: + writeJSONError(w, http.StatusNotFound, "not found") + } + }) +} + +// withTrustedPublicOrigin records the browser origin this host is reachable at, +// but only when the gateway credential is present beside it. Every document the +// authorization server publishes names that origin, so the one path that must +// not exist is an unauthenticated caller choosing it with a header: a metadata +// document served under an attacker's issuer would send the next client's +// authorization request there. +// +// These are raw HTTP routes, so they do not pass through the Connect or gRPC +// interceptors that normally do this — the same rule is applied here rather +// than assumed. A configured APP_BASE_URL still wins over it inside the +// service (see publicBaseURL), which is what a deployment pins when it does not +// want a per-request value at all. +func withTrustedPublicOrigin(r *http.Request) context.Context { + ctx := r.Context() + if !singleValidGatewayToken(r.Header.Values("X-Codefly-Gateway-Token")) { + return ctx + } + origin := r.Header.Get("X-Codefly-Public-Origin") + if origin == "" { + return ctx + } + stamped, err := auth.WithVerifiedPublicOrigin(ctx, origin) + if err != nil { + return ctx + } + return stamped +} + +// maxOAuthRequestBytes bounds every body read here. These are all small, +// fixed-shape requests; the bound exists so an unauthenticated endpoint cannot +// be made to buffer. +const maxOAuthRequestBytes = 16 << 10 + +func serveAuthorizationServerMetadata(svc *business.Service, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + metadata, err := svc.AuthorizationServerMetadata(r.Context()) + if err != nil { + // No trusted public origin means every URL in the document would be a + // guess. A client that fetched such a document would send authorization + // requests to whatever it named, so this answers 503 and says why + // rather than publishing one. + wool.Get(r.Context()).In("oauthMetadata").Warn("cannot publish authorization-server metadata", + wool.ErrField(err)) + writeJSONError(w, http.StatusServiceUnavailable, + "the authorization server has no configured public address") + return + } + // RFC 8414 §3.2: the document is cacheable. Five minutes is short enough + // that enabling metadata clients or changing the public origin takes effect + // while a person is still trying, and long enough that a client's discovery + // chain costs one fetch per session rather than one per request. + w.Header().Set("Cache-Control", "public, max-age=300") + writeJSON(w, http.StatusOK, metadata) +} + +// oauthAuthorizeRequestBody is the authorization request as the frontend's +// `/oauth2/authorize` handler and its login page send it on. The field names +// are the OAuth parameter names, so what the browser received and what accounts +// validates are spelled the same and cannot drift in transcription. +type oauthAuthorizeRequestBody struct { + ResponseType string `json:"response_type"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + State string `json:"state"` + Scope string `json:"scope"` + Resource string `json:"resource"` + ConsentGranted bool `json:"consent_granted"` +} + +func (b oauthAuthorizeRequestBody) request() business.OAuthAuthorizationRequest { + return business.OAuthAuthorizationRequest{ + ResponseType: b.ResponseType, + ClientID: b.ClientID, + RedirectURI: b.RedirectURI, + CodeChallenge: b.CodeChallenge, + CodeChallengeMethod: b.CodeChallengeMethod, + State: b.State, + Scope: b.Scope, + Resource: b.Resource, + ConsentGranted: b.ConsentGranted, + } +} + +// oauthAuthorizeValidateResponse is what the page needs to render: who is +// asking, where from, for what, and whether the person must approve it. The +// registry itself — other redirect URIs, the client's CORS origins — is never +// served to a browser. +// +// The field names are the wire contract with the frontend, which DECODES them +// (features/auth/model/oauth-authorization.ts). They were once read by casting +// this JSON to a camelCase interface, which type-checked, compiled, and made +// `requires_consent` read as undefined — so consent was skipped for every +// client that needed it. Renaming a field here without changing the decoder +// there breaks that seam silently again; the cross-seam test is what catches it. +type oauthAuthorizeValidateResponse struct { + // ClientName is untrusted presentation, from the client's own document. + ClientName string `json:"client_name"` + // ClientOrigin is the origin this host VERIFIED, for a metadata client the + // one it fetched the document from. Never the document's own `client_uri`. + ClientOrigin string `json:"client_origin"` + ClientSource string `json:"client_source"` + Resource string `json:"resource,omitempty"` + ResourceName string `json:"resource_name,omitempty"` + Scope string `json:"scope"` + RequiresConsent bool `json:"requires_consent"` + // Issuer is this authorization server's own identity, so the page can echo + // `iss` on the redirect it builds (RFC 9207) — including the decline. + Issuer string `json:"issuer"` +} + +func serveOAuthAuthorizeValidate(svc *business.Service, w http.ResponseWriter, r *http.Request) { + body, ok := decodeOAuthAuthorizeBody(w, r) + if !ok { + return + } + resolved, err := svc.ResolveOAuthAuthorization(r.Context(), body.request()) + if err != nil { + writeOAuthAuthorizationError(w, svc.OAuthIssuer(), err) + return + } + source := "registry" + origin := resolved.Client.Origin + if resolved.Client.Metadata { + source = "metadata_document" + } else if origin == "" { + // An operator-declared client has no fetched origin to verify, and the + // page must still have something non-empty to show: the deployment + // vouched for this client by the id it declared, so that id is the + // verified fact about it. + origin = resolved.Client.ClientID + } + writeJSON(w, http.StatusOK, oauthAuthorizeValidateResponse{ + ClientName: resolved.Client.Name, + ClientOrigin: origin, + ClientSource: source, + Resource: resolved.Resource.Value, + ResourceName: resolved.Resource.SolutionID, + Scope: resolved.Scope, + RequiresConsent: resolved.RequiresConsent, + Issuer: svc.OAuthIssuer(), + }) +} + +type oauthAuthorizeGrantResponse struct { + Code string `json:"code"` + ExpiresIn int64 `json:"expires_in"` + // Issuer is echoed so the page puts `iss` on the redirect (RFC 9207). A + // client configured with more than one authorization server uses it to + // refuse a code that came back from the wrong one. + Issuer string `json:"issuer"` +} + +func serveOAuthAuthorizeGrant(svc *business.Service, w http.ResponseWriter, r *http.Request) { + body, ok := decodeOAuthAuthorizeBody(w, r) + if !ok { + return + } + // The caller is the signed-in person's own host session, verified the one + // way every non-proto route here verifies: forwarded identity beside a + // valid gateway credential, or the bearer checked locally. + // + // Without the tenant requirement, matching IssueClientAuthorizationCode's + // TENANT_REQUIREMENT_NONE: the code names the person and redemption resolves + // their current authorization through their session, so an orgless person + // authorizing a client is an ordinary state this flow has always served. + ctx, err := authenticateHTTPSession(svc, r) + if err != nil { + writeBillingAuthnError(w, r, err) + return + } + code, expiresIn, err := svc.GrantOAuthAuthorization(ctx, body.request()) + if err != nil { + writeOAuthAuthorizationError(w, svc.OAuthIssuer(), err) + return + } + writeJSON(w, http.StatusOK, oauthAuthorizeGrantResponse{ + Code: code, + ExpiresIn: expiresIn, + // Empty when this deployment publishes no OAuth metadata, and that is a + // supported mode: the registered-client browser handoff predates + // discovery and must keep working with no issuer configured. An earlier + // revision read the issuer out of the metadata document and answered 503 + // when there was none — after the code had already been created, so the + // person's browser never received a code that existed. + // + // RFC 9207's `iss` is only something a client can expect when it + // discovered the metadata advertising it, which cannot have happened + // where no metadata is published. + Issuer: svc.OAuthIssuer(), + }) +} + +func decodeOAuthAuthorizeBody(w http.ResponseWriter, r *http.Request) (oauthAuthorizeRequestBody, bool) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return oauthAuthorizeRequestBody{}, false + } + var body oauthAuthorizeRequestBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOAuthRequestBytes)).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json") + return oauthAuthorizeRequestBody{}, false + } + return body, true +} + +// oauthErrorBody is the RFC 6749 §5.2 error object. It is the same shape at +// the authorize and token endpoints, which is why a client can read either. +type oauthErrorBody struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// writeOAuthAuthorizationError answers an authorize-side refusal. The response +// carries the machine-readable code and the `redirectable` fact, because only +// the frontend knows whether it is holding a browser it may send to the +// client's redirect URI — and sending one there before the URI is validated is +// how an authorization endpoint becomes an open redirector. +func writeOAuthAuthorizationError(w http.ResponseWriter, issuer string, err error) { + var refusal *business.OAuthAuthorizationError + if !errors.As(err, &refusal) { + if errors.Is(err, auth.ErrClientAuthorizationRejected) { + refusal = &business.OAuthAuthorizationError{ + Code: business.OAuthErrorInvalidClient, + Description: "this application is not registered to sign in here", + } + } else { + writeJSONError(w, http.StatusInternalServerError, "authorization failed") + return + } + } + status := http.StatusBadRequest + switch refusal.Code { + case business.OAuthErrorInvalidClient: + // 400, not 401. There is no client authentication at this endpoint to + // have failed, and a 401 would make a browser prompt for credentials. + status = http.StatusBadRequest + case business.OAuthErrorServerError: + status = http.StatusServiceUnavailable + } + writeJSON(w, status, struct { + oauthErrorBody + Redirectable bool `json:"redirectable"` + Issuer string `json:"issuer,omitempty"` + }{ + oauthErrorBody: oauthErrorBody{Error: refusal.Code, ErrorDescription: refusal.Description}, + Redirectable: refusal.Redirectable, + // RFC 9207 §2 covers error responses, and this host advertises support + // for the parameter: a client enforcing that protection cannot + // authenticate an error redirect that carries no `iss`. The frontend + // echoes it on the redirect it builds. + Issuer: issuer, + }) +} + +func serveOAuthToken(svc *business.Service, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeOAuthTokenError(w, http.StatusMethodNotAllowed, + business.OAuthErrorInvalidRequest, "the token endpoint takes POST") + return + } + // RFC 6749 §3.2: the request body is form-encoded. A JSON body is accepted + // too, because the host's own frontend and its first registered client speak + // JSON everywhere else and a 400 for the wrong content type would be a trap + // with no security value — the credential is in the body either way. + request, err := decodeOAuthTokenRequest(w, r) + if err != nil { + description := "the request body could not be read" + if errors.Is(err, errDuplicateOAuthParameter) { + description = "a request parameter was given more than once" + } + writeOAuthTokenError(w, http.StatusBadRequest, + business.OAuthErrorInvalidRequest, description) + return + } + // Authorization-code and refresh-token responses must not be cached by + // anything between here and the client: they ARE the credential. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + + tokens, err := svc.ExchangeOAuthToken(r.Context(), request) + if err != nil { + var refusal *business.OAuthAuthorizationError + switch { + case errors.As(err, &refusal): + status := http.StatusBadRequest + if refusal.Code == business.OAuthErrorServerError { + status = http.StatusServiceUnavailable + } + writeOAuthTokenError(w, status, refusal.Code, refusal.Description) + case errors.Is(err, auth.ErrClientAuthorizationRejected): + writeOAuthTokenError(w, http.StatusBadRequest, business.OAuthErrorInvalidGrant, + "the grant is invalid, expired, or already used") + case errors.Is(err, auth.ErrRevocationUnavailable): + writeOAuthTokenError(w, http.StatusServiceUnavailable, business.OAuthErrorServerError, + "authorization is temporarily unavailable") + default: + wool.Get(r.Context()).In("oauthToken").Error("token exchange failed", wool.ErrField(err)) + writeOAuthTokenError(w, http.StatusBadRequest, business.OAuthErrorInvalidGrant, + "the grant is invalid, expired, or already used") + } + return + } + writeJSON(w, http.StatusOK, tokens) +} + +func decodeOAuthTokenRequest(w http.ResponseWriter, r *http.Request) (business.OAuthTokenRequest, error) { + contentType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") + if strings.EqualFold(strings.TrimSpace(contentType), "application/json") { + // Decoded strictly, by the same rule the form path applies. Go's JSON + // decoder takes the LAST of a repeated member and ignores anything after + // the first value, so a body with two `resource` members — or a second + // object appended — was accepted here while the identical form request + // was refused. One encoding being lenient is the whole strict-input + // policy being advisory. + members, err := decodeStrictJSONObject( + http.MaxBytesReader(w, r.Body, maxOAuthRequestBytes)) + if err != nil { + return business.OAuthTokenRequest{}, err + } + return business.OAuthTokenRequest{ + GrantType: members["grant_type"], + ClientID: members["client_id"], + Code: members["code"], + RedirectURI: members["redirect_uri"], + CodeVerifier: members["code_verifier"], + RefreshToken: members["refresh_token"], + Resource: members["resource"], + Scope: members["scope"], + }, nil + } + r.Body = http.MaxBytesReader(w, r.Body, maxOAuthRequestBytes) + if err := r.ParseForm(); err != nil { + return business.OAuthTokenRequest{}, err + } + // RFC 6749 §3.2: request parameters MUST NOT be included more than once. + // `Get` reads the first and discards the rest, which turns a duplicated + // parameter into a silent choice between two values a caller sent — so a + // request carrying one is refused instead. + // + // `resource` is in the singleton set here even though RFC 8707 §2 allows it + // repeated: this host serves exactly one resource shape per token, so two + // distinct targets cannot both be honoured, and discarding the second would + // mint a token for a resource the client did not think it asked for. + for _, name := range []string{ + "grant_type", "client_id", "code", "redirect_uri", + "code_verifier", "refresh_token", "resource", "scope", + } { + if len(r.PostForm[name]) > 1 { + return business.OAuthTokenRequest{}, fmt.Errorf( + "%w: %s", errDuplicateOAuthParameter, name) + } + } + return business.OAuthTokenRequest{ + GrantType: r.PostForm.Get("grant_type"), + ClientID: r.PostForm.Get("client_id"), + Code: r.PostForm.Get("code"), + RedirectURI: r.PostForm.Get("redirect_uri"), + CodeVerifier: r.PostForm.Get("code_verifier"), + RefreshToken: r.PostForm.Get("refresh_token"), + Resource: r.PostForm.Get("resource"), + Scope: r.PostForm.Get("scope"), + }, nil +} + +// errDuplicateOAuthParameter is a request carrying a singleton parameter twice. +var errDuplicateOAuthParameter = errors.New("duplicate OAuth parameter") + +// decodeStrictJSONObject reads exactly one flat JSON object of string members, +// refusing a repeated member name and anything after the closing brace. +// +// encoding/json does neither: it silently keeps the last of a repeated member +// and stops at the first value. Both are the kind of leniency that makes two +// callers disagree about what a request said — and for `resource` the two +// values name different solutions. +// +// Non-string values are refused rather than coerced: every parameter on this +// endpoint is a string, and accepting `"resource": 1` would only move the +// question of what it meant further downstream. +func decodeStrictJSONObject(body io.Reader) (map[string]string, error) { + decoder := json.NewDecoder(body) + open, err := decoder.Token() + if err != nil { + return nil, err + } + if delimiter, ok := open.(json.Delim); !ok || delimiter != '{' { + return nil, errors.New("the request body must be a JSON object") + } + members := map[string]string{} + for decoder.More() { + nameToken, err := decoder.Token() + if err != nil { + return nil, err + } + name, ok := nameToken.(string) + if !ok { + return nil, errors.New("malformed JSON object") + } + if _, repeated := members[name]; repeated { + return nil, fmt.Errorf("%w: %s", errDuplicateOAuthParameter, name) + } + valueToken, err := decoder.Token() + if err != nil { + return nil, err + } + value, ok := valueToken.(string) + if !ok { + return nil, fmt.Errorf("the %s parameter must be a string", name) + } + members[name] = value + } + // Consume the closing brace, then require end of input: a second value + // after it is content this request never acknowledged sending. + if _, err := decoder.Token(); err != nil { + return nil, err + } + if decoder.More() { + return nil, errors.New("the request body carries content after its object") + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return nil, errors.New("the request body carries content after its object") + } + return members, nil +} + +func writeOAuthTokenError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, oauthErrorBody{Error: code, ErrorDescription: description}) +} diff --git a/module/services/accounts/code/pkg/adapters/oauth_http_test.go b/module/services/accounts/code/pkg/adapters/oauth_http_test.go new file mode 100644 index 000000000..d8a518a8e --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/oauth_http_test.go @@ -0,0 +1,422 @@ +package adapters + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// The wire shape of the OAuth 2.1 surface. The shape IS the contract here: an +// MCP client reaches this host through a standard OAuth library, which sends a +// form-encoded token request and reads an RFC 6749 §5.2 error object. These +// tests exist because a transcoded RPC would have satisfied every business-level +// test in this repo and still been unusable by the clients the surface is for. + +func oauthHandler(t *testing.T, declaredMetadata string) http.Handler { + t.Helper() + service, err := business.NewService(nil) + require.NoError(t, err) + + registry, err := auth.NewClientRegistry(`[{ + "client_id": "example-addin", "name": "Example Add-in", + "redirect_uris": ["https://addin.example.com/auth/callback"] + }]`) + require.NoError(t, err) + service.SetClientRegistry(registry) + + policy, err := auth.NewClientMetadataPolicy(declaredMetadata) + require.NoError(t, err) + service.SetClientMetadataResolver(auth.NewClientMetadataResolver(policy)) + // The issuer is configuration, resolved once at startup — not the request's + // origin. The published metadata and the `iss` of every minted token are the + // same value, which is the only way the two can be proved equal. + service.SetOAuthIssuer("https://host.example.com") + return NewOAuthHTTPHandler(service) +} + +// trustedOrigin stamps what the gateway stamps on an accounts route: the +// gateway credential plus the public origin it resolved. The origin is only +// believed beside the credential, which is the one rule that must not slip — +// a metadata document served under an attacker's issuer would send the next +// client's authorization request there. +func trustedOrigin(req *http.Request, origin string) { + req.Header.Set("X-Codefly-Gateway-Token", "test-gateway-token") + req.Header.Set("X-Codefly-Public-Origin", origin) +} + +func TestTheMetadataDocumentIsServedAtItsOwnPath(t *testing.T) { + withGatewayToken(t) + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodGet, OAuthMetadataPath, nil) + trustedOrigin(req, "https://host.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "public, max-age=300", w.Header().Get("Cache-Control")) + + var document map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &document)) + require.Equal(t, "https://host.example.com", document["issuer"]) + require.Equal(t, "https://host.example.com/oauth2/authorize", document["authorization_endpoint"]) + require.Equal(t, "https://host.example.com/oauth2/token", document["token_endpoint"]) + require.Equal(t, true, document["client_id_metadata_document_supported"]) + require.NotContains(t, document, "registration_endpoint") +} + +// A1007-02. No request can choose the issuer: it is configuration, resolved +// once, and the same value every minted token carries. A claimed origin — with +// or without the gateway credential — changes nothing about the document. +// +// This is stronger than what it replaced. The earlier version read the issuer +// from the request's VERIFIED origin and so only had to refuse an unverified +// claim; the issuer could still vary by Host header between two legitimately +// forwarded requests, and a token minted under one would fail verification +// against the other. +func TestNoRequestCanChooseTheIssuer(t *testing.T) { + withGatewayToken(t) + handler := oauthHandler(t, "any") + + for _, header := range []func(*http.Request){ + func(r *http.Request) { r.Header.Set("X-Codefly-Public-Origin", "https://evil.example.com") }, + func(r *http.Request) { trustedOrigin(r, "https://also-not-the-issuer.example.com") }, + func(*http.Request) {}, + } { + req := httptest.NewRequest(http.MethodGet, OAuthMetadataPath, nil) + header(req) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + var document map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &document)) + require.Equal(t, "https://host.example.com", document["issuer"]) + require.NotContains(t, w.Body.String(), "evil.example.com") + require.NotContains(t, w.Body.String(), "also-not-the-issuer") + } +} + +// And with no configured issuer nothing is published at all: every URL in the +// document would name a host this service cannot vouch for, and a client that +// fetched it would authorize somewhere else. +func TestNoIssuerMeansNoPublishedMetadata(t *testing.T) { + withGatewayToken(t) + service, err := business.NewService(nil) + require.NoError(t, err) + handler := NewOAuthHTTPHandler(service) + + req := httptest.NewRequest(http.MethodGet, OAuthMetadataPath, nil) + trustedOrigin(req, "https://host.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 503, w.Code) + require.NotContains(t, w.Body.String(), "host.example.com") +} + +// RFC 6749 §3.2: the token request is form-encoded. This is the assertion that +// a standard OAuth library can talk to this endpoint at all. +func TestTheTokenEndpointReadsAFormEncodedRequest(t *testing.T) { + handler := oauthHandler(t, "any") + + body := strings.NewReader( + "grant_type=authorization_code&client_id=example-addin&code=abc" + + "&redirect_uri=https%3A%2F%2Faddin.example.com%2Fauth%2Fcallback" + + "&code_verifier=ZmFrZS12ZXJpZmllci10aGF0LWlzLWxvbmctZW5vdWdoLTAxMjM0NTY3ODk") + req := httptest.NewRequest(http.MethodPost, OAuthTokenPath, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // There is no store behind this service, so the exchange cannot succeed — + // what matters is that the request was PARSED and answered as an OAuth + // error rather than refused as a malformed body. + require.Equal(t, 400, w.Code) + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + require.Equal(t, business.OAuthErrorInvalidGrant, refusal["error"]) + // The response IS the credential on success, so nothing between here and + // the client may cache it. + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) +} + +// A grant type this host does not serve is NAMED as such. A client that asked +// for client_credentials needs to know the host has none, not that its +// credential was wrong. +func TestTheTokenEndpointNamesAnUnsupportedGrant(t *testing.T) { + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodPost, OAuthTokenPath, + strings.NewReader("grant_type=client_credentials&client_id=example-addin")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 400, w.Code) + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + require.Equal(t, business.OAuthErrorUnsupportedGrant, refusal["error"]) +} + +// An unknown client is `invalid_client` with 400, not 401. There is no client +// authentication at this endpoint to have failed — a public client authenticates +// with `none` — and a 401 would make a browser prompt for credentials. +func TestAnUnknownClientIsInvalidClientWithoutA401(t *testing.T) { + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodPost, OAuthTokenPath, + strings.NewReader("grant_type=authorization_code&client_id=nobody&code=abc")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 400, w.Code) + require.Empty(t, w.Header().Get("WWW-Authenticate")) + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + require.Equal(t, business.OAuthErrorInvalidClient, refusal["error"]) +} + +// The host's own frontend and its first registered client speak JSON +// everywhere else. A 400 for the wrong content type would be a trap with no +// security value — the credential is in the body either way. +func TestTheTokenEndpointAlsoReadsJSON(t *testing.T) { + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodPost, OAuthTokenPath, + strings.NewReader(`{"grant_type":"client_credentials","client_id":"example-addin"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + require.Equal(t, business.OAuthErrorUnsupportedGrant, refusal["error"]) +} + +// The authorize validate endpoint tells the frontend whether a refusal may be +// delivered to the client's redirect URI. Before the URI is validated it may +// NOT — that is what keeps this from being an open redirector. +func TestTheValidateEndpointReportsWhetherARefusalIsDeliverable(t *testing.T) { + withGatewayToken(t) + handler := oauthHandler(t, "any") + + // An unregistered client: nothing has validated a redirect URI, so the + // browser must not be sent to the one it named. + req := httptest.NewRequest(http.MethodPost, OAuthAuthorizeValidatePath, + strings.NewReader(`{"response_type":"code","client_id":"nobody","redirect_uri":"https://evil.example.com/cb","code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","code_challenge_method":"S256"}`)) + req.Header.Set("Content-Type", "application/json") + trustedOrigin(req, "https://host.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 400, w.Code) + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + require.Equal(t, business.OAuthErrorInvalidClient, refusal["error"]) + require.Equal(t, false, refusal["redirectable"]) + + // A registered client with a bad scope: the URI is its own, so the client + // is told rather than the person being shown an error page. + req = httptest.NewRequest(http.MethodPost, OAuthAuthorizeValidatePath, + strings.NewReader(`{"response_type":"code","client_id":"example-addin","redirect_uri":"https://addin.example.com/auth/callback","code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","code_challenge_method":"S256","scope":"admin"}`)) + req.Header.Set("Content-Type", "application/json") + trustedOrigin(req, "https://host.example.com") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 400, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + require.Equal(t, business.OAuthErrorInvalidScope, refusal["error"]) + require.Equal(t, true, refusal["redirectable"]) +} + +func TestTheValidateEndpointNamesTheClientAndTheResource(t *testing.T) { + withGatewayToken(t) + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodPost, OAuthAuthorizeValidatePath, + strings.NewReader(`{"response_type":"code","client_id":"example-addin","redirect_uri":"https://addin.example.com/auth/callback","code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","code_challenge_method":"S256","resource":"https://host.example.com/api/solutions/example/proxy/mcp"}`)) + req.Header.Set("Content-Type", "application/json") + trustedOrigin(req, "https://host.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + var resolved map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resolved)) + require.Equal(t, "Example Add-in", resolved["client_name"]) + require.Equal(t, "registry", resolved["client_source"]) + require.Equal(t, "https://host.example.com/api/solutions/example/proxy/mcp", resolved["resource"]) + require.Equal(t, "example", resolved["resource_name"]) + require.Equal(t, true, resolved["requires_consent"]) +} + +// The code is issued from the signed-in person's own session, so the grant call +// is authenticated. Without a credential it must refuse rather than mint. +func TestTheGrantEndpointRefusesAnUnauthenticatedCaller(t *testing.T) { + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodPost, OAuthAuthorizeGrantPath, + strings.NewReader(`{"response_type":"code","client_id":"example-addin","redirect_uri":"https://addin.example.com/auth/callback","code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","code_challenge_method":"S256"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) +} + +// Anything else under the prefix is a 404 from here. The prefix match has +// already shadowed the grpc-gateway mux, so falling through would be a 404 with +// a confusing body at best and a shadowed RPC at worst. +func TestAnUnknownPathUnderThePrefixIs404(t *testing.T) { + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodGet, OAuthRoutePrefix+"whatever", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, 404, w.Code) +} + +// The prefix must be disjoint from every other route registered through +// RegisterHTTPRoute, in both directions: combineHandlers iterates a map, so an +// overlap would resolve nondeterministically and shadow an RPC at random. +// `/v1/auth/oauth/begin` — the host's own provider hop — is the near miss this +// guards. +func TestTheOAuthPrefixIsDisjointFromTheProviderHop(t *testing.T) { + require.False(t, strings.HasPrefix("/v1/auth/oauth/begin", OAuthRoutePrefix)) + require.False(t, strings.HasPrefix(OAuthRoutePrefix, "/v1/auth/oauth/")) + for _, path := range []string{ + OAuthMetadataPath, OAuthAuthorizeValidatePath, OAuthAuthorizeGrantPath, OAuthTokenPath, + } { + require.True(t, strings.HasPrefix(path, OAuthRoutePrefix), "%q", path) + } +} + +// A1007B-03. A deployment with no configured issuer publishes no OAuth +// metadata, and that is a supported mode — the registered-client browser +// handoff predates discovery entirely. An earlier revision read the issuer out +// of the metadata document on the grant path and answered 503 when there was +// none, AFTER the code had been created: the browser never received a code that +// existed, so an add-in that worked before the change stopped working. +// +// Adopted from the Astra review (registered_client_without_issuer). +func TestTheGrantPathWorksWithNoPublishedIssuer(t *testing.T) { + withGatewayToken(t) + service, err := business.NewService(nil) + require.NoError(t, err) + registry, err := auth.NewClientRegistry(`[{ + "client_id": "example-addin", "name": "Example Add-in", + "redirect_uris": ["https://addin.example.com/auth/callback"] + }]`) + require.NoError(t, err) + service.SetClientRegistry(registry) + // No SetOAuthIssuer: exactly the default local configuration. + handler := NewOAuthHTTPHandler(service) + + body := `{"response_type":"code","client_id":"example-addin",` + + `"redirect_uri":"https://addin.example.com/auth/callback",` + + `"code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",` + + `"code_challenge_method":"S256"}` + + // Validation answers, with no consent required and an empty issuer. + req := httptest.NewRequest(http.MethodPost, OAuthAuthorizeValidatePath, + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + trustedOrigin(req, "https://host.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, 200, w.Code) + var resolved map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resolved)) + require.Equal(t, false, resolved["requires_consent"]) + require.Equal(t, "", resolved["issuer"]) + + // And the grant with a VERIFIED caller is not a 503. + // + // The caller matters: with no bearer the handler refuses at authentication, + // before it could ever consult the issuer, so such a request stays green + // however the issuer is handled and proves nothing about it. A verified + // session is what carries the request past authentication and into the code + // issuer, which is where the published issuer would be read. + service.SetJWTMinter(&fixedAccessMinter{identity: &auth.Identity{ + UserID: uuid.New(), + OrgID: uuid.New(), + SessionID: uuid.New(), + }}) + + req = httptest.NewRequest(http.MethodPost, OAuthAuthorizeGrantPath, + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer verified") + trustedOrigin(req, "https://host.example.com") + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.NotEqual(t, 503, w.Code, + "a missing OAuth issuer must not break the registered-client handoff") + require.NotEqual(t, 401, w.Code, + "the caller is verified, so a 401 here would mean this test never reached the issuer") + require.NotContains(t, strings.ToLower(w.Body.String()), "issuer", + "whatever refuses this harness must not be the absent issuer") +} + +// A1007B-05. The JSON encoding must be as strict as the form one. Go's decoder +// keeps the LAST of a repeated member and ignores anything after the first +// value, so a body with two `resource` members — naming different solutions — +// was accepted here while the identical form request was refused. One lenient +// encoding makes the whole strict-input policy advisory. +// +// Adopted from the Astra review (json_duplicate_token_parameters, +// json_trailing_content). +func TestTheJSONTokenRequestIsAsStrictAsTheForm(t *testing.T) { + handler := oauthHandler(t, "any") + + post := func(body string) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodPost, OAuthTokenPath, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, 400, w.Code, body) + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + return refusal + } + + // A repeated member naming two different solutions. + refusal := post(`{"resource":"https://host.example.com/api/solutions/other/proxy/mcp",` + + `"grant_type":"authorization_code","client_id":"example-addin","code":"abc",` + + `"resource":"https://host.example.com/api/solutions/example/proxy/mcp"}`) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal["error"]) + require.Contains(t, refusal["error_description"], "more than once") + + // A second object appended after the first. + refusal = post(`{"grant_type":"authorization_code","client_id":"example-addin",` + + `"code":"abc"}{"grant_type":"refresh_token"}`) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal["error"]) + + // A non-string value, refused rather than coerced. + refusal = post(`{"grant_type":"authorization_code","client_id":"example-addin","resource":1}`) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal["error"]) + + // A single well-formed object still parses: it reaches the grant and is + // refused for the grant's own reason, not for its encoding. + req := httptest.NewRequest(http.MethodPost, OAuthTokenPath, + strings.NewReader(`{"grant_type":"client_credentials","client_id":"example-addin"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + var answered map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &answered)) + require.Equal(t, business.OAuthErrorUnsupportedGrant, answered["error"]) +} diff --git a/module/services/accounts/code/pkg/adapters/oauth_wire_contract_test.go b/module/services/accounts/code/pkg/adapters/oauth_wire_contract_test.go new file mode 100644 index 000000000..ec75c9f21 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/oauth_wire_contract_test.go @@ -0,0 +1,114 @@ +package adapters + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The frontend↔accounts wire contract for the OAuth surface. +// +// A1007-01. The invariant: the names this handler serialises are the names the +// browser decodes, and neither side may assert the other's shape without +// checking it. +// +// Accounts serialises OAuth snake_case (`requires_consent`); the browser's +// interface is camelCase. The two spellings are different strings, so a cast +// across the seam type-checks while producing `undefined` for every field, and +// an undefined boolean is falsy — a value that cannot be distinguished from a +// real answer. A unit test on either side cannot catch that, because each +// side's test fixes the shape that side expects; so this one is spelled against +// the OTHER side's source. +// +// No unit suite could see it, because each side mocked the other's shape. These +// tests read the REAL serialised response and the REAL decoder source, so the +// seam has something holding it from both ends. The TypeScript half of the same +// contract is in +// frontend/code/src/features/auth/model/__tests__/wire-contract.test.ts, which +// decodes bytes produced by this handler's own field names. + +// oauthValidateResponseKeys is the key set the frontend decoder reads. Pinned +// here as a literal rather than derived, so a rename on either side has to be +// made in two places that are checked against each other. +var oauthValidateResponseKeys = []string{ + "client_name", + "client_origin", + "client_source", + "requires_consent", + "scope", + "issuer", +} + +func TestTheValidateResponseCarriesExactlyTheKeysTheFrontendDecodes(t *testing.T) { + withGatewayToken(t) + handler := oauthHandler(t, "any") + + req := httptest.NewRequest(http.MethodPost, OAuthAuthorizeValidatePath, + strings.NewReader(`{"response_type":"code","client_id":"example-addin","redirect_uri":"https://addin.example.com/auth/callback","code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","code_challenge_method":"S256"}`)) + req.Header.Set("Content-Type", "application/json") + trustedOrigin(req, "https://host.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, 200, w.Code) + + var wire map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wire)) + for _, key := range oauthValidateResponseKeys { + require.Contains(t, wire, key, + "the frontend decoder reads %q; serialising it under another name makes "+ + "that field read as absent, and for requires_consent absent means "+ + "consent is skipped", key) + } + // The one that decides whether a person is asked must be a real boolean, not + // a string or a number that a decoder would have to coerce. + require.IsType(t, false, wire["requires_consent"]) +} + +// The decoder on the other side must read those same names. Checked against the +// TypeScript source, because the alternative is trusting that two languages +// agree about a string neither compiler compares. +func TestTheFrontendDecoderReadsTheKeysThisHandlerSerialises(t *testing.T) { + source := frontendOAuthModelSource(t) + for _, key := range oauthValidateResponseKeys { + require.Contains(t, source, "wire."+key, + "the frontend decoder must read %q off the wire object; a camelCase "+ + "property name silently yields undefined", key) + } + // And it must not go back to casting. A cast is what made the mismatch + // invisible: `as OAuthAuthorizationResolution` type-checks against JSON + // whose keys are all different. + require.NotRegexp(t, + regexp.MustCompile(`as\s+\|?\s*OAuthAuthorizationResolution`), source, + "the validate response must be decoded, never cast") +} + +// Every `json:"..."` name this file's responses declare, as a guard against a +// new field being added in camelCase by habit. +func TestTheOAuthWireNamesAreSnakeCase(t *testing.T) { + source, err := os.ReadFile("oauth_http.go") + require.NoError(t, err) + tags := regexp.MustCompile(`json:"([a-zA-Z_]+)`).FindAllStringSubmatch(string(source), -1) + require.NotEmpty(t, tags) + for _, tag := range tags { + require.NotRegexp(t, regexp.MustCompile(`[a-z][A-Z]`), tag[1], + "wire name %q is camelCase; this surface is OAuth, which is snake_case, "+ + "and the frontend decoder reads snake_case", tag[1]) + } +} + +func frontendOAuthModelSource(t *testing.T) string { + t.Helper() + path := filepath.Join("..", "..", "..", "..", "frontend", "code", "src", + "features", "auth", "model", "oauth-authorization.ts") + source, err := os.ReadFile(path) + require.NoError(t, err, "the frontend decoder must exist at %s; if it moved, "+ + "move this check with it rather than deleting it", path) + return string(source) +} diff --git a/module/services/accounts/code/pkg/auth/client_metadata.go b/module/services/accounts/code/pkg/auth/client_metadata.go new file mode 100644 index 000000000..2811bb688 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/client_metadata.go @@ -0,0 +1,620 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "syscall" + "time" +) + +// Client ID Metadata Documents (the OAuth CIMD draft, and the registration +// mechanism the MCP specification prefers since 2026-07-28). +// +// A client whose `client_id` is an https URL publishes its own registration at +// that URL. The host fetches it, validates it, and treats it as a public client +// of the registry for that one flow. Nothing durable is written: there is no +// row, no admin surface, and no state that outlives the authorization request. +// That is the property that makes this acceptable where dynamic client +// registration (RFC 7591) is not — a registry that grows on unauthenticated +// request is a registry an attacker sizes, and a registered name is a name that +// can impersonate. See the handbook's decisions/registered-clients.md. +// +// Trust boundary: the document is fetched from a URL a caller supplied, so +// every fetch is bounded (size, time, redirects) and SSRF-guarded at the dial, +// exactly as the tenant-supplied data-source URLs are. + +// Named refusals. Each says which rule the document broke, for operators +// reading logs and for the tests that hold each rule. The public answer stays +// ErrClientAuthorizationRejected: an unauthenticated caller must not learn +// which clients this deployment would admit. +var ( + // ErrClientMetadataDisabled is a metadata client presented to a deployment + // that has not enabled them. + ErrClientMetadataDisabled = errors.New("client metadata documents are not enabled") + // ErrClientMetadataNotAllowed is a client_id URL outside the operator's + // allowlist. + ErrClientMetadataNotAllowed = errors.New("client metadata document is not allow-listed") + // ErrClientMetadataClientID is a client_id that is not a usable https URL. + ErrClientMetadataClientID = errors.New("client metadata client_id must be an https URL") + // ErrClientMetadataUnreachable is a document that could not be fetched. + ErrClientMetadataUnreachable = errors.New("client metadata document is unreachable") + // ErrClientMetadataTooLarge is a document above the size bound. + ErrClientMetadataTooLarge = errors.New("client metadata document is too large") + // ErrClientMetadataRedirected is a fetch redirected off its own origin. + ErrClientMetadataRedirected = errors.New("client metadata document redirected to another origin") + // ErrClientMetadataMalformed is a document that is not a JSON object. + ErrClientMetadataMalformed = errors.New("client metadata document is malformed") + // ErrClientMetadataIdentityMismatch is a document whose own client_id is not + // the URL it was served from. Without this check one document could claim + // any client id, which is the impersonation DCR invites. + ErrClientMetadataIdentityMismatch = errors.New("client metadata document names a different client_id") + // ErrClientMetadataAuthMethod is a document that does not declare itself a + // public client. RFC 7591's default is client_secret_basic, so an absent + // value is refused rather than read as "none". + ErrClientMetadataAuthMethod = errors.New("client metadata document must declare token_endpoint_auth_method none") + // ErrClientMetadataRedirectURIs is a document with no usable redirect URI. + ErrClientMetadataRedirectURIs = errors.New("client metadata document declares no usable redirect URI") + // ErrClientMetadataGrantTypes is a document that does not declare the + // authorization-code grant, or declares a response type other than code. + ErrClientMetadataGrantTypes = errors.New("client metadata document must declare the authorization_code grant and the code response type") +) + +const ( + // maxClientMetadataBytes bounds the body read. A registration document is a + // few hundred bytes; this is three orders of magnitude of headroom and still + // far below anything that could pressure the process. + maxClientMetadataBytes = 64 << 10 + // clientMetadataFetchTimeout bounds one fetch, including connect and body + // read. An authorize request is a person waiting in a browser. + clientMetadataFetchTimeout = 5 * time.Second + // clientMetadataNoRedirects: CIMD draft-02 §5.1 says a client-ID document + // MUST be served at its own URL and the fetch MUST NOT follow redirects. + // Following even a same-origin one means the bytes that decide a client's + // identity came from a URL that is not its client_id, and the draft removes + // the question by forbidding the hop outright. + clientMetadataNoRedirects = 0 + // clientMetadataMinTTL / clientMetadataMaxTTL bound what a document's own + // Cache-Control may ask for. The floor keeps an aggressive no-store from + // turning every authorize request into an outbound fetch; the ceiling keeps + // a client's own rotation of its redirect URIs from taking a day to apply. + clientMetadataMinTTL = 60 * time.Second + clientMetadataMaxTTL = time.Hour + // Failures are NOT cached. CIMD draft-02 §5.2 says a server must not cache + // an error or an invalid document: a client whose origin had a 503, or whose + // document was briefly wrong, must work on its next attempt rather than + // staying refused for a window it cannot see. + // + // The abuse that a negative cache would have bounded — an unauthenticated + // caller making this host fetch an arbitrary https URL once per request — is + // bounded where every other pre-credential route bounds it: the authorize + // routes carry RATE_LIMIT_CLASS_AUTHENTICATION at the gateway, and the + // resolver's own per-fetch size, time and destination limits cap what one + // admitted request can cost. +) + +// ClientMetadataDocument is the subset of the registration document the host +// reads. Unknown members are ignored, as the draft requires — but every member +// the host acts on is read explicitly, never inferred from a default. +type ClientMetadataDocument struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + // ClientURI is parsed so the document's shape stays documented here, and is + // then deliberately read by nothing: see RegisteredClient.Origin for why a + // document may not choose what a person is shown. + ClientURI string `json:"client_uri"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` +} + +// ClientMetadataPolicy is the operator's answer to "may a client register +// itself by publishing a document?". It is a deployment-level decision, not a +// per-tenant one: whether this host trusts the CIMD mechanism at all is a +// property of the host, and a per-organisation allowlist would be exactly the +// durable, growing registry this mechanism exists to avoid. +type ClientMetadataPolicy struct { + // enabled is false for the zero value, so a deployment that configured + // nothing admits no metadata client. + enabled bool + // allowed, when non-empty, is the exact set of client_id URLs admitted. An + // empty set with enabled=true admits any document that validates. + allowed map[string]struct{} +} + +// NewClientMetadataPolicy parses IDENTITY_CLIENT_METADATA_DOCUMENTS. +// +// "" — refused. No metadata client may sign anyone in. +// "any" — any client_id URL whose document validates. +// "," — only these exact client_id URLs. +// +// Unset means off for the same reason an unset client registry registers +// nobody: an allowlist that defaults to admitting is not an allowlist. Every +// malformed input fails startup rather than being dropped. +func NewClientMetadataPolicy(raw string) (ClientMetadataPolicy, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return ClientMetadataPolicy{}, nil + } + if strings.EqualFold(raw, "any") { + return ClientMetadataPolicy{enabled: true}, nil + } + policy := ClientMetadataPolicy{enabled: true, allowed: map[string]struct{}{}} + for _, candidate := range strings.Split(raw, ",") { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + clientID, err := CanonicalMetadataClientID(candidate) + if err != nil { + return ClientMetadataPolicy{}, fmt.Errorf( + "client metadata documents: %q is not a usable client_id URL: %w", candidate, err) + } + policy.allowed[clientID] = struct{}{} + } + if len(policy.allowed) == 0 { + return ClientMetadataPolicy{}, errors.New( + "client metadata documents: the declaration names no client_id URL; leave it unset to refuse metadata clients") + } + return policy, nil +} + +// Enabled reports whether any metadata client may sign a person in. +func (p ClientMetadataPolicy) Enabled() bool { return p.enabled } + +// Admits reports whether this exact client_id URL is within the policy. +func (p ClientMetadataPolicy) Admits(clientID string) error { + if !p.enabled { + return ErrClientMetadataDisabled + } + if len(p.allowed) == 0 { + return nil + } + if _, ok := p.allowed[clientID]; !ok { + return ErrClientMetadataNotAllowed + } + return nil +} + +// IsMetadataClientID reports whether a presented client_id is a URL rather than +// a registry slug. The two namespaces cannot collide: ValidClientID admits only +// lowercase letters, digits, `-` and `_`, none of which can spell `https://`. +func IsMetadataClientID(clientID string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(clientID)), "https://") +} + +// CanonicalMetadataClientID validates a metadata client_id and returns the +// exact string the document must echo. No normalisation beyond trimming +// surrounding space: the document's own client_id is compared for equality, and +// a host the host lower-cased would not match a document that did not. +func CanonicalMetadataClientID(candidate string) (string, error) { + candidate = strings.TrimSpace(candidate) + if candidate == "" || len(candidate) > 2048 { + return "", ErrClientMetadataClientID + } + parsed, err := url.Parse(candidate) + if err != nil || !parsed.IsAbs() || parsed.Host == "" { + return "", ErrClientMetadataClientID + } + // https only, with no exception for loopback. A client_id is a public + // identity the host fetches over the internet; a loopback client_id would + // name a document only this process can read, and would be an SSRF target + // dressed as an identity. + if !strings.EqualFold(parsed.Scheme, "https") { + return "", ErrClientMetadataClientID + } + if parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.ForceQuery { + return "", ErrClientMetadataClientID + } + if parsed.EscapedPath() == "" || parsed.EscapedPath() == "/" { + return "", ErrClientMetadataClientID + } + return candidate, nil +} + +// MetadataDocumentFetcher retrieves one client's metadata document. The +// production implementation is the bounded, SSRF-guarded HTTP fetch below; the +// seam exists so the authorization flow can be exercised — here and in the +// accounts service's own story tests — without a network, and so a composition +// that resolves documents some other way has somewhere to say so. +// +// freshness is how long the answer may be cached, which for HTTP comes from the +// document's own Cache-Control. A fetcher that has no opinion returns zero and +// gets the floor. +type MetadataDocumentFetcher interface { + Fetch(ctx context.Context, clientID string) (document []byte, freshness time.Duration, err error) +} + +// ClientMetadataResolver fetches, validates and caches metadata documents. +// Construct with NewClientMetadataResolver; the zero value resolves nothing. +type ClientMetadataResolver struct { + policy ClientMetadataPolicy + fetcher MetadataDocumentFetcher + now func() time.Time + + mu sync.Mutex + cached map[string]cachedMetadataClient + // inflight is one entry per document being fetched right now, so N + // concurrent requests for one client_id make ONE outbound request. + // + // Without it the cache only helps after a fetch completes, so a burst for + // an uncached document is a burst of outbound requests — this host + // reflecting inbound concurrency at a third-party origin one-for-one, each + // held for up to the fetch timeout. The inbound rate-limit class bounds how + // fast that burst can arrive; this bounds what one document costs while it + // is in flight. + inflight map[string]*metadataFetchInFlight +} + +// metadataFetchInFlight is one fetch several callers are waiting on. done is +// closed exactly once, by the caller that started it, after client and err are +// written — so a reader that observes the close observes both. +type metadataFetchInFlight struct { + done chan struct{} + client RegisteredClient + err error +} + +// StaticMetadataDocuments answers from a fixed set of documents, keyed by +// client_id. The documents still go through every validation rule, so a test +// using it is testing the real admission decision and only skipping the +// transport. +type StaticMetadataDocuments map[string]string + +// Fetch implements MetadataDocumentFetcher. +func (s StaticMetadataDocuments) Fetch(_ context.Context, clientID string) ([]byte, time.Duration, error) { + document, ok := s[clientID] + if !ok { + return nil, 0, ErrClientMetadataUnreachable + } + return []byte(document), 0, nil +} + +type cachedMetadataClient struct { + client RegisteredClient + expiresAt time.Time +} + +// metadataDialGuard refuses to open a connection to a non-public address. It +// runs after DNS resolution with the concrete IP being dialed, so it blocks the +// address the connection would actually reach, closing the DNS-rebinding gap a +// hostname-only check leaves open. Package-level var so tests can point a +// resolver at a loopback server; production always uses the guard. +var metadataDialGuard = func(_, address string, _ syscall.RawConn) error { + if !IsPublicDestinationDialAddress(address) { + return ErrClientMetadataUnreachable + } + return nil +} + +// NewClientMetadataResolver builds a resolver for one policy, fetching +// documents over HTTPS with the production bounds and SSRF guard. +func NewClientMetadataResolver(policy ClientMetadataPolicy) *ClientMetadataResolver { + return NewClientMetadataResolverWith(policy, newHTTPMetadataFetcher()) +} + +// NewClientMetadataResolverWith builds a resolver over a given fetcher. +func NewClientMetadataResolverWith( + policy ClientMetadataPolicy, + fetcher MetadataDocumentFetcher, +) *ClientMetadataResolver { + return &ClientMetadataResolver{ + policy: policy, + fetcher: fetcher, + now: time.Now, + cached: map[string]cachedMetadataClient{}, + inflight: map[string]*metadataFetchInFlight{}, + } +} + +// httpMetadataFetcher is the production fetch: https only, bounded in size and +// time, no redirect off the document's own origin, and guarded at the dial +// against every non-public address. +type httpMetadataFetcher struct { + client *http.Client +} + +func newHTTPMetadataFetcher() *httpMetadataFetcher { + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: clientMetadataFetchTimeout, + Control: metadataDialGuard, + }).DialContext, + TLSHandshakeTimeout: clientMetadataFetchTimeout, + ResponseHeaderTimeout: clientMetadataFetchTimeout, + DisableKeepAlives: false, + MaxIdleConnsPerHost: 2, + } + return &httpMetadataFetcher{ + client: &http.Client{ + Transport: transport, + Timeout: clientMetadataFetchTimeout, + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + // Every redirect, same-origin included. See + // clientMetadataNoRedirects. + if len(via) > clientMetadataNoRedirects { + return ErrClientMetadataRedirected + } + return nil + }, + }, + } +} + +// Enabled reports whether this resolver would admit any metadata client. +func (r *ClientMetadataResolver) Enabled() bool { + return r != nil && r.policy.Enabled() +} + +// Resolve turns a metadata client_id into a client of the registry for one +// flow. The returned error is one of the named refusals above; callers serving +// an unauthenticated caller must collapse it to ErrClientAuthorizationRejected. +func (r *ClientMetadataResolver) Resolve(ctx context.Context, clientID string) (RegisteredClient, error) { + if r == nil { + return RegisteredClient{}, ErrClientMetadataDisabled + } + canonical, err := CanonicalMetadataClientID(clientID) + if err != nil { + return RegisteredClient{}, err + } + if err := r.policy.Admits(canonical); err != nil { + return RegisteredClient{}, err + } + // One lock covers the cache read and the in-flight claim, so two callers + // arriving together cannot both decide to fetch. + r.mu.Lock() + if entry, ok := r.cached[canonical]; ok && r.now().Before(entry.expiresAt) { + r.mu.Unlock() + return entry.client, nil + } + if waiting, ok := r.inflight[canonical]; ok { + r.mu.Unlock() + return r.awaitFetch(ctx, waiting) + } + flight := &metadataFetchInFlight{done: make(chan struct{})} + r.inflight[canonical] = flight + r.mu.Unlock() + + client, freshness, err := r.fetchAndValidate(ctx, canonical) + + // Publish to the waiters BEFORE touching the cache, so nobody blocks on the + // lock to learn an answer that is already decided. + flight.client, flight.err = client, err + close(flight.done) + + r.mu.Lock() + delete(r.inflight, canonical) + r.mu.Unlock() + + if err != nil { + // Not cached: see the clientMetadataFailureTTL comment. A refusal is + // re-derived on the next request, so a recovered origin works at once. + return RegisteredClient{}, err + } + r.remember(canonical, client, freshness) + return client, nil +} + +// fetchAndValidate performs the one outbound request and applies every +// admission rule to what came back. +func (r *ClientMetadataResolver) fetchAndValidate( + ctx context.Context, + canonical string, +) (RegisteredClient, time.Duration, error) { + document, freshness, err := r.fetcher.Fetch(ctx, canonical) + if err != nil { + return RegisteredClient{}, 0, err + } + client, err := ClientFromMetadataDocument(canonical, document) + if err != nil { + return RegisteredClient{}, 0, err + } + return client, freshness, nil +} + +// awaitFetch waits for the fetch another caller started, or gives up when THIS +// caller's own request is cancelled — a shared fetch must not outlive the +// caller waiting on it. +func (r *ClientMetadataResolver) awaitFetch( + ctx context.Context, + waiting *metadataFetchInFlight, +) (RegisteredClient, error) { + select { + case <-waiting.done: + return waiting.client, waiting.err + case <-ctx.Done(): + return RegisteredClient{}, ErrClientMetadataUnreachable + } +} + +func (r *ClientMetadataResolver) fromCache(clientID string) (RegisteredClient, bool) { + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.cached[clientID] + if !ok || !r.now().Before(entry.expiresAt) { + return RegisteredClient{}, false + } + return entry.client, true +} + +// remember caches a VALID document only. Nothing records a failure, so a +// refusal is never served from here. +func (r *ClientMetadataResolver) remember(clientID string, client RegisteredClient, ttl time.Duration) { + switch { + case ttl < clientMetadataMinTTL: + ttl = clientMetadataMinTTL + case ttl > clientMetadataMaxTTL: + ttl = clientMetadataMaxTTL + } + r.mu.Lock() + defer r.mu.Unlock() + // The cache is keyed by an attacker-choosable URL, so it is bounded. At the + // bound the whole map is dropped rather than evicted one entry at a time: a + // resolver that needs more than this many distinct clients in a TTL window + // is not a deployment this cache is sized for, and refetching is correct + // where guessing which entry to keep is not. + const maxCachedMetadataClients = 512 + if len(r.cached) >= maxCachedMetadataClients { + r.cached = map[string]cachedMetadataClient{} + } + r.cached[clientID] = cachedMetadataClient{ + client: client, + expiresAt: r.now().Add(ttl), + } +} + +// Fetch implements MetadataDocumentFetcher. +func (f *httpMetadataFetcher) Fetch(ctx context.Context, clientID string) ([]byte, time.Duration, error) { + ctx, cancel := context.WithTimeout(ctx, clientMetadataFetchTimeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, clientID, nil) + if err != nil { + return nil, 0, ErrClientMetadataUnreachable + } + request.Header.Set("Accept", "application/json") + response, err := f.client.Do(request) + if err != nil { + if errors.Is(err, ErrClientMetadataRedirected) { + return nil, 0, ErrClientMetadataRedirected + } + return nil, 0, fmt.Errorf("%w: %v", ErrClientMetadataUnreachable, err) + } + defer func() { _, _ = io.Copy(io.Discard, response.Body); _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("%w: status %d", ErrClientMetadataUnreachable, response.StatusCode) + } + // One byte over the bound is read deliberately, so a document AT the bound + // is accepted and one above it is refused rather than silently truncated + // into a document that parses to something smaller than what was published. + body, err := io.ReadAll(io.LimitReader(response.Body, maxClientMetadataBytes+1)) + if err != nil { + return nil, 0, fmt.Errorf("%w: %v", ErrClientMetadataUnreachable, err) + } + if len(body) > maxClientMetadataBytes { + return nil, 0, ErrClientMetadataTooLarge + } + return body, metadataCacheTTL(response.Header.Get("Cache-Control")), nil +} + +// ClientFromMetadataDocument validates one document body against the URL it was +// served from and projects it onto a registry client. Exported so the rules can +// be tested against a real published document without a network. +func ClientFromMetadataDocument(clientID string, body []byte) (RegisteredClient, error) { + // Unmarshal, not a streaming Decode: Decode reads the first JSON value and + // ignores whatever follows it, so a document with a second object appended + // would be accepted as though only the first were published. Unknown MEMBERS + // are still ignored, as the draft requires — it is trailing CONTENT that is + // refused. + var document ClientMetadataDocument + if err := json.Unmarshal(body, &document); err != nil { + return RegisteredClient{}, fmt.Errorf("%w: %v", ErrClientMetadataMalformed, err) + } + // The document's own client_id must be the URL it was served from. This is + // the load-bearing check of the whole mechanism: without it, anyone who can + // publish a JSON file can claim to be any client. + if document.ClientID != clientID { + return RegisteredClient{}, ErrClientMetadataIdentityMismatch + } + if document.TokenEndpointAuthMethod != "none" { + return RegisteredClient{}, ErrClientMetadataAuthMethod + } + if err := requireDeclared(document.GrantTypes, "authorization_code"); err != nil { + return RegisteredClient{}, err + } + if err := requireDeclared(document.ResponseTypes, "code"); err != nil { + return RegisteredClient{}, err + } + if len(document.RedirectURIs) == 0 { + return RegisteredClient{}, ErrClientMetadataRedirectURIs + } + client := RegisteredClient{ClientID: clientID, Metadata: true} + for _, candidate := range document.RedirectURIs { + // Every redirect URI goes through the rule operator-declared clients + // already obey: absolute, https or loopback http, no userinfo, no + // fragment. One that fails is dropped rather than failing the whole + // document — a client may publish a URI for a scheme this host does not + // serve (a custom app scheme, say) and still have a usable loopback one. + redirectURI, err := canonicalClientRedirectURI(candidate) + if err != nil { + continue + } + client.RedirectURIs = append(client.RedirectURIs, redirectURI) + } + if len(client.RedirectURIs) == 0 { + return RegisteredClient{}, ErrClientMetadataRedirectURIs + } + // The verified origin, from the URL this document was fetched from. Set + // before the name, because the name may fall back to it. + client.Origin = metadataClientOrigin(clientID) + client.Name = strings.TrimSpace(document.ClientName) + if client.Name == "" || len(client.Name) > 128 { + // A name is presentational and comes from an unvetted document, so a + // missing or oversized one falls back to the verified origin. It is never + // truncated: a truncated name is still a name chosen by whoever published + // it, just shorter. + client.Name = client.Origin + } + // Origins (the CORS kind) are deliberately NOT derived from the document + // either. A browser origin is what the gateway's CORS pass binds a token to, + // and a client that published its own would be choosing its own CORS grant. A + // metadata client is a native or server-side client talking to the host + // directly; a browser client still needs an operator-declared row. + return client, nil +} + +// requireDeclared accepts an absent list (RFC 7591's defaults are exactly the +// values this host serves) and otherwise requires the value to be present. +func requireDeclared(declared []string, required string) error { + if len(declared) == 0 { + return nil + } + for _, value := range declared { + if value == required { + return nil + } + } + return ErrClientMetadataGrantTypes +} + +func metadataClientOrigin(clientID string) string { + parsed, err := url.Parse(clientID) + if err != nil || parsed.Host == "" { + return clientID + } + return strings.ToLower(parsed.Scheme) + "://" + parsed.Host +} + +// metadataCacheTTL reads the document's own freshness, bounded. A document that +// says nothing, or says no-store, gets the floor rather than zero: the point of +// the floor is that a document's cache policy cannot turn this host into a +// request-rate amplifier against its own publisher. +func metadataCacheTTL(cacheControl string) time.Duration { + for _, directive := range strings.Split(cacheControl, ",") { + name, value, found := strings.Cut(strings.TrimSpace(directive), "=") + if !found || !strings.EqualFold(strings.TrimSpace(name), "max-age") { + continue + } + seconds, err := time.ParseDuration(strings.TrimSpace(value) + "s") + if err != nil { + continue + } + switch { + case seconds < clientMetadataMinTTL: + return clientMetadataMinTTL + case seconds > clientMetadataMaxTTL: + return clientMetadataMaxTTL + default: + return seconds + } + } + return clientMetadataMinTTL +} diff --git a/module/services/accounts/code/pkg/auth/client_metadata_export_test.go b/module/services/accounts/code/pkg/auth/client_metadata_export_test.go new file mode 100644 index 000000000..c0ae76ee2 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/client_metadata_export_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "net/http" + "syscall" + "time" +) + +// Test hooks for the metadata resolver, in package auth so the external +// auth_test package can reach them without any of this being production API. +// +// There are exactly two, and each exists because the thing it replaces cannot +// be exercised otherwise: the SSRF dial guard refuses every loopback address, +// which is every address a test server listens on, and the transport pins TLS +// to the public roots, which no test certificate is in. + +// SetMetadataDialGuardForTest replaces the dial guard and returns a function +// restoring it. +func SetMetadataDialGuardForTest(guard func(string, string, syscall.RawConn) error) func() { + previous := metadataDialGuard + metadataDialGuard = guard + return func() { metadataDialGuard = previous } +} + +// MetadataDialGuardForTest exposes the production guard so a test can assert +// what it refuses without opening a connection. +func MetadataDialGuardForTest() func(string, string, syscall.RawConn) error { + return metadataDialGuard +} + +// SetTransportForTest points this resolver's HTTP fetcher at a test server's +// transport. It panics on a resolver built over any other fetcher, which is a +// test wiring mistake rather than a condition to tolerate. +func (r *ClientMetadataResolver) SetTransportForTest(transport http.RoundTripper) { + r.fetcher.(*httpMetadataFetcher).client.Transport = transport +} + +// CachedMetadataTTLForTest exposes how long a document's own Cache-Control +// would be honoured, bounds applied. +func CachedMetadataTTLForTest(cacheControl string) time.Duration { + return metadataCacheTTL(cacheControl) +} diff --git a/module/services/accounts/code/pkg/auth/client_metadata_singleflight_test.go b/module/services/accounts/code/pkg/auth/client_metadata_singleflight_test.go new file mode 100644 index 000000000..9c22e99c0 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/client_metadata_singleflight_test.go @@ -0,0 +1,103 @@ +package auth_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" +) + +// blockingFetcher counts outbound fetches and holds each one until released, so +// a test can have several callers in flight at once. +type blockingFetcher struct { + calls atomic.Int32 + release chan struct{} + document string +} + +func (f *blockingFetcher) Fetch(_ context.Context, _ string) ([]byte, time.Duration, error) { + f.calls.Add(1) + <-f.release + return []byte(f.document), time.Minute, nil +} + +// r1/f8. Concurrent resolutions of one client_id make ONE outbound request. +// +// The cache alone cannot do this: it is populated only after a fetch returns, so +// a burst for an uncached document is a burst of outbound requests — this host +// reflecting inbound concurrency at a third-party origin one-for-one, each held +// for up to the fetch timeout. +func TestConcurrentResolutionsOfOneDocumentFetchOnce(t *testing.T) { + const clientID = "https://client.example.com/metadata" + fetcher := &blockingFetcher{ + release: make(chan struct{}), + document: `{"client_id":"https://client.example.com/metadata",` + + `"client_name":"Example",` + + `"redirect_uris":["http://127.0.0.1/callback"],` + + `"token_endpoint_auth_method":"none"}`, + } + policy, err := auth.NewClientMetadataPolicy("any") + require.NoError(t, err) + resolver := auth.NewClientMetadataResolverWith(policy, fetcher) + + const callers = 8 + var wg sync.WaitGroup + results := make([]auth.RegisteredClient, callers) + errs := make([]error, callers) + for i := range callers { + wg.Add(1) + go func() { + defer wg.Done() + results[i], errs[i] = resolver.Resolve(context.Background(), clientID) + }() + } + // Let every caller reach the resolver before the fetch can complete, so they + // are genuinely concurrent rather than serialised by a fast return. + require.Eventually(t, func() bool { return fetcher.calls.Load() >= 1 }, + 2*time.Second, time.Millisecond) + close(fetcher.release) + wg.Wait() + + require.Equal(t, int32(1), fetcher.calls.Load(), + "eight concurrent callers must produce one outbound fetch") + for i := range callers { + require.NoError(t, errs[i]) + require.Equal(t, clientID, results[i].ClientID, "every caller gets the resolved client") + } + + // And the fetch is not repeated once it is cached. + _, err = resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + require.Equal(t, int32(1), fetcher.calls.Load()) +} + +// A caller whose own request is cancelled while waiting on another caller's +// fetch gives up: a shared fetch must not outlive the caller waiting on it. +func TestAWaitingResolverGivesUpWhenItsOwnRequestIsCancelled(t *testing.T) { + const clientID = "https://client.example.com/metadata" + fetcher := &blockingFetcher{release: make(chan struct{}), document: "{}"} + policy, err := auth.NewClientMetadataPolicy("any") + require.NoError(t, err) + resolver := auth.NewClientMetadataResolverWith(policy, fetcher) + + started := make(chan struct{}) + go func() { + close(started) + _, _ = resolver.Resolve(context.Background(), clientID) + }() + <-started + require.Eventually(t, func() bool { return fetcher.calls.Load() == 1 }, + 2*time.Second, time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = resolver.Resolve(ctx, clientID) + require.ErrorIs(t, err, auth.ErrClientMetadataUnreachable) + + close(fetcher.release) +} diff --git a/module/services/accounts/code/pkg/auth/client_metadata_test.go b/module/services/accounts/code/pkg/auth/client_metadata_test.go new file mode 100644 index 000000000..0645c384d --- /dev/null +++ b/module/services/accounts/code/pkg/auth/client_metadata_test.go @@ -0,0 +1,589 @@ +package auth_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "syscall" + "testing" + "time" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +// claudeCodeDocument is the document Claude Code actually publishes at +// https://claude.ai/oauth/claude-code-client-metadata, captured verbatim on +// 2026-10-04. The point of testing against the real shape rather than an +// invented one is that this host is useless if it refuses the client it exists +// to admit, and every field below is one a hand-written fixture could get +// wrong: there is no `scope`, the redirect URIs carry NO port, and +// `token_endpoint_auth_method` is the only auth-method member present. +const claudeCodeClientID = "https://claude.ai/oauth/claude-code-client-metadata" + +const claudeCodeDocument = `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","client_name":"Claude Code","client_uri":"https://claude.ai","redirect_uris":["http://localhost/callback","http://127.0.0.1/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}` + +func TestClaudeCodeMetadataDocumentIsAdmittedAsPublished(t *testing.T) { + client, err := auth.ClientFromMetadataDocument(claudeCodeClientID, []byte(claudeCodeDocument)) + require.NoError(t, err) + require.True(t, client.Metadata) + require.Equal(t, claudeCodeClientID, client.ClientID) + require.Equal(t, "Claude Code", client.Name) + // The VERIFIED origin: the client_id URL's own. The document also declares + // `client_uri: https://claude.ai`, which happens to agree here — the test + // below proves the host does not read it even when it does not. + require.Equal(t, "https://claude.ai", client.Origin) + require.Equal(t, + []string{"http://localhost/callback", "http://127.0.0.1/callback"}, + client.RedirectURIs) + // A metadata client never gets a browser origin. The gateway binds a token + // to the origins a client registered, and a client that published its own + // would be choosing its own CORS grant. + require.Empty(t, client.Origins) +} + +// The loopback port rule (RFC 8252 §7.3) is what makes Claude Code's document +// usable at all: it listens on an ephemeral port it cannot know in advance, and +// the document it published names no port. +func TestLoopbackRedirectWithoutAPortMatchesAnyPort(t *testing.T) { + client, err := auth.ClientFromMetadataDocument(claudeCodeClientID, []byte(claudeCodeDocument)) + require.NoError(t, err) + + for _, presented := range []string{ + "http://localhost/callback", + "http://localhost:54321/callback", + "http://localhost:1/callback", + "http://127.0.0.1:8976/callback", + } { + require.True(t, client.AllowsRedirect(presented), "should admit %q", presented) + } + for _, presented := range []string{ + // A different path is a different redirect, port or no port. + "http://localhost:54321/other", + "http://localhost:54321/callback/sub", + // https is not the registered scheme. + "https://localhost:54321/callback", + // A different loopback spelling is a different host: ::1 is not 127.0.0.1, + // and admitting one for the other would widen a declaration nobody made. + "http://[::1]:54321/callback", + // Not loopback at all. + "http://evil.example.com:54321/callback", + // A query the registration did not declare. + "http://localhost:54321/callback?next=https://evil.example.com", + // A fragment is invisible to the server issuing the redirect. + "http://localhost:54321/callback#x", + } { + require.False(t, client.AllowsRedirect(presented), "should refuse %q", presented) + } +} + +// RFC 8252 §7.3 is unconditional for loopback: a native client takes an +// ephemeral port from the operating system at the moment of the request, so it +// cannot have registered the port it will listen on, and the authorization +// server MUST allow any port at request time. The rule applies whether or not +// the registration named a port — this test replaces one that asserted a +// declared port stayed exact. +// +// The port is the only component relaxed, which is what the refusals below +// establish: a different path, a different host, https, userinfo and a fragment +// are all still refused on the same registration. +func TestALoopbackRedirectMatchesAnyPort(t *testing.T) { + registry, err := auth.NewClientRegistry(`[{ + "client_id": "example-cli", + "name": "Example CLI", + "redirect_uris": ["http://localhost:3000/auth/callback"] + }]`) + require.NoError(t, err) + client, ok := registry.Lookup("example-cli") + require.True(t, ok) + + for _, presented := range []string{ + "http://localhost:3000/auth/callback", + "http://localhost:3001/auth/callback", + "http://localhost:49152/auth/callback", // an OS-assigned ephemeral port + "http://localhost/auth/callback", + } { + require.True(t, client.AllowsRedirect(presented), "should allow %q", presented) + } + + for _, presented := range []string{ + "http://localhost:3000/auth/other", // a different callback + "http://localhost:3000/auth/callback/x", // an added segment + "http://example.com:3000/auth/callback", // not loopback + "https://localhost:3000/auth/callback", // not http + "http://localhost:3000/auth/callback?x=1", + "http://user@localhost:3000/auth/callback", + "http://localhost:3000/auth/callback#f", + "http://localhost:notaport/auth/callback", + } { + require.False(t, client.AllowsRedirect(presented), "should refuse %q", presented) + } +} + +// Each validation rule, refused by name. The public answer collapses to one +// sentinel so an unauthenticated caller cannot enumerate the registry, but the +// reason must survive in the error an operator reads — and in these tests, +// which are the only thing that holds each rule separately. +func TestEachMetadataDocumentRuleIsRefusedByName(t *testing.T) { + tests := []struct { + name string + clientID string + document string + wantErr error + }{ + { + name: "a document naming a different client_id", + clientID: claudeCodeClientID, + document: `{"client_id":"https://evil.example.com/metadata","redirect_uris":["https://evil.example.com/cb"],"token_endpoint_auth_method":"none"}`, + wantErr: auth.ErrClientMetadataIdentityMismatch, + }, + { + name: "a document declaring no auth method", + clientID: claudeCodeClientID, + document: `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","redirect_uris":["http://localhost/callback"]}`, + wantErr: auth.ErrClientMetadataAuthMethod, + }, + { + name: "a document declaring a confidential auth method", + clientID: claudeCodeClientID, + document: `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"client_secret_basic"}`, + wantErr: auth.ErrClientMetadataAuthMethod, + }, + { + name: "a document with no redirect URI", + clientID: claudeCodeClientID, + document: `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","redirect_uris":[],"token_endpoint_auth_method":"none"}`, + wantErr: auth.ErrClientMetadataRedirectURIs, + }, + { + name: "a document whose only redirect URI is non-loopback http", + clientID: claudeCodeClientID, + document: `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","redirect_uris":["http://evil.example.com/cb"],"token_endpoint_auth_method":"none"}`, + wantErr: auth.ErrClientMetadataRedirectURIs, + }, + { + name: "a document not declaring the authorization_code grant", + clientID: claudeCodeClientID, + document: `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","redirect_uris":["http://localhost/callback"],"grant_types":["client_credentials"],"token_endpoint_auth_method":"none"}`, + wantErr: auth.ErrClientMetadataGrantTypes, + }, + { + name: "a document not declaring the code response type", + clientID: claudeCodeClientID, + document: `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","redirect_uris":["http://localhost/callback"],"response_types":["token"],"token_endpoint_auth_method":"none"}`, + wantErr: auth.ErrClientMetadataGrantTypes, + }, + { + name: "a document that is not JSON", + clientID: claudeCodeClientID, + document: `not json`, + wantErr: auth.ErrClientMetadataMalformed, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := auth.ClientFromMetadataDocument(test.clientID, []byte(test.document)) + require.ErrorIs(t, err, test.wantErr) + }) + } +} + +// A document whose name is absent or absurd falls back to the one fact about +// the client this host verified: the origin it served the document from. +func TestAMetadataClientWithNoUsableNameIsNamedByItsOrigin(t *testing.T) { + client, err := auth.ClientFromMetadataDocument(claudeCodeClientID, + []byte(`{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","client_name":" ","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"none"}`)) + require.NoError(t, err) + require.Equal(t, "https://claude.ai", client.Name) + + long := strings.Repeat("a", 200) + client, err = auth.ClientFromMetadataDocument(claudeCodeClientID, + []byte(`{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","client_name":"`+long+`","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"none"}`)) + require.NoError(t, err) + require.Equal(t, "https://claude.ai", client.Name) +} + +func TestAClientIDMustBeAnHTTPSURLWithAPath(t *testing.T) { + for _, candidate := range []string{ + "", + "example-addin", + "http://claude.ai/oauth/metadata", + "https://claude.ai", + "https://claude.ai/", + "https://user:pass@claude.ai/metadata", + "https://claude.ai/metadata#frag", + "https://claude.ai/metadata?q=1", + } { + _, err := auth.CanonicalMetadataClientID(candidate) + require.ErrorIs(t, err, auth.ErrClientMetadataClientID, "should refuse %q", candidate) + } + canonical, err := auth.CanonicalMetadataClientID(" " + claudeCodeClientID + " ") + require.NoError(t, err) + require.Equal(t, claudeCodeClientID, canonical) +} + +// The two namespaces cannot collide, which is what makes the fallback to a +// metadata document unambiguous: a registry slug can never spell `https://`. +func TestARegistrySlugIsNeverReadAsAMetadataClientID(t *testing.T) { + require.False(t, auth.IsMetadataClientID("example-addin")) + require.True(t, auth.IsMetadataClientID(claudeCodeClientID)) + require.False(t, auth.ValidClientID(claudeCodeClientID)) +} + +func TestAnUnconfiguredPolicyAdmitsNoMetadataClient(t *testing.T) { + policy, err := auth.NewClientMetadataPolicy("") + require.NoError(t, err) + require.False(t, policy.Enabled()) + require.ErrorIs(t, policy.Admits(claudeCodeClientID), auth.ErrClientMetadataDisabled) + + resolver := auth.NewClientMetadataResolver(policy) + require.False(t, resolver.Enabled()) + _, err = resolver.Resolve(context.Background(), claudeCodeClientID) + require.ErrorIs(t, err, auth.ErrClientMetadataDisabled) +} + +func TestAnAllowlistPolicyAdmitsOnlyWhatItNames(t *testing.T) { + policy, err := auth.NewClientMetadataPolicy(claudeCodeClientID) + require.NoError(t, err) + require.True(t, policy.Enabled()) + require.NoError(t, policy.Admits(claudeCodeClientID)) + require.ErrorIs(t, policy.Admits("https://other.example.com/metadata"), + auth.ErrClientMetadataNotAllowed) + + anyPolicy, err := auth.NewClientMetadataPolicy("any") + require.NoError(t, err) + require.NoError(t, anyPolicy.Admits("https://other.example.com/metadata")) +} + +// A malformed declaration fails startup. An allowlist that silently drops the +// entry it could not read either locks out a working client or admits more than +// was written. +func TestAMalformedMetadataPolicyFailsStartup(t *testing.T) { + for _, declaration := range []string{ + "not-a-url", + "http://claude.ai/metadata", + ",", + } { + _, err := auth.NewClientMetadataPolicy(declaration) + require.Error(t, err, "should refuse %q", declaration) + } +} + +// The fetch half, against a real TLS server. Both test hooks are needed: the +// SSRF dial guard refuses every loopback address, and the transport pins TLS to +// the public roots, which no test certificate is in. +func TestTheResolverFetchesValidatesAndCachesADocument(t *testing.T) { + allowLoopbackMetadataDial(t) + + var fetches int + var clientID string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetches++ + w.Header().Set("Cache-Control", "public, max-age=300") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"client_id":"` + clientID + `","client_name":"Example MCP Client","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"none"}`)) + })) + defer server.Close() + clientID = server.URL + "/metadata" + + resolver := testResolver(t, "any", server) + client, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + require.Equal(t, "Example MCP Client", client.Name) + require.True(t, client.Metadata) + require.Equal(t, 1, fetches) + + // Cached for the document's own freshness, so an authorize request does not + // cost an outbound fetch every time. + _, err = resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + require.Equal(t, 1, fetches) +} + +// A redirect off the document's own origin is refused. The origin IS the +// client's identity, so following one would let any site that can publish a +// redirect claim another's client_id. +func TestARedirectOffTheOriginIsRefused(t *testing.T) { + allowLoopbackMetadataDial(t) + + elsewhere := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer elsewhere.Close() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Redirect(w, &http.Request{}, elsewhere.URL+"/metadata", http.StatusFound) + })) + defer server.Close() + + resolver := testResolver(t, "any", server) + _, err := resolver.Resolve(context.Background(), server.URL+"/metadata") + require.ErrorIs(t, err, auth.ErrClientMetadataRedirected) +} + +// A body above the bound is refused rather than truncated. +func TestADocumentOverTheSizeBoundIsRefused(t *testing.T) { + allowLoopbackMetadataDial(t) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"padding":"` + strings.Repeat("x", 70<<10) + `"}`)) + })) + defer server.Close() + + resolver := testResolver(t, "any", server) + _, err := resolver.Resolve(context.Background(), server.URL+"/metadata") + require.ErrorIs(t, err, auth.ErrClientMetadataTooLarge) +} + +// A document outside the allowlist is never fetched at all. An operator who +// named three client_id URLs has not agreed to this host making requests to a +// fourth. +func TestADisallowedClientIDIsNeverFetched(t *testing.T) { + allowLoopbackMetadataDial(t) + + var fetches int + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetches++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + resolver := testResolver(t, "https://claude.ai/oauth/claude-code-client-metadata", server) + _, err := resolver.Resolve(context.Background(), server.URL+"/metadata") + require.ErrorIs(t, err, auth.ErrClientMetadataNotAllowed) + require.Equal(t, 0, fetches) +} + +// A document's own Cache-Control is honoured within bounds. The floor stops a +// no-store document turning every authorize request into an outbound fetch; the +// ceiling stops a client's rotation of its redirect URIs taking a day to apply. +func TestDocumentFreshnessIsBounded(t *testing.T) { + require.Equal(t, 5*time.Minute, auth.CachedMetadataTTLForTest("public, max-age=300")) + require.Equal(t, time.Minute, auth.CachedMetadataTTLForTest("no-store")) + require.Equal(t, time.Minute, auth.CachedMetadataTTLForTest("")) + require.Equal(t, time.Minute, auth.CachedMetadataTTLForTest("max-age=1")) + require.Equal(t, time.Hour, auth.CachedMetadataTTLForTest("max-age=86400")) +} + +func testResolver(t *testing.T, declaration string, server *httptest.Server) *auth.ClientMetadataResolver { + t.Helper() + policy, err := auth.NewClientMetadataPolicy(declaration) + require.NoError(t, err) + resolver := auth.NewClientMetadataResolver(policy) + // The test server's certificate is not in the public roots, which the + // production transport is pinned to. Only the TLS trust is relaxed; the size, + // time and redirect bounds are the production ones. + resolver.SetTransportForTest(server.Client().Transport) + return resolver +} + +// allowLoopbackMetadataDial is the one seam the SSRF guard has. Production +// refuses every non-public address at dial time, after DNS resolution, which is +// what closes the rebinding gap a hostname check leaves open. +func allowLoopbackMetadataDial(t *testing.T) { + t.Helper() + restore := auth.SetMetadataDialGuardForTest(func(_, _ string, _ syscall.RawConn) error { return nil }) + t.Cleanup(restore) +} + +// The guard itself: the addresses it refuses are the ones an attacker-supplied +// client_id would be aimed at. +func TestTheMetadataDialGuardRefusesEveryNonPublicAddress(t *testing.T) { + guard := auth.MetadataDialGuardForTest() + for _, address := range []string{ + "127.0.0.1:443", + "[::1]:443", + "10.0.0.1:443", + "192.168.1.1:443", + "172.16.0.1:443", + "169.254.169.254:443", + "not-an-address", + } { + require.Error(t, guard("tcp", address, nil), "should refuse %q", address) + } + require.NoError(t, guard("tcp", net.JoinHostPort("93.184.216.34", "443"), nil)) +} + +// A1007-06. A document may claim any `client_uri`; the host must display the +// origin it VERIFIED by fetching, not the claim. Adopted from the Astra review +// (A1007-06, client_origin_not_spoofable): a consent screen showing +// `https://trusted.example.com` for a document served from +// `https://client.example.com` names a publisher the host never reached. +func TestAMetadataDocumentCannotChooseTheOriginShownToAPerson(t *testing.T) { + const clientID = "https://client.example.com/doc" + client, err := auth.ClientFromMetadataDocument(clientID, []byte( + `{"client_id":"https://client.example.com/doc",`+ + `"client_name":"Totally Trusted",`+ + `"client_uri":"https://trusted.example.com",`+ + `"redirect_uris":["http://localhost/callback"],`+ + `"token_endpoint_auth_method":"none"}`)) + require.NoError(t, err) + + require.Equal(t, "https://client.example.com", client.Origin, + "the origin shown must be the one the document was fetched from") + require.NotContains(t, client.Origin, "trusted.example.com") + // The name is untrusted presentation and is carried as published — the + // consent screen shows it BESIDE the verified origin, never instead of it. + require.Equal(t, "Totally Trusted", client.Name) +} + +// A1007-04. The guard answers for the concrete address a dial is about to +// reach. Adopted from the Astra review (A1007-04, special_use_guard), which +// found the previous IsGlobalUnicast+IsPrivate pair admitting shared, +// benchmarking and reserved space. +func TestNonPublicDestinationsAreRefusedAcrossEverySpecialUseRange(t *testing.T) { + refused := []string{ + // The ranges the previous guard caught. + "127.0.0.1:443", "[::1]:443", "10.0.0.1:443", "192.168.1.1:443", + "172.16.0.1:443", "169.254.169.254:443", "[fe80::1]:443", + // The ranges it admitted. Each is routable somewhere and none is public. + "100.64.0.1:443", // shared address space, carrier NAT + "198.18.0.1:443", // benchmarking + "240.0.0.1:443", // reserved + "192.0.0.1:443", // IETF protocol assignments + "192.0.2.1:443", // documentation + "198.51.100.1:443", // documentation + "203.0.113.1:443", // documentation + "192.88.99.1:443", // 6to4 relay anycast + "0.0.0.0:443", // this network + "255.255.255.255:443", // limited broadcast + "[fc00::1]:443", // unique local + "[2001:db8::1]:443", // documentation + "[2001:2::1]:443", // benchmarking + "[64:ff9b::a00:1]:443", // NAT64 wrapping 10.0.0.1 + // A v4-mapped form of private space: classified as the v4 address it + // wraps, or the v6 registry alone would find no match and admit it. + "[::ffff:10.0.0.1]:443", + "[::ffff:127.0.0.1]:443", + // A1007B-04. A ZONED address: netip.Prefix.Contains returns false for + // any zoned address, so every prefix missed these and they were + // admitted while their unzoned forms were refused. url.Parse accepts + // the `%25lo0` spelling that produces them. + "[fd00::1%lo0]:443", + "[fe80::1%eth0]:443", + "[::1%lo0]:443", + // Ranges the first registry list omitted. + "[3fff::1]:443", // documentation (RFC 9637) + "[100:0:0:1::1]:443", // dummy prefix, outside the discard-only /64 + "[2001:100::1]:443", // unassigned inside IETF protocol assignments + "[2001:1::1]:443", // port control protocol anycast + "[2001:20::1]:443", // ORCHIDv2 + "[2002:a00:1::1]:443", // 6to4 wrapping 10.0.0.1 + "[5f00::1]:443", // segment routing + // Unparseable. A guard that cannot tell what it is about to reach must + // not admit it. + "not-an-address", "", "example.com:443", + } + for _, address := range refused { + require.False(t, auth.IsPublicDestinationDialAddress(address), + "should refuse %q", address) + } + for _, address := range []string{ + "93.184.216.34:443", "1.1.1.1:443", "[2606:4700::1111]:443", + // Global unicast just outside the blocks above, so the additions do not + // over-refuse: 2001:4860::/32 is outside 2001::/23, and 4000::/3 is + // ordinary assignable space. + "[2001:4860:4860::8888]:443", "[4001::1]:443", + } { + require.True(t, auth.IsPublicDestinationDialAddress(address), + "should admit %q", address) + } +} + +// A1007-09. Every redirect is refused, same-origin included: CIMD draft-02 +// §5.1 says the document MUST be served at its own URL and the fetch MUST NOT +// follow redirects, so the bytes deciding a client's identity always come from +// its client_id. Adopted from the Astra review +// (error_cache_and_same_origin_redirect). +func TestASameOriginRedirectIsRefusedToo(t *testing.T) { + allowLoopbackMetadataDial(t) + + var hops int + var server *httptest.Server + server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hops++ + if r.URL.Path == "/metadata" { + http.Redirect(w, r, server.URL+"/moved", http.StatusFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"client_id":"` + server.URL + `/metadata","client_name":"Moved","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"none"}`)) + })) + defer server.Close() + + resolver := testResolver(t, "any", server) + _, err := resolver.Resolve(context.Background(), server.URL+"/metadata") + require.ErrorIs(t, err, auth.ErrClientMetadataRedirected) + require.Equal(t, 1, hops, "the redirect target must never be fetched") +} + +// A1007-09. A failed fetch is NOT cached: CIMD draft-02 §5.2 forbids it, and a +// client whose origin had a 503 must work on its next attempt rather than +// staying refused for a window it cannot see. The abuse a negative cache would +// have bounded is bounded by the gateway's authentication rate-limit class on +// the routes that reach here. +func TestAFailedFetchIsNotCached(t *testing.T) { + allowLoopbackMetadataDial(t) + + var fetches int + healthy := false + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetches++ + if !healthy { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"client_id":"https://` + r.Host + `/metadata","client_name":"Recovered","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"none"}`)) + })) + defer server.Close() + clientID := server.URL + "/metadata" + + resolver := testResolver(t, "any", server) + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, auth.ErrClientMetadataUnreachable) + require.Equal(t, 1, fetches) + + // Recovered, and the very next resolve sees it — no waiting out a cached + // refusal. + healthy = true + client, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + require.Equal(t, "Recovered", client.Name) + require.Equal(t, 2, fetches) +} + +// An invalid document is not cached either, for the same reason: a publisher +// who fixes it must not wait out a window. +func TestAnInvalidDocumentIsNotCached(t *testing.T) { + allowLoopbackMetadataDial(t) + + var fetches int + valid := false + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetches++ + w.Header().Set("Content-Type", "application/json") + if !valid { + // Names a different client_id: the load-bearing validation rule. + _, _ = w.Write([]byte(`{"client_id":"https://elsewhere.example.com/doc","token_endpoint_auth_method":"none","redirect_uris":["http://localhost/callback"]}`)) + return + } + _, _ = w.Write([]byte(`{"client_id":"https://` + r.Host + `/metadata","client_name":"Fixed","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"none"}`)) + })) + defer server.Close() + clientID := server.URL + "/metadata" + + resolver := testResolver(t, "any", server) + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, auth.ErrClientMetadataIdentityMismatch) + + valid = true + client, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + require.Equal(t, "Fixed", client.Name) + require.Equal(t, 2, fetches) +} diff --git a/module/services/accounts/code/pkg/auth/clients.go b/module/services/accounts/code/pkg/auth/clients.go index 7a0851873..ed2bd89ef 100644 --- a/module/services/accounts/code/pkg/auth/clients.go +++ b/module/services/accounts/code/pkg/auth/clients.go @@ -15,34 +15,111 @@ import ( // a distinguishable answer would enumerate the registry. var ErrClientAuthorizationRejected = errors.New("client authorization rejected") -// RegisteredClient is one first-party client the operator has declared: an -// add-in, a CLI, a mobile app. It is a public client — it ships to the person -// using it and can keep no secret — so it authenticates with PKCE and a -// rotating refresh token. +// RegisteredClient is one public client the host will sign a person in for: an +// add-in, a CLI, a mobile app, an MCP client. It is a public client — it ships +// to the person using it and can keep no secret — so it authenticates with PKCE +// and a rotating refresh token. // // RedirectURIs and Origins are independent: where the host may deliver an // authorization code and where the client's browser context issues requests // from are different facts, and deriving one from the other would silently // widen whichever was not declared. +// +// A client reaches this type two ways. The operator declares it in +// IDENTITY_REGISTERED_CLIENTS, in which case Metadata is false and the +// deployment has vouched for it. Or the client presents a Client ID Metadata +// Document (client_metadata.go), in which case Metadata is true, nothing +// durable was written, and the person is asked to consent by name. type RegisteredClient struct { ClientID string Name string RedirectURIs []string Origins []string + // Origin is the one fact about a metadata client this host VERIFIED: the + // scheme and host of the client_id URL the document was actually fetched + // from. Consent displays it, always. + // + // It is deliberately NOT the document's own `client_uri`. A document at + // https://client.example.com/doc may claim `client_uri: + // https://trusted.example.com`, and a consent screen showing that claim + // would name a publisher the host never reached — the exact confusion CIMD + // draft-02 §8.5 answers by telling a server to display the client_id's + // hostname. `client_uri` reaches nothing, so there is no path by which it + // can reach a person. + // + // Empty for an operator-declared client, which the deployment vouched for by + // name and whose origin the person does not need to judge. + Origin string + // Metadata marks a client resolved from a Client ID Metadata Document + // rather than from operator configuration. + Metadata bool } -// AllowsRedirect reports whether candidate is one of the exact URIs registered -// for this client. Matching is exact — never by prefix, suffix, or host — so no -// alternate port, added path segment, or appended query can widen it. +// AllowsRedirect reports whether candidate is one of the URIs registered for +// this client. Matching is exact — never by prefix, suffix, or host — so no +// added path segment or appended query can widen it, with one bounded exception +// below for loopback redirects. +// +// The exception is RFC 8252 §7.3, which is unconditional for loopback: a native +// client obtains an ephemeral port from the operating system at the moment of +// the request, so it cannot have registered the port it will listen on, and the +// authorization server MUST allow any port to be specified at request time. The +// rule therefore applies whether or not the registration names a port — +// `http://127.0.0.1:3000/callback` and `http://127.0.0.1/callback` both match +// the same path on any port. +// +// The port is the ONLY component this relaxes. Scheme, host, path and query +// must still be identical, userinfo and a fragment are refused outright, and +// the rule reaches only http on a loopback host — so it cannot turn a +// registration for one callback into a match for another, or let a non-loopback +// redirect vary at all. func (c RegisteredClient) AllowsRedirect(candidate string) bool { for _, registered := range c.RedirectURIs { if registered == candidate { return true } + if loopbackRedirectMatchesAnyPort(registered, candidate) { + return true + } } return false } +// loopbackRedirectMatchesAnyPort implements the RFC 8252 §7.3 loopback rule for +// one registered/presented pair. It applies when the registered URI is http on +// a loopback host, with or without a declared port; everything else about the +// two URIs — scheme, host, path, query — must still be identical. +func loopbackRedirectMatchesAnyPort(registered, candidate string) bool { + registeredURL, err := url.Parse(registered) + if err != nil || !strings.EqualFold(registeredURL.Scheme, "http") || + !isLoopbackHost(registeredURL.Hostname()) { + return false + } + candidateURL, err := url.Parse(strings.TrimSpace(candidate)) + if err != nil || candidateURL.User != nil || candidateURL.Fragment != "" { + return false + } + if !strings.EqualFold(candidateURL.Scheme, "http") { + return false + } + if !strings.EqualFold(candidateURL.Hostname(), registeredURL.Hostname()) { + return false + } + // A port is optional on the presented URI too, but if present it must be a + // port and nothing else — url.Parse accepts an empty or malformed port in + // some forms, and a non-numeric one would make this compare host strings + // that are not hosts. + if port := candidateURL.Port(); port != "" { + for _, digit := range port { + if digit < '0' || digit > '9' { + return false + } + } + } + return candidateURL.EscapedPath() == registeredURL.EscapedPath() && + candidateURL.RawQuery == registeredURL.RawQuery +} + // ClientRegistry is the resolved set of declared clients. It is built once at // startup from operator configuration and never mutated, so lookups are // lock-free. @@ -201,3 +278,31 @@ func canonicalClientRedirectURI(candidate string) (string, error) { } return candidate, nil } + +// ChallengeMethodS256 is the only PKCE method this host accepts (RFC 7636). +// "plain" puts the verifier itself in the authorization request, which defeats +// the point, and OAuth 2.1 requires S256. It lives here rather than in business +// code because the published authorization-server metadata, the request +// validation, and the redemption check must all name the same constant. +const ChallengeMethodS256 = "S256" + +// ValidCodeChallenge reports whether a presented S256 challenge is well formed: +// base64url, unpadded, in the length band a 32-byte digest produces. Checked at +// the authorization request so a challenge no verifier could ever match is +// refused before a code is minted against it. +func ValidCodeChallenge(challenge string) bool { + if len(challenge) < 43 || len(challenge) > 128 { + return false + } + for _, character := range challenge { + switch { + case character >= 'A' && character <= 'Z', + character >= 'a' && character <= 'z', + character >= '0' && character <= '9', + character == '-', character == '_', character == '.', character == '~': + default: + return false + } + } + return true +} diff --git a/module/services/accounts/code/pkg/auth/ed25519/client_session_test.go b/module/services/accounts/code/pkg/auth/ed25519/client_session_test.go index bbc66507d..91ac86854 100644 --- a/module/services/accounts/code/pkg/auth/ed25519/client_session_test.go +++ b/module/services/accounts/code/pkg/auth/ed25519/client_session_test.go @@ -27,7 +27,7 @@ func TestMintForClientIssuesAnIndependentSessionCarryingAzp(t *testing.T) { m, store := newMinter(t) host := mintHostSession(t, m, store) - pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin") + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin", "") require.NoError(t, err) require.NotEmpty(t, pair.AccessToken) require.NotEmpty(t, pair.RefreshToken, "a client session is refreshable on its own") @@ -62,7 +62,7 @@ func TestHostSessionOmitsAzpEntirely(t *testing.T) { func TestMintForClientRefusesWithoutAClient(t *testing.T) { m, store := newMinter(t) host := mintHostSession(t, m, store) - _, err := m.MintForClient(context.Background(), host.UserID, host.ID, "") + _, err := m.MintForClient(context.Background(), host.UserID, host.ID, "", "") require.Error(t, err) } @@ -70,10 +70,10 @@ func TestMintForClientRefusesAnUnknownOrForeignSession(t *testing.T) { m, store := newMinter(t) host := mintHostSession(t, m, store) - _, err := m.MintForClient(context.Background(), host.UserID, uuid.Must(uuid.NewV7()), "example-addin") + _, err := m.MintForClient(context.Background(), host.UserID, uuid.Must(uuid.NewV7()), "example-addin", "") require.ErrorIs(t, err, auth.ErrSessionUnavailable) - _, err = m.MintForClient(context.Background(), uuid.Must(uuid.NewV7()), host.ID, "example-addin") + _, err = m.MintForClient(context.Background(), uuid.Must(uuid.NewV7()), host.ID, "example-addin", "") require.ErrorIs(t, err, auth.ErrSessionUnavailable, "a session id may not cross principals") } @@ -84,11 +84,11 @@ func TestAClientSessionCannotAuthorizeASecondClient(t *testing.T) { m, store := newMinter(t) host := mintHostSession(t, m, store) - _, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin") + _, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin", "") require.NoError(t, err) client := store.records[len(store.records)-1] - _, err = m.MintForClient(ctx, client.UserID, client.ID, "example-cli") + _, err = m.MintForClient(ctx, client.UserID, client.ID, "example-cli", "") require.ErrorIs(t, err, auth.ErrSessionUnavailable) } @@ -97,10 +97,10 @@ func TestClientRefreshRotationPreservesAzp(t *testing.T) { m, store := newMinter(t) host := mintHostSession(t, m, store) - pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin") + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin", "") require.NoError(t, err) - rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-addin") + rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-addin", "") require.NoError(t, err) require.NotEqual(t, pair.RefreshToken, rotated.RefreshToken, "the refresh half rotates") @@ -117,14 +117,14 @@ func TestClientRefreshRefusesAnotherClientsToken(t *testing.T) { m, store := newMinter(t) host := mintHostSession(t, m, store) - pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin") + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin", "") require.NoError(t, err) - _, err = m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-cli") + _, err = m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-cli", "") require.ErrorIs(t, err, auth.ErrRefreshRevoked) // The refusal must not have revoked the family it did not belong to. - rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-addin") + rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-addin", "") require.NoError(t, err) require.NotEmpty(t, rotated.AccessToken) } @@ -136,10 +136,10 @@ func TestClientRefreshRefusesTheHostsOwnWebSession(t *testing.T) { hostPair, err := m.Mint(ctx, newIdentity()) require.NoError(t, err) - _, err = m.VerifyClientRefresh(ctx, hostPair.RefreshToken, "example-addin") + _, err = m.VerifyClientRefresh(ctx, hostPair.RefreshToken, "example-addin", "") require.ErrorIs(t, err, auth.ErrRefreshRevoked) - _, err = m.VerifyClientRefresh(ctx, hostPair.RefreshToken, "") + _, err = m.VerifyClientRefresh(ctx, hostPair.RefreshToken, "", "") require.Error(t, err, "a client refresh must name a client") } @@ -160,7 +160,7 @@ func TestClientAuthorizationRefusesStaleMFAEvidenceWithoutRevokingTheHost(t *tes MFAEnrolled: true, } - _, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin") + _, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin", "") require.ErrorIs(t, err, auth.ErrSessionUnavailable) for i := range store.records { @@ -170,3 +170,109 @@ func TestClientAuthorizationRefusesStaleMFAEvidenceWithoutRevokingTheHost(t *tes } } } + +// RFC 8707: a client session minted for a resource carries that resource as a +// SECOND audience beside the host's own. +// +// Both, not one. The resource is what the gateway checks to refuse this token +// at another solution; the host audience is what every existing verifier +// validates, including this minter's own VerifyAccess, which the redemption +// path calls to build its audit row. Dropping either breaks something real. +func TestMintForClientBindsTheResourceAsASecondAudience(t *testing.T) { + ctx := context.Background() + m, store := newMinter(t) + host := mintHostSession(t, m, store) + const resource = "https://host.example.com/api/solutions/example/proxy/mcp" + + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-mcp", resource) + require.NoError(t, err) + + payload := decodeJWTPayload(t, pair.AccessToken) + require.Contains(t, payload, `"aud":["test-audience","`+resource+`"]`) + + identity, err := m.VerifyAccess(pair.AccessToken) + require.NoError(t, err, "the host audience must still verify") + require.Equal(t, resource, identity.Resource) + + // Persisted on the session row, which is what a rotation reads. + client := store.records[len(store.records)-1] + require.Equal(t, resource, client.Resource) +} + +// A session that named no resource carries only the host audience, so nothing +// about the browser's own session or the first registered client changes. +func TestASessionWithNoResourceCarriesOnlyTheHostAudience(t *testing.T) { + ctx := context.Background() + m, store := newMinter(t) + host := mintHostSession(t, m, store) + + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-addin", "") + require.NoError(t, err) + require.Contains(t, decodeJWTPayload(t, pair.AccessToken), `"aud":["test-audience"]`) + + identity, err := m.VerifyAccess(pair.AccessToken) + require.NoError(t, err) + require.Empty(t, identity.Resource) +} + +// The binding is fixed when the person consented to it. A rotation reissues the +// resource from the locked session row, never from the request — otherwise a +// client could move its own audience by asking at refresh time. +func TestARotationReissuesTheBoundResource(t *testing.T) { + ctx := context.Background() + m, store := newMinter(t) + host := mintHostSession(t, m, store) + const resource = "https://host.example.com/api/solutions/example/proxy/mcp" + + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-mcp", resource) + require.NoError(t, err) + + rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-mcp", "") + require.NoError(t, err) + require.Contains(t, decodeJWTPayload(t, rotated.AccessToken), + `"aud":["test-audience","`+resource+`"]`) + + identity, err := m.VerifyAccess(rotated.AccessToken) + require.NoError(t, err) + require.Equal(t, resource, identity.Resource) +} + +// A client naming the wrong resource on refresh is refused AND keeps its +// token. Checking the binding after the rotation would have cost it the session +// it legitimately holds, for sending a parameter RFC 8707 only lets it repeat. +func TestARefreshNamingTheWrongResourceDoesNotConsumeTheToken(t *testing.T) { + ctx := context.Background() + m, store := newMinter(t) + host := mintHostSession(t, m, store) + const resource = "https://host.example.com/api/solutions/example/proxy/mcp" + + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-mcp", resource) + require.NoError(t, err) + + _, err = m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-mcp", + "https://host.example.com/api/solutions/other/proxy/mcp") + require.ErrorIs(t, err, auth.ErrRefreshResourceMismatch) + + // The token survived: the rotation never happened. + rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-mcp", resource) + require.NoError(t, err) + require.NotEmpty(t, rotated.AccessToken) +} + +// Repeating the resource it was granted is accepted, which is what a standard +// OAuth client actually does. +func TestARefreshRepeatingItsOwnResourceIsAccepted(t *testing.T) { + ctx := context.Background() + m, store := newMinter(t) + host := mintHostSession(t, m, store) + const resource = "https://host.example.com/api/solutions/example/proxy/mcp" + + pair, err := m.MintForClient(ctx, host.UserID, host.ID, "example-mcp", resource) + require.NoError(t, err) + + rotated, err := m.VerifyClientRefresh(ctx, pair.RefreshToken, "example-mcp", resource) + require.NoError(t, err) + identity, err := m.VerifyAccess(rotated.AccessToken) + require.NoError(t, err) + require.Equal(t, resource, identity.Resource) +} diff --git a/module/services/accounts/code/pkg/auth/ed25519/minter.go b/module/services/accounts/code/pkg/auth/ed25519/minter.go index 5bcbd8498..eb46cfec7 100644 --- a/module/services/accounts/code/pkg/auth/ed25519/minter.go +++ b/module/services/accounts/code/pkg/auth/ed25519/minter.go @@ -51,8 +51,31 @@ import ( // Config controls Minter behaviour. Zero values are safe defaults. type Config struct { - // Issuer is set as `iss` on every access token. + // Issuer is set as `iss` on every access token. A deployment that publishes + // RFC 8414 metadata sets it to the same https URL the metadata names, which + // is what lets a client that discovered the metadata verify the token. Issuer string + // RegistrationIssuer is the `iss` on the module- and solution-registration + // credentials, which is deliberately NOT Issuer. + // + // Those are internal cluster credentials with their own audiences and their + // own verifiers — in the gateway and in the frontend, each pinned to a + // literal. Access tokens had to move to an https URL because RFC 8414 + // requires the published OAuth issuer to be one; registration credentials + // are not OAuth and publish nothing, so moving them with the access token + // bought nothing and broke every verifier that had not moved in the same + // release. A solution's gateway half would register while its frontend half + // was refused, and a registration needs both. + // + // Keeping it fixed means there is no migration: the verifiers are correct as + // they stand, including for credentials minted before and after the change. + RegistrationIssuer string + // AdditionalAcceptedIssuers are `iss` values VerifyAccess accepts besides + // Issuer. It exists for exactly one migration: a deployment moving from the + // pre-metadata literal issuer to its https URL keeps accepting the literal + // while tokens minted under it are still in flight, so the change does not + // sign out every live session at deploy. + AdditionalAcceptedIssuers []string // Audience is set as `aud` on every access token. Audience string // AccessTokenTTL is the lifetime of an access token. Default 3 min — kept @@ -92,6 +115,9 @@ func (c *Config) withDefaults() error { if c.Audience == "" { c.Audience = "saas-starter" } + if c.RegistrationIssuer == "" { + c.RegistrationIssuer = "saas-starter" + } if c.AccessTokenTTL == 0 { c.AccessTokenTTL = 3 * time.Minute } @@ -305,7 +331,8 @@ func (m *Minter) MintModuleRegistration(prefix string) (string, time.Time, error } claims := moduleRegistrationClaims{ RegisteredClaims: jwt.RegisteredClaims{ - Issuer: m.cfg.Issuer, + // RegistrationIssuer, not Issuer: see the Config field's comment. + Issuer: m.cfg.RegistrationIssuer, Subject: "module:" + prefix, Audience: jwt.ClaimStrings{ModuleRegistrationAudience}, IssuedAt: jwt.NewNumericDate(now), @@ -427,6 +454,7 @@ func (m *Minter) prepareMint(identity *auth.Identity, familyID uuid.UUID) (*auth IPAddress: identity.IPAddress, FamilyID: familyID, ClientID: identity.ClientID, + Resource: identity.Resource, ActingAsUserID: identity.ActingAsUserID, RefreshHash: hash, IssuedAt: now, @@ -454,23 +482,30 @@ func (m *Minter) prepareMint(identity *auth.Identity, familyID uuid.UUID) (*auth // unknown tokens return the same ErrRefreshRevoked sentinel to avoid an // existence oracle. func (m *Minter) VerifyRefresh(ctx context.Context, refreshToken string) (*auth.TokenPair, error) { - return m.rotateRefresh(ctx, refreshToken, "") + return m.rotateRefresh(ctx, refreshToken, "", "") } -// VerifyClientRefresh implements auth.JWTMinter.VerifyClientRefresh. The client -// check happens inside the locked rotation, on the stored row, so it cannot be -// raced by a concurrent rotation that changes which session the hash resolves -// to. A mismatch is not a terminal rejection: the token is a legitimate one -// held by its own client, and revoking its family because a different client -// named it would make one client able to sign another out. -func (m *Minter) VerifyClientRefresh(ctx context.Context, refreshToken, clientID string) (*auth.TokenPair, error) { +// VerifyClientRefresh implements auth.JWTMinter.VerifyClientRefresh. Both +// checks happen inside the locked rotation, on the stored row, so neither can +// be raced by a concurrent rotation that changes which session the hash +// resolves to. Neither is a terminal rejection: the token is a legitimate one +// held by its own client, and revoking its family because a different client — +// or the same client naming a different resource — presented it would make one +// request able to sign a session out. +func (m *Minter) VerifyClientRefresh( + ctx context.Context, + refreshToken, clientID, requiredResource string, +) (*auth.TokenPair, error) { if clientID == "" { return nil, errors.New("ed25519minter: client refresh names no client") } - return m.rotateRefresh(ctx, refreshToken, clientID) + return m.rotateRefresh(ctx, refreshToken, clientID, requiredResource) } -func (m *Minter) rotateRefresh(ctx context.Context, refreshToken, requiredClientID string) (*auth.TokenPair, error) { +func (m *Minter) rotateRefresh( + ctx context.Context, + refreshToken, requiredClientID, requiredResource string, +) (*auth.TokenPair, error) { if m.configErr != nil { return nil, fmt.Errorf("ed25519minter: invalid session policy: %w", m.configErr) } @@ -488,6 +523,13 @@ func (m *Minter) rotateRefresh(ctx context.Context, refreshToken, requiredClient if requiredClientID != "" && rec.ClientID != requiredClientID { return nil, auth.ErrRefreshRevoked } + // RFC 8707 §2.2 lets a token request repeat `resource`, and it must be + // one the authorization granted. Checked here, on the locked row and + // before consumption, so naming the wrong one costs the client nothing + // but the refusal — it still holds the token it was legitimately issued. + if requiredResource != "" && rec.Resource != requiredResource { + return nil, auth.ErrRefreshResourceMismatch + } now := m.now() if !now.Before(rec.ExpiresAt) { return nil, auth.RejectRefresh(auth.RefreshRejectionAbsoluteLifetime) @@ -517,7 +559,8 @@ func (m *Minter) rotateRefresh(ctx context.Context, refreshToken, requiredClient return next, err }) if err != nil { - if errors.Is(err, auth.ErrRefreshRevoked) || errors.Is(err, auth.ErrRefreshReuse) { + if errors.Is(err, auth.ErrRefreshRevoked) || errors.Is(err, auth.ErrRefreshReuse) || + errors.Is(err, auth.ErrRefreshResourceMismatch) { return nil, err } return nil, fmt.Errorf("ed25519minter: rotate refresh: %w", err) @@ -614,6 +657,7 @@ func identityFromCurrentAuthorization( ScopedRolesTruncated: authorization.ScopedRolesTruncated, SessionID: sessionID, ClientID: rec.ClientID, + Resource: rec.Resource, Email: rec.Email, DisplayName: rec.DisplayName, MFASatisfied: mfaSatisfied, @@ -652,9 +696,13 @@ func (m *Minter) Revoke(ctx context.Context, refreshToken string) error { // // alg is locked to EdDSA. iss/aud/exp/nbf are validated. Clock skew tolerated. func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { + // The issuer is checked below rather than by the parser: jwt.WithIssuer takes + // one value, and a deployment mid-migration must accept two — what it mints + // now and what it minted before. Dropping the parser option without + // replacing the check would accept ANY issuer, so the explicit comparison + // is the whole of it and runs before any claim is read. parser := jwt.NewParser( jwt.WithValidMethods([]string{"EdDSA"}), - jwt.WithIssuer(m.cfg.Issuer), jwt.WithAudience(m.cfg.Audience), jwt.WithExpirationRequired(), jwt.WithLeeway(m.cfg.ClockSkew), @@ -694,6 +742,9 @@ func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { if !token.Valid { return nil, auth.ErrTokenMalformed } + if !m.acceptsIssuer(claims.Issuer) { + return nil, auth.ErrTokenWrongIssuer + } userID, err := uuid.Parse(claims.Subject) if err != nil { @@ -757,6 +808,14 @@ func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { } } + // Refused rather than projected: a token whose audience set names no single + // valid resource has no scope this host can state, and every caller + // downstream reads Identity.Resource as that scope. + resource, err := resourceAudienceOf(claims.Audience, m.cfg.Audience) + if err != nil { + return nil, err + } + return &auth.Identity{ UserID: userID, OrgID: orgID, @@ -766,6 +825,7 @@ func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { ScopedRolesTruncated: claims.ScopedRolesTruncated, SessionID: sessionID, ClientID: claims.AuthorizedParty, + Resource: resource, Email: claims.Email, DisplayName: claims.Name, ActingAsUserID: actingAs, @@ -778,6 +838,20 @@ func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { }, nil } +// acceptsIssuer reports whether an `iss` is one this minter trusts: the one it +// signs with, or one explicitly declared for a migration window. An empty +// candidate is never accepted, so a token carrying no issuer at all is refused +// rather than matching an unset slot. +func (m *Minter) acceptsIssuer(candidate string) bool { + if candidate == "" { + return false + } + if candidate == m.cfg.Issuer { + return true + } + return slices.Contains(m.cfg.AdditionalAcceptedIssuers, candidate) +} + // RevokeAccess parses the given access token (signature + claim // validation included) and adds its jti to the revocation list with // TTL = remaining lifetime. Returns nil for tokens that are already @@ -860,11 +934,25 @@ func (m *Minter) signAccess( return "", time.Time{}, err } expiresAt := now.Add(m.accessTTL(identity)) + // The host's own audience is always present, and a resource-bound token + // carries the RFC 8707 indicator beside it. Both, not one: the token is + // genuinely for two audiences — the named resource, and the host's own API + // that the resource resolves the caller's authority against — and dropping + // the host audience would make a verifier that validates it (the gateway's + // parser, this minter's own VerifyAccess) refuse a token it minted. + // + // What makes the indicator load-bearing is the gateway's check: a request + // to a solution's surface whose token carries a resource must carry THIS + // solution's resource. So the extra audience narrows rather than widens. + audience := jwt.ClaimStrings{m.cfg.Audience} + if identity.Resource != "" { + audience = append(audience, identity.Resource) + } claims := accessClaims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: m.cfg.Issuer, Subject: identity.UserID.String(), - Audience: jwt.ClaimStrings{m.cfg.Audience}, + Audience: audience, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)), ExpiresAt: jwt.NewNumericDate(expiresAt), @@ -910,6 +998,41 @@ func (m *Minter) signAccess( return signed, expiresAt, nil } +// resourceAudienceOf projects the resource a verified token is bound to, and +// refuses an audience set that names no single valid one. +// +// Three outcomes, not two. An empty resource means "bound to nothing" — an +// ordinary session credential — so it cannot also stand for "I could not read +// this", which would make an unreadable binding indistinguishable from the +// classification that is accepted most widely. Two resource audiences, or one +// that is not a resource identifier this host issues, are refused here. +// +// The shape question is answered by auth.ParseResourceIndicator, the same +// function the authorization endpoint uses when it ISSUES one, so the verifier +// cannot drift looser than the issuer. The gateway holds the identical rule on +// its own side of the module boundary (classifyResourceAudience), which it must +// spell separately because nothing may be imported across that boundary. +func resourceAudienceOf(audience jwt.ClaimStrings, hostAudience string) (string, error) { + var resources []string + for _, value := range audience { + if value == hostAudience { + continue + } + resources = append(resources, value) + } + switch len(resources) { + case 0: + return "", nil + case 1: + if _, err := auth.ParseResourceIndicator(resources[0]); err != nil { + return "", auth.ErrInvalidResourceAudience + } + return resources[0], nil + default: + return "", auth.ErrInvalidResourceAudience + } +} + func numericDateTime(value *jwt.NumericDate) time.Time { if value == nil { return time.Time{} @@ -983,7 +1106,7 @@ func (m *Minter) MintSolutionRegistration(solutionID string) (string, time.Time, } claims := solutionRegistrationClaims{ RegisteredClaims: jwt.RegisteredClaims{ - Issuer: m.cfg.Issuer, + Issuer: m.cfg.RegistrationIssuer, Subject: "solution:" + solutionID, Audience: jwt.ClaimStrings{SolutionRegistrationAudience}, IssuedAt: jwt.NewNumericDate(now), @@ -1009,7 +1132,7 @@ func (m *Minter) MintSolutionRegistration(solutionID string) (string, time.Time, func (m *Minter) MintForClient( ctx context.Context, userID, authorizingSessionID uuid.UUID, - clientID string, + clientID, resource string, ) (*auth.TokenPair, error) { if m.configErr != nil { return nil, fmt.Errorf("ed25519minter: invalid session policy: %w", m.configErr) @@ -1027,6 +1150,7 @@ func (m *Minter) MintForClient( return nil, err } identity.ClientID = clientID + identity.Resource = resource // The browser's device description belongs to the browser. A client runs // somewhere else entirely, so carrying it over would label the new // session with a device that is not the one holding its token. diff --git a/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go b/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go new file mode 100644 index 000000000..441785601 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go @@ -0,0 +1,67 @@ +package ed25519minter_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + ed25519minter "accounts/pkg/auth/ed25519" + + "github.com/stretchr/testify/require" +) + +// A1007B-02. The module- and solution-registration credentials keep their own +// `iss`, independent of the access-token issuer. +// +// They are internal cluster credentials with their own audiences and their own +// verifiers — one in the gateway, one in the frontend — each pinned to the +// literal `saas-starter`. Access tokens had to move to an https URL because +// RFC 8414 requires the published OAuth issuer to be one; registration +// credentials publish nothing, so moving them with the access token bought +// nothing and refused every verifier that had not moved in the same release. +// +// The failure that caused was asymmetric and therefore quiet: the gateway +// accepted the new token, the frontend did not, so a solution's backend half +// registered while its frontend half was refused — and an active registration +// needs both, so the solution simply never became routable. +func TestRegistrationCredentialsKeepTheLiteralIssuer(t *testing.T) { + // A deployment that HAS moved its access-token issuer to its public URL. + const publicIssuer = "https://host.example.com" + _, priv, err := ed25519minter.GenerateKey() + require.NoError(t, err) + store := &memoryStore{} + m := ed25519minter.New(ed25519minter.Config{ + Issuer: publicIssuer, + Audience: "saas-starter", + }, priv, store) + + solutionToken, _, err := m.MintSolutionRegistration("example") + require.NoError(t, err) + require.Contains(t, decodeJWTPayload(t, solutionToken), `"iss":"saas-starter"`) + require.NotContains(t, decodeJWTPayload(t, solutionToken), publicIssuer) + + moduleToken, _, err := m.MintModuleRegistration("example") + require.NoError(t, err) + require.Contains(t, decodeJWTPayload(t, moduleToken), `"iss":"saas-starter"`) + require.NotContains(t, decodeJWTPayload(t, moduleToken), publicIssuer) + + // And the access token in the same deployment DOES carry the URL, so this + // is a separation rather than a refusal to move anything. + pair, err := m.Mint(context.Background(), newIdentity()) + require.NoError(t, err) + require.Contains(t, decodeJWTPayload(t, pair.AccessToken), + `"iss":"`+publicIssuer+`"`) +} + +// The verifier on the other side of that contract. The frontend is TypeScript +// and this is Go, so nothing but a check like this keeps the two literals equal +// — and their disagreement is precisely what broke. +func TestTheFrontendRegistrationVerifierExpectsTheSameIssuer(t *testing.T) { + path := filepath.Join("..", "..", "..", "..", "..", "frontend", "code", "src", + "solutions", "registration-authority.ts") + source, err := os.ReadFile(path) + require.NoError(t, err, "the frontend registration verifier must exist at %s", path) + require.Contains(t, string(source), `const ISSUER = "saas-starter"`, + "the frontend verifier's issuer must match what MintSolutionRegistration signs") +} diff --git a/module/services/accounts/code/pkg/auth/ed25519/resource_audience_test.go b/module/services/accounts/code/pkg/auth/ed25519/resource_audience_test.go new file mode 100644 index 000000000..758655866 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/ed25519/resource_audience_test.go @@ -0,0 +1,106 @@ +package ed25519minter_test + +import ( + "crypto/ed25519" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" + ed25519minter "accounts/pkg/auth/ed25519" +) + +// A verified token's audience set is read as exactly one of three things: +// bound to nothing, bound to one valid resource, or unreadable. The third is +// REFUSED rather than projected as the first. +// +// Identity.Resource is the scope every caller downstream reads, and an empty +// value there means "no resource binding" — the classification admitted most +// widely. So an audience set this cannot reduce to one valid resource must not +// produce an empty one; it must produce no identity at all. +// +// The gateway holds the identical rule on its own side of the module boundary, +// which it spells separately because nothing crosses that boundary +// (TestTheAudienceClassificationHasThreeOutcomes). +func TestAnUnreadableResourceAudienceIsRefusedNotProjected(t *testing.T) { + const host = "test-audience" + const good = "https://h.example.com/api/solutions/example/proxy/mcp" + + for name, audience := range map[string]jwt.ClaimStrings{ + "two resources": {host, good, "https://h.example.com/api/solutions/other/proxy/mcp"}, + "carries query": {host, good + "?x=1"}, + "carries anchor": {host, good + "#f"}, + "carries userinfo": {host, + "https://user@h.example.com/api/solutions/example/proxy/mcp"}, + "not a resource this host issues": {host, "https://h.example.com/elsewhere"}, + "not absolute": {host, "/api/solutions/example/proxy/mcp"}, + } { + t.Run(name, func(t *testing.T) { + m, priv := minterWithKey(t) + token := signWithAudience(t, priv, audience) + + identity, err := m.VerifyAccess(token) + + require.ErrorIs(t, err, auth.ErrInvalidResourceAudience) + require.Nil(t, identity, "no identity may be returned for a scope that cannot be stated") + }) + } +} + +// The two readable shapes still verify, so the refusal above is about the +// audience set and not about resource binding in general. +func TestAReadableResourceAudienceStillVerifies(t *testing.T) { + const host = "test-audience" + const good = "https://h.example.com/api/solutions/example/proxy/mcp" + + for name, expect := range map[string]struct { + audience jwt.ClaimStrings + resource string + }{ + "bound to nothing": {jwt.ClaimStrings{host}, ""}, + "bound to one resource": {jwt.ClaimStrings{host, good}, good}, + "loopback origin is fine": {jwt.ClaimStrings{host, "http://localhost:3000/api/solutions/example/proxy/mcp"}, "http://localhost:3000/api/solutions/example/proxy/mcp"}, + } { + t.Run(name, func(t *testing.T) { + m, priv := minterWithKey(t) + identity, err := m.VerifyAccess(signWithAudience(t, priv, expect.audience)) + require.NoError(t, err) + require.Equal(t, expect.resource, identity.Resource) + }) + } +} + +// minterWithKey builds a minter and hands back the key to sign with, so a test +// can present a token shape the minter itself would never produce. +func minterWithKey(t *testing.T) (*ed25519minter.Minter, ed25519.PrivateKey) { + t.Helper() + _, priv, err := ed25519minter.GenerateKey() + require.NoError(t, err) + m := ed25519minter.New(ed25519minter.Config{ + Issuer: "test-issuer", + Audience: "test-audience", + }, priv, &memoryStore{}) + return m, priv +} + +func signWithAudience(t *testing.T, priv ed25519.PrivateKey, audience jwt.ClaimStrings) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": "test-issuer", + "aud": []string(audience), + "sub": uuid.NewString(), + "org": uuid.NewString(), + "sid": uuid.NewString(), + "iat": time.Now().Unix(), + "nbf": time.Now().Add(-time.Second).Unix(), + "exp": time.Now().Add(15 * time.Minute).Unix(), + "jti": "jti", + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(priv) + require.NoError(t, err) + return signed +} diff --git a/module/services/accounts/code/pkg/auth/errors.go b/module/services/accounts/code/pkg/auth/errors.go index 2ae789d8f..3455cd881 100644 --- a/module/services/accounts/code/pkg/auth/errors.go +++ b/module/services/accounts/code/pkg/auth/errors.go @@ -65,7 +65,13 @@ var ( ErrSsoProvisioningMisconfigured = errors.New("auth: organization sso jit provisioning has no allowed email domains configured") // Refresh rotation - ErrRefreshRevoked = errors.New("auth: refresh token revoked") + ErrRefreshRevoked = errors.New("auth: refresh token revoked") + // ErrRefreshResourceMismatch is a token request repeating an RFC 8707 + // `resource` the presented refresh token is not bound to. Distinct from + // ErrRefreshRevoked because the token is fine: the request disagrees with + // it, the rotation never happened, and the client still holds a usable + // credential. A caller surfaces it as `invalid_target`, not `invalid_grant`. + ErrRefreshResourceMismatch = errors.New("auth: refresh token is bound to a different resource") ErrRefreshReuse = errors.New("auth: refresh token reuse detected") ErrSessionUnavailable = errors.New("auth: device session unavailable") ErrOrganizationAccessDenied = errors.New("auth: organization membership required") diff --git a/module/services/accounts/code/pkg/auth/identity.go b/module/services/accounts/code/pkg/auth/identity.go index 97a4c2e9d..6cd00891f 100644 --- a/module/services/accounts/code/pkg/auth/identity.go +++ b/module/services/accounts/code/pkg/auth/identity.go @@ -94,6 +94,15 @@ type Identity struct { // authorization still resolves from the subject and their roles. ClientID string + // Resource is the RFC 8707 resource indicator this session's tokens are + // bound to — a solution's MCP endpoint at this host. It becomes a second + // `aud` value beside the host's own audience, so a token minted for one + // resource is refused at another while still reaching the host's own API + // (which is what the resource delegates its authorization to). Empty for + // every session that named no resource, which is every session the host + // minted before resource indicators existed. + Resource string + // Email and DisplayName are purely presentational identity, carried into // the access token's `email`/`name` claims so a client can render the // signed-in person by name or address instead of the raw user id. Neither diff --git a/module/services/accounts/code/pkg/auth/minter.go b/module/services/accounts/code/pkg/auth/minter.go index 69c2760e1..74ccad718 100644 --- a/module/services/accounts/code/pkg/auth/minter.go +++ b/module/services/accounts/code/pkg/auth/minter.go @@ -65,17 +65,25 @@ type JWTMinter interface { // ErrRefreshRevoked without being misclassified as an attacker replay. VerifyRefresh(ctx context.Context, refreshToken string) (*TokenPair, error) - // VerifyClientRefresh rotates a refresh token that must belong to clientID. - // The owning client is read from the locked session row rather than from the - // caller, so one client presenting another's token is refused exactly as a - // token that never existed is. - VerifyClientRefresh(ctx context.Context, refreshToken, clientID string) (*TokenPair, error) + // VerifyClientRefresh rotates a refresh token that must belong to clientID + // and, when requiredResource is non-empty, to that resource. Both are read + // from the locked session row rather than from the caller, so one client + // presenting another's token is refused exactly as a token that never + // existed is — and a mismatch aborts BEFORE the token is consumed, so a + // client that names the wrong resource is told so without losing the session + // it legitimately holds. + VerifyClientRefresh(ctx context.Context, refreshToken, clientID, requiredResource string) (*TokenPair, error) // MintForClient issues the first token pair for a registered client, from // the host session that authorized it. The client's session is independent: // its own family, its own refresh token, and an `azp` claim naming clientID // so audit and downstream services read "this person, via this client". - MintForClient(ctx context.Context, userID, authorizingSessionID uuid.UUID, clientID string) (*TokenPair, error) + // + // resource is the RFC 8707 indicator the person consented to, or empty. It + // is persisted on the session and reissued on every rotation, so the + // audience a client holds is the one the authorization bound, not one the + // client can ask to change at refresh time. + MintForClient(ctx context.Context, userID, authorizingSessionID uuid.UUID, clientID, resource string) (*TokenPair, error) // SwitchOrganization issues a fresh access token for a current membership // while preserving the refresh token, session row, device family, and both diff --git a/module/services/accounts/code/pkg/auth/oauth_state.go b/module/services/accounts/code/pkg/auth/oauth_state.go index 87586961c..0031f76c2 100644 --- a/module/services/accounts/code/pkg/auth/oauth_state.go +++ b/module/services/accounts/code/pkg/auth/oauth_state.go @@ -190,11 +190,21 @@ func (s *OAuthStateSigner) Verify(ctx context.Context, state, provider, redirect } firstUse, err := s.consumer.Consume(ctx, claims.Nonce, remaining) if err != nil { - // Fail open on a store outage: the IdP's own single-use authorization - // code is the authoritative anti-replay, so a Redis blip must not break - // every OAuth login. Observable so an outage isn't silent. - log.Printf("oauth-state: nonce consume failed, admitting (fail-open): %v", err) - return nil + // REFUSE when single use cannot be recorded (handbook SP-IDENT-04). + // + // This check is what makes a state single-use. If the store cannot + // record the consumption, the question "has this state been used + // before?" has no answer — and admitting on no answer means that for + // the duration of the outage the single-use property simply does not + // hold, which is indistinguishable from not having the check. + // + // Not delegated to the provider's own code single-use either. That is a + // second anti-replay living in another system, and "someone else + // probably also checks" is not a property this host can state about + // itself. A sign-in that cannot be made single-use is refused, and the + // outage is the operator's to fix. + log.Printf("oauth-state: nonce consume failed, refusing: %v", err) + return ErrOAuthStateNotVerifiable } if !firstUse { return ErrInvalidOAuthState @@ -202,6 +212,15 @@ func (s *OAuthStateSigner) Verify(ctx context.Context, state, provider, redirect return nil } +// ErrOAuthStateNotVerifiable is returned when the single-use record could not +// be written, so the state could be neither accepted nor shown to be a replay. +// +// Separate from ErrInvalidOAuthState because the two are different facts and an +// operator needs to tell them apart: one is a bad or replayed state, the other +// is this host's own dependency failing. The caller maps both to the canonical +// sentinel before answering, so the distinction never reaches the client. +var ErrOAuthStateNotVerifiable = errors.New("oauth state single-use cannot be recorded") + // ErrInvalidOAuthState is the single error returned for any state // validation failure (signature, expiry, provider mismatch, redirect // mismatch, malformed). Callers MUST NOT branch on the cause — that diff --git a/module/services/accounts/code/pkg/auth/oauth_state_test.go b/module/services/accounts/code/pkg/auth/oauth_state_test.go index 648759ad4..21e3617f3 100644 --- a/module/services/accounts/code/pkg/auth/oauth_state_test.go +++ b/module/services/accounts/code/pkg/auth/oauth_state_test.go @@ -119,14 +119,40 @@ func (boomConsumer) Consume(context.Context, string, time.Duration) (bool, error return false, errors.New("redis down") } -func TestOAuthStateSigner_FailsOpenWhenConsumerErrors(t *testing.T) { +// A sign-in whose single use cannot be recorded is REFUSED (handbook +// SP-IDENT-04). This replaces a test that asserted the opposite. +// +// The state here is otherwise entirely valid — correct signature, unexpired, +// right provider and redirect — so the only reason to refuse it is that the +// consumption could not be written. While the store is unavailable the question +// "has this been used before?" has no answer, and admitting on no answer means +// the single-use property does not hold for the duration of the outage. +func TestAStateWhoseSingleUseCannotBeRecordedIsRefused(t *testing.T) { s := newSigner(t, "seed") s.SetNonceConsumer(boomConsumer{}) state, _ := s.Mint("workos", "https://x.example.com/cb") - // The IdP's own single-use code is the authoritative anti-replay, so a - // consumer-store outage must admit rather than break every OAuth login. + + err := s.Verify(context.Background(), state, "workos", "https://x.example.com/cb") + + if !errors.Is(err, auth.ErrOAuthStateNotVerifiable) { + t.Errorf("Verify = %v, want ErrOAuthStateNotVerifiable", err) + } + // A distinct error from a bad state, because an operator has to tell their + // own dependency failing from someone presenting a replayed state. The + // caller maps both to one sentinel before answering, so the client sees no + // difference. + if errors.Is(err, auth.ErrInvalidOAuthState) { + t.Error("an outage must not be reported as an invalid state") + } +} + +// And the same signer admits that state once the store answers again: the +// refusal above is about the outage, not about the state. +func TestAValidStateIsAdmittedOnceTheStoreAnswers(t *testing.T) { + s := newSigner(t, "seed") + state, _ := s.Mint("workos", "https://x.example.com/cb") if err := s.Verify(context.Background(), state, "workos", "https://x.example.com/cb"); err != nil { - t.Errorf("Verify should fail open on consumer error, got %v", err) + t.Errorf("Verify = %v, want the state admitted", err) } } diff --git a/module/services/accounts/code/pkg/auth/pg/client_session_store.go b/module/services/accounts/code/pkg/auth/pg/client_session_store.go index f5d77e23a..99713ff15 100644 --- a/module/services/accounts/code/pkg/auth/pg/client_session_store.go +++ b/module/services/accounts/code/pkg/auth/pg/client_session_store.go @@ -53,7 +53,7 @@ func (s *SessionStore) AuthorizeClientSession( device_info, ip_address, org_id, org_role, platform_role, mfa_satisfied, authentication_methods, auth_time, assurance_level, mfa_verified_at, - email, display_name, acting_as_user_id, client_id + email, display_name, acting_as_user_id, client_id, resource FROM sessions WHERE id = $1 AND user_id = $2 LIMIT 1 diff --git a/module/services/accounts/code/pkg/auth/pg/refresh_replacement_test.go b/module/services/accounts/code/pkg/auth/pg/refresh_replacement_test.go new file mode 100644 index 000000000..b14e60473 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/pg/refresh_replacement_test.go @@ -0,0 +1,90 @@ +package pgauth + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" +) + +// r1/f5. A rotation reissues the audience the authorization bound, so the +// replacement's resource must equal the locked row's. +// +// The projection that builds the replacement carries the field today, which is +// why this is a guard rather than a bug fix: it is what stops a later edit to +// that projection from silently unbinding a narrowed credential. An unbound +// credential is admitted wherever a resource-bound one is refused, so losing the +// binding widens the token rather than breaking it — the failure mode that does +// not announce itself. +// +// This is the first test in this package; validateRefreshReplacement is a pure +// function over two records and needs no database. +func TestARotationMustPreserveTheResourceBinding(t *testing.T) { + const resource = "https://app.example.com/api/solutions/example/proxy/mcp" + current, next, authorization := rotatablePair(resource) + + // The baseline pair is accepted, so every assertion below is about the one + // field it changes rather than about an unrelated check firing first. + require.NoError(t, validateRefreshReplacement(current, next, authorization)) + + for name, mutate := range map[string]func(*auth.SessionRecord){ + "unbound": func(r *auth.SessionRecord) { r.Resource = "" }, + "other solution": func(r *auth.SessionRecord) { r.Resource = "https://app.example.com/api/solutions/other/proxy/mcp" }, + } { + t.Run(name, func(t *testing.T) { + // A COPY of the accepted replacement, so the only difference from + // the pair that passed above is the field this case changes. + changed := *next + mutate(&changed) + err := validateRefreshReplacement(current, &changed, authorization) + require.Error(t, err) + require.Contains(t, err.Error(), "changed resource") + }) + } + + // And a session that never named a resource rotates normally: the rule is + // equality, not presence. + unboundCurrent, unboundNext, unboundAuth := rotatablePair("") + require.NoError(t, validateRefreshReplacement(unboundCurrent, unboundNext, unboundAuth)) +} + +// rotatablePair is a current/next pair that validateRefreshReplacement accepts, +// so a test can change exactly one field and know what refused it. +func rotatablePair(resource string) (*auth.SessionRecord, *auth.SessionRecord, auth.RefreshAuthorization) { + user := uuid.New() + family := uuid.New() + org := uuid.New() + issued := time.Now().Add(-time.Hour) + expires := issued.Add(24 * time.Hour) + active := time.Now() + + authorization := auth.RefreshAuthorization{ + OrgID: org, + OrgRole: "member", + PlatformRole: "", + } + base := func(id uuid.UUID, hash []byte) *auth.SessionRecord { + return &auth.SessionRecord{ + ID: id, + UserID: user, + FamilyID: family, + OrgID: org, + OrgRole: "member", + RefreshHash: hash, + Resource: resource, + ClientID: "example-cli", + IssuedAt: issued, + ExpiresAt: expires, + LastActiveAt: active, + IdleExpiresAt: active.Add(time.Hour), + DeviceInfo: map[string]string{"ua": "test"}, + IPAddress: "203.0.113.1", + } + } + return base(uuid.New(), []byte("current-hash")), + base(uuid.New(), []byte("next-hash")), + authorization +} diff --git a/module/services/accounts/code/pkg/auth/pg/session_store.go b/module/services/accounts/code/pkg/auth/pg/session_store.go index 1c1ab5be8..9e018c757 100644 --- a/module/services/accounts/code/pkg/auth/pg/session_store.go +++ b/module/services/accounts/code/pkg/auth/pg/session_store.go @@ -310,16 +310,17 @@ func insertSession(ctx context.Context, tx pgx.Tx, rec *auth.SessionRecord) erro created_at, last_active_at, idle_expires_at, expires_at, org_id, org_role, platform_role, mfa_satisfied, authentication_methods, auth_time, assurance_level, mfa_verified_at, - email, display_name, acting_as_user_id, client_id + email, display_name, acting_as_user_id, client_id, resource ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - $14, $15, $16, $17, $18, $19, $20, $21, $22 + $14, $15, $16, $17, $18, $19, $20, $21, $22, $23 )`, rec.ID, rec.UserID, hashArg, rec.FamilyID, deviceInfo, nilIfEmpty(rec.IPAddress), rec.IssuedAt, rec.LastActiveAt, rec.IdleExpiresAt, rec.ExpiresAt, orgIDArg, rec.OrgRole, rec.PlatformRole, rec.MFASatisfied, authenticationMethods, authenticatedAtArg, rec.AssuranceLevel, mfaVerifiedAtArg, nilIfEmpty(rec.Email), nilIfEmpty(rec.DisplayName), actingAsArg, nilIfEmpty(rec.ClientID), + nilIfEmpty(rec.Resource), ) return err } @@ -348,7 +349,7 @@ func (s *SessionStore) FindByRefreshHash(ctx context.Context, hash []byte) (*aut device_info, ip_address, org_id, org_role, platform_role, mfa_satisfied, authentication_methods, auth_time, assurance_level, mfa_verified_at, - email, display_name, acting_as_user_id, client_id + email, display_name, acting_as_user_id, client_id, resource FROM sessions WHERE refresh_token_hash = $1 LIMIT 1`, hashHex), hash) @@ -381,13 +382,14 @@ func scanSession(row rowScanner, hash []byte) (*auth.SessionRecord, error) { var displayName *string var actingAsUserID *uuid.UUID var clientID *string + var resource *string if err := row.Scan( &rec.ID, &rec.UserID, &rec.FamilyID, &rec.IssuedAt, &rec.LastActiveAt, &rec.IdleExpiresAt, &rec.ExpiresAt, &revokedAt, &revokedReason, &deviceInfo, &ipAddress, &orgID, &rec.OrgRole, &rec.PlatformRole, &rec.MFASatisfied, &rec.AuthenticationMethods, &authenticatedAt, &rec.AssuranceLevel, &mfaVerifiedAt, - &email, &displayName, &actingAsUserID, &clientID, + &email, &displayName, &actingAsUserID, &clientID, &resource, ); err != nil { return nil, err } @@ -425,6 +427,9 @@ func scanSession(row rowScanner, hash []byte) (*auth.SessionRecord, error) { if clientID != nil { rec.ClientID = *clientID } + if resource != nil { + rec.Resource = *resource + } return &rec, nil } @@ -457,7 +462,7 @@ func (s *SessionStore) RotateRefresh( device_info, ip_address, org_id, org_role, platform_role, mfa_satisfied, authentication_methods, auth_time, assurance_level, mfa_verified_at, - email, display_name, acting_as_user_id, client_id + email, display_name, acting_as_user_id, client_id, resource FROM sessions WHERE refresh_token_hash = $1 LIMIT 1 @@ -570,7 +575,7 @@ func (s *SessionStore) ExchangeOrganization( device_info, ip_address, org_id, org_role, platform_role, mfa_satisfied, authentication_methods, auth_time, assurance_level, mfa_verified_at, - email, display_name, acting_as_user_id, client_id + email, display_name, acting_as_user_id, client_id, resource FROM sessions WHERE id = $1 AND user_id = $2 LIMIT 1 @@ -801,6 +806,20 @@ func validateRefreshReplacement( if next.ClientID != current.ClientID { return errors.New("pgauth: refresh replacement changed client") } + // The audience a rotation reissues is the one the authorization bound, read + // from the locked row — never from the refresh request, which a client + // controls. Checked here because this is the authority every rotation passes + // through: the projection that builds `next` carries the field today, and + // this is what keeps a later edit to it from silently unbinding a narrowed + // credential, which would then be admitted wherever an unbound one is. + // + // The other two paths that insert a session need no such check: a new + // browser session names no resource, and a client session's resource is SET + // from the authorization code rather than inherited from the session that + // authorized it. + if next.Resource != current.Resource { + return errors.New("pgauth: refresh replacement changed resource") + } if next.OrgID != authorization.OrgID || next.OrgRole != authorization.OrgRole || next.PlatformRole != authorization.PlatformRole { diff --git a/module/services/accounts/code/pkg/auth/public_address.go b/module/services/accounts/code/pkg/auth/public_address.go new file mode 100644 index 000000000..f2cf26eac --- /dev/null +++ b/module/services/accounts/code/pkg/auth/public_address.go @@ -0,0 +1,151 @@ +package auth + +import ( + "net" + "net/netip" +) + +// Whether an address is on the public internet. +// +// The invariant: a destination this host may be asked to fetch from reaches +// only the public internet. This classifier is the single authority for that +// question, and it enumerates the IANA special-purpose address registries for +// both families. +// +// It does not compose Go's net.IP predicates, because they answer a different +// question. net.IP.IsGlobalUnicast is not a public-routability test — it +// answers "is this a unicast address outside the loopback, link-local and +// multicast sets" — and IsPrivate covers the three RFC 1918 ranges. Their +// conjunction therefore classes a number of special-purpose registry ranges as +// public, so a predicate built from them would not mean what this one must +// mean. The registries are the specification of "not the public internet", so +// they are what this reads. +// +// It is deliberately a denylist of non-public space rather than an allowlist of +// public space: ranges unassigned today become public assignments tomorrow, and +// a deployment must not need a release to reach a newly assigned host. + +// specialUseIPv4 is the IANA IPv4 Special-Purpose Address Registry, minus the +// ranges net.IP already answers for (loopback, link-local, multicast). Each +// entry is non-public destination space. +var specialUseIPv4 = []string{ + "0.0.0.0/8", // "this network" + "10.0.0.0/8", // private + "100.64.0.0/10", // shared address space (carrier NAT) + "172.16.0.0/12", // private + "192.0.0.0/24", // IETF protocol assignments + "192.0.2.0/24", // documentation (TEST-NET-1) + "192.31.196.0/24", // AS112 + "192.52.193.0/24", // AMT + "192.88.99.0/24", // 6to4 relay anycast, deprecated + "192.168.0.0/16", // private + "192.175.48.0/24", // AS112 direct delegation + "198.18.0.0/15", // benchmarking + "198.51.100.0/24", // documentation (TEST-NET-2) + "203.0.113.0/24", // documentation (TEST-NET-3) + "240.0.0.0/4", // reserved + "255.255.255.255/32", // limited broadcast +} + +// specialUseIPv6 is the IANA IPv6 Special-Purpose Address Registry, same +// basis. fc00::/7 (unique local) is listed explicitly rather than left to +// net.IP.IsPrivate, so the set reads as one inventory. +var specialUseIPv6 = []string{ + "::/128", // unspecified + "::1/128", // loopback + "64:ff9b::/96", // NAT64 + "64:ff9b:1::/48", // local-use NAT64 + "100::/64", // discard-only + // The dummy prefix. Inside 100::/8 but NOT inside the discard-only /64 + // above, so that /64 alone does not cover it. + "100:0:0:1::/64", + // IETF protocol assignments, as one block rather than per sub-block. It + // covers Teredo (2001::/32), benchmarking (2001:2::/48), both ORCHID + // blocks, the anycast singletons AND the unassigned remainder; enumerating + // the sub-blocks individually would not cover that remainder, which is why + // the whole /23 is listed. 2001:db8::/32 is OUTSIDE this /23 and stays + // listed separately; ordinary global unicast such as 2001:4860::/32 is + // outside it too, so this does not over-refuse. + "2001::/23", + "2001:db8::/32", // documentation + "2002::/16", // 6to4 + "3fff::/20", // documentation (RFC 9637) + "5f00::/16", // segment routing + "fc00::/7", // unique local + "fe80::/10", // link-local unicast +} + +// specialUsePrefixes is both registries, parsed once. +var specialUsePrefixes = parseSpecialUsePrefixes() + +func parseSpecialUsePrefixes() []netip.Prefix { + raw := make([]string, 0, len(specialUseIPv4)+len(specialUseIPv6)) + raw = append(raw, specialUseIPv4...) + raw = append(raw, specialUseIPv6...) + prefixes := make([]netip.Prefix, 0, len(raw)) + for _, candidate := range raw { + // MustParsePrefix is right here: these are constants in this file, so a + // bad one is a defect that must fail at startup rather than quietly + // shrink the guard. + prefixes = append(prefixes, netip.MustParsePrefix(candidate)) + } + return prefixes +} + +// IsPublicDestination reports whether address is a unicast address on the +// public internet, and so a destination this host may be asked to fetch from. +// +// It is applied to the CONCRETE address a connection is about to reach, after +// DNS resolution, which is what closes the rebinding gap a hostname check +// leaves open: a name that resolved public once may resolve to 10.0.0.1 on the +// next lookup, and only the dial knows which it got. +func IsPublicDestination(address netip.Addr) bool { + if !address.IsValid() { + return false + } + // A zone makes this a SCOPED address, reachable only on the interface it + // names. A scope identifier is meaningless for a destination on the public + // internet, so its presence is itself disqualifying and such an address is + // refused here, before the registries are consulted at all. + // + // Two reasons it is refused rather than stripped. netip.Prefix.Contains + // reports false for every zoned address, so a zoned address compared + // against the prefixes below would match no entry in either registry; and + // stripping the zone would classify an address the dialer is then not going + // to use. Refusing is the only answer that stays true of the destination. + if address.Zone() != "" { + return false + } + // A v4-mapped v6 address (::ffff:10.0.0.1) is the v4 address it wraps, and + // must be classified as such — comparing it against the v6 registry alone + // would find no match and admit it. + address = address.Unmap() + if address.IsLoopback() || address.IsMulticast() || + address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || + address.IsInterfaceLocalMulticast() || address.IsUnspecified() { + return false + } + for _, prefix := range specialUsePrefixes { + // Prefixes of the other family never contain this address, so one pass + // over both registries is correct without branching on family. + if prefix.Contains(address) { + return false + } + } + return true +} + +// IsPublicDestinationDialAddress reports whether a dialer's "host:port" names +// a public destination. A address it cannot parse is refused: a guard that +// cannot tell what it is about to reach must not admit it. +func IsPublicDestinationDialAddress(address string) bool { + host, _, err := net.SplitHostPort(address) + if err != nil { + return false + } + parsed, err := netip.ParseAddr(host) + if err != nil { + return false + } + return IsPublicDestination(parsed) +} diff --git a/module/services/accounts/code/pkg/auth/resource.go b/module/services/accounts/code/pkg/auth/resource.go new file mode 100644 index 000000000..b23cd8ce5 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/resource.go @@ -0,0 +1,198 @@ +package auth + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// RFC 8707 resource indicators. +// +// An MCP client names the thing it wants a token for — a solution's MCP +// endpoint — and the token it receives carries that URL as an audience. The +// point of the indicator is that the token is then useless at a different +// resource: a confused deputy cannot replay one solution's token against another +// solution, because the audience says which one it was minted for. +// +// The host accepts exactly one shape of resource today: a solution's MCP +// endpoint at this host's own public origin. That is deliberately narrow. A +// resource the host cannot describe is a resource the gateway cannot enforce, +// and an audience nothing checks is worse than no audience at all. + +// ErrResourceRejected is every reason a resource indicator is refused. Like +// ErrClientAuthorizationRejected it is a single sentinel: the authorize +// endpoint answers an unauthenticated browser, and a distinguishable refusal +// would report which solutions exist. +// ErrInvalidResourceAudience is a verified token whose audience set names no +// single valid resource — two or more resource audiences, or one that is not a +// resource identifier this host issues. Distinct from ErrResourceRejected, +// which is a resource a CALLER asked for: this one is a token already signed, +// so it is a credential whose scope cannot be stated rather than a bad request. +var ErrInvalidResourceAudience = errors.New("token audience names no single valid resource") + +var ErrResourceRejected = errors.New("resource indicator rejected") + +// A resource indicator names the URL an MCP CLIENT CAN REACH, which on a +// deployed cell is the host frontend's solution proxy and nothing else. +// +// `/solutions//*` is the gateway's own internal surface: it is not public, +// and on the public origin that path is a page of the frontend that redirects +// to login. The only public route to a solution's backend is +// `/api/solutions//proxy/*`, which forwards the caller's bearer to the +// gateway and already serves the solution's `.well-known` anonymously. An +// audience naming anything else is an audience no client could present at the +// resource it describes. +// +// The shape is the frontend's `solutionProxyBase` + `/mcp` +// (frontend/code/src/solutions/registry.ts). It is spelled here because +// pkg/auth cannot import TypeScript, and +// TestTheResourceShapeMatchesTheFrontendProxyBase holds the two together. +const ( + solutionResourcePrefix = "/api/solutions/" + solutionResourceMiddle = "/proxy" + solutionMCPSuffix = "/mcp" +) + +// SolutionResourceMetadataPath is where a client looks for the RFC 9728 +// document describing `/api/solutions//proxy/mcp`: the solution's own +// well-known, under the same proxy base. +// +// That document is the RUNTIME's — the gateway proxies the solution's +// `.well-known` GET unauthenticated and the runtime answers it, naming this +// host as its authorization server. The host does not serve a second copy: two +// documents for one resource is two places to disagree, and the client follows +// whichever URL the challenge names anyway. +func SolutionResourceMetadataPath(solutionID string) string { + return fmt.Sprintf("%s%s%s/.well-known/oauth-protected-resource", + solutionResourcePrefix, solutionID, solutionResourceMiddle) +} + +// ResourceIndicator is a validated RFC 8707 `resource` value: the exact string +// that becomes the token's audience, plus the parts the gateway and the consent +// screen need without re-parsing it. +type ResourceIndicator struct { + // Value is the canonical indicator, byte-for-byte what goes in `aud` and + // what a verifier compares against. RFC 8707 requires the indicator be used + // as given, so it is never re-rendered from the parts below. + Value string + // Origin is the scheme://host[:port] the indicator names. + Origin string + // SolutionID is the solution whose MCP endpoint this is. + SolutionID string +} + +// SolutionMCPResource builds the indicator for one solution at one origin. It +// is the single place the string is composed, so the authorization server, the +// published metadata, and the gateway's expectation cannot drift apart. +func SolutionMCPResource(origin, solutionID string) string { + return fmt.Sprintf("%s%s%s%s%s", strings.TrimSuffix(origin, "/"), + solutionResourcePrefix, solutionID, solutionResourceMiddle, solutionMCPSuffix) +} + +// ParseResourceIndicator validates a client-supplied `resource` parameter +// structurally: an absolute https URI (http only for a loopback development +// host), no query, no fragment, no userinfo, naming a solution's MCP endpoint. +// +// RFC 8707 §2 requires the indicator be an absolute URI without a fragment and +// says a query component SHOULD NOT be used; both are refused here rather than +// normalised away, because the value is compared by equality downstream and a +// tolerated variant would be an audience the gateway never matches. +// +// It deliberately does NOT check that the solution is registered. Registration +// is live state that comes and goes on a lease, and a token naming a solution +// that is not serving is harmless — the gateway answers 503 for it. Refusing +// here would instead make the authorize endpoint an existence oracle for every +// solution in the deployment, to an unauthenticated caller. +func ParseResourceIndicator(candidate string) (ResourceIndicator, error) { + candidate = strings.TrimSpace(candidate) + if candidate == "" || len(candidate) > 2048 { + return ResourceIndicator{}, ErrResourceRejected + } + parsed, err := url.Parse(candidate) + if err != nil || !parsed.IsAbs() || parsed.Host == "" { + return ResourceIndicator{}, ErrResourceRejected + } + if parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Opaque != "" { + return ResourceIndicator{}, ErrResourceRejected + } + switch strings.ToLower(parsed.Scheme) { + case "https": + case "http": + if !isLoopbackHost(parsed.Hostname()) { + return ResourceIndicator{}, ErrResourceRejected + } + default: + return ResourceIndicator{}, ErrResourceRejected + } + // The escaped form is compared, not the decoded one: `parsed.Path` would + // read `/solutions/a%2Fb/mcp` as a two-segment id, and the audience string + // the client sent is the escaped one. + path := parsed.EscapedPath() + rest, ok := strings.CutPrefix(path, solutionResourcePrefix) + if !ok { + return ResourceIndicator{}, ErrResourceRejected + } + rest, ok = strings.CutSuffix(rest, solutionResourceMiddle+solutionMCPSuffix) + if !ok || !ValidSolutionID(rest) { + return ResourceIndicator{}, ErrResourceRejected + } + solutionID := rest + origin := strings.ToLower(parsed.Scheme) + "://" + parsed.Host + return ResourceIndicator{ + Value: candidate, + Origin: origin, + SolutionID: solutionID, + }, nil +} + +// RequireResourceAtOrigin parses candidate and requires it to name a resource +// at origin. The authorization server pins the origin to its own public base so +// it cannot be talked into minting an audience for somebody else's host — which +// is the whole confused-deputy risk resource indicators exist to close. +func RequireResourceAtOrigin(candidate, origin string) (ResourceIndicator, error) { + resource, err := ParseResourceIndicator(candidate) + if err != nil { + return ResourceIndicator{}, err + } + expected, err := CanonicalPublicOrigin(origin) + if err != nil { + return ResourceIndicator{}, fmt.Errorf("%w: no trusted public origin", ErrResourceRejected) + } + // EXACT, code-point equality, on the WHOLE identifier — not a case-folded + // comparison of a reconstructed origin. + // + // The identifier is used as given (see ResourceIndicator.Value): this string + // goes into `aud`, the runtime publishes the same string as its `resource`, + // and RFC 9728 §3.3 has the client require the document's `resource` to + // equal the URL it dialled. Nothing in that chain folds case, so a variant + // admitted here would be minted into an audience that the very client it was + // issued for then rejects — and a host that accepts several spellings of one + // resource has several resources. + // + // Compared against SolutionMCPResource, the single place the string is + // composed, so issuance and admission cannot drift apart. + if resource.Value != SolutionMCPResource(expected, resource.SolutionID) { + return ResourceIndicator{}, ErrResourceRejected + } + return resource, nil +} + +// ValidSolutionID mirrors the identity rule the solution registry and the +// gateway's own path matcher enforce: one lowercase segment usable both as a +// path element and as a routing key. Spelled out here rather than imported, +// because pkg/auth may not depend on the solution registry. +func ValidSolutionID(id string) bool { + if len(id) == 0 || len(id) > 64 { + return false + } + for index, character := range id { + switch { + case character >= 'a' && character <= 'z', character >= '0' && character <= '9': + case (character == '-' || character == '_') && index > 0 && index < len(id)-1: + default: + return false + } + } + return true +} diff --git a/module/services/accounts/code/pkg/auth/resource_test.go b/module/services/accounts/code/pkg/auth/resource_test.go new file mode 100644 index 000000000..7bd461a23 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/resource_test.go @@ -0,0 +1,173 @@ +package auth_test + +import ( + "strings" + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +func TestASolutionMCPResourceIsComposedInOnePlace(t *testing.T) { + require.Equal(t, "https://host.example.com/api/solutions/example/proxy/mcp", + auth.SolutionMCPResource("https://host.example.com", "example")) + // A trailing slash on the origin must not produce a doubled separator: the + // indicator is compared by equality downstream, so a second spelling of the + // same resource is an audience nothing matches. + require.Equal(t, "https://host.example.com/api/solutions/example/proxy/mcp", + auth.SolutionMCPResource("https://host.example.com/", "example")) +} + +func TestAResourceIndicatorNamesOneSolutionsMCPEndpoint(t *testing.T) { + resource, err := auth.ParseResourceIndicator("https://host.example.com/api/solutions/example/proxy/mcp") + require.NoError(t, err) + require.Equal(t, "https://host.example.com/api/solutions/example/proxy/mcp", resource.Value) + require.Equal(t, "https://host.example.com", resource.Origin) + require.Equal(t, "example", resource.SolutionID) + + // Loopback http is accepted so local development works; nothing else is. + _, err = auth.ParseResourceIndicator("http://localhost:3000/api/solutions/example/proxy/mcp") + require.NoError(t, err) +} + +func TestEveryOtherResourceShapeIsRefused(t *testing.T) { + for _, candidate := range []string{ + "", + "example", + "/api/solutions/example/proxy/mcp", + // Not this host's one resource shape. + "https://host.example.com/solutions/example", + "https://host.example.com/api/solutions/example/proxy/mcp/tools", + "https://host.example.com/v1/users", + "https://host.example.com/api/solutions//proxy/mcp", + // The gateway's own internal path. It is not public — on the public + // origin that prefix is a page of the frontend — so an audience naming + // it is one no client could present at the resource it describes. + "https://host.example.com/solutions/example/mcp", + // RFC 8707 §2 forbids a fragment and discourages a query; both are + // refused rather than normalised away, because a tolerated variant + // would be an audience the gateway never matches. + "https://host.example.com/api/solutions/example/proxy/mcp#x", + "https://host.example.com/api/solutions/example/proxy/mcp?x=1", + "https://user:pass@host.example.com/api/solutions/example/proxy/mcp", + // Non-loopback plaintext, and a scheme that is not HTTP at all. + "http://host.example.com/api/solutions/example/proxy/mcp", + "ftp://host.example.com/api/solutions/example/proxy/mcp", + // An escaped separator would read as a two-segment solution id on the + // decoded path while the audience string carries the escaped form. + "https://host.example.com/solutions/a%2Fb/mcp", + // Solution ids are one lowercase segment. + "https://host.example.com/api/solutions/Example/proxy/mcp", + "https://host.example.com/api/solutions/-example/proxy/mcp", + "https://host.example.com/api/solutions/example-/proxy/mcp", + } { + _, err := auth.ParseResourceIndicator(candidate) + require.ErrorIs(t, err, auth.ErrResourceRejected, "should refuse %q", candidate) + } +} + +// The authorization server pins the origin to its own public base, so it cannot +// be talked into minting an audience for somebody else's host — which is the +// confused-deputy risk resource indicators exist to close. +func TestAResourceMustNameThisHostsOwnOrigin(t *testing.T) { + resource, err := auth.RequireResourceAtOrigin( + "https://host.example.com/api/solutions/example/proxy/mcp", "https://host.example.com") + require.NoError(t, err) + require.Equal(t, "example", resource.SolutionID) + + _, err = auth.RequireResourceAtOrigin( + "https://evil.example.com/api/solutions/example/proxy/mcp", "https://host.example.com") + require.ErrorIs(t, err, auth.ErrResourceRejected) + + // With no trusted origin there is nothing to pin against, so nothing is + // minted. Failing closed here is what keeps a deployment that has not + // configured its own address from issuing audiences it cannot enforce. + _, err = auth.RequireResourceAtOrigin( + "https://host.example.com/api/solutions/example/proxy/mcp", "") + require.ErrorIs(t, err, auth.ErrResourceRejected) +} + +func TestAValidCodeChallengeIsBase64URLInTheS256Band(t *testing.T) { + require.True(t, auth.ValidCodeChallenge("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")) + require.False(t, auth.ValidCodeChallenge("")) + require.False(t, auth.ValidCodeChallenge("too-short")) + require.False(t, auth.ValidCodeChallenge("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw+cM")) + require.False(t, auth.ValidCodeChallenge("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw/cM")) +} + +// The cross-repository contract, stated where a change to either side will trip +// over it. +// +// This host ACCEPTS exactly one resource shape, and a solution's runtime DERIVES +// the identifier it publishes. Both halves must spell the same URL or discovery +// cannot establish resource identity: RFC 9728 §3.3 has the client compare the +// `resource` in the runtime's metadata document against the URL it dialled, and +// RFC 8707 makes that identifier the audience its token is bound to — so a +// mismatch is rejected by the client, before a token is ever requested. +// +// The two live in different repositories and no compiler relates them, so this +// records the agreed string and names the other side. The runtime's derivation +// is `PUBLIC_URL + "/api/solutions/" + id + "/proxy" + "/mcp"` +// (codefly-dev/solution-runtime-go, resolveMCPPublicURL). +// +// A deployment whose host routes solutions differently overrides the runtime's +// derivation with its `mcp/public-url` configuration value; this host still +// accepts only the shape below, so such an override has to name it. +func TestTheResourceShapeThisHostAcceptsIsTheOneASolutionRuntimePublishes(t *testing.T) { + const origin = "https://app.example.com" + + // What this host composes, which is what it requires on the way in. + require.Equal(t, + origin+"/api/solutions/example/proxy/mcp", + auth.SolutionMCPResource(origin, "example")) + + // Spelled out rather than built from the same constants, so a change to + // those constants fails here instead of agreeing with itself. + indicator, err := auth.ParseResourceIndicator(origin + "/api/solutions/example/proxy/mcp") + require.NoError(t, err, "the runtime's derived identifier must be one this host accepts") + require.Equal(t, "example", indicator.SolutionID) + + // And the metadata path the challenge names is that URL's sibling, which is + // where the runtime serves its document. + require.Equal(t, + "/api/solutions/example/proxy/.well-known/oauth-protected-resource", + auth.SolutionResourceMetadataPath("example")) + + // The gateway's own internal route is NOT accepted. It is unreachable from + // outside, so an identifier naming it could never be one a client dialled. + _, err = auth.ParseResourceIndicator(origin + "/solutions/example/mcp") + require.ErrorIs(t, err, auth.ErrResourceRejected) +} + +// R54-04, the issuance half. The authorization server mints an audience only for +// the EXACT identifier, by code-point equality — a variant is not "the same +// resource spelled differently", it is a different string that the client it was +// issued for would reject. +// +// The pair matters: the gateway holds the same rule on admission, so neither +// side can admit a spelling the other refuses. A host that accepted several +// spellings of one resource would have several resources, and the audience +// binding would mean less than it says. +func TestOnlyTheExactResourceIdentifierIsIssuedAnAudience(t *testing.T) { + const origin = "https://app.example.com" + exact := auth.SolutionMCPResource(origin, "example") + + indicator, err := auth.RequireResourceAtOrigin(exact, origin) + require.NoError(t, err) + // Used as given (RFC 8707): the value that goes in `aud` is the request's + // own string, never re-rendered from parsed parts. + require.Equal(t, exact, indicator.Value) + + for _, variant := range []string{ + strings.Replace(exact, "app.example.com", "APP.example.com", 1), + strings.Replace(exact, "app.example.com", "App.Example.Com", 1), + strings.Replace(exact, "https://", "HTTPS://", 1), + strings.Replace(exact, "/example/", "/Example/", 1), + strings.Replace(exact, "app.example.com", "app.example.com:443", 1), + exact + "/", + } { + _, err := auth.RequireResourceAtOrigin(variant, origin) + require.ErrorIs(t, err, auth.ErrResourceRejected, "must refuse %q", variant) + } +} diff --git a/module/services/accounts/code/pkg/auth/session_store.go b/module/services/accounts/code/pkg/auth/session_store.go index 4d4965159..d556b3e3e 100644 --- a/module/services/accounts/code/pkg/auth/session_store.go +++ b/module/services/accounts/code/pkg/auth/session_store.go @@ -33,6 +33,13 @@ type SessionRecord struct { // revoked without touching the person's other sessions. Empty for the host's // own web session. ClientID string + // Resource is the RFC 8707 resource indicator the session's tokens are + // bound to, persisted so a refresh rotation reissues the same audience. A + // rotation that re-read it from the request instead would let a client + // widen or move its own audience by asking; reading it from the locked row + // means the binding is fixed when the person consented to it. Empty for a + // session that named no resource. + Resource string // ActingAsUserID is non-zero only on an impersonation window, where UserID // is the admin and this is the user being viewed. Such a row carries no // RefreshHash: an impersonation session is never rotatable, and its diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 4bf2f8413..af9aa1223 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -626,7 +626,7 @@ var auditEventCatalog = []AuditEventDefinition{ observation(EventAuthOrgSwitched, CategorySecurity, "A user switched active organization."), observation(EventAuthMFAChallengeStart, CategorySecurity, "An MFA challenge was started."), observation(EventAuthMFAChallengeDone, CategorySecurity, "An MFA challenge was completed.", enum("factor", "totp", "webauthn", "backup_code")), - observation(EventAuthClientAuthorized, CategorySecurity, "A person authorized a registered client to act for them.", str("client_id")), + observation(EventAuthClientAuthorized, CategorySecurity, "A person authorized a registered client to act for them.", str("client_id"), str("resource"), str("client_registration")), mutation(EventMFATOTPSetupStarted, CategorySecurity, "TOTP enrollment was started."), mutation(EventMFATOTPVerified, CategorySecurity, "A TOTP device was verified."), mutation(EventMFAWebAuthnRegStarted, CategorySecurity, "WebAuthn registration was started."), diff --git a/module/services/accounts/code/pkg/business/client_authorization.go b/module/services/accounts/code/pkg/business/client_authorization.go index 5c34f250a..40e590547 100644 --- a/module/services/accounts/code/pkg/business/client_authorization.go +++ b/module/services/accounts/code/pkg/business/client_authorization.go @@ -22,10 +22,6 @@ import ( // already waiting for it. const clientAuthorizationCodeTTL = time.Minute -// challengeMethodS256 is the only PKCE method accepted. "plain" puts the -// verifier itself in the authorization request, which defeats the point. -const challengeMethodS256 = "S256" - // ClientAuthorizationCode is a one-time credential binding a signed-in person // to the client they authorized. It carries no authority of its own: what it // names is the host session, and redemption resolves the person's current @@ -37,11 +33,18 @@ type ClientAuthorizationCode struct { ClientID string RedirectURI string CodeChallenge string - UserID string - SessionID string - ExpiresAt time.Time - ConsumedAt *time.Time - CreatedAt time.Time + // Resource is the RFC 8707 indicator the person approved, or empty. It is + // recorded on the code and read back at redemption, so the audience the + // client ends up holding is the one the authorization bound — never one the + // client names for the first time at the token endpoint. + Resource string + // Scope is what was granted, echoed on the token response. + Scope string + UserID string + SessionID string + ExpiresAt time.Time + ConsumedAt *time.Time + CreatedAt time.Time } // ClientAuthorizationCodeStore persists authorization codes. Consume must lock @@ -86,56 +89,26 @@ func (s *Service) IssueClientAuthorizationCode( ctx context.Context, req *gen.IssueClientAuthorizationCodeRequest, ) (*gen.IssueClientAuthorizationCodeResponse, error) { - w := wool.Get(ctx).In("IssueClientAuthorizationCode") - store, hasStore := s.store.(ClientAuthorizationCodeStore) - if !hasStore { - return nil, w.NewError("store does not implement ClientAuthorizationCodeStore") - } authorization := req.GetAuthorization() client, err := s.resolveClientAuthorization(authorization) if err != nil { return nil, err } - - caller, ok := auth.VerifiedRequestIdentity(ctx) - if !ok || caller.EffectiveSubject == uuid.Nil || caller.SessionID == uuid.Nil { - return nil, auth.ErrClientAuthorizationRejected - } - // An impersonated session may not hand a client a credential: the client - // would hold a rotatable token for a person who never authorized it. - if caller.Impersonated() { - return nil, auth.ErrClientAuthorizationRejected - } - - plaintext, hash, err := newClientAuthorizationCode() - if err != nil { - return nil, w.Wrapf(err, "cannot generate client authorization code") - } - now := time.Now() - record := &ClientAuthorizationCode{ - ID: NewIDString(), - CodeHash: hash, - ClientID: client.ClientID, + // This RPC predates resource indicators and carries none, so the session it + // authorizes is audience-unbound: exactly the credential the first + // registered client was built against. A client that wants a resource-bound + // token uses the standard authorize endpoint, which carries one. + issued, err := s.issueAuthorizationCode(ctx, authorizationCodeGrant{ + Client: client, RedirectURI: authorization.GetRedirectUri(), CodeChallenge: authorization.GetCodeChallenge(), - UserID: caller.EffectiveSubjectID(), - SessionID: caller.SessionID.String(), - ExpiresAt: now.Add(clientAuthorizationCodeTTL), - CreatedAt: now, - } - if err := s.store.WithUserTx(ctx, record.UserID, func(ctx context.Context) error { - return store.CreateClientAuthorizationCode(ctx, record) - }); err != nil { - return nil, w.Wrapf(err, "persist client authorization code") + }) + if err != nil { + return nil, err } - - s.emit(ctx, record.UserID, "user", EventAuthClientAuthorized, - "session", record.SessionID, renderOrgID(caller.OrgID), - map[string]any{"client_id": client.ClientID}) - return &gen.IssueClientAuthorizationCodeResponse{ - Code: plaintext, - ExpiresIn: expiresInSeconds(record.ExpiresAt), + Code: issued.Code, + ExpiresIn: issued.ExpiresIn, }, nil } @@ -150,6 +123,14 @@ func (s *Service) ExchangeClientToken( if s.minter == nil { return nil, auth.ErrClientAuthorizationRejected } + // Registry only, deliberately: this RPC must not fetch a remote document. + // + // Its paired issue path (resolveClientAuthorization) is registry-only too, + // so no metadata-document client can obtain a code through this flow, and + // the standard endpoints resolve their own client without coming through + // here. Resolving metadata documents here would therefore add an outbound + // fetch for a caller-supplied URL — before any code is examined — to a + // published RPC, for a capability nothing can reach. client, ok := s.clientRegistry.Lookup(req.GetClientId()) if !ok { return nil, auth.ErrClientAuthorizationRejected @@ -170,71 +151,23 @@ func (s *Service) redeemClientAuthorizationCode( client auth.RegisteredClient, grant *gen.AuthorizationCodeGrant, ) (*gen.ExchangeClientTokenResponse, error) { - w := wool.Get(ctx).In("redeemClientAuthorizationCode") - store, ok := s.store.(ClientAuthorizationCodeStore) - if !ok { - return nil, w.NewError("store does not implement ClientAuthorizationCodeStore") - } - - var pair *auth.TokenPair - var redeemed *ClientAuthorizationCode - err := store.ConsumeClientAuthorizationCode( - ctx, - hashClientAuthorizationCode(grant.GetCode()), - time.Now(), - func(txCtx context.Context, code *ClientAuthorizationCode) error { - // The code is consumed before any of this is checked, so a mismatch - // burns it rather than leaving it to be guessed at again. - if code.ClientID != client.ClientID || - code.RedirectURI != grant.GetRedirectUri() || - !verifyCodeChallenge(code.CodeChallenge, grant.GetCodeVerifier()) { - return auth.ErrClientAuthorizationRejected - } - userID, err := uuid.Parse(code.UserID) - if err != nil { - return auth.ErrClientAuthorizationRejected - } - sessionID, err := uuid.Parse(code.SessionID) - if err != nil { - return auth.ErrClientAuthorizationRejected - } - // The code's consumption and the client's session must commit - // together: the marker is what lets the session store reuse this - // transaction instead of opening its own, which would commit the - // session while the code stayed redeemable. - pair, err = s.minter.MintForClient( - auth.WithAtomicSessionTransaction(txCtx), userID, sessionID, client.ClientID) - if err != nil { - return err - } - redeemed = code - return nil - }, - ) + // One code consumption path, shared with the standard token endpoint. This + // RPC's response shape is the older one (no token_type, no scope), so the + // standard fields are projected away rather than a second redemption being + // written beside the first. + redeemed, err := s.redeemAuthorizationCode(ctx, client, authorizationCodeRedemption{ + Code: grant.GetCode(), + RedirectURI: grant.GetRedirectUri(), + CodeVerifier: grant.GetCodeVerifier(), + }) if err != nil { - if errors.Is(err, auth.ErrClientAuthorizationRejected) || errors.Is(err, auth.ErrSessionUnavailable) { - return nil, auth.ErrClientAuthorizationRejected - } - return nil, w.Wrapf(err, "redeem client authorization code") - } - if pair == nil || redeemed == nil { return nil, auth.ErrClientAuthorizationRejected } - - // The audit row describes the session this exchange created, not the host - // session that authorized it, and it must carry that session's organization: - // audit_events' tenant policy matches on a non-null org_id, so a row emitted - // without one is readable by no tenant at all. The minted token is the - // authoritative record of both. - minted, err := s.minter.VerifyAccess(pair.AccessToken) - if err != nil { - return nil, w.Wrapf(err, "verify freshly minted client access token") - } - s.emit(ctx, redeemed.UserID, "user", EventAuthLogin, - "session", minted.SessionID.String(), renderOrgID(minted.OrgID), - map[string]any{"client_id": client.ClientID}) - - return clientTokenResponse(pair), nil + return &gen.ExchangeClientTokenResponse{ + AccessToken: redeemed.AccessToken, + RefreshToken: redeemed.RefreshToken, + ExpiresIn: redeemed.ExpiresIn, + }, nil } func (s *Service) rotateClientRefreshToken( @@ -246,7 +179,10 @@ func (s *Service) rotateClientRefreshToken( // Which client a refresh token belongs to is read from the locked session // row, not from the request, so one client naming another's token is // refused the same way a token that never existed is. - pair, err := s.minter.VerifyClientRefresh(ctx, grant.GetRefreshToken(), client.ClientID) + // No resource: this RPC predates resource indicators and carries none, so a + // resource-bound session rotates through it unchanged rather than being + // refused for a parameter the caller cannot send. + pair, err := s.minter.VerifyClientRefresh(ctx, grant.GetRefreshToken(), client.ClientID, "") if err != nil { if errors.Is(err, auth.ErrRefreshRevoked) || errors.Is(err, auth.ErrRefreshReuse) { return nil, auth.ErrClientAuthorizationRejected @@ -281,7 +217,7 @@ func (s *Service) resolveClientAuthorization( if !client.AllowsRedirect(request.GetRedirectUri()) { return auth.RegisteredClient{}, auth.ErrClientAuthorizationRejected } - if request.GetCodeChallengeMethod() != challengeMethodS256 { + if request.GetCodeChallengeMethod() != auth.ChallengeMethodS256 { return auth.RegisteredClient{}, auth.ErrClientAuthorizationRejected } if request.GetCodeChallenge() == "" { diff --git a/module/services/accounts/code/pkg/business/client_authorization_caller_test.go b/module/services/accounts/code/pkg/business/client_authorization_caller_test.go new file mode 100644 index 000000000..f44b9fa12 --- /dev/null +++ b/module/services/accounts/code/pkg/business/client_authorization_caller_test.go @@ -0,0 +1,78 @@ +package business + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" +) + +// Each of the four conditions under which a caller may NOT hand a client a +// credential, decided separately. +// +// They were previously reachable only through the code-issuing path, which +// needs a database — so one test stood for four rules, and because any of them +// produces the same error, a test that stopped at the first refusal it met +// proved nothing about the other three. Removing any one of these guards now +// fails exactly one case here. +func TestWhoMayAuthorizeAClient(t *testing.T) { + person := uuid.New() + session := uuid.New() + + t.Run("a signed-in person, as themselves, may", func(t *testing.T) { + require.NoError(t, callerMayAuthorizeAClient(auth.RequestIdentity{ + EffectiveSubject: person, + SessionID: session, + }, true)) + }) + + t.Run("no verified identity at all may not", func(t *testing.T) { + // The interceptor would normally have stopped this; a rule this + // consequential does not delegate. + require.ErrorIs(t, callerMayAuthorizeAClient(auth.RequestIdentity{}, false), + auth.ErrClientAuthorizationRejected) + }) + + t.Run("a complete-looking identity that was not verified may not", func(t *testing.T) { + // The case that makes the "not verified" check load-bearing rather than + // a restatement of the two below: every field is populated, so only the + // verification flag can refuse it. Written this way deliberately — with + // a ZERO identity the next condition catches it anyway, so a test using + // one cannot tell whether this guard exists. + require.ErrorIs(t, callerMayAuthorizeAClient(auth.RequestIdentity{ + EffectiveSubject: person, + SessionID: session, + }, false), auth.ErrClientAuthorizationRejected) + }) + + t.Run("a verified request naming no person may not", func(t *testing.T) { + // Verified, but there is nobody for the client to act as. + require.ErrorIs(t, callerMayAuthorizeAClient(auth.RequestIdentity{ + SessionID: session, + }, true), auth.ErrClientAuthorizationRejected) + }) + + t.Run("a person with no session may not", func(t *testing.T) { + // The credential is session-bound for its whole life — that is what + // revoking a session revokes — so one minted outside a session could + // never be withdrawn. + require.ErrorIs(t, callerMayAuthorizeAClient(auth.RequestIdentity{ + EffectiveSubject: person, + }, true), auth.ErrClientAuthorizationRejected) + }) + + t.Run("an impersonated session may not", func(t *testing.T) { + // The client would hold a rotatable token for a person who never + // authorized it, and would keep it after the impersonation ended. + impersonated := auth.RequestIdentity{ + EffectiveSubject: person, + SessionID: session, + RealActor: uuid.New(), + } + require.True(t, impersonated.Impersonated(), "the fixture must actually be impersonated") + require.ErrorIs(t, callerMayAuthorizeAClient(impersonated, true), + auth.ErrClientAuthorizationRejected) + }) +} diff --git a/module/services/accounts/code/pkg/business/legacy_token_endpoint_test.go b/module/services/accounts/code/pkg/business/legacy_token_endpoint_test.go new file mode 100644 index 000000000..16442aca0 --- /dev/null +++ b/module/services/accounts/code/pkg/business/legacy_token_endpoint_test.go @@ -0,0 +1,75 @@ +package business_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "accounts/pkg/auth" + ed25519minter "accounts/pkg/auth/ed25519" + "accounts/pkg/business" +) + +// countingFetcher records whether a metadata document was fetched at all. +type countingFetcher struct{ calls int } + +func (f *countingFetcher) Fetch(_ context.Context, _ string) ([]byte, time.Duration, error) { + f.calls++ + return nil, 0, auth.ErrClientMetadataUnreachable +} + +// r1/f6. The legacy registered-client token RPC resolves from the registry only, +// and must not perform an outbound fetch for a caller-supplied URL. +// +// Its paired issue path is registry-only, so no metadata-document client can +// obtain a code through this flow, and the standard endpoints resolve their own +// client without coming through here. Resolving documents here would therefore +// add an unauthenticated outbound fetch — before any code is examined — to a +// published RPC, for a capability nothing can reach. +// +// The assertion is the fetch COUNT, not the refusal: the refusal is the same +// either way, which is exactly why the capability could be added without any +// test noticing. +func TestTheLegacyTokenEndpointNeverFetchesAMetadataDocument(t *testing.T) { + service, err := business.NewService(nil) + require.NoError(t, err) + + registry, err := auth.NewClientRegistry(`[{ + "client_id": "example-cli", "name": "Example CLI", + "redirect_uris": ["http://127.0.0.1/callback"] + }]`) + require.NoError(t, err) + service.SetClientRegistry(registry) + + // A policy that WOULD admit any document, over a fetcher that records the + // attempt — so a fetch here is visible rather than merely refused. + fetcher := &countingFetcher{} + policy, err := auth.NewClientMetadataPolicy("any") + require.NoError(t, err) + service.SetClientMetadataResolver(auth.NewClientMetadataResolverWith(policy, fetcher)) + + // Non-nil so the call reaches client resolution rather than stopping at the + // minter guard above it. + _, priv, err := ed25519minter.GenerateKey() + require.NoError(t, err) + service.SetJWTMinter(ed25519minter.New(ed25519minter.Config{}, priv, nil)) + + _, err = service.ExchangeClientToken(context.Background(), &gen.ExchangeClientTokenRequest{ + ClientId: "https://client.example.com/metadata", + Grant: &gen.ExchangeClientTokenRequest_AuthorizationCode{ + AuthorizationCode: &gen.AuthorizationCodeGrant{ + Code: "whatever", + RedirectUri: "http://127.0.0.1:4000/callback", + CodeVerifier: "verifier", + }, + }, + }) + + require.ErrorIs(t, err, auth.ErrClientAuthorizationRejected) + require.Zero(t, fetcher.calls, + "the legacy token endpoint must not fetch a document for a caller-supplied URL") +} diff --git a/module/services/accounts/code/pkg/business/oauth_authorization_server.go b/module/services/accounts/code/pkg/business/oauth_authorization_server.go new file mode 100644 index 000000000..a942fb4f8 --- /dev/null +++ b/module/services/accounts/code/pkg/business/oauth_authorization_server.go @@ -0,0 +1,790 @@ +package business + +import ( + "context" + "errors" + "strings" + "time" + + "accounts/pkg/auth" + + "github.com/codefly-dev/core/wool" + "github.com/google/uuid" +) + +// The host as an OAuth 2.1 authorization server (issue #1003). +// +// This is not a second authorization server beside the registered-client flow: +// it is the standard spelling of the same one. The registry, the one-time code +// table, the PKCE check, the minter and the session kind are all the ones +// pkg/business/client_authorization.go already uses. What is added here is what +// a client that was written against the specifications rather than against this +// host needs in order to find and use it: +// +// - RFC 8414 authorization-server metadata, so a client discovers the +// endpoints instead of being configured with them. +// - A `resource` parameter (RFC 8707) carried from the authorization request +// into the token's audience, so a token minted for one solution's MCP +// endpoint is refused at another's. +// - Client ID Metadata Documents, so a client the operator never declared can +// still sign a person in without anything durable being written. +// +// Deliberately absent: dynamic client registration (RFC 7591). The MCP +// specification deprecated it in favour of metadata documents, and it is the +// one mechanism here that would grow a durable registry on unauthenticated +// request — an attacker-sized table, and a registered display name that can +// impersonate a client the operator did meant to vouch for. The published +// metadata therefore carries no `registration_endpoint`, which is how a +// conforming client learns not to try. + +// supportedOAuthScope is the one scope value the host accepts. +// +// It is `offline_access` because that is the one thing a scope could honestly +// say about a token this host issues: the credential is refreshable. Everything +// else a scope might narrow — which organisation, which permissions, which +// resources — the host resolves from the person's own authority at every call, +// and it does not narrow by scope at all. Publishing a scope the host does not +// enforce would be a capability claim nothing backs, so the list says only what +// is true, and any other requested value is refused by name rather than +// silently dropped (RFC 6749 §4.1.2.1 `invalid_scope`). +const supportedOAuthScope = "offline_access" + +// OAuth 2.1 / RFC 6749 §5.2 error codes, as a client reads them. +const ( + OAuthErrorInvalidRequest = "invalid_request" + OAuthErrorInvalidClient = "invalid_client" + OAuthErrorInvalidGrant = "invalid_grant" + OAuthErrorUnsupportedGrant = "unsupported_grant_type" + OAuthErrorInvalidScope = "invalid_scope" + // OAuthErrorUnsupportedResponse is RFC 6749 §4.1.2.1's own code for a + // response type the server does not serve. It is distinct from + // invalid_request on purpose: a client asking for `token` has made a + // well-formed request for something this server does not do, and telling it + // "invalid_request" sends it looking for a malformed parameter. + OAuthErrorUnsupportedResponse = "unsupported_response_type" + OAuthErrorAccessDenied = "access_denied" + OAuthErrorInvalidTarget = "invalid_target" + OAuthErrorServerError = "server_error" +) + +// ErrOAuthIssuerUnavailable is the authorization server with no configured +// issuer. Every document it publishes names that issuer, and a metadata +// document served under the wrong one is worse than none: a client that +// fetched it would send authorization requests somewhere else. Fail closed. +var ErrOAuthIssuerUnavailable = errors.New("the authorization server has no configured issuer") + +// LegacyTokenIssuer is the literal `iss` this host minted before it published +// RFC 8414 metadata. RFC 8414 §2 requires the issuer to be an https URL, and a +// client that discovers the metadata and then verifies `iss` against it would +// refuse every token carrying the literal — so a deployment with a configured +// issuer mints the URL instead. +// +// It stays ACCEPTED on verification, because changing what is minted does not +// change what is already in flight: every unexpired access token, and every +// session that will rotate into one, carries the literal. Refusing it at the +// same moment the new value starts being minted would sign out every live +// session at deploy. Minting it is a fallback only for a deployment that has +// configured no issuer at all, where no metadata is published either and the +// two therefore cannot disagree. +const LegacyTokenIssuer = "saas-starter" + +// SetOAuthIssuer records this authorization server's own identity: the +// operator-configured public base URL. One value, resolved once at startup, +// used for the published metadata AND for the `iss` of every token minted — +// which is the only way the two can be proved equal. +// +// Deliberately not the per-request verified public origin. That value is +// derived from the request, so it would make the issuer vary by Host header: +// two clients discovering the same deployment could be handed two different +// issuers, and a token minted under one would fail verification against the +// other. +func (s *Service) SetOAuthIssuer(issuer string) { + s.oauthIssuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") +} + +// OAuthIssuer returns the configured issuer, or empty when none is configured. +func (s *Service) OAuthIssuer() string { return s.oauthIssuer } + +// AuthorizationServerMetadata is the RFC 8414 document, in the field order and +// spelling the registry uses. It is produced from what this service actually +// enforces, so the document cannot claim a capability the code does not have. +type AuthorizationServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` + AuthorizationResponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"` + ServiceDocumentation string `json:"service_documentation,omitempty"` +} + +// Public paths of the authorization server, as a client discovers them. +const ( + // OAuthAuthorizeEndpointPath is the standard authorization endpoint. It is + // served by the frontend — the host's login page is the only sign-in UI, and + // consent is a page, not a response body — which then drives the two RPCs + // below. + OAuthAuthorizeEndpointPath = "/oauth2/authorize" + // OAuthTokenEndpointPath is the standard token endpoint, form-encoded per + // RFC 6749 §3.2. + OAuthTokenEndpointPath = "/oauth2/token" + // OAuthJWKSPath is where the host already publishes its verification keys. + OAuthJWKSPath = "/v1/auth/.well-known/jwks.json" +) + +// AuthorizationServerMetadata builds the published document for this request's +// issuer. The issuer is the operator-trusted public base URL; without one the +// call fails rather than naming a host it cannot vouch for. +func (s *Service) AuthorizationServerMetadata(_ context.Context) (*AuthorizationServerMetadata, error) { + issuer := s.oauthIssuer + if issuer == "" { + return nil, ErrOAuthIssuerUnavailable + } + return &AuthorizationServerMetadata{ + Issuer: issuer, + AuthorizationEndpoint: issuer + OAuthAuthorizeEndpointPath, + TokenEndpoint: issuer + OAuthTokenEndpointPath, + JWKSURI: issuer + OAuthJWKSPath, + ScopesSupported: []string{supportedOAuthScope}, + // `code` only. OAuth 2.1 removes the implicit and password grants, and + // this host never had them. + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + // A public client has nowhere to keep a secret, so there is exactly one + // way to authenticate at the token endpoint: not at all. Possession of + // the code plus its PKCE verifier, or of the rotating refresh token, is + // the credential. + TokenEndpointAuthMethodsSupported: []string{"none"}, + // S256 only. `plain` puts the verifier in the authorization request, + // which defeats the point, and OAuth 2.1 requires S256 for it. + CodeChallengeMethodsSupported: []string{auth.ChallengeMethodS256}, + ClientIDMetadataDocumentSupported: s.clientMetadata.Enabled(), + // The authorization response carries `iss` (RFC 9207), so a client with + // more than one configured authorization server cannot be made to + // redeem one server's code at another. + AuthorizationResponseISSParameterSupported: true, + }, nil +} + +// SetClientMetadataResolver wires Client ID Metadata Document support. Left +// unset, a metadata client_id is refused exactly as an unregistered slug is. +func (s *Service) SetClientMetadataResolver(resolver *auth.ClientMetadataResolver) { + s.clientMetadata = resolver +} + +// OAuthAuthorizationRequest is one client's authorization request, as the +// authorize endpoint received it. Every field is caller-supplied and none is +// trusted before ResolveOAuthAuthorization has returned. +type OAuthAuthorizationRequest struct { + ResponseType string + ClientID string + RedirectURI string + CodeChallenge string + CodeChallengeMethod string + State string + Scope string + Resource string + // ConsentGranted is the caller stating that the person approved this client + // by name. Only the consent page sets it, and only after they pressed Allow. + // + // The host REQUIRES it wherever it requires consent, rather than trusting the + // page to have asked. It is not a boundary against a hostile browser — one + // holding the person's session can claim anything — but it is what stops a + // defect in the browser code from issuing credentials nobody approved: the + // host does not rely on the page having read `requires_consent` correctly. + ConsentGranted bool +} + +// ResolvedOAuthAuthorization is a request the host has accepted: which client, +// which resource, and what the person must be shown before a code is issued. +type ResolvedOAuthAuthorization struct { + Client auth.RegisteredClient + Resource auth.ResourceIndicator + Scope string + // RequiresConsent is true when the person must name-and-approve this client + // before a code is issued. + // + // Two cases need it. A metadata client is one the operator never declared, + // so nobody but the person can vouch for it. And a request naming a + // resource is a request to narrow a credential to something specific, which + // the person should see named — "this client, for that one solution" is a different + // sentence from "Claude Code". An operator-declared client asking for + // nothing in particular keeps the silent handoff it has today: the + // deployment already vouched for it, and adding a prompt would change a + // shipped client's flow for no gain in what the person can decide. + RequiresConsent bool +} + +// OAuthAuthorizationError is a refusal the authorize endpoint can report to the +// client, as RFC 6749 §4.1.2.1 requires: once the redirect URI is validated, +// errors go back to the client rather than being rendered to the person, and +// before it is validated they must NOT, or the endpoint becomes an open +// redirector. +type OAuthAuthorizationError struct { + // Code is the RFC 6749 error code. + Code string + // Description is a short, non-enumerating explanation. + Description string + // Redirectable reports whether the redirect URI was validated before this + // refusal, and so whether the error may be delivered to it. + Redirectable bool +} + +func (e *OAuthAuthorizationError) Error() string { + return e.Code + ": " + e.Description +} + +func authorizationError(code, description string, redirectable bool) *OAuthAuthorizationError { + return &OAuthAuthorizationError{Code: code, Description: description, Redirectable: redirectable} +} + +// ResolveOAuthAuthorization validates an authorization request completely, +// before any sign-in UI is shown: the client, its redirect URI, the PKCE +// challenge, the scope, and the resource indicator. It is callable +// unauthenticated, which is the point — a request that cannot succeed must be +// refused while the browser is still on the host and the person has typed +// nothing. +func (s *Service) ResolveOAuthAuthorization( + ctx context.Context, + request OAuthAuthorizationRequest, +) (*ResolvedOAuthAuthorization, error) { + // The redirect URI is resolved first and everything else after it, so the + // `Redirectable` flag on each refusal below is a fact about what has already + // been checked rather than a judgement call at each site. + client, err := s.resolveOAuthClient(ctx, request.ClientID) + if err != nil { + return nil, authorizationError(OAuthErrorInvalidClient, + "this application is not registered to sign in here", false) + } + if !client.AllowsRedirect(request.RedirectURI) { + return nil, authorizationError(OAuthErrorInvalidRequest, + "this application asked to be returned to an address it has not registered", false) + } + + // From here the redirect URI is the client's own, so a refusal is delivered + // to it with `error` and `state`, which is what lets the client show the + // person something better than a hung browser tab. + // response_type is REQUIRED (RFC 6749 §4.1.1), and `code` is the only one + // this server serves. It is not defaulted: a request missing it is not a + // request for a code, and repairing it here would make the host accept + // something no conforming client sends and hide the client's own bug. + switch strings.TrimSpace(request.ResponseType) { + case "code": + case "": + return nil, authorizationError(OAuthErrorInvalidRequest, + "response_type is required", true) + default: + return nil, authorizationError(OAuthErrorUnsupportedResponse, + "this authorization server supports only the code response type", true) + } + // Likewise not defaulted. OAuth 2.1 requires S256, and a request that names + // no method has not asked for it — silently supplying it would make the host + // the only reason the flow worked, and would mask a client that believed it + // was sending `plain`. + if request.CodeChallengeMethod != auth.ChallengeMethodS256 || request.CodeChallenge == "" { + return nil, authorizationError(OAuthErrorInvalidRequest, + "a PKCE S256 code challenge and code_challenge_method=S256 are required", true) + } + if !auth.ValidCodeChallenge(request.CodeChallenge) { + return nil, authorizationError(OAuthErrorInvalidRequest, + "the code challenge is malformed", true) + } + // `state` is not required by RFC 6749 and OAuth 2.1 makes PKCE the CSRF + // defence, so a client that omits it is not refused — but one that sends a + // value longer than the redirect can carry is, rather than having it + // truncated into a value the client will not recognise. + if len(request.State) > maxOAuthStateLength { + return nil, authorizationError(OAuthErrorInvalidRequest, + "the state value is too long", true) + } + scope, err := resolveOAuthScope(request.Scope) + if err != nil { + return nil, authorizationError(OAuthErrorInvalidScope, + "this authorization server supports only the "+supportedOAuthScope+" scope", true) + } + + resolved := &ResolvedOAuthAuthorization{Client: client, Scope: scope} + if raw := strings.TrimSpace(request.Resource); raw != "" { + // Pinned to the CONFIGURED issuer, with no fallback to the request's + // origin. + // + // A resource names a thing this host will later have to recognise, and + // the gateway recognises it against the one configured address — it + // refuses the tool endpoint outright when there is none, because a rule + // it cannot evaluate must not wave a request through. A second notion of + // "this host's address" here would mint a credential that endpoint then + // rejects: the authorization would succeed, the token would issue, and + // the first tool call would fail with nothing in the flow pointing at + // the configuration that caused it. + // + // So it is refused HERE, where an operator is watching a browser, and + // the deployment that wants this surface configures its public address. + origin := s.oauthIssuer + if origin == "" { + return nil, authorizationError(OAuthErrorServerError, + "this host has no configured public address, so it cannot issue "+ + "a token bound to a resource", true) + } + resource, err := auth.RequireResourceAtOrigin(raw, origin) + if err != nil { + return nil, authorizationError(OAuthErrorInvalidTarget, + "that resource is not one this host issues tokens for", true) + } + resolved.Resource = resource + } + resolved.RequiresConsent = client.Metadata || resolved.Resource.Value != "" + return resolved, nil +} + +// maxOAuthStateLength bounds the opaque value echoed back on the redirect. It +// is generous — a signed state token fits — and exists so the endpoint cannot be +// used to build an arbitrarily long URL. +const maxOAuthStateLength = 2048 + +// resolveOAuthScope applies the published scopes_supported. An absent scope is +// the supported one: the credential is refreshable whether or not the client +// asked, so answering "no scope" with a token that has a refresh half and an +// empty `scope` would under-report what was granted. +func resolveOAuthScope(requested string) (string, error) { + requested = strings.TrimSpace(requested) + if requested == "" { + return supportedOAuthScope, nil + } + for _, value := range strings.Fields(requested) { + if value != supportedOAuthScope { + return "", errors.New("unsupported scope") + } + } + return supportedOAuthScope, nil +} + +// resolveOAuthClient resolves a presented client_id through the operator's +// registry, falling back to a Client ID Metadata Document when the id is an +// https URL. The two namespaces cannot collide — a registry slug can never +// spell `https://` — so there is no precedence question to get wrong. +func (s *Service) resolveOAuthClient(ctx context.Context, clientID string) (auth.RegisteredClient, error) { + clientID = strings.TrimSpace(clientID) + if auth.IsMetadataClientID(clientID) { + client, err := s.clientMetadata.Resolve(ctx, clientID) + if err != nil { + // The named reason is logged for the operator and collapsed for the + // caller: an unauthenticated browser must not learn which documents + // this deployment would admit. + wool.Get(ctx).In("resolveOAuthClient").Debug("client metadata document refused", + wool.Field("client_id", clientID), wool.ErrField(err)) + return auth.RegisteredClient{}, auth.ErrClientAuthorizationRejected + } + return client, nil + } + if !auth.ValidClientID(clientID) { + return auth.RegisteredClient{}, auth.ErrClientAuthorizationRejected + } + client, ok := s.clientRegistry.Lookup(clientID) + if !ok { + return auth.RegisteredClient{}, auth.ErrClientAuthorizationRejected + } + return client, nil +} + +// GrantOAuthAuthorization issues the one-time code for an authorization request +// the signed-in person has approved. The caller is that person's own host +// session — the same requirement IssueClientAuthorizationCode has — so the code +// names an identity the client never watched authenticate. +func (s *Service) GrantOAuthAuthorization( + ctx context.Context, + request OAuthAuthorizationRequest, +) (code string, expiresIn int64, err error) { + resolved, err := s.ResolveOAuthAuthorization(ctx, request) + if err != nil { + return "", 0, err + } + // Consent is enforced here, not merely advertised by ResolveOAuthAuthorization. + // A caller that was told consent is required and asks for a code without it + // is refused: the page is not the only thing deciding whether the person + // approved. + if resolved.RequiresConsent && !request.ConsentGranted { + return "", 0, authorizationError(OAuthErrorAccessDenied, + "this client needs the person's approval before a code is issued", true) + } + issued, err := s.issueAuthorizationCode(ctx, authorizationCodeGrant{ + Client: resolved.Client, + RedirectURI: request.RedirectURI, + CodeChallenge: request.CodeChallenge, + Resource: resolved.Resource.Value, + Scope: resolved.Scope, + }) + if err != nil { + return "", 0, err + } + return issued.Code, issued.ExpiresIn, nil +} + +// OAuthTokenRequest is one token-endpoint request, as RFC 6749 §3.2 delivers +// it: form-encoded, with the grant type selecting which of the other fields +// matter. +type OAuthTokenRequest struct { + GrantType string + ClientID string + Code string + RedirectURI string + CodeVerifier string + RefreshToken string + Resource string + Scope string +} + +// OAuthTokenResponse is the RFC 6749 §5.1 response. `token_type` is required by +// the specification and was missing from this host's own token RPC, which is +// why a client written against a standard library could not use it. +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// ExchangeOAuthToken is the standard token endpoint over the registered-client +// exchange. Both grants return the credential in the body: a public client has +// no cookie jar the host controls, and the refresh half rotates on every use. +func (s *Service) ExchangeOAuthToken( + ctx context.Context, + request OAuthTokenRequest, +) (*OAuthTokenResponse, error) { + switch strings.TrimSpace(request.GrantType) { + case "authorization_code": + return s.exchangeOAuthAuthorizationCode(ctx, request) + case "refresh_token": + return s.exchangeOAuthRefreshToken(ctx, request) + case "": + return nil, authorizationError(OAuthErrorInvalidRequest, "grant_type is required", false) + default: + return nil, authorizationError(OAuthErrorUnsupportedGrant, + "this authorization server supports the authorization_code and refresh_token grants", false) + } +} + +func (s *Service) exchangeOAuthAuthorizationCode( + ctx context.Context, + request OAuthTokenRequest, +) (*OAuthTokenResponse, error) { + client, err := s.resolveOAuthClient(ctx, request.ClientID) + if err != nil { + return nil, authorizationError(OAuthErrorInvalidClient, "unknown client", false) + } + redeemed, err := s.redeemAuthorizationCode(ctx, client, authorizationCodeRedemption{ + Code: request.Code, + RedirectURI: request.RedirectURI, + CodeVerifier: request.CodeVerifier, + // RFC 8707 §2.2: a token request may repeat `resource`, and it must be + // one the authorization granted. It is checked against the code's own + // binding rather than being allowed to set it, so a client cannot widen + // at redemption what the person approved at authorization. + Resource: strings.TrimSpace(request.Resource), + }) + if err != nil { + if invalid := new(OAuthAuthorizationError); errors.As(err, &invalid) { + return nil, err + } + return nil, authorizationError(OAuthErrorInvalidGrant, + "the authorization code is invalid, expired, or already used", false) + } + return redeemed, nil +} + +func (s *Service) exchangeOAuthRefreshToken( + ctx context.Context, + request OAuthTokenRequest, +) (*OAuthTokenResponse, error) { + client, err := s.resolveOAuthClient(ctx, request.ClientID) + if err != nil { + return nil, authorizationError(OAuthErrorInvalidClient, "unknown client", false) + } + if strings.TrimSpace(request.RefreshToken) == "" { + return nil, authorizationError(OAuthErrorInvalidRequest, "refresh_token is required", false) + } + // RFC 6749 §6: a refresh request's scope must not exceed the grant. This + // host issues one scope, so anything else is refused — and refused BEFORE + // the rotation, so a client that asked for more does not lose the token it + // has for asking. + if _, err := resolveOAuthScope(request.Scope); err != nil { + return nil, authorizationError(OAuthErrorInvalidScope, + "this authorization server supports only the "+supportedOAuthScope+" scope", false) + } + // The resource is checked on the locked session row, before the token is + // consumed: a client naming the wrong one is refused and still holds the + // credential it legitimately has. Checking it after the rotation — on the + // token just minted — would have cost that client its session for sending a + // parameter RFC 8707 only lets it repeat. + pair, err := s.minter.VerifyClientRefresh( + ctx, request.RefreshToken, client.ClientID, strings.TrimSpace(request.Resource)) + if err != nil { + if errors.Is(err, auth.ErrRefreshResourceMismatch) { + return nil, authorizationError(OAuthErrorInvalidTarget, + "this refresh token is not bound to that resource", false) + } + return nil, authorizationError(OAuthErrorInvalidGrant, + "the refresh token is invalid, expired, or has been revoked", false) + } + return oauthTokenResponse(pair, supportedOAuthScope), nil +} + +func oauthTokenResponse(pair *auth.TokenPair, scope string) *OAuthTokenResponse { + return &OAuthTokenResponse{ + AccessToken: pair.AccessToken, + TokenType: "Bearer", + ExpiresIn: expiresInSeconds(pair.AccessTokenExpiresAt), + RefreshToken: pair.RefreshToken, + Scope: scope, + } +} + +// authorizationCodeGrant is what issueAuthorizationCode needs: a resolved +// client plus the request fields the code is bound to. +type authorizationCodeGrant struct { + Client auth.RegisteredClient + RedirectURI string + CodeChallenge string + Resource string + Scope string +} + +type issuedAuthorizationCode struct { + Code string + ExpiresIn int64 +} + +// issueAuthorizationCode is the one place a code is minted, shared by the +// standard authorize endpoint and by the IssueClientAuthorizationCode RPC the +// first registered client was built against. +// callerMayAuthorizeAClient decides whether a verified caller may hand a client +// a credential. A code issued here authorizes a client to act as the person for +// as long as it keeps rotating, so what is checked is not "is this request +// authenticated" but "is there a real person, in a real session, who is +// themselves". +// +// Extracted from issueAuthorizationCode so each refusal is decidable without a +// database. The path it lives on needs a store, so covering these four +// conditions only through that path meant one DB test standing for four rules — +// and a test that stops at the first refusal it meets proves nothing about the +// others, because any of them would have produced the same error. +func callerMayAuthorizeAClient(caller auth.RequestIdentity, hasCaller bool) error { + switch { + case !hasCaller: + // No verified identity at all. The interceptor would normally have + // stopped this, but a rule this consequential does not delegate. + return auth.ErrClientAuthorizationRejected + case caller.EffectiveSubject == uuid.Nil: + // Verified, but naming no person. There is nobody for the client to act + // as, so there is nothing to authorize. + return auth.ErrClientAuthorizationRejected + case caller.SessionID == uuid.Nil: + // A person with no session. The credential the client receives is bound + // to a session for its whole life — that is what revoking a session + // revokes — so one minted outside a session could not be withdrawn. + return auth.ErrClientAuthorizationRejected + case caller.Impersonated(): + // An impersonated session may not hand a client a credential: the + // client would hold a rotatable token for a person who never + // authorized it, and would keep it after the impersonation ended. + return auth.ErrClientAuthorizationRejected + default: + return nil + } +} + +func (s *Service) issueAuthorizationCode( + ctx context.Context, + grant authorizationCodeGrant, +) (*issuedAuthorizationCode, error) { + w := wool.Get(ctx).In("issueAuthorizationCode") + store, hasStore := s.store.(ClientAuthorizationCodeStore) + if !hasStore { + return nil, w.NewError("store does not implement ClientAuthorizationCodeStore") + } + caller, hasCaller := auth.VerifiedRequestIdentity(ctx) + if err := callerMayAuthorizeAClient(caller, hasCaller); err != nil { + return nil, err + } + + plaintext, hash, err := newClientAuthorizationCode() + if err != nil { + return nil, w.Wrapf(err, "cannot generate client authorization code") + } + now := time.Now() + record := &ClientAuthorizationCode{ + ID: NewIDString(), + CodeHash: hash, + ClientID: grant.Client.ClientID, + RedirectURI: grant.RedirectURI, + CodeChallenge: grant.CodeChallenge, + Resource: grant.Resource, + Scope: grant.Scope, + UserID: caller.EffectiveSubjectID(), + SessionID: caller.SessionID.String(), + ExpiresAt: now.Add(clientAuthorizationCodeTTL), + CreatedAt: now, + } + if err := s.store.WithUserTx(ctx, record.UserID, func(ctx context.Context) error { + return store.CreateClientAuthorizationCode(ctx, record) + }); err != nil { + return nil, w.Wrapf(err, "persist client authorization code") + } + + payload := map[string]any{"client_id": grant.Client.ClientID} + if grant.Resource != "" { + payload["resource"] = grant.Resource + } + if grant.Client.Metadata { + // Which clients signed in by publishing a document, rather than by + // being declared, is exactly the thing an operator who enabled the + // mechanism needs to be able to read back out of the audit trail. + payload["client_registration"] = "metadata_document" + } + s.emit(ctx, record.UserID, "user", EventAuthClientAuthorized, + "session", record.SessionID, renderOrgID(caller.OrgID), payload) + + return &issuedAuthorizationCode{ + Code: plaintext, + ExpiresIn: expiresInSeconds(record.ExpiresAt), + }, nil +} + +// redemptionBindingsHold reports whether a presented code may be redeemed: +// every binding the code carries, AND the client's policy as it stands now. +// +// Extracted so the rule is testable without a database — it is a security +// decision, and the only other cover for it was a DB-backed story, which means +// nothing holds it in a suite that runs without one. +func redemptionBindingsHold( + client auth.RegisteredClient, + code *ClientAuthorizationCode, + presented authorizationCodeRedemption, +) bool { + // The bindings recorded when the person approved it. + if code.ClientID != client.ClientID || + code.RedirectURI != presented.RedirectURI || + !verifyCodeChallenge(code.CodeChallenge, presented.CodeVerifier) { + return false + } + // And the client's CURRENT policy must still permit that URI. A metadata + // client's document is re-fetched on a short TTL, so between authorization + // and redemption it may have withdrawn the callback — which is how such a + // client revokes one, since there is no row to delete. Comparing only + // against the code row would honour a callback the publisher had already + // taken down, and on another replica with a colder cache the same code + // would be refused: the binding must come from the policy in force now, + // not from the policy at issue time. + return client.AllowsRedirect(presented.RedirectURI) +} + +// authorizationCodeRedemption is what a token request presents for the +// authorization-code grant. +type authorizationCodeRedemption struct { + Code string + RedirectURI string + CodeVerifier string + Resource string +} + +// redeemAuthorizationCode consumes a code and mints the client's session. It is +// shared with ExchangeClientToken, so there is one code consumption path, one +// PKCE check, and one place the session is created. +func (s *Service) redeemAuthorizationCode( + ctx context.Context, + client auth.RegisteredClient, + presented authorizationCodeRedemption, +) (*OAuthTokenResponse, error) { + w := wool.Get(ctx).In("redeemAuthorizationCode") + store, ok := s.store.(ClientAuthorizationCodeStore) + if !ok { + return nil, w.NewError("store does not implement ClientAuthorizationCodeStore") + } + if s.minter == nil { + return nil, auth.ErrClientAuthorizationRejected + } + + var pair *auth.TokenPair + var redeemed *ClientAuthorizationCode + var mismatch *OAuthAuthorizationError + err := store.ConsumeClientAuthorizationCode( + ctx, + hashClientAuthorizationCode(presented.Code), + time.Now(), + func(txCtx context.Context, code *ClientAuthorizationCode) error { + // The code is consumed before any of this is checked, so a mismatch + // burns it rather than leaving it to be guessed at again. + if !redemptionBindingsHold(client, code, presented) { + return auth.ErrClientAuthorizationRejected + } + // A repeated `resource` must be the one the code was issued for. + // Naming a different one is reported as invalid_target rather than + // as a bad grant, because the grant was fine and the client's own + // request is what disagrees with it. + if presented.Resource != "" && presented.Resource != code.Resource { + mismatch = authorizationError(OAuthErrorInvalidTarget, + "this authorization was not granted for that resource", false) + return auth.ErrClientAuthorizationRejected + } + userID, err := uuid.Parse(code.UserID) + if err != nil { + return auth.ErrClientAuthorizationRejected + } + sessionID, err := uuid.Parse(code.SessionID) + if err != nil { + return auth.ErrClientAuthorizationRejected + } + // The code's consumption and the client's session must commit + // together: the marker is what lets the session store reuse this + // transaction instead of opening its own, which would commit the + // session while the code stayed redeemable. + pair, err = s.minter.MintForClient( + auth.WithAtomicSessionTransaction(txCtx), userID, sessionID, + client.ClientID, code.Resource) + if err != nil { + return err + } + redeemed = code + return nil + }, + ) + if mismatch != nil { + return nil, mismatch + } + if err != nil { + if errors.Is(err, auth.ErrClientAuthorizationRejected) || errors.Is(err, auth.ErrSessionUnavailable) { + return nil, auth.ErrClientAuthorizationRejected + } + return nil, w.Wrapf(err, "redeem client authorization code") + } + if pair == nil || redeemed == nil { + return nil, auth.ErrClientAuthorizationRejected + } + + // The audit row describes the session this exchange created, not the host + // session that authorized it, and it must carry that session's organization: + // audit_events' tenant policy matches on a non-null org_id, so a row emitted + // without one is readable by no tenant at all. The minted token is the + // authoritative record of both. + minted, err := s.minter.VerifyAccess(pair.AccessToken) + if err != nil { + return nil, w.Wrapf(err, "verify freshly minted client access token") + } + payload := map[string]any{"client_id": client.ClientID} + if redeemed.Resource != "" { + payload["resource"] = redeemed.Resource + } + s.emit(ctx, redeemed.UserID, "user", EventAuthLogin, + "session", minted.SessionID.String(), renderOrgID(minted.OrgID), payload) + + scope := redeemed.Scope + if scope == "" { + scope = supportedOAuthScope + } + return oauthTokenResponse(pair, scope), nil +} diff --git a/module/services/accounts/code/pkg/business/oauth_authorization_server_test.go b/module/services/accounts/code/pkg/business/oauth_authorization_server_test.go new file mode 100644 index 000000000..5dd1091f1 --- /dev/null +++ b/module/services/accounts/code/pkg/business/oauth_authorization_server_test.go @@ -0,0 +1,496 @@ +package business_test + +import ( + "context" + "encoding/json" + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + + "github.com/stretchr/testify/require" +) + +// The authorization-server surface, without a database. Everything here is +// decided from configuration and the request: the registry, the metadata +// document, the PKCE shape, the scope, and the resource indicator. The code +// table and the minting are exercised in the DB-backed story beside it. + +const claudeCodeMetadataClientID = "https://claude.ai/oauth/claude-code-client-metadata" + +const theHost = "https://host.example.com" + +// oauthService builds a service with the two client sources wired and nothing +// else. `declaredMetadata` is the IDENTITY_CLIENT_METADATA_DOCUMENTS value. +func oauthService(t *testing.T, declaredMetadata string) *business.Service { + t.Helper() + service, err := business.NewService(nil) + require.NoError(t, err) + + registry, err := auth.NewClientRegistry(`[ + {"client_id": "example-addin", "name": "Example Add-in", "kind": "public", + "redirect_uris": ["https://addin.example.com/auth/callback"], + "origins": ["https://addin.example.com"]}, + {"client_id": "example-cli", "name": "Example CLI", + "redirect_uris": ["http://localhost/callback"]} + ]`) + require.NoError(t, err) + service.SetClientRegistry(registry) + + policy, err := auth.NewClientMetadataPolicy(declaredMetadata) + require.NoError(t, err) + service.SetClientMetadataResolver(auth.NewClientMetadataResolver(policy)) + // The issuer is configuration, resolved once — not the request's origin. A + // test that wants it absent clears it explicitly (see + // TestTheMetadataDocumentIsNotPublishedWithoutAnIssuer). + service.SetOAuthIssuer(theHost) + return service +} + +// issuerContext is what the frontend's trusted public origin looks like by the +// time it reaches the service. +func issuerContext(t *testing.T) context.Context { + t.Helper() + ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), theHost) + require.NoError(t, err) + return ctx +} + +func authorizeRequest() business.OAuthAuthorizationRequest { + return business.OAuthAuthorizationRequest{ + ResponseType: "code", + ClientID: "example-addin", + RedirectURI: "https://addin.example.com/auth/callback", + CodeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod: "S256", + State: "opaque-state", + } +} + +// RFC 8414 conformance, which is the first link in an MCP client's discovery +// chain. Every value is asserted because every value is a promise about what +// this code does, and a document claiming a capability the host lacks sends +// clients into a flow that cannot complete. +func TestTheAuthorizationServerMetadataDocumentConforms(t *testing.T) { + service := oauthService(t, "any") + metadata, err := service.AuthorizationServerMetadata(issuerContext(t)) + require.NoError(t, err) + + require.Equal(t, theHost, metadata.Issuer) + require.Equal(t, theHost+"/oauth2/authorize", metadata.AuthorizationEndpoint) + require.Equal(t, theHost+"/oauth2/token", metadata.TokenEndpoint) + require.Equal(t, theHost+"/v1/auth/.well-known/jwks.json", metadata.JWKSURI) + require.Equal(t, []string{"S256"}, metadata.CodeChallengeMethodsSupported) + require.Equal(t, []string{"authorization_code", "refresh_token"}, metadata.GrantTypesSupported) + require.Equal(t, []string{"none"}, metadata.TokenEndpointAuthMethodsSupported) + require.Equal(t, []string{"code"}, metadata.ResponseTypesSupported) + require.Equal(t, []string{"offline_access"}, metadata.ScopesSupported) + require.True(t, metadata.ClientIDMetadataDocumentSupported) + require.True(t, metadata.AuthorizationResponseISSParameterSupported) + require.Empty(t, metadata.ServiceDocumentation) +} + +// No registration_endpoint, ever. Dynamic client registration (RFC 7591) is +// deprecated by the MCP specification in favour of metadata documents, and it +// is the one mechanism here that would grow a durable registry on +// unauthenticated request. Its ABSENCE from the document is how a conforming +// client learns not to try, so it is asserted rather than assumed — the field +// is not in the struct at all, which this holds by serialising the document. +func TestTheMetadataDocumentPublishesNoRegistrationEndpoint(t *testing.T) { + service := oauthService(t, "any") + metadata, err := service.AuthorizationServerMetadata(issuerContext(t)) + require.NoError(t, err) + require.NotContains(t, marshalJSON(t, metadata), "registration_endpoint") +} + +// A deployment that has not enabled metadata documents says so in the +// document, so a client reads "do not bother" rather than trying and failing. +func TestTheMetadataDocumentReportsWhetherMetadataClientsAreEnabled(t *testing.T) { + service := oauthService(t, "") + metadata, err := service.AuthorizationServerMetadata(issuerContext(t)) + require.NoError(t, err) + require.False(t, metadata.ClientIDMetadataDocumentSupported) +} + +// Without a trusted origin every URL in the document would be a guess, and a +// client that fetched it would send its authorization request to whatever it +// named. Fail closed. +func TestTheMetadataDocumentIsNotPublishedWithoutAnIssuer(t *testing.T) { + service := oauthService(t, "any") + service.SetOAuthIssuer("") + _, err := service.AuthorizationServerMetadata(context.Background()) + require.ErrorIs(t, err, business.ErrOAuthIssuerUnavailable) +} + +// And a resource is refused for the same reason, rather than being pinned to +// whatever origin the request arrived on. +// +// The gateway recognises a resource against the one CONFIGURED address, and +// refuses a tool endpoint outright when there is none. A second notion of this +// host's address here would mint a credential that endpoint rejects: the +// authorization succeeds, the token issues, and the first tool call fails with +// nothing in the flow naming the configuration that caused it. Refusing here +// puts the answer where an operator is already watching. +func TestAResourceIsRefusedWithoutAConfiguredAddress(t *testing.T) { + service := oauthService(t, "any") + service.SetOAuthIssuer("") + + request := authorizeRequest() + request.Resource = theHost + "/api/solutions/example/proxy/mcp" + _, err := service.ResolveOAuthAuthorization(issuerContext(t), request) + + require.Error(t, err) + require.Contains(t, err.Error(), "no configured public address") +} + +// With no resource the same deployment still signs a person in to a registered +// client. The legacy browser handoff never named a resource and does not +// acquire a new configuration prerequisite from this change (A1007B-03). +func TestAResourcelessAuthorizationStillWorksWithoutAConfiguredAddress(t *testing.T) { + service := oauthService(t, "any") + service.SetOAuthIssuer("") + + resolved, err := service.ResolveOAuthAuthorization(issuerContext(t), authorizeRequest()) + + require.NoError(t, err) + require.Equal(t, "Example Add-in", resolved.Client.Name) + require.Empty(t, resolved.Resource.Value) +} + +func TestAnOperatorDeclaredClientNeedsNoConsent(t *testing.T) { + service := oauthService(t, "any") + resolved, err := service.ResolveOAuthAuthorization(issuerContext(t), authorizeRequest()) + require.NoError(t, err) + require.Equal(t, "Example Add-in", resolved.Client.Name) + require.False(t, resolved.Client.Metadata) + require.Equal(t, "offline_access", resolved.Scope) + // The deployment vouched for this client and the request narrows nothing, + // so adding a prompt would change a shipped client's flow for no gain in + // what the person can decide. + require.False(t, resolved.RequiresConsent) +} + +// A request naming a resource is a request to narrow a credential to something +// specific, which the person should see named. +func TestARequestNamingAResourceNeedsConsent(t *testing.T) { + service := oauthService(t, "any") + request := authorizeRequest() + request.Resource = theHost + "/api/solutions/example/proxy/mcp" + + resolved, err := service.ResolveOAuthAuthorization(issuerContext(t), request) + require.NoError(t, err) + require.Equal(t, request.Resource, resolved.Resource.Value) + require.Equal(t, "example", resolved.Resource.SolutionID) + require.True(t, resolved.RequiresConsent) +} + +// A resource at somebody else's origin is refused: this host does not mint +// audiences for hosts it is not. +func TestAResourceAtAnotherOriginIsRefused(t *testing.T) { + service := oauthService(t, "any") + request := authorizeRequest() + request.Resource = "https://evil.example.com/api/solutions/example/proxy/mcp" + + _, err := service.ResolveOAuthAuthorization(issuerContext(t), request) + refusal := requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorInvalidTarget, refusal.Code) + require.True(t, refusal.Redirectable, + "the redirect URI was validated first, so the client is told") +} + +// Each authorize-side rule, and whether the refusal may be delivered to the +// client. The `redirectable` half is the load-bearing one: redirecting to a +// redirect_uri before it is validated turns this endpoint into an open +// redirector any site can use. +func TestEachAuthorizeRuleRefusesWithTheRightDeliverability(t *testing.T) { + service := oauthService(t, "any") + ctx := issuerContext(t) + + tests := []struct { + name string + mutate func(*business.OAuthAuthorizationRequest) + wantCode string + wantRedirectable bool + }{ + { + name: "an unregistered client id", + mutate: func(r *business.OAuthAuthorizationRequest) { r.ClientID = "nobody" }, + wantCode: business.OAuthErrorInvalidClient, + // Nothing has validated a redirect URI for a client that does not + // exist, so the browser must not be sent to the one it named. + wantRedirectable: false, + }, + { + name: "a redirect URI the client did not register", + mutate: func(r *business.OAuthAuthorizationRequest) { r.RedirectURI = "https://evil.example.com/cb" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: false, + }, + { + name: "a redirect URI registered to a DIFFERENT client", + mutate: func(r *business.OAuthAuthorizationRequest) { r.RedirectURI = "http://localhost/callback" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: false, + }, + { + name: "a response type other than code", + mutate: func(r *business.OAuthAuthorizationRequest) { r.ResponseType = "token" }, + // RFC 6749 §4.1.2.1's own code, not invalid_request: the request is + // well formed and asks for something this server does not serve. + wantCode: business.OAuthErrorUnsupportedResponse, + wantRedirectable: true, + }, + { + name: "no response type at all", + mutate: func(r *business.OAuthAuthorizationRequest) { r.ResponseType = "" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: true, + }, + { + name: "no PKCE method at all", + mutate: func(r *business.OAuthAuthorizationRequest) { r.CodeChallengeMethod = "" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: true, + }, + { + name: "the plain PKCE method", + mutate: func(r *business.OAuthAuthorizationRequest) { r.CodeChallengeMethod = "plain" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: true, + }, + { + name: "no PKCE challenge at all", + mutate: func(r *business.OAuthAuthorizationRequest) { r.CodeChallenge = "" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: true, + }, + { + name: "a malformed PKCE challenge", + mutate: func(r *business.OAuthAuthorizationRequest) { r.CodeChallenge = "not+base64url/at+all" }, + wantCode: business.OAuthErrorInvalidRequest, + wantRedirectable: true, + }, + { + name: "a scope this host does not issue", + mutate: func(r *business.OAuthAuthorizationRequest) { r.Scope = "admin" }, + wantCode: business.OAuthErrorInvalidScope, + wantRedirectable: true, + }, + { + name: "a resource that is not a solution MCP endpoint", + mutate: func(r *business.OAuthAuthorizationRequest) { r.Resource = theHost + "/v1/users" }, + wantCode: business.OAuthErrorInvalidTarget, + wantRedirectable: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := authorizeRequest() + test.mutate(&request) + _, err := service.ResolveOAuthAuthorization(ctx, request) + refusal := requireAuthorizationError(t, err) + require.Equal(t, test.wantCode, refusal.Code) + require.Equal(t, test.wantRedirectable, refusal.Redirectable) + }) + } +} + +// The one scope this host publishes is accepted, present or absent, and +// anything else is refused rather than silently dropped. A dropped scope is a +// client believing it was granted something. +func TestTheSupportedScopeIsAcceptedAndEveryOtherIsRefused(t *testing.T) { + service := oauthService(t, "any") + ctx := issuerContext(t) + + for _, scope := range []string{"", "offline_access"} { + request := authorizeRequest() + request.Scope = scope + resolved, err := service.ResolveOAuthAuthorization(ctx, request) + require.NoError(t, err, "should accept scope %q", scope) + require.Equal(t, "offline_access", resolved.Scope) + } + for _, scope := range []string{"admin", "offline_access admin", "openid"} { + request := authorizeRequest() + request.Scope = scope + _, err := service.ResolveOAuthAuthorization(ctx, request) + require.Error(t, err, "should refuse scope %q", scope) + } +} + +// A metadata client is refused outright where the deployment did not enable +// the mechanism — the same answer an unregistered slug gets, so an +// unauthenticated caller cannot tell the two apart. +func TestAMetadataClientIsRefusedWhenTheDeploymentHasNotEnabledThem(t *testing.T) { + service := oauthService(t, "") + request := authorizeRequest() + request.ClientID = claudeCodeMetadataClientID + request.RedirectURI = "http://localhost:54321/callback" + + _, err := service.ResolveOAuthAuthorization(issuerContext(t), request) + refusal := requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorInvalidClient, refusal.Code) + require.False(t, refusal.Redirectable) +} + +// An unsupported grant type is named as such, not collapsed into a bad grant: +// a client that asked for client_credentials needs to know this host has none, +// not that its credential was wrong. +func TestAnUnsupportedGrantTypeIsNamed(t *testing.T) { + service := oauthService(t, "any") + _, err := service.ExchangeOAuthToken(context.Background(), business.OAuthTokenRequest{ + GrantType: "client_credentials", + ClientID: "example-addin", + }) + refusal := requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorUnsupportedGrant, refusal.Code) + + _, err = service.ExchangeOAuthToken(context.Background(), business.OAuthTokenRequest{ + ClientID: "example-addin", + }) + refusal = requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal.Code) +} + +func requireAuthorizationError(t *testing.T, err error) *business.OAuthAuthorizationError { + t.Helper() + require.Error(t, err) + refusal, ok := err.(*business.OAuthAuthorizationError) + require.True(t, ok, "expected an OAuthAuthorizationError, got %T: %v", err, err) + return refusal +} + +func marshalJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + require.NoError(t, err) + return string(raw) +} + +// The grant endpoint re-resolves the whole request rather than trusting what +// the page hands it. The page carries the request through sessionStorage across +// a sign-in and a navigation, so by the time it asks for a code the values have +// been outside the host's hands — and a tampered one must not be able to widen +// anything. This is the test that re-resolution is real and not an optimisation +// someone could remove. +func TestTheGrantPathReResolvesTheRequestItIsGiven(t *testing.T) { + service := oauthService(t, "any") + ctx := issuerContext(t) + + // Same values the page would hold, with the redirect URI swapped for one the + // client never registered. No session is needed to prove the point: the + // refusal must come from resolution, before the code issuer is reached at + // all, which is what makes it independent of who is calling. + request := authorizeRequest() + request.RedirectURI = "https://evil.example.com/cb" + + _, _, err := service.GrantOAuthAuthorization(ctx, request) + refusal := requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal.Code) + require.False(t, refusal.Redirectable) +} + +// A1007-02. One issuer: what the metadata publishes is what tokens carry. The +// metadata is built from the CONFIGURED value, never from the request, so two +// clients reaching the same deployment through different Host headers cannot be +// handed two different issuers — and a token minted under one cannot fail +// verification against the other. Adopted from the Astra review +// (issuer_matches_metadata). +func TestTheMetadataIssuerIsTheConfiguredOneAndNotTheRequestOrigin(t *testing.T) { + service := oauthService(t, "any") + service.SetOAuthIssuer(theHost) + + // A request arriving with a different verified origin must not move it. + elsewhere, err := auth.WithVerifiedPublicOrigin(context.Background(), + "https://other-host.example.com") + require.NoError(t, err) + metadata, err := service.AuthorizationServerMetadata(elsewhere) + require.NoError(t, err) + require.Equal(t, theHost, metadata.Issuer) + require.Equal(t, theHost+"/oauth2/authorize", metadata.AuthorizationEndpoint) + + // And with no configured issuer nothing is published, whatever the request + // claims: every URL in the document would name a host this service cannot + // vouch for, and a client that fetched it would authorize somewhere else. + bare := oauthService(t, "any") + bare.SetOAuthIssuer("") + _, err = bare.AuthorizationServerMetadata(elsewhere) + require.ErrorIs(t, err, business.ErrOAuthIssuerUnavailable) +} + +// A1007-07. response_type is required and `code` is the only one served, and a +// request for another gets RFC 6749 §4.1.2.1's own code rather than +// invalid_request — a client asking for `token` has made a well-formed request +// for something this server does not do. +func TestTheResponseTypeIsRequiredAndOnlyCodeIsServed(t *testing.T) { + service := oauthService(t, "any") + ctx := issuerContext(t) + + request := authorizeRequest() + request.ResponseType = "" + refusal := requireAuthorizationError(t, + mustFailResolve(t, service, ctx, request)) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal.Code) + require.Contains(t, refusal.Description, "response_type is required") + + request.ResponseType = "token" + refusal = requireAuthorizationError(t, mustFailResolve(t, service, ctx, request)) + require.Equal(t, business.OAuthErrorUnsupportedResponse, refusal.Code) + require.True(t, refusal.Redirectable) + + // And the method is not defaulted either: a request naming none has not + // asked for S256, and supplying it would mask a client that believed it was + // sending `plain`. + request = authorizeRequest() + request.CodeChallengeMethod = "" + refusal = requireAuthorizationError(t, mustFailResolve(t, service, ctx, request)) + require.Equal(t, business.OAuthErrorInvalidRequest, refusal.Code) +} + +// A1007-07. A refresh request's scope must not exceed the grant (RFC 6749 §6), +// and it is refused BEFORE the rotation — so a client that asked for more does +// not lose the token it has for asking. +func TestARefreshScopeBeyondTheGrantIsRefusedBeforeRotation(t *testing.T) { + service := oauthService(t, "any") + + _, err := service.ExchangeOAuthToken(context.Background(), business.OAuthTokenRequest{ + GrantType: "refresh_token", + ClientID: "example-addin", + RefreshToken: "whatever", + Scope: "admin", + }) + refusal := requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorInvalidScope, refusal.Code) +} + +// Consent is enforced by the HOST, not merely advertised to the page. A caller +// told consent is required and asking for a code without saying the person +// approved is refused — which is what stops a browser-side defect (a +// mis-decoded `requires_consent`, a dropped navigation) from issuing +// credentials nobody approved. +func TestAGrantNeedingConsentIsRefusedWithoutIt(t *testing.T) { + service := oauthService(t, "any") + ctx := issuerContext(t) + request := authorizeRequest() + request.Resource = theHost + "/api/solutions/example/proxy/mcp" + + resolved, err := service.ResolveOAuthAuthorization(ctx, request) + require.NoError(t, err) + require.True(t, resolved.RequiresConsent) + + _, _, err = service.GrantOAuthAuthorization(ctx, request) + refusal := requireAuthorizationError(t, err) + require.Equal(t, business.OAuthErrorAccessDenied, refusal.Code) + require.Contains(t, refusal.Description, "approval") +} + +func mustFailResolve( + t *testing.T, + service *business.Service, + ctx context.Context, + request business.OAuthAuthorizationRequest, +) error { + t.Helper() + _, err := service.ResolveOAuthAuthorization(ctx, request) + require.Error(t, err) + return err +} diff --git a/module/services/accounts/code/pkg/business/oauth_mcp_story_test.go b/module/services/accounts/code/pkg/business/oauth_mcp_story_test.go new file mode 100644 index 000000000..cf4d05c6f --- /dev/null +++ b/module/services/accounts/code/pkg/business/oauth_mcp_story_test.go @@ -0,0 +1,331 @@ +//go:build !pure + +package business_test + +import ( + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" +) + +// The MCP client's whole path, end to end against the real database: a client +// that registered itself by publishing a metadata document, a loopback redirect +// on an ephemeral port, a resource indicator, consent, the code, the token, and +// the audience the token ends up carrying. +// +// The story the issue asks for. Everything in it is a thing that could be built +// separately and still not work together — the registry fallback, the loopback +// port rule, the code's resource binding, the audience on the minted token, and +// the rotation that must reissue it. + +const mcpClientID = "https://claude.ai/oauth/claude-code-client-metadata" + +const mcpClientDocument = `{"client_id":"https://claude.ai/oauth/claude-code-client-metadata","client_name":"Claude Code","client_uri":"https://claude.ai","redirect_uris":["http://localhost/callback","http://127.0.0.1/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}` + +const mcpHostOrigin = "https://host.example.com" + +// admitMetadataClient wires the published document as a resolved client for the +// duration of one test, without a network fetch. The fetch itself — its bounds, +// its SSRF guard, its cache — is covered in pkg/auth; what this story needs is +// the document's effect on the flow. +func admitMetadataClient(t *testing.T) { + t.Helper() + policy, err := auth.NewClientMetadataPolicy("any") + require.NoError(t, err) + // The document goes through every validation rule; only the HTTP fetch is + // stood in for. Its bounds, SSRF guard and cache are covered in pkg/auth. + testService.SetClientMetadataResolver(auth.NewClientMetadataResolverWith(policy, + auth.StaticMetadataDocuments{mcpClientID: mcpClientDocument})) + // The issuer is configuration, and the resource origin is pinned to it. + testService.SetOAuthIssuer(mcpHostOrigin) + t.Cleanup(func() { + testService.SetClientMetadataResolver(nil) + testService.SetOAuthIssuer("") + }) +} + +func mcpAuthorizationRequest() business.OAuthAuthorizationRequest { + return business.OAuthAuthorizationRequest{ + ResponseType: "code", + ClientID: mcpClientID, + // The ephemeral loopback port Claude Code actually listens on. The + // document names no port; RFC 8252 §7.3 is what makes this match. + RedirectURI: "http://localhost:54321/callback", + CodeChallenge: testCodeChallenge(), + CodeChallengeMethod: "S256", + State: "opaque-state", + Resource: mcpHostOrigin + "/api/solutions/example/proxy/mcp", + } +} + +func TestStory_HOST_MCP_001(t *testing.T) { + clearData(t) + admitMetadataClient(t) + + // The authorization endpoint validates everything before any sign-in UI. + // The browser is still on the host and nothing has been typed. + publicCtx, err := auth.WithVerifiedPublicOrigin(testCtx, mcpHostOrigin) + require.NoError(t, err) + + resolved, err := testService.ResolveOAuthAuthorization(publicCtx, mcpAuthorizationRequest()) + require.NoError(t, err) + require.Equal(t, "Claude Code", resolved.Client.Name) + require.True(t, resolved.Client.Metadata, "nothing durable was written for this client") + require.Equal(t, "example", resolved.Resource.SolutionID) + require.True(t, resolved.RequiresConsent, + "a client the operator never declared, narrowing to a named resource, is the person's decision") + + // The person signs in on the host's own login page and approves. The code is + // issued from THEIR session, so it names an identity the client never saw + // authenticate. + signedIn, hostSession := signInAsClientUser(t, "google-mcp-flow", "mcp-flow@test.com") + signedInWithOrigin, err := auth.WithVerifiedPublicOrigin(signedIn, mcpHostOrigin) + require.NoError(t, err) + + // Consent stated, as the consent page states it after the person pressed + // Allow. Without it the host refuses — see + // TestAGrantNeedingConsentIsRefusedWithoutIt. + approved := mcpAuthorizationRequest() + approved.ConsentGranted = true + code, expiresIn, err := testService.GrantOAuthAuthorization( + signedInWithOrigin, approved) + require.NoError(t, err) + require.NotEmpty(t, code) + require.Positive(t, expiresIn) + + // The token endpoint. Form encoding is the adapter's business; what matters + // here is the response shape a standard OAuth library reads, and the + // audience the access token carries. + tokens, err := testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "authorization_code", + ClientID: mcpClientID, + Code: code, + RedirectURI: "http://localhost:54321/callback", + CodeVerifier: testCodeVerifier, + Resource: mcpHostOrigin + "/api/solutions/example/proxy/mcp", + }) + require.NoError(t, err) + require.Equal(t, "Bearer", tokens.TokenType, "RFC 6749 §5.1 requires it") + require.NotEmpty(t, tokens.AccessToken) + require.NotEmpty(t, tokens.RefreshToken) + require.Equal(t, "offline_access", tokens.Scope) + require.Positive(t, tokens.ExpiresIn) + + // The token is a SESSION credential for the person, bound to the resource. + identity, err := testService.JWTMinter().VerifyAccess(tokens.AccessToken) + require.NoError(t, err) + require.Equal(t, mcpClientID, identity.ClientID, "azp names the client") + require.Equal(t, hostSession.User.Uuid, identity.UserID.String(), "the subject is the person") + require.NotEmpty(t, identity.SessionID, "a session id is what lets the SDK mint a Work Context") + require.Equal(t, mcpHostOrigin+"/api/solutions/example/proxy/mcp", identity.Resource) + + // Rotation keeps the audience. A rotation that re-read it from the request + // would let the client move its own binding by asking. + rotated, err := testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "refresh_token", + ClientID: mcpClientID, + RefreshToken: tokens.RefreshToken, + }) + require.NoError(t, err) + require.NotEqual(t, tokens.RefreshToken, rotated.RefreshToken, "the refresh half rotates") + rotatedIdentity, err := testService.JWTMinter().VerifyAccess(rotated.AccessToken) + require.NoError(t, err) + require.Equal(t, mcpHostOrigin+"/api/solutions/example/proxy/mcp", rotatedIdentity.Resource) + + // Revocation follows the registered-client tokens: the person's own browser + // session is untouched by any of it. + _, err = testService.RefreshToken(testCtx, &gen.RefreshTokenRequest{RefreshToken: hostSession.RefreshToken}) + require.NoError(t, err, "authorizing an MCP client must not consume the host session") +} + +// A repeated `resource` at the token endpoint must be the one the +// authorization granted. Naming a different one cannot widen what the person +// approved — and because the code is consumed before the check, the attempt +// burns it rather than leaving it to be tried again with another resource. +func TestAnMCPCodeCannotBeRedeemedForADifferentResource(t *testing.T) { + clearData(t) + admitMetadataClient(t) + + signedIn, _ := signInAsClientUser(t, "google-mcp-swap", "mcp-swap@test.com") + signedInWithOrigin, err := auth.WithVerifiedPublicOrigin(signedIn, mcpHostOrigin) + require.NoError(t, err) + + approved := mcpAuthorizationRequest() + approved.ConsentGranted = true + code, _, err := testService.GrantOAuthAuthorization(signedInWithOrigin, approved) + require.NoError(t, err) + + _, err = testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "authorization_code", + ClientID: mcpClientID, + Code: code, + RedirectURI: "http://localhost:54321/callback", + CodeVerifier: testCodeVerifier, + Resource: mcpHostOrigin + "/api/solutions/audit/proxy/mcp", + }) + require.Error(t, err) + + // And the code is spent, not left live for a second attempt. + _, err = testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "authorization_code", + ClientID: mcpClientID, + Code: code, + RedirectURI: "http://localhost:54321/callback", + CodeVerifier: testCodeVerifier, + Resource: mcpHostOrigin + "/api/solutions/example/proxy/mcp", + }) + require.Error(t, err) +} + +// A client naming the wrong resource on refresh is refused as invalid_target +// and keeps the session it legitimately holds. The refusal happens on the +// locked session row, before the token is consumed. +func TestAnMCPRefreshNamingAnotherResourceKeepsTheSession(t *testing.T) { + clearData(t) + admitMetadataClient(t) + + signedIn, _ := signInAsClientUser(t, "google-mcp-refresh", "mcp-refresh@test.com") + signedInWithOrigin, err := auth.WithVerifiedPublicOrigin(signedIn, mcpHostOrigin) + require.NoError(t, err) + approved := mcpAuthorizationRequest() + approved.ConsentGranted = true + code, _, err := testService.GrantOAuthAuthorization(signedInWithOrigin, approved) + require.NoError(t, err) + + tokens, err := testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "authorization_code", + ClientID: mcpClientID, + Code: code, + RedirectURI: "http://localhost:54321/callback", + CodeVerifier: testCodeVerifier, + }) + require.NoError(t, err) + + _, err = testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "refresh_token", + ClientID: mcpClientID, + RefreshToken: tokens.RefreshToken, + Resource: mcpHostOrigin + "/api/solutions/other/proxy/mcp", + }) + require.Error(t, err) + + // Still usable: a client told `invalid_target` has lost nothing. + rotated, err := testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "refresh_token", + ClientID: mcpClientID, + RefreshToken: tokens.RefreshToken, + Resource: mcpHostOrigin + "/api/solutions/example/proxy/mcp", + }) + require.NoError(t, err) + require.NotEmpty(t, rotated.AccessToken) +} + +// A loopback redirect on a port the document did not name is admitted; one with +// a different path is not. The rule applies to the presented port only, never to +// the path, which is what keeps a local attacker from registering a listener on +// a path the client never asked to be returned to. +func TestAnMCPRedirectMustStillMatchItsPath(t *testing.T) { + clearData(t) + admitMetadataClient(t) + + publicCtx, err := auth.WithVerifiedPublicOrigin(testCtx, mcpHostOrigin) + require.NoError(t, err) + + request := mcpAuthorizationRequest() + request.RedirectURI = "http://localhost:54321/stolen" + _, err = testService.ResolveOAuthAuthorization(publicCtx, request) + require.Error(t, err) + + request.RedirectURI = "http://127.0.0.1:1/callback" + _, err = testService.ResolveOAuthAuthorization(publicCtx, request) + require.NoError(t, err) +} + +// An impersonated session may not hand a client a credential: the client would +// hold a rotatable token for a person who never authorized it, and it would +// outlive the window, which is capped precisely so nothing rotatable does. The +// standard authorize endpoint inherits that rule from the shared code issuer +// rather than restating it, so this is the test that the inheritance is real. +func TestAnImpersonatedSessionCannotAuthorizeAnMCPClient(t *testing.T) { + clearData(t) + admitMetadataClient(t) + fixture := seedImpersonationFixture(t, "mcp") + + issued, err := testService.ImpersonateUser(testCtx, fixture.supportID, + &gen.ImpersonateUserRequest{UserId: fixture.memberID, Reason: impersonationReason}) + require.NoError(t, err) + identity, err := testService.JWTMinter().VerifyAccess(issued.AccessToken) + require.NoError(t, err) + impersonating := auth.WithVerifiedRequestIdentity(testCtx, auth.RequestIdentityOf(identity)) + impersonating, err = auth.WithVerifiedPublicOrigin(impersonating, mcpHostOrigin) + require.NoError(t, err) + + approved := mcpAuthorizationRequest() + approved.ConsentGranted = true + _, _, err = testService.GrantOAuthAuthorization(impersonating, approved) + require.ErrorIs(t, err, auth.ErrClientAuthorizationRejected) +} + +// And a caller with no verified session at all. The request itself is valid — +// the client resolves, the resource resolves — so the refusal can only come +// from the code issuer having nobody to bind the code to. +func TestAnUnauthenticatedCallerCannotAuthorizeAnMCPClient(t *testing.T) { + clearData(t) + admitMetadataClient(t) + + publicCtx, err := auth.WithVerifiedPublicOrigin(testCtx, mcpHostOrigin) + require.NoError(t, err) + _, err = testService.ResolveOAuthAuthorization(publicCtx, mcpAuthorizationRequest()) + require.NoError(t, err, "the request is well formed; only the caller is missing") + + approved := mcpAuthorizationRequest() + approved.ConsentGranted = true + _, _, err = testService.GrantOAuthAuthorization(publicCtx, approved) + require.ErrorIs(t, err, auth.ErrClientAuthorizationRejected) +} + +// A1007-05. A metadata client's document is re-fetched on a short TTL, so a +// publisher withdraws a callback by removing it — there is no row to delete. +// Redemption must therefore honour the policy in force NOW, not the one at +// issue time: comparing only against the code row would keep accepting a +// callback the publisher had already taken down, and on a replica with a colder +// cache the same code would be refused. Adopted from the Astra review +// (document_removed_redirect_refused). +func TestACodeIsRefusedOnceTheDocumentWithdrawsItsCallback(t *testing.T) { + clearData(t) + admitMetadataClient(t) + + signedIn, _ := signInAsClientUser(t, "google-mcp-withdraw", "mcp-withdraw@test.com") + signedInWithOrigin, err := auth.WithVerifiedPublicOrigin(signedIn, mcpHostOrigin) + require.NoError(t, err) + approved := mcpAuthorizationRequest() + approved.ConsentGranted = true + code, _, err := testService.GrantOAuthAuthorization(signedInWithOrigin, approved) + require.NoError(t, err) + + // The publisher removes the loopback callback. A fresh resolution — another + // replica, or this one past the TTL — now sees a document without it. + withdrawn := `{"client_id":"` + mcpClientID + `",` + + `"client_name":"Claude Code",` + + `"redirect_uris":["https://claude.ai/oauth/callback"],` + + `"grant_types":["authorization_code","refresh_token"],` + + `"response_types":["code"],` + + `"token_endpoint_auth_method":"none"}` + policy, err := auth.NewClientMetadataPolicy("any") + require.NoError(t, err) + testService.SetClientMetadataResolver(auth.NewClientMetadataResolverWith(policy, + auth.StaticMetadataDocuments{mcpClientID: withdrawn})) + + _, err = testService.ExchangeOAuthToken(testCtx, business.OAuthTokenRequest{ + GrantType: "authorization_code", + ClientID: mcpClientID, + Code: code, + RedirectURI: "http://localhost:54321/callback", + CodeVerifier: testCodeVerifier, + }) + require.Error(t, err, "a callback the publisher withdrew must not still redeem") +} diff --git a/module/services/accounts/code/pkg/business/oauth_redemption_bindings_test.go b/module/services/accounts/code/pkg/business/oauth_redemption_bindings_test.go new file mode 100644 index 000000000..e5a0dbcb1 --- /dev/null +++ b/module/services/accounts/code/pkg/business/oauth_redemption_bindings_test.go @@ -0,0 +1,108 @@ +package business + +import ( + "crypto/sha256" + "encoding/base64" + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +// The bindings a presented authorization code must satisfy, as a decision +// rather than as a side effect of a database transaction — so the rules are +// held in a suite that needs no database. +// +// A1007-05 is the rule this exists for: redemption holds the presented redirect +// URI against BOTH the code row and the client's policy as it stands NOW. A +// metadata client has no registry row to delete, so re-publishing its document +// without a callback is how it withdraws one; honouring the code +// row alone kept accepting one the publisher had taken down. + +const redemptionVerifier = "ZmFrZS12ZXJpZmllci10aGF0LWlzLWxvbmctZW5vdWdoLTAxMjM0NTY3ODk" + +func redemptionChallenge() string { + digest := sha256.Sum256([]byte(redemptionVerifier)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func metadataClientAllowing(redirectURIs ...string) auth.RegisteredClient { + return auth.RegisteredClient{ + ClientID: "https://claude.ai/oauth/claude-code-client-metadata", + Name: "Claude Code", + Origin: "https://claude.ai", + RedirectURIs: redirectURIs, + Metadata: true, + } +} + +func issuedCode(client auth.RegisteredClient, redirectURI string) *ClientAuthorizationCode { + return &ClientAuthorizationCode{ + ClientID: client.ClientID, + RedirectURI: redirectURI, + CodeChallenge: redemptionChallenge(), + } +} + +func presented(redirectURI string) authorizationCodeRedemption { + return authorizationCodeRedemption{ + RedirectURI: redirectURI, + CodeVerifier: redemptionVerifier, + } +} + +func TestEveryRedemptionBindingMustHold(t *testing.T) { + const callback = "http://localhost/callback" + client := metadataClientAllowing(callback) + + require.True(t, redemptionBindingsHold( + client, issuedCode(client, callback), presented(callback))) + + // RFC 8252 §7.3: the registered loopback URI names no port, so the code may + // have been issued against an ephemeral one and still redeem. + ephemeral := "http://localhost:54321/callback" + require.True(t, redemptionBindingsHold( + client, issuedCode(client, ephemeral), presented(ephemeral))) + + t.Run("a different client", func(t *testing.T) { + code := issuedCode(client, callback) + code.ClientID = "https://elsewhere.example.com/doc" + require.False(t, redemptionBindingsHold(client, code, presented(callback))) + }) + + t.Run("a redirect URI the code was not issued for", func(t *testing.T) { + require.False(t, redemptionBindingsHold( + client, issuedCode(client, callback), presented(ephemeral))) + }) + + t.Run("the wrong PKCE verifier", func(t *testing.T) { + wrong := presented(callback) + wrong.CodeVerifier = "ZmFrZS12ZXJpZmllci10aGF0LWlzLWxvbmctZW5vdWdoLTk4NzY1NDMyMTA" + require.False(t, redemptionBindingsHold( + client, issuedCode(client, callback), wrong)) + }) + + // A1007-05. The code's own bindings all hold — same client, same URI, right + // verifier — and redemption must STILL be refused, because the client's + // document no longer lists that callback. + t.Run("a callback the document has since withdrawn", func(t *testing.T) { + code := issuedCode(client, callback) + withdrawn := metadataClientAllowing("https://claude.ai/oauth/callback") + require.False(t, redemptionBindingsHold(withdrawn, code, presented(callback)), + "a publisher withdraws a callback by removing it from its document; "+ + "there is no row to delete, so redemption is where it must take effect") + }) + + // And an operator-declared client is unaffected: its registry is startup + // configuration, so its policy cannot change under a live code. + t.Run("an operator-declared client is unchanged", func(t *testing.T) { + declared := auth.RegisteredClient{ + ClientID: "example-addin", + RedirectURIs: []string{"https://addin.example.com/auth/callback"}, + } + uri := "https://addin.example.com/auth/callback" + require.True(t, redemptionBindingsHold( + declared, issuedCode(declared, uri), presented(uri))) + }) +} diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index 14d1f1e53..65e579935 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -44,6 +44,8 @@ type Service struct { solutionRegistrar *registrationAuthority oauthPolicy *auth.OAuthRequestPolicy clientRegistry *auth.ClientRegistry + clientMetadata *auth.ClientMetadataResolver + oauthIssuer string webhookJobs jobs.Producer // request-scoped, transactional outbound producer mfaCipher SecretCipher // required for TOTP enrollment and verification webhookCipher SecretCipher // required for outbound-webhook signing keys diff --git a/module/services/accounts/code/pkg/cataloggen/frontend_plugins_test.go b/module/services/accounts/code/pkg/cataloggen/frontend_plugins_test.go index 5e6fcd5ca..9c1e16b09 100644 --- a/module/services/accounts/code/pkg/cataloggen/frontend_plugins_test.go +++ b/module/services/accounts/code/pkg/cataloggen/frontend_plugins_test.go @@ -40,7 +40,7 @@ func TestFrontendPluginCatalogIsDeterministicAndCurrent(t *testing.T) { func TestFrontendPageDiscoveryPinsAccessAndMatch(t *testing.T) { routes, err := cataloggen.DiscoverNextPageRoutes(filepath.Clean("../../../../frontend/code")) require.NoError(t, err) - require.Len(t, routes, 54) + require.Len(t, routes, 55) byPath := make(map[string]*catalogv1.FrontendRoute, len(routes)) for _, route := range routes { byPath[route.GetPath()] = route @@ -49,6 +49,10 @@ func TestFrontendPageDiscoveryPinsAccessAndMatch(t *testing.T) { // Public so a user stuck behind a blocking surface can always end the session. require.Equal(t, catalogv1.FrontendRouteAccess_FRONTEND_ROUTE_ACCESS_PUBLIC, byPath["/auth/logout"].GetAccess()) require.Equal(t, catalogv1.FrontendRouteAccess_FRONTEND_ROUTE_ACCESS_AUTHENTICATED, byPath["/settings/mfa"].GetAccess()) + // Approving a client is an act of the person, so the consent page is + // authenticated — not public like the login page it follows. The route group + // is what the generator reads, which is why the page lives in (dashboard). + require.Equal(t, catalogv1.FrontendRouteAccess_FRONTEND_ROUTE_ACCESS_AUTHENTICATED, byPath["/oauth2/consent"].GetAccess()) require.Equal(t, catalogv1.FrontendRouteAccess_FRONTEND_ROUTE_ACCESS_ADMIN, byPath["/admin/users"].GetAccess()) require.Equal(t, catalogv1.FrontendRouteAccess_FRONTEND_ROUTE_ACCESS_AUTHENTICATED, byPath["/admin/teams"].GetAccess()) require.Equal(t, catalogv1.FrontendRouteAccess_FRONTEND_ROUTE_ACCESS_AUTHENTICATED, byPath["/admin/teams/{teamId}"].GetAccess()) diff --git a/module/services/accounts/code/pkg/infra/postgres_client_authorization.go b/module/services/accounts/code/pkg/infra/postgres_client_authorization.go index eb547887c..2722789d2 100644 --- a/module/services/accounts/code/pkg/infra/postgres_client_authorization.go +++ b/module/services/accounts/code/pkg/infra/postgres_client_authorization.go @@ -18,9 +18,10 @@ func (s *PostgresStore) CreateClientAuthorizationCode(ctx context.Context, code _, err := q.Exec(ctx, ` INSERT INTO client_authorization_codes ( id, code_hash, client_id, redirect_uri, code_challenge, - user_id, session_id, expires_at, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + resource, scope, user_id, session_id, expires_at, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, code.ID, code.CodeHash, code.ClientID, code.RedirectURI, code.CodeChallenge, + nilIfEmpty(code.Resource), nilIfEmpty(code.Scope), code.UserID, code.SessionID, code.ExpiresAt, code.CreatedAt, ) return err @@ -45,16 +46,19 @@ func (s *PostgresStore) ConsumeClientAuthorizationCode( err := s.WithControlPlane(ctx, func(txCtx context.Context) error { q := s.getQueryExecutor(txCtx) var code business.ClientAuthorizationCode - err := q.QueryRow(txCtx, ` + row := q.QueryRow(txCtx, ` UPDATE client_authorization_codes SET consumed_at = $2 WHERE code_hash = $1 AND consumed_at IS NULL AND expires_at > $2 RETURNING id, code_hash, client_id, redirect_uri, code_challenge, - user_id, session_id, expires_at, consumed_at, created_at`, - codeHash, now).Scan( + resource, scope, user_id, session_id, expires_at, consumed_at, created_at`, + codeHash, now) + var resource, scope *string + err := row.Scan( &code.ID, &code.CodeHash, &code.ClientID, &code.RedirectURI, &code.CodeChallenge, + &resource, &scope, &code.UserID, &code.SessionID, &code.ExpiresAt, &code.ConsumedAt, &code.CreatedAt, ) if err != nil { @@ -63,6 +67,12 @@ func (s *PostgresStore) ConsumeClientAuthorizationCode( } return err } + if resource != nil { + code.Resource = *resource + } + if scope != nil { + code.Scope = *scope + } if err := redeem(txCtx, &code); err != nil { if errors.Is(err, auth.ErrClientAuthorizationRejected) { // Commit the consumption even though the public result is a diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index a29f0bec6..3006cc8d0 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -400,13 +400,32 @@ func doWork(ctx context.Context) (Clean, error) { if revocationFailOpen { wool.Get(ctx).Warn("ACCOUNTS_REVOCATION_FAIL_OPEN enabled: a revocation-store outage will admit possibly-revoked access tokens on the direct verify path until they expire") } + // One issuer, resolved once, for the published RFC 8414 metadata AND for the + // `iss` of every token minted — the only way the two can be proved equal. A + // deployment with no configured base URL keeps the pre-metadata literal and + // publishes no metadata, so the two cannot disagree there either. + tokenIssuer, err := configuredTokenIssuer() + if err != nil { + return nil, err + } + var migratingIssuers []string + if tokenIssuer != business.LegacyTokenIssuer { + // Accepted, not minted: every unexpired token and every session about to + // rotate into one still carries the literal, so refusing it at the moment + // the URL starts being minted would sign out every live session. + migratingIssuers = []string{business.LegacyTokenIssuer} + wool.Get(ctx).Info("access tokens now name the host's own URL as issuer; the previous literal stays accepted until outstanding tokens expire", + wool.Field("issuer", tokenIssuer)) + } minter := ed25519minter.New(ed25519minter.Config{ - Issuer: "saas-starter", + Issuer: tokenIssuer, Audience: "saas-starter", + AdditionalAcceptedIssuers: migratingIssuers, SessionPolicy: sessionPolicy, AdditionalVerificationKeys: previousSigningKeys(ctx), RevocationFailOpen: revocationFailOpen, }, priv, sessionStore) + service.SetOAuthIssuer(oauthIssuerOf(tokenIssuer)) service.SetIdentityResolver(resolver) service.SetJWTMinter(minter) @@ -483,6 +502,35 @@ func doWork(ctx context.Context) (Clean, error) { } service.SetClientRegistry(clientRegistry) + // Client ID Metadata Document clients (issue #1003). A client whose + // client_id is an https URL publishes its own registration there; the host + // fetches and validates it, and writes nothing durable. Whether this host + // trusts that mechanism is a DEPLOYMENT decision, not a per-tenant one — + // see the handbook's decisions/registered-clients.md — and an unset + // declaration refuses every such client, exactly as an unset registry + // registers none. + clientMetadataPolicy, err := auth.NewClientMetadataPolicy( + identityEnv("IDENTITY_CLIENT_METADATA_DOCUMENTS")) + if err != nil { + return nil, fmt.Errorf("configure client metadata documents: %w", err) + } + service.SetClientMetadataResolver(auth.NewClientMetadataResolver(clientMetadataPolicy)) + + // The OAuth 2.1 / MCP authorization-server surface. Raw HTTP rather than + // transcoded RPCs because the token endpoint's form encoding and error + // bodies are the contract a standards-written client reads; see + // pkg/adapters/oauth_http.go. + // Each path on its own, spelled out rather than looped: RegisterHTTPRoute + // matches by prefix, so registering the `/v1/oauth2/` namespace would claim + // every path under it and promise the gateway routes something below it — + // and the correspondence gate in module/tools can only check a call site it + // can resolve statically, which a loop over a slice is not. + oauthHandler := adapters.NewOAuthHTTPHandler(service) + adapters.RegisterHTTPRoute(adapters.OAuthMetadataPath, oauthHandler) + adapters.RegisterHTTPRoute(adapters.OAuthAuthorizeValidatePath, oauthHandler) + adapters.RegisterHTTPRoute(adapters.OAuthAuthorizeGrantPath, oauthHandler) + adapters.RegisterHTTPRoute(adapters.OAuthTokenPath, oauthHandler) + // Authentication mode is explicit in the Codefly identity configuration. // A selected fixture is an optional data seed and cannot replace the // configured provider. Fixture authentication must itself be selected and @@ -1510,6 +1558,38 @@ func configuredSessionPolicy() (auth.SessionPolicy, error) { return policy, nil } +// configuredTokenIssuer is the `iss` every access token carries: the +// operator-configured public base URL, or the pre-metadata literal when no base +// URL is configured. +// +// RFC 8414 §2 requires an authorization server's issuer to be an https URL, so +// a deployment that publishes metadata must mint that URL — otherwise a client +// that discovered the metadata and then verified `iss` against it refuses every +// token. A deployment with no base URL publishes no metadata (the document +// would name URLs it cannot vouch for), so the literal is safe there: there is +// no published issuer for it to contradict. +func configuredTokenIssuer() (string, error) { + base, err := configuredApplicationBaseURL() + if err != nil { + return "", err + } + if base == "" { + return business.LegacyTokenIssuer, nil + } + return base, nil +} + +// oauthIssuerOf is the issuer the authorization server PUBLISHES, which is the +// token issuer unless that is the literal — a literal is not a usable RFC 8414 +// issuer, and publishing one would send a conforming client to +// `saas-starter/.well-known/...`. Empty means no metadata is published at all. +func oauthIssuerOf(tokenIssuer string) string { + if tokenIssuer == business.LegacyTokenIssuer { + return "" + } + return tokenIssuer +} + func configuredApplicationBaseURL() (string, error) { raw := strings.TrimSpace(applicationEnv("APP_BASE_URL")) if raw == "" { diff --git a/module/services/auth-gateway/code/auth_metrics.go b/module/services/auth-gateway/code/auth_metrics.go index 479ebdbbf..b27bee4be 100644 --- a/module/services/auth-gateway/code/auth_metrics.go +++ b/module/services/auth-gateway/code/auth_metrics.go @@ -24,6 +24,18 @@ const ( jwtRejectionRevoked = "revoked" jwtRejectionSessionRevoked = "session_revoked" jwtRejectionRevocationUnavailable = "revocation_unavailable" + // jwtRejectionWrongResource is a token whose RFC 8707 resource audience + // names a different resource than the one being requested — an MCP token + // for one solution presented at another. + jwtRejectionWrongResource = "wrong_resource" + // jwtRejectionGenericAudience is a token carrying only the host audience + // presented at a solution's tool endpoint, which admits only a token issued + // for its own resource (SP-SOL-07). + jwtRejectionGenericAudience = "generic_audience" + // jwtRejectionInvalidAudience is an audience set that names no single valid + // resource — two or more, or one that is not shaped like a resource + // identifier. Refused on every path. + jwtRejectionInvalidAudience = "invalid_audience" ) // Instruments are created once against the global meter provider. main installs diff --git a/module/services/auth-gateway/code/ext_authz.go b/module/services/auth-gateway/code/ext_authz.go index 4e2b4be14..71dc901d3 100644 --- a/module/services/auth-gateway/code/ext_authz.go +++ b/module/services/auth-gateway/code/ext_authz.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "log" + "net/netip" + "net/url" "strconv" "strings" "time" @@ -79,9 +81,26 @@ type ExtAuthz struct { // `kid`, so tokens signed by either half of an overlapping key pair verify // without restarting the gateway. Nil means verification is unconfigured // and every JWT is refused 503. - keys accessKeys - issuer string - audience string + keys accessKeys + // issuer is the `iss` accounts mints today. acceptedIssuers is that value + // plus any still accepted for a migration window, which is how the move from + // the pre-metadata literal to the host's own URL does not refuse every token + // already in flight at the moment of deploy. + issuer string + acceptedIssuers []string + audience string + // publicBase is this host's configured public origin, resolved ONCE at + // construction like issuer and acceptedIssuers above. + // + // Not read per request. workspaceEnv falls back to os.Getenv when the SDK + // read fails, so a deployment that supplies APP_BASE_URL through the + // `application` configuration group and not as a process variable would, on + // a transient read failure, see "" for one request — and with exact + // identifier comparison that refuses every resource-bound token for exactly + // that request, with nothing logged and no state changed. Configuration + // cannot change after process start, so reading it once is both correct and + // the pattern this struct already follows. + publicBase string internalToken string // previousInternalToken stays accepted alongside internalToken during an // overlapping rotation window. Outbound calls always present the current @@ -124,10 +143,17 @@ func constantTimeMatch(candidate, expected string) bool { // backend minter config. func NewExtAuthz(backendConn *grpc.ClientConn, keys accessKeys) *ExtAuthz { return &ExtAuthz{ - apiKey: apigen.NewAPIKeyServiceClient(backendConn), - backendConn: backendConn, - keys: keys, - issuer: "saas-starter", + apiKey: apigen.NewAPIKeyServiceClient(backendConn), + backendConn: backendConn, + keys: keys, + // RFC 8414 §2 requires the authorization server's issuer to be an https + // URL, and accounts mints the configured public base URL when it has one + // (accounts work.go configuredTokenIssuer). This verifier must accept the + // same value, and the pre-metadata literal alongside it while tokens + // minted under the literal are still unexpired. + issuer: gatewayTokenIssuer(), + acceptedIssuers: gatewayAcceptedIssuers(), + publicBase: publicBaseURL(), audience: "saas-starter", internalToken: workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"), previousInternalToken: workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"), @@ -164,8 +190,19 @@ func (s *ExtAuthz) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3 } } - // No credentials — deny. The gateway decides whether to enforce - // or skip based on the route's auth requirement. + // No credential this perimeter accepts. It verifies a bearer — this host's + // own access token, or an API key — and nothing else; a session cookie is + // the frontend's way of holding a session for its own pages, never a + // credential presented here, so a caller that sent one is told that rather + // than being left to read "authentication required" while holding what + // looks to them like a credential. + // + // Stated only when a cookie was actually sent, so the ordinary + // no-credential answer is unchanged for every caller that sent nothing. + if headers["cookie"] != "" { + return deny(401, "a session cookie is not a credential at this surface; "+ + "present an access token as `Authorization: Bearer`"), nil + } return deny(401, "authentication required"), nil } @@ -200,9 +237,13 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut } claims := &accessClaims{} + // The issuer is checked after the parse rather than by jwt.WithIssuer, which + // takes a single value: a deployment mid-migration accepts two. Dropping the + // option without replacing the check would accept ANY issuer, so the + // explicit comparison below is the whole of it and runs before any claim is + // projected onto a header. parser := jwt.NewParser( jwt.WithValidMethods([]string{"EdDSA"}), - jwt.WithIssuer(s.issuer), jwt.WithAudience(s.audience), jwt.WithExpirationRequired(), jwt.WithLeeway(tokenClockSkewLeeway), @@ -238,6 +279,10 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut recordJWTRejection(ctx, jwtRejectionInvalidToken) return deny(401, "invalid or expired token"), nil } + if !s.acceptsIssuer(claims.Issuer) { + recordJWTRejection(ctx, jwtRejectionInvalidToken) + return deny(401, "invalid or expired token"), nil + } // Revocation is checked AFTER signature/claim validation so a forged or // expired token never reaches the store. This closes the gateway-path gap: @@ -287,6 +332,80 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut } } + // RFC 8707 resource binding, with the tool endpoint held to a stricter rule + // than the rest of a solution's surface. + // + // Every refusal here answers 401 rather than 403: RFC 6750 §3.1 classes a + // token that is not valid for this resource as `invalid_token`, and an MCP + // client answers a 401 by re-reading the challenge and authorizing for the + // right resource. A 403 would read as "you may not", which is not what + // happened — and would leave the client with nothing to do. + binding, resource := classifyResourceAudience(claims.Audience, s.audience) + if binding == resourceInvalid { + // Refused on EVERY path, not only a solution's. + // + // An audience set this cannot read as one resource is not a credential + // whose scope is known, and the earlier shape — collapsing it to an + // empty string — made it indistinguishable from a token bound to + // nothing, which is the one classification that is admitted most + // widely. No issuance path produces this; a token carrying it did not + // come from this host's authorization endpoint. + recordJWTRejection(ctx, jwtRejectionInvalidAudience) + return deny(401, "the token's audience does not name a single valid resource"), nil + } + if solutionID, isSolutionPath := solutionIDFromPath(path); isSolutionPath { + base := s.publicBase + switch { + case isSolutionToolRequestPath(path): + // SP-SOL-07 (register finding SA-F-MCPAUD): a solution's tool + // endpoint is an OAuth protected resource in its own right, so it + // admits only a token ISSUED FOR it. A token naming just the host + // audience is a credential for the host's own API, and the whole + // point of the resource indicator is that one audience is not the + // other: a client, a solution or an intermediary holding a host + // token must not be able to act at a tool endpoint with it. + // + // This is deliberately stricter than issue #1003's original wording, + // which also admitted the session-kind token. The register's + // invariant wins: nothing that is not addressed to this endpoint + // reaches it. + if base == "" { + // Fail closed, by name. Without a configured public address this + // process cannot state which resource it requires, so it cannot + // judge a token against it — and the challenge it would emit + // would carry no absolute URL for a client to follow. + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, "this host has no configured public address, "+ + "so it cannot state the resource this endpoint requires"), nil + } + if binding == resourceUnbound { + recordJWTRejection(ctx, jwtRejectionGenericAudience) + return deny(401, "this endpoint requires a token issued for its own "+ + "resource; the token presented names only the host. Authorize for "+ + "this resource and retry"), nil + } + if !resourceAudienceAdmits(resource, solutionID, base) { + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, "the token presented was issued for a different "+ + "resource than this endpoint"), nil + } + case binding == resourceBound: + // A solution's other routes are the product's own API surface, + // reached with an ordinary session. A resource-bound token is still + // confined to the solution it names, so one minted for solution A + // cannot read solution B's data either. + if !resourceAudienceAdmits(resource, solutionID, base) { + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, "the token presented was issued for a different "+ + "resource than this endpoint"), nil + } + } + } + // A resource-bound token still reaches the host's own API, which is not a + // solution path: the host is where a solution's runtime resolves the + // caller's authority, and the token names the host audience too for exactly + // that reason (see the minter's two-audience comment). + hdrs := []*corev3.HeaderValueOption{ hdr("x-user-id", claims.Subject), hdr("x-org-id", claims.OrgID), @@ -343,6 +462,94 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut return s.allow(hdrs), nil } +// acceptsIssuer reports whether an `iss` is one this gateway trusts. An empty +// candidate never matches, so a token carrying no issuer is refused rather than +// matching an unset slot. +func (s *ExtAuthz) acceptsIssuer(candidate string) bool { + if candidate == "" { + return false + } + for _, accepted := range s.acceptedIssuers { + if candidate == accepted { + return true + } + } + return candidate == s.issuer +} + +// resourceAudience returns the one audience value that is not this host's own — +// the RFC 8707 resource the token is bound to — or empty when the token names +// only the host. More than one such value is read as none, matching the +// minter: a token this host produced carries at most one resource, so a second +// is a token it did not produce and must not be treated as a binding. +func classifyResourceAudience(audience jwt.ClaimStrings, hostAudience string) (resourceBindingKind, string) { + var resources []string + for _, value := range audience { + if value == hostAudience { + continue + } + resources = append(resources, value) + } + switch len(resources) { + case 0: + return resourceUnbound, "" + case 1: + if !wellFormedResourceIdentifier(resources[0]) { + return resourceInvalid, "" + } + return resourceBound, resources[0] + default: + // Two or more. There is no single resource this token is bound to, so + // no answer to "is it bound to THIS one" is true of it. + return resourceInvalid, "" + } +} + +// wellFormedResourceIdentifier reports whether a single audience value is +// shaped like a resource identifier at all. +// +// It mirrors what the authorization endpoint accepts when it ISSUES one +// (accounts' auth.ParseResourceIndicator): an absolute http or https URL with a +// host, no userinfo, no query, no fragment and no opaque part, with plain http +// confined to loopback for local development. The two must agree, because a +// verifier looser than the issuer admits a token no issuance path would have +// produced — and the identifier is supposed to be byte-exact with the URL a +// client dialled, so a query or a fragment in it means the audience names +// something other than the resource it appears to name. +func wellFormedResourceIdentifier(candidate string) bool { + if candidate == "" || len(candidate) > 2048 || candidate != strings.TrimSpace(candidate) { + return false + } + parsed, err := url.Parse(candidate) + if err != nil || !parsed.IsAbs() || parsed.Host == "" { + return false + } + if parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || + parsed.ForceQuery || parsed.Opaque != "" { + return false + } + switch strings.ToLower(parsed.Scheme) { + case "https": + return true + case "http": + return isLoopbackResourceHost(parsed.Hostname()) + default: + return false + } +} + +// isLoopbackResourceHost matches the issuer's own loopback rule, for the local +// development origin that is not https. +func isLoopbackResourceHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + if address, err := netip.ParseAddr(host); err == nil { + return address.IsLoopback() + } + return false +} + // checkAPIKey delegates to the backend for api-key validation. // We send the plaintext key over TLS; the backend hashes (Vault transit HMAC) // and verifies, and we just thread the result. diff --git a/module/services/auth-gateway/code/gateway_mcp_resource.go b/module/services/auth-gateway/code/gateway_mcp_resource.go new file mode 100644 index 000000000..739b6502d --- /dev/null +++ b/module/services/auth-gateway/code/gateway_mcp_resource.go @@ -0,0 +1,261 @@ +package main + +import ( + "fmt" + "net/http" + "strings" +) + +// The solution MCP endpoint as an OAuth protected resource (issue #1003). +// +// A solution's MCP surface is `/solutions//mcp`, served by the solution's +// own runtime and fronted by this gateway. The gateway owns the authentication +// half of it and nothing else: there is no MCP code here, no tool list, no +// protocol handling. What it owns is the three OAuth facts a client needs and +// the one rule only this process can enforce: +// +// 1. An unauthenticated request is answered 401 with `WWW-Authenticate` +// naming where the resource describes itself (RFC 6750 §3, RFC 9728 §5.1). +// 2. That description — RFC 9728 protected-resource metadata — is served here, +// because the gateway is the component that decides what a token must look +// like to get in. A document published by anything else would be a claim +// about this code made somewhere else. +// 3. A token carrying a resource audience is admitted only at the solution it +// names. That is the whole point of RFC 8707: a token the person approved +// for one solution must be refused at another, so one solution's +// runtime cannot replay it against another's. +// +// The identity headers a solution receives are unchanged by all of this: the +// same ext_authz Check runs, stamps the same `x-user-id`/`x-org-id`/ +// `x-session-id`/`x-credential-kind: session`, and the runtime's auth is +// exactly what it was. + +const ( + // solutionMCPSegment is the sub-path a solution serves Streamable HTTP on. + // One segment, no children: an MCP server is a single endpoint, and + // matching a prefix here would extend the resource binding to paths no + // resource indicator describes. + solutionMCPSegment = "mcp" + // solutionProxyBase is the host frontend's public route to a solution's + // backend, and on a deployed cell the ONLY one: it forwards the caller's + // bearer to this gateway and already serves the solution's `.well-known` + // anonymously. `/solutions//*` is this gateway's internal surface — not + // public, and on the public origin a page of the frontend that redirects to + // login — so a challenge naming it sends a client to a login page instead + // of a JSON document. + // + // Spelled here because this is Go and the frontend is TypeScript + // (registry.ts solutionProxyBase); TestTheChallengeNamesTheFrontendProxyBase + // holds the two together. + solutionProxyBase = "/api/solutions/" + solutionProxyMid = "/proxy" +) + +// isSolutionMCPPath reports whether a solution sub-path (the suffix after +// `/solutions//`, with no leading slash) is that solution's MCP endpoint. +func isSolutionMCPPath(subPath string) bool { + return subPath == solutionMCPSegment +} + +// isSolutionToolRequestPath reports whether a FULL request path addresses a +// solution's MCP endpoint — the surface the security posture treats as an OAuth +// protected resource in its own right (SP-SOL-07). +// +// It is deliberately narrower than "any solution path". A solution's other +// routes are the product's own API surface, reached by a signed-in person's +// session through the host's pages; requiring a resource-bound token there +// would refuse the product. The tool endpoint is the one a third-party client +// connects to, and the one whose metadata this host publishes, so it is the one +// that must demand a token issued for itself. +func isSolutionToolRequestPath(path string) bool { + pathOnly, _, _ := strings.Cut(path, "?") + rest, ok := strings.CutPrefix(pathOnly, solutionPrefix) + if !ok { + return false + } + _, subPath, found := strings.Cut(rest, "/") + return found && isSolutionMCPPath(subPath) +} + +// publicBaseURL is the operator-trusted public origin of this host, read from +// the `application` configuration group — the same APP_BASE_URL accounts uses +// for the links it mints. It is empty when the deployment has not pinned one. +// +// When it is set, a resource audience must name that exact origin, so a token +// minted against some other host's spelling of this solution is refused. When +// it is not (local development, where no runtime port is pinned), the origin +// cannot be checked and only the solution the resource names is — which is +// still what stops one solution's token reaching another, and is the property +// the gateway exists to enforce. +func publicBaseURL() string { + return strings.TrimSuffix(strings.TrimSpace(workspaceEnv("application", "APP_BASE_URL")), "/") +} + +// resourceAudienceAdmits reports whether a token's resource audience permits a +// request to solutionID. The audience must be a URL naming that solution's MCP +// endpoint, at this host's configured origin when one is configured. +// +// A token with no resource audience never reaches here: the caller treats an +// unbound session token as admissible, which is what keeps the browser's own +// session and the first registered client working at a solution's surface. +// resourceBindingKind is what a token's audience set says about the resource it +// is bound to. Exactly one of three, and never inferred from an empty string: +// "no resource" and "a resource I could not read" are different facts, and +// conflating them makes the second one admissible wherever the first is. +type resourceBindingKind int + +const ( + // resourceUnbound: the audience names this host and nothing else — an + // ordinary session credential, which every surface except a solution's tool + // endpoint accepts. + resourceUnbound resourceBindingKind = iota + // resourceBound: exactly one audience beside the host's, well-formed as a + // resource identifier. Admitted only at the resource it names. + resourceBound + // resourceInvalid: two or more resource audiences, or one whose shape is not + // a resource identifier. Refused everywhere. + resourceInvalid +) + +func resourceAudienceAdmits(resource, solutionID, publicBase string) bool { + if publicBase == "" { + // Without a configured public address this process cannot say what the + // expected identifier IS, so it cannot find anything equal to it. + // Accounts refuses to issue a resource-bound token in the same state, + // so the two sides agree rather than one minting what the other rejects. + return false + } + // EXACT, code-point equality on the whole identifier. + // + // Not a parsed comparison of components, and not a case-folded origin. The + // resource URL IS the audience value this host mints, the runtime publishes + // that same string as its `resource`, and RFC 9728 §3.3 has the client + // require the document's `resource` to equal the URL it dialled — no step in + // that chain folds case or re-renders the URL. A host that treats several + // spellings as one resource has several resources, and the audience binding + // means less than it says. + // + // The request arrived on this gateway's INTERNAL path; what is compared is + // the token's audience against the public identifier for the solution that + // path addresses. + return resource == solutionMCPResource(publicBase, solutionID) +} + +// solutionMCPResource is the one resource identifier this host accepts for a +// solution's tool endpoint: the public proxy route a client actually dials. +// Composed in exactly one place, so admission and the challenge cannot drift. +func solutionMCPResource(publicBase, solutionID string) string { + return fmt.Sprintf("%s%s%s%s/%s", + publicBase, solutionProxyBase, solutionID, solutionProxyMid, solutionMCPSegment) +} + +// solutionIDFromPath extracts the solution a request path addresses, for the +// audience check. It returns ok=false for every path that is not a solution +// path, which is how a resource-bound token keeps reaching the host's own API: +// the host is where the resource resolves the caller's authority, so the token +// names it too (see the minter's two-audience comment). +func solutionIDFromPath(path string) (string, bool) { + pathOnly, _, _ := strings.Cut(path, "?") + rest, ok := strings.CutPrefix(pathOnly, solutionPrefix) + if !ok { + return "", false + } + id, _, _ := strings.Cut(rest, "/") + if id == "" || !solutionIDPattern.MatchString(id) { + return "", false + } + return id, true +} + +// solutionResourceMetadataPath is the URL the 401 challenge names: the +// solution's own well-known under the host frontend's PROXY base. +// +// The requirement is that the URL be derivable from the route AND fetchable by +// the client the challenge is sent to. Those are two conditions, and only the +// frontend's proxy base satisfies both: it answers 200 anonymously, the gateway +// proxies a solution's `.well-known` GET unauthenticated, and the runtime +// serves the RFC 9728 document there naming this host as the authorization +// server. This gateway's own `/solutions//...` prefix is an internal +// surface — on the public origin that prefix is a page of the frontend — so a +// challenge naming it would name a URL no client can resolve to a document. +// +// The host deliberately serves no second copy of that document. Two documents +// for one resource are two places to disagree, and a client follows whichever +// URL the challenge gives it. +func solutionResourceMetadataPath(solutionID string) string { + return solutionProxyBase + solutionID + solutionProxyMid + + "/.well-known/oauth-protected-resource" +} + +// stampResourceChallenge puts the RFC 6750 / RFC 9728 challenge on a denied +// request to a solution's surface. It is what starts an MCP client's discovery +// chain: the client reads `resource_metadata`, fetches the document, finds this +// host as the authorization server, and runs the authorization-code flow. +// Without this header a client has nowhere to begin and reports only +// "unauthorized". +// +// Stamped for EVERY protected `/solutions//*` path, not only the exact +// `/mcp`. The gateway is where such a request is denied — it strips identity, +// runs ext_authz and answers 401 itself, never proxying — so a challenge the +// runtime would have sent can only reach a direct-to-port caller. A 401 here +// without it is a dead end, and that was true for `/solutions//mcp/` (a +// trailing slash) and for every other solution path. Required by issue #1003's +// comment of 2026-10-04. +func stampResourceChallenge(w http.ResponseWriter, solutionID, publicBase string) { + w.Header().Set("WWW-Authenticate", + bearerChallenge(resourceMetadataURL(publicBase, solutionID))) +} + +// resourceMetadataURL is the absolute URL of a solution's protected-resource +// metadata document — what a client fetches to learn which authorization server +// to use. Empty when this host has no configured public address, because there +// is then no absolute URL to name. +// +// RFC 9728 and RFC 6750 require an absolute URI, and a header value carries no +// document base for a client to resolve a relative one against: a path here is +// not a weaker answer, it is an unusable one, leaving a client to reject the +// challenge or dial its own guess at the origin. +func resourceMetadataURL(publicBase, solutionID string) string { + if publicBase == "" { + return "" + } + return fmt.Sprintf("%s%s", publicBase, solutionResourceMetadataPath(solutionID)) +} + +// bearerChallenge renders the RFC 6750 §3 `WWW-Authenticate` value for a 401: +// the token was not accepted, and — when there is one to give — where the +// resource describes itself. +// +// Without a metadata URL the challenge still says `invalid_token`, because +// "you must authenticate" is what a 401 means and remains true. Where to do so +// is then in the response body, which names the missing configuration. +func bearerChallenge(resourceMetadata string) string { + if resourceMetadata == "" { + return `Bearer error="invalid_token"` + } + return fmt.Sprintf(`Bearer error="invalid_token", resource_metadata=%q`, resourceMetadata) +} + +// legacyTokenIssuer is the literal `iss` accounts minted before it published +// RFC 8414 metadata. Spelled here rather than imported: the gateway is its own +// Go module and may not depend on accounts. TestTokenIssuerMatchesAccounts +// holds the two together. +const legacyTokenIssuer = "saas-starter" + +// gatewayTokenIssuer is the `iss` accounts mints today — the configured public +// base URL, or the literal when none is configured. Derived from the same +// APP_BASE_URL both sides read, so the two cannot be configured apart. +func gatewayTokenIssuer() string { + if base := publicBaseURL(); base != "" { + return base + } + return legacyTokenIssuer +} + +// gatewayAcceptedIssuers is what verification admits besides the minted one: +// the literal, while tokens minted under it are still unexpired. A deployment +// that never moved mints the literal anyway, so the extra entry is a no-op +// there rather than a second trusted issuer. +func gatewayAcceptedIssuers() []string { + return []string{legacyTokenIssuer} +} diff --git a/module/services/auth-gateway/code/gateway_mcp_resource_test.go b/module/services/auth-gateway/code/gateway_mcp_resource_test.go new file mode 100644 index 000000000..1b64caee9 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_mcp_resource_test.go @@ -0,0 +1,771 @@ +package main + +import ( + "crypto/ed25519" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// A solution's MCP endpoint as an OAuth protected resource (issue #1003). +// +// Two things are enforced here and nowhere else: the RFC 8707 audience binding +// (a token minted for one solution is refused at another) and the RFC 6750 / +// RFC 9728 challenge that starts an MCP client's discovery chain. + +const testPublicBase = "https://host.example.com" + +// withPublicBase pins the operator-trusted public origin for one test. The +// gateway reads it from the `application` configuration group, which falls back +// to the process environment. +func withPublicBase(t *testing.T, base string) { + t.Helper() + t.Setenv("APP_BASE_URL", base) +} + +// signResourceToken signs an access token carrying the host audience plus one +// resource audience — the shape accounts mints for a resource-bound session. +// signTwoResourceToken signs a token carrying the host audience plus TWO +// resource audiences — a shape no issuance path produces, which is the point: +// the verifier must refuse it rather than reduce it to something it admits. +func signTwoResourceToken(t *testing.T, priv ed25519.PrivateKey, first, second string) string { + t.Helper() + claims := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: uuid.Must(uuid.NewV7()).String(), + Audience: jwt.ClaimStrings{"saas-starter", first, second}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti", + }, + OrgID: uuid.Must(uuid.NewV7()).String(), + SessionID: uuid.Must(uuid.NewV7()).String(), + } + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + signed, err := token.SignedString(priv) + require.NoError(t, err) + return signed +} + +func signResourceToken(t *testing.T, priv ed25519.PrivateKey, resource string) string { + t.Helper() + audience := jwt.ClaimStrings{"saas-starter"} + if resource != "" { + audience = append(audience, resource) + } + claims := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: uuid.Must(uuid.NewV7()).String(), + Audience: audience, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti", + }, + OrgID: uuid.Must(uuid.NewV7()).String(), + OrgRole: "admin", + SessionID: uuid.Must(uuid.NewV7()).String(), + AuthorizedParty: "https://claude.ai/oauth/claude-code-client-metadata", + } + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = accessKeyID(priv.Public().(ed25519.PublicKey)) + signed, err := token.SignedString(priv) + require.NoError(t, err) + return signed +} + +// The whole point of the resource indicator: a token the person approved for +// one solution reaches that solution and is refused at every other. +func TestGateway_MCPToken_IsAdmittedAtItsOwnResourceOnly(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + other := registerSolutionUpstream(t, gw, "audit") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 200, w.Code) + require.Equal(t, "/mcp", example.lastPath) + // The challenge is not part of a served answer: a conforming client would + // read it as "your token was refused" on a response carrying its data. + require.Empty(t, w.Header().Get("WWW-Authenticate")) + + // The same token at another solution's MCP endpoint. 401, not 403: RFC 6750 + // classes a token that is not valid for this resource as invalid_token, and + // an MCP client answers 401 by re-running discovery for the right resource. + req = httptest.NewRequest(http.MethodPost, "/solutions/audit/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 401, w.Code) + require.Contains(t, w.Body.String(), "issued for a different resource") + require.Empty(t, other.lastPath, "the other solution must never be reached") +} + +// A resource-bound token is refused at ANOTHER solution's ordinary data +// endpoints too, not only at its MCP path. Narrowing only the MCP path would +// leave the one replay the audience exists to stop. +func TestGateway_MCPToken_IsRefusedAtAnotherSolutionsDataRoutes(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + other := registerSolutionUpstream(t, gw, "audit") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Empty(t, other.lastPath) +} + +// The solution whose resource the token names may serve its own other routes: +// its runtime, having been reached with this token, calls back through the +// gateway on the person's behalf. +func TestGateway_MCPToken_ReachesItsOwnSolutionsOtherRoutes(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + req := httptest.NewRequest(http.MethodGet, "/solutions/example/resolve", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "/resolve", example.lastPath) +} + +// A resource-bound token still reaches the host's own API. That is not a hole: +// the host is where a solution's runtime resolves the caller's authority, and +// the token names the host audience for precisely that reason. Refusing it here +// would break the runtime without closing anything — nothing a solution can do +// with the host's API is narrowed by which solution asked. +func TestGateway_MCPToken_StillReachesTheHostAPI(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, apiFake, _, priv := newGatewayHarness(t) + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "/v1/users", apiFake.lastPath) +} + +// A token carrying no resource is the ordinary session credential the host has +// always minted — the browser's own session, and the first registered client. +// It keeps reaching every solution surface, so enabling resource indicators +// changes nothing for anything that predates them. +// SP-SOL-07, register finding SA-F-MCPAUD. A solution's tool endpoint is an +// OAuth protected resource in its own right, so a token carrying only the host +// audience — a credential addressed to a different thing — does not reach it. +// +// This test replaces one that asserted the opposite. Issue #1003's wording also +// admitted the plain session-kind token here; the register's invariant is +// narrower and wins, because "any token this host signed" is precisely the +// property a resource indicator exists to stop being sufficient. +func TestGateway_AGenericHostTokenIsRefusedAtTheToolEndpoint(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Contains(t, w.Body.String(), "requires a token issued for its own resource") + // The refusal must carry the challenge, or the client it refuses has nothing + // to act on: it learns what to authorize FOR from this header and nowhere + // else. A 401 without one is the measured defect this surface exists to fix. + require.Contains(t, w.Header().Get("WWW-Authenticate"), "resource_metadata=") + require.Empty(t, example.lastPath, "the refused request must not have reached the solution") +} + +// The line drawn above is the tool endpoint, NOT every solution route. A +// solution's other routes are the product's own API surface, reached by a +// signed-in person whose session token carries no resource at all; refusing +// those would refuse the product. Tightening one surface and tightening all of +// them are different changes, and only the first is the register's. +func TestGateway_AGenericHostTokenStillReachesASolutionsOtherRoutes(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/saas.example.v1.Items/List", nil) + req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "/saas.example.v1.Items/List", example.lastPath) +} + +// Fail closed, by name. Without a configured public address this process cannot +// state which resource its tool endpoint requires, so it cannot judge a token +// against one — and any challenge it emitted would carry no absolute URL for a +// client to follow. It says that, rather than falling back to admitting +// whatever arrived. +func TestGateway_TheToolEndpointFailsClosedWithoutAConfiguredPublicAddress(t *testing.T) { + withPublicBase(t, "") + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", + "Bearer "+signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp")) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Contains(t, w.Body.String(), "no configured public address") + require.Empty(t, example.lastPath) +} + +// Point 5 of the staging measurements, answered deliberately: a browser session +// cookie is NOT a credential at this perimeter. The gateway verifies a bearer — +// this host's access token, or an API key — and reads no cookie anywhere, so a +// cookie-bearing caller is refused. What changes here is that the refusal says +// so, instead of answering "authentication required" to someone who believes +// they presented a credential. +func TestGateway_ASessionCookieIsRefusedAndSaysWhy(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, _ := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Cookie", "refresh_token=whatever") + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Contains(t, w.Body.String(), "session cookie is not a credential") + require.Contains(t, w.Body.String(), "Authorization: Bearer") + require.Contains(t, w.Header().Get("WWW-Authenticate"), "resource_metadata=") + require.Empty(t, example.lastPath) +} + +// And a caller that presented nothing at all still gets the plain answer: the +// cookie sentence is an explanation for a specific mistake, not a new generic +// message for every unauthenticated request. +func TestGateway_ARequestWithNoCredentialKeepsThePlainRefusal(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, _ := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Contains(t, w.Body.String(), "authentication required") + require.NotContains(t, w.Body.String(), "session cookie") +} + +// A resource at a host this deployment is not. The origin is pinned to +// APP_BASE_URL so a token minted against some other spelling of this solution +// is refused — which is what stops a second deployment's token being replayed +// here. +func TestGateway_MCPToken_MustNameThisHostsOrigin(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, "https://other-host.example.com/api/solutions/example/proxy/mcp") + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Empty(t, example.lastPath) +} + +// Without a configured public address a resource-bound token is refused at every +// solution path, the tool endpoint included. +// +// This replaces a test that asserted the origin simply went unchecked there. The +// comparison is now exact agreement with one composed identifier, and a process +// that cannot say what that identifier IS cannot find anything equal to it. +// Accounts refuses to ISSUE a resource-bound token in the same state, so the two +// sides agree rather than one minting what the other rejects — which is why this +// is fail-closed rather than a gap. +func TestGateway_WithNoConfiguredAddressAResourceBoundTokenIsRefused(t *testing.T) { + withPublicBase(t, "") + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, "http://localhost:3000/api/solutions/example/proxy/mcp") + + for _, path := range []string{"/solutions/example/mcp", "/solutions/example/v1/items"} { + req := httptest.NewRequest(http.MethodPost, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 401, w.Code, path) + } + require.Empty(t, example.lastPath) + + // An unbound session token still reaches the product's own API surface, so + // the refusal above is about the resource binding and not about this + // deployment having no configured address. + req := httptest.NewRequest(http.MethodPost, "/solutions/example/v1/items", nil) + req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 200, w.Code) +} + +// R54-04. The audience must EQUAL this endpoint's resource identifier by +// code-point equality — not match a parsed, case-folded reconstruction of it. +// +// The resource URL is itself the audience value, the runtime publishes that same +// string as its `resource`, and RFC 9728 §3.3 has the client require the +// document's `resource` to equal the URL it dialled. No step in that chain folds +// case or re-renders the URL, so a variant admitted here is one the client it was +// issued for would reject — and a host treating several spellings as one resource +// has several resources. +func TestTheAudienceMustEqualThisEndpointsResourceExactly(t *testing.T) { + const exact = testPublicBase + "/api/solutions/example/proxy/mcp" + + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + call := func(resource string) int { + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+signResourceToken(t, priv, resource)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + return w.Code + } + + require.Equal(t, 200, call(exact), "the exact identifier must be admitted") + require.Equal(t, "/mcp", example.lastPath) + + for _, variant := range []string{ + // Case-only differences, in the host and in the scheme. url.Parse and a + // case-folded origin comparison call these equal; code-point equality + // does not, and neither does the client. + strings.Replace(exact, "host.example.com", "HOST.example.com", 1), + strings.Replace(exact, "host.example.com", "Host.Example.Com", 1), + strings.Replace(exact, "https://", "HTTPS://", 1), + // Case in the path, which names the solution. + strings.Replace(exact, "/example/", "/Example/", 1), + // Shapes that differ by more than case. + exact + "/", + strings.Replace(exact, "host.example.com", "host.example.com:443", 1), + exact + "/extra", + strings.Replace(exact, "/proxy/mcp", "/mcp", 1), + } { + require.Equal(t, 401, call(variant), "must refuse %q", variant) + } +} + +func TestGateway_MCPDiscoveryChain_401ThenProtectedResourceMetadata(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, _ := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + + // Link 1: an unauthenticated request is refused WITH somewhere to go. A 401 + // without the challenge is a dead end — the client reports "unauthorized" + // and has nothing to try. + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 401, w.Code) + challenge := w.Header().Get("WWW-Authenticate") + require.Contains(t, challenge, `error="invalid_token"`) + // The URL decided in issue #1003 (2026-10-04): the solution's own + // well-known, derivable from the route, which the runtime serves and the + // gateway already proxies unauthenticated. + require.Contains(t, challenge, + `resource_metadata="`+testPublicBase+`/api/solutions/example/proxy/.well-known/oauth-protected-resource"`) + +} + +// An expired or wrong-resource token gets the challenge too, so a client whose +// token went stale re-discovers rather than reporting a flat failure. +func TestGateway_MCPChallengeIsStampedOnAWrongResourceRefusal(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/audit/proxy/mcp") + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + require.Contains(t, w.Header().Get("WWW-Authenticate"), + `resource_metadata="`+testPublicBase+`/api/solutions/example/proxy/.well-known/oauth-protected-resource"`) +} + +// A1007-11. Every protected solution path carries the challenge, not only the +// exact `/mcp`. Adopted from the Astra review +// (TestReview1007GeneralSolutionChallenge), which measured the dead ends: a +// trailing slash and any ordinary solution route got a 401 with no header, so a +// client had nowhere to begin. +// +// This gateway is where such a request is denied — it strips identity, runs +// ext_authz and answers itself, never proxying — so a challenge the runtime +// would have sent cannot reach anyone through it. +func TestGateway_EveryProtectedSolutionPathCarriesTheChallenge(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, _ := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + + for _, path := range []string{ + "/solutions/example/mcp", + "/solutions/example/mcp/", + "/solutions/example/v1/items", + "/solutions/example/resolve", + } { + req := httptest.NewRequest(http.MethodPost, path, nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code, path) + require.Contains(t, w.Header().Get("WWW-Authenticate"), + `resource_metadata="`+testPublicBase+`/api/solutions/example/proxy/.well-known/oauth-protected-resource"`, + "no challenge on %s leaves a client with nowhere to begin", path) + } +} + +// And it is absent once a request is served: leaving it on a 200 would tell a +// conforming client its token was refused on the response carrying its data. +func TestGateway_TheChallengeIsAbsentOnAServedSolutionResponse(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + + // Each path with a credential that path actually admits: the tool endpoint + // takes only a token issued for its own resource, the product surface takes + // the ordinary session token. + for path, token := range map[string]string{ + "/solutions/example/mcp": signResourceToken(t, priv, + testPublicBase+"/api/solutions/example/proxy/mcp"), + "/solutions/example/v1/items": signValidToken(t, priv), + } { + req := httptest.NewRequest(http.MethodPost, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code, path) + require.Empty(t, w.Header().Get("WWW-Authenticate"), path) + } +} + +// A1007-02. The gateway verifies the issuer accounts mints — the configured +// public base URL — and keeps accepting the pre-metadata literal while tokens +// minted under it are unexpired. A token naming anything else is refused. +func TestGateway_AcceptsTheConfiguredIssuerAndTheLegacyLiteralOnly(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, apiFake, _, priv := newGatewayHarness(t) + + for _, issuer := range []string{testPublicBase, legacyTokenIssuer} { + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("Authorization", "Bearer "+signTokenWithIssuer(t, priv, issuer)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 200, w.Code, issuer) + } + + for _, issuer := range []string{"", "https://evil.example.com", "saas-starter-2"} { + apiFake.lastPath = "" + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("Authorization", "Bearer "+signTokenWithIssuer(t, priv, issuer)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 401, w.Code, "should refuse iss=%q", issuer) + require.Empty(t, apiFake.lastPath) + } +} + +func signTokenWithIssuer(t *testing.T, priv ed25519.PrivateKey, issuer string) string { + t.Helper() + claims := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: issuer, + Subject: uuid.Must(uuid.NewV7()).String(), + Audience: jwt.ClaimStrings{"saas-starter"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti", + }, + OrgID: uuid.Must(uuid.NewV7()).String(), + OrgRole: "admin", + SessionID: uuid.Must(uuid.NewV7()).String(), + } + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = accessKeyID(priv.Public().(ed25519.PublicKey)) + signed, err := token.SignedString(priv) + require.NoError(t, err) + return signed +} + +// An MCP token is a session credential, which is what lets the solution SDK +// mint the viewer's Work Context from it. The identity headers the runtime +// receives are the same ones every other authenticated route gets — this is +// the assertion that "the runtime changes nothing for auth" is true. +func TestGateway_MCPToken_StampsTheSameSessionIdentityHeaders(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + // A caller-supplied identity header must not survive the allow decision. + req.Header.Set("X-User-Id", "00000000-0000-0000-0000-000000000000") + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "session", example.lastHeaders.Get("x-credential-kind")) + require.NotEmpty(t, example.lastHeaders.Get("x-session-id")) + require.NotEmpty(t, example.lastHeaders.Get("x-user-id")) + require.NotEqual(t, "00000000-0000-0000-0000-000000000000", + example.lastHeaders.Get("x-user-id")) + require.NotEmpty(t, example.lastHeaders.Get("x-org-id")) + // The client the person came through, so audit reads "person, via client". + require.Equal(t, "https://claude.ai/oauth/claude-code-client-metadata", + example.lastHeaders.Get("x-client-id")) + // The caller's bearer is preserved so the runtime can call the host as them. + require.Equal(t, "Bearer "+token, example.lastHeaders.Get("authorization")) +} + +// The audience classification, directly. Three outcomes, never two: +// "unbound", "bound to this resource" and "I cannot read this" are different +// facts, and an empty string cannot carry the third — it reads as the first, +// which is the classification admitted most widely. +// +// This replaces a test that asserted several resource audiences were read as +// none. +func TestTheAudienceClassificationHasThreeOutcomes(t *testing.T) { + const host = "saas-starter" + const good = "https://h.example.com/api/solutions/w/proxy/mcp" + + kind, resource := classifyResourceAudience(jwt.ClaimStrings{host}, host) + require.Equal(t, resourceUnbound, kind) + require.Empty(t, resource) + + kind, resource = classifyResourceAudience(jwt.ClaimStrings{host, good}, host) + require.Equal(t, resourceBound, kind) + require.Equal(t, good, resource) + + // Two resources: there is no single resource this token is bound to, so no + // answer to "is it bound to THIS one" is true of it. + kind, resource = classifyResourceAudience( + jwt.ClaimStrings{host, good, "https://h.example.com/api/solutions/x/proxy/mcp"}, host) + require.Equal(t, resourceInvalid, kind) + require.Empty(t, resource) + + // Shapes the issuer would never produce. The identifier is meant to be + // byte-exact with the URL a client dialled, so a query, a fragment or + // userinfo means the audience names something other than what it appears + // to name — and the verifier must be no looser than the issuer. + for _, malformed := range []string{ + good + "?x=1", + good + "#f", + "https://user@h.example.com/api/solutions/w/proxy/mcp", + "http://h.example.com/api/solutions/w/proxy/mcp", // plain http, not loopback + "ftp://h.example.com/api/solutions/w/proxy/mcp", + "/api/solutions/w/proxy/mcp", // not absolute + "https:///api/solutions/w/proxy/mcp", + "mailto:someone@example.com", + " " + good, + } { + kind, resource = classifyResourceAudience(jwt.ClaimStrings{host, malformed}, host) + require.Equal(t, resourceInvalid, kind, "should be invalid: %q", malformed) + require.Empty(t, resource, "an invalid binding carries no resource: %q", malformed) + } + + // Loopback http IS accepted, because local development's origin is not + // https and the issuer accepts it there too. + kind, _ = classifyResourceAudience( + jwt.ClaimStrings{host, "http://localhost:3000/api/solutions/w/proxy/mcp"}, host) + require.Equal(t, resourceBound, kind) +} + +// And an unreadable audience is refused on EVERY path, including the host's own +// API — not only at a solution. A credential whose scope cannot be determined +// has no surface it is known to be good for. +func TestAnUnreadableAudienceIsRefusedEverywhere(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + + twoResources := signTwoResourceToken(t, priv, + testPublicBase+"/api/solutions/example/proxy/mcp", + testPublicBase+"/api/solutions/other/proxy/mcp") + + for _, path := range []string{ + "/solutions/example/mcp", + "/solutions/example/v1/items", + "/v1/users", + } { + req := httptest.NewRequest(http.MethodPost, path, nil) + req.Header.Set("Authorization", "Bearer "+twoResources) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code, path) + require.Contains(t, w.Body.String(), "does not name a single valid resource", path) + } + require.Empty(t, example.lastPath, "no path may be reached with an unreadable audience") +} + +func TestSolutionIDFromPathOnlyMatchesSolutionPaths(t *testing.T) { + id, ok := solutionIDFromPath("/solutions/example/mcp") + require.True(t, ok) + require.Equal(t, "example", id) + + id, ok = solutionIDFromPath("/solutions/example/mcp?x=1") + require.True(t, ok) + require.Equal(t, "example", id) + + for _, path := range []string{"/v1/users", "/solutions/", "/solutions/_register", "/"} { + _, ok := solutionIDFromPath(path) + require.False(t, ok, "should not be a solution path: %q", path) + } +} + +// A1007B-01. The challenge must name the path a client can actually fetch. On +// a deployed cell the only public route to a solution's backend is the host +// frontend's solution proxy; `/solutions//*` is this gateway's internal +// surface, and on the public origin that path is a frontend page that redirects +// to login — so the first attempt's challenge sent a client to a login page +// instead of a JSON document. +// +// Spelled against the frontend's own source, because the two are in different +// languages and nothing else makes them agree. +func TestTheChallengeNamesTheFrontendProxyBase(t *testing.T) { + registry, err := os.ReadFile(filepath.Join("..", "..", "frontend", "code", + "src", "solutions", "registry.ts")) + require.NoError(t, err, "the frontend's proxy base must exist where this expects it") + + // registry.ts: `return `/api/solutions/${encodeURIComponent(id)}/proxy`;` + require.Contains(t, string(registry), "/api/solutions/", + "solutionProxyBase's prefix must match this gateway's challenge") + require.Contains(t, string(registry), "/proxy", + "solutionProxyBase's suffix must match this gateway's challenge") + require.Equal(t, "/api/solutions/", solutionProxyBase) + require.Equal(t, "/proxy", solutionProxyMid) + + require.Equal(t, + "/api/solutions/example/proxy/.well-known/oauth-protected-resource", + solutionResourceMetadataPath("example")) +} + +// r1/f1. The challenge rides on a 401 and ONLY on a 401. +// +// `error="invalid_token"` is an assertion about the caller's credential, and RFC +// 6750 §3 has a client read the challenge on a 403 as well as a 401. Putting it +// on an answer that is not about the credential tells a client to +// re-authenticate where that cannot help: on the 503 this gateway returns when a +// revocation store is unavailable, every refused request would trigger a fresh +// discovery and authorization round against this host's own endpoints — adding +// load while a dependency is already down. +func TestOnlyA401CarriesTheDiscoveryChallenge(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + + // A 401 carries it: an unauthenticated caller has nowhere else to begin. + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 401, w.Code) + require.Contains(t, w.Header().Get("WWW-Authenticate"), "resource_metadata=") + + // A 503 does not. Verification being unavailable says nothing about the + // token, and this is a credential that would otherwise be admitted. + gw.authz.keys = nil + req = httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, 503, w.Code) + require.Empty(t, w.Header().Get("WWW-Authenticate"), + "a 503 must not tell a client its token was refused") +} + +// r1/f2. With no configured public address there is no absolute URL to name, and +// a relative one is unusable rather than merely weaker: a header carries no +// document base for a client to resolve it against. The challenge still says +// `invalid_token`, because that is what a 401 means. +func TestWithoutAPublicAddressTheChallengeNamesNoMetadataURL(t *testing.T) { + withPublicBase(t, "") + gw, _, _, _ := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 401, w.Code) + challenge := w.Header().Get("WWW-Authenticate") + require.Equal(t, `Bearer error="invalid_token"`, challenge) + require.NotContains(t, challenge, "resource_metadata", + "a relative metadata URL is not a usable answer") +} + +// r1/f3. The public address is resolved ONCE, at construction, so a request's +// outcome does not depend on the environment as it stands at request time. +// +// This is the property a per-request read cannot have. `workspaceEnv` falls back +// to `os.Getenv` when the SDK read fails, so a deployment that supplies +// APP_BASE_URL through the `application` configuration group rather than as a +// process variable sees empty on a transient failure — and because the resource +// identifier is compared exactly, an empty base refuses every resource-bound +// token. That would be one request refused between two that are admitted, with +// no state changed and nothing logged. +// +// The environment is cleared AFTER the gateway is built, which is what such a +// transient read looks like from inside checkJWT. A test that only sets the +// value before construction cannot tell the two implementations apart — reading +// it per request returns the same string — which is why this test moves it. +func TestThePublicAddressIsResolvedOnceAtConstruction(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + example := registerSolutionUpstream(t, gw, "example") + token := signResourceToken(t, priv, testPublicBase+"/api/solutions/example/proxy/mcp") + + t.Setenv("APP_BASE_URL", "") + + req := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code, + "a resource-bound token must not be refused because the environment moved under the process") + require.Equal(t, "/mcp", example.lastPath) +} diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 15cdcc4d1..c6b2e2987 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -153,6 +153,28 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) if code == 0 { code = http.StatusForbidden } + // The discovery challenge rides on a 401 and ONLY on a 401. + // + // An MCP client discovers how to authenticate from the 401 itself: the + // challenge names where the resource describes itself, that document + // names this host as its authorization server, and the client then runs + // the authorization-code flow. A 401 without it is a dead end. + // + // But `error="invalid_token"` is an assertion about the caller's + // credential, and RFC 6750 §3 has a client read the challenge on a 403 + // as well as a 401 — so putting it on an answer that is not about the + // credential tells a client to re-authenticate when re-authenticating + // cannot help. On the 503 this gateway returns when a revocation store + // is unavailable, that would mean every refused request triggers a + // fresh discovery and authorization round against this host's own + // endpoints, adding load while a dependency is already down. + // + // Stamped here rather than before the check, so there is no state to + // undo on the paths that must not carry it: the 500 above, the 403 + // below, and every served response. + if code == http.StatusUnauthorized { + stampResourceChallenge(w, id, g.authz.publicBase) + } httpError(w, code, denied.GetBody()) return true } diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go index 7d5ad130f..36410e1f5 100644 --- a/module/services/auth-gateway/code/gateway_test.go +++ b/module/services/auth-gateway/code/gateway_test.go @@ -100,12 +100,21 @@ func newGatewayHarness(t *testing.T) (*Gateway, *fakeUpstream, *fakeUpstream, ed require.NoError(t, err) authz := &ExtAuthz{ - keys: staticAccessKeys(pub), - issuer: "saas-starter", - audience: "saas-starter", - internalToken: "test-internal-token", - gatewayToken: "test-gateway-token", - revoker: noopRevoker{}, + keys: staticAccessKeys(pub), + // Resolved the way production resolves it, so a test that pins + // APP_BASE_URL exercises the real issuer rather than a constant the + // harness chose. Both halves: what is minted now, and the legacy literal + // still accepted while tokens carrying it are unexpired. + issuer: gatewayTokenIssuer(), + // Resolved the same way production resolves it, from the same + // APP_BASE_URL the test sets — not a constant, so the harness exercises + // the real resolution rather than agreeing with itself. + publicBase: publicBaseURL(), + acceptedIssuers: gatewayAcceptedIssuers(), + audience: "saas-starter", + internalToken: "test-internal-token", + gatewayToken: "test-gateway-token", + revoker: noopRevoker{}, } apiFake := &fakeUpstream{body: "api-response"} diff --git a/module/services/auth-gateway/code/perimeter_workos_free_test.go b/module/services/auth-gateway/code/perimeter_workos_free_test.go index 921d56a65..44f93bf10 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -46,6 +46,14 @@ func serviceConfigPath(t *testing.T) string { // means a WorkOS config group added under a different name still trips the test; // a legitimate new dependency forces a human to add it here and eyeball it. var allowedPerimeterConfigDeps = map[string]bool{ + // application carries APP_BASE_URL — this host's own public origin — and the + // bootstrap admin address. Reviewed for #1003: no external-IdP material, and + // nothing a provider could be configured through. The gateway needs the + // origin to publish an RFC 9728 document for a solution's MCP endpoint and + // to require a token's resource audience to name it, so the origin a client + // is sent to and the origin a token is accepted at are one operator-pinned + // value rather than two headers that can disagree. + "application": true, "gateway": true, "gateway-trust": true, "internal-auth": true, @@ -154,10 +162,16 @@ var httpAllowedPerimeterFiles = map[string]bool{ // decides from the registry snapshot and from the client id the ext_authz // check already resolved from a locally verified token; it makes no network // call of its own and reaches no external IdP. - "gateway_cors.go": true, - "main.go": true, - "ratelimit.go": true, - "telemetry_metrics.go": true, + "gateway_cors.go": true, + // gateway_mcp_resource.go serves the RFC 9728 document for a solution's MCP + // endpoint and decides whether a token's resource audience admits a request. + // It makes NO network call of its own — not to an IdP, not anywhere: it reads + // the request path, a configured origin, and claims the ext_authz check has + // already verified with local Ed25519 crypto. + "gateway_mcp_resource.go": true, + "main.go": true, + "ratelimit.go": true, + "telemetry_metrics.go": true, // work_context.go and access_keys.go verify against the cluster's own // published key set, never an external IdP: both read the same accounts // JWKS the ext_authz check has always loaded, through jwks_cache.go, whose origin diff --git a/module/services/auth-gateway/code/routing_rest_test.go b/module/services/auth-gateway/code/routing_rest_test.go index fb2a1eddf..9ca18aa11 100644 --- a/module/services/auth-gateway/code/routing_rest_test.go +++ b/module/services/auth-gateway/code/routing_rest_test.go @@ -52,7 +52,7 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { extensions, err := LoadRESTExtensionsFromDir(context.Background(), DefaultRoutingDir()) require.NoError(t, err) - require.Len(t, extensions, 11) + require.Len(t, extensions, 15) wantExtensions := map[string]bool{ "POST /v1/auth/magic-link": false, "POST /v1/auth/magic-link/verify": false, @@ -65,6 +65,10 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { "POST /v1/billing/free-plan": true, "POST /v1/billing/portal": true, "GET /v1/subscriptions/stream": true, + "GET /v1/oauth2/authorization-server": false, + "POST /v1/oauth2/authorize/validate": false, + "POST /v1/oauth2/token": false, + "POST /v1/oauth2/authorize/grant": true, } for _, entry := range extensions { require.Empty(t, entry.Procedure) @@ -145,6 +149,7 @@ func TestReservedV1PrefixesArePinned(t *testing.T) { "invitations", "mfa", "notifications", + "oauth2", "organizations", "platform", "principals", diff --git a/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml b/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml index af2207ca9..94e57a193 100644 --- a/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml +++ b/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml @@ -79,3 +79,51 @@ routes: extension: exposed: true protected: true + # The OAuth 2.1 / MCP authorization-server surface (accounts + # pkg/adapters/oauth_http.go). These are not protobuf RPCs because the shape + # IS the contract: RFC 6749 §3.2 requires a form-encoded token request and + # §5.2 a specific JSON error body, neither of which grpc-gateway produces. + # + # They reserve the `/v1/oauth2` prefix against runtime module registration + # (ReservedV1Prefixes); reservedV1Prefixes in routing_rest_test.go pins that + # consequence. + # + # The host's public edge maps the standard paths onto these — see + # frontend/code/src/proxy.ts: + # GET /.well-known/oauth-authorization-server -> authorization-server + # POST /oauth2/token -> token + # + # Unprotected, because they run before the person holds a credential for + # this client: the metadata document is public by RFC 8414, the validate + # call must refuse an unregistered client while the browser is still on the + # host and nothing has been typed, and the token endpoint's credential is + # the code plus its PKCE verifier (a public client authenticates with + # `none`). RATE_LIMIT_CLASS_AUTHENTICATION is the budget for all three, the + # same one every other pre-credential route carries. + - path: /v1/oauth2/authorization-server + method: GET + extension: + exposed: true + protected: false + rate-limit-class: RATE_LIMIT_CLASS_AUTHENTICATION + - path: /v1/oauth2/authorize/validate + method: POST + extension: + exposed: true + protected: false + rate-limit-class: RATE_LIMIT_CLASS_AUTHENTICATION + - path: /v1/oauth2/token + method: POST + extension: + exposed: true + protected: false + rate-limit-class: RATE_LIMIT_CLASS_AUTHENTICATION + # The code is issued from the signed-in person's own host session, so this + # one is protected: the full ext_authz bearer path runs and accounts reads + # the forwarded identity beside the gateway credential. + - path: /v1/oauth2/authorize/grant + method: POST + extension: + exposed: true + protected: true + rate-limit-class: RATE_LIMIT_CLASS_SENSITIVE diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index 4ac901bde..f7eb093c3 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -24,6 +24,12 @@ service-dependencies: endpoints: - name: grpc workspace-configuration-dependencies: + # APP_BASE_URL: this host's own public origin. The gateway publishes it in + # the RFC 9728 document for a solution's MCP endpoint and requires a token's + # resource audience to name it, so the origin a client was sent to and the + # origin a token is accepted at are one operator-pinned value rather than + # two headers that can disagree. + - application - gateway - gateway-trust - internal-auth diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index a45908b74..1ac7c323a 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -3879,13 +3879,13 @@ "license": "MIT" }, "node_modules/@swagger-api/apidom-ast": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.12.0.tgz", - "integrity": "sha512-C4Px0M/hNIBf2jFphrHlKe8rN5OuUaxNb3FNandzM8++Uyp9TKjdt0+FI6+sX4s8u3axvQ4c4jrjb0LW7r2jPA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.6.tgz", + "integrity": "sha512-HzudlOOzs17o2lDnDfcOFT5STKq4ckOYAK6jOaN5udDRA9luDm0axRuc3sHlImHDaOHFN+Yf+rp0zUpT+2Dsiw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-error": "^1.12.0", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -3893,14 +3893,14 @@ } }, "node_modules/@swagger-api/apidom-core": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.12.0.tgz", - "integrity": "sha512-HXiJ6c7R/Sfpj1nz8Tgzy6jvaKE333b3FA4XTuKrwne/fgGq+ITksp6SaDMp7F/FbvP8iik2OFT9wJ3BRw1wjw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.11.6.tgz", + "integrity": "sha512-OdHzm974FpfYuO0L+W5B5x+byBMjs9gWuQy37LZYjIe4K8X8IHsN00KAgODLSKgccGlnWi4QBBs8WfHNgRWqGA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "minim": "~0.23.8", "ramda": "~0.30.0", @@ -3910,52 +3910,37 @@ } }, "node_modules/@swagger-api/apidom-error": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.12.0.tgz", - "integrity": "sha512-mdQbyeolFyhyJiVchAJlfOUYntD4p10ORtpVRTLw0vFfqQZtDfr+uMVgiB/kmmyTZ27PoJPR51RDqaXB88aq2Q==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.11.6.tgz", + "integrity": "sha512-q44Zh9Ro0qDH/GLiFvGfe9tISSOSmq6ktrwmjuADVHwpAB//SfvhihIE9LwqNXYEZJTA4ExnFuxy01jiql++HQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7" } }, "node_modules/@swagger-api/apidom-json-pointer": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.12.0.tgz", - "integrity": "sha512-L6JaaeNjG6J5Ros4MLd+Yb2ZS0RhfJ5fj8w5UtCyf1I0cnwJo7hrRExE5RWXVBtF5nl8mO3eIGUe959hC1DtVQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.11.6.tgz", + "integrity": "sha512-sqRsfWsTEy+NQp5Ea3xwsGlPRYnXVLcf2WXCCrexcD2OrTOxhlZ6rCZQyc2trrUwAptna/Px/czVO+TjofXf4g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@swaggerexpert/json-pointer": "^2.10.1" } }, - "node_modules/@swagger-api/apidom-ns-a2a-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-a2a-1/-/apidom-ns-a2a-1-1.12.0.tgz", - "integrity": "sha512-kYfltECH/D3+bT+7IxYtF7VGNrSeDqqDttiIjx7R3pBgPPbxNWAi/EA4Rx+c7qY56sDz0CdbtZNTH28bNi00og==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0", - "ts-mixer": "^6.0.3" - } - }, "node_modules/@swagger-api/apidom-ns-api-design-systems": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.12.0.tgz", - "integrity": "sha512-opXxaAZGxy/IDdUxqfTugtoGeKarb+pDchdVMOWNUD4NZH5weE519dcqCdLMqWtcHX2RL2m9UdcJjGLnAt+GQQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.11.6.tgz", + "integrity": "sha512-lp7YvMSXoW/UQVxOzJYCLVFIIPU2NYsT7mljIiPQTjUZAFuQ20Vi6v93YHfgVIXUj0yQYSraAnvXuO4GciWlPQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -3963,15 +3948,15 @@ } }, "node_modules/@swagger-api/apidom-ns-arazzo-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.12.0.tgz", - "integrity": "sha512-xqtr7pZ5IQBsHZA6yWbSprVSTzpIbM5FtX40rY7lx4wsm4TyhIfJBxOf99+jFmELt7QHoKCUZ8zZQOdf+uOqLw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.11.6.tgz", + "integrity": "sha512-GiAuYiAMwfX2AhUq4ANpmNmjJxSWiTV4QJYJUX3wHZpyU4EMXEM/BTaExGQpbmu7ewRPF4Nve/d9PKNouNXhJQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2020-12": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -3979,15 +3964,15 @@ } }, "node_modules/@swagger-api/apidom-ns-asyncapi-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.12.0.tgz", - "integrity": "sha512-xe3Y3PeRqohaOinEtKTqnUxPoMAopTYAPEnXew5qRcnH7wVWO99J3+XrbY+x9JEXhcAKHpKSWmuNx5o7oU0WdA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.11.6.tgz", + "integrity": "sha512-RHTv4qINvCKe40bDyoWkhve/2/qv6GObFY0umALw2Lv1KiW2hjHrBO63Y+kU8vYu34bnhFCUDIqyCyEmJt6zkQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-7": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -3995,15 +3980,15 @@ } }, "node_modules/@swagger-api/apidom-ns-asyncapi-3": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.12.0.tgz", - "integrity": "sha512-WZ/j/Hk8nsX0lkHiTUFfESkQgGGEba9dwiKMP9OvX9qLsoxJ04Dg1Z7E58WCrmwistEpynMHO+KY3qo2trWOUQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.11.6.tgz", + "integrity": "sha512-bGUXNP+vS9bjNl8ehjmoyUck1liWjxOUQReLZG4yqIix7dHWvLSeDw1hi0pq1A+VWjixPazAcb9Y6f/t5Bqvyg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4011,15 +3996,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-2019-09": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.12.0.tgz", - "integrity": "sha512-fWRjOvOiOFqKp0vJgFJn1fm9i+H5eSOQ1J0xWldTnHJPHsYPQkYPLAQdpXcBbBiB5o0JYVeJe7PyWv3DvNxqSA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.11.6.tgz", + "integrity": "sha512-XZmo85MS8Y5bYzrxmYbbqvXOghWZt4tInhE26c5nGbKyCx5CbsLlhzcp0wMzkb5k22BnUmlwmniVFrnveYnOGQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-7": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4027,15 +4012,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-2020-12": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.12.0.tgz", - "integrity": "sha512-U0sv+7aD6njyxcvQOS9Lkpr4feJRowMn0/shiIlXrXY1ox3BDJBgipsgx/0IqfQNseP3Zj43Ec5kGk4PFmsjCg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.11.6.tgz", + "integrity": "sha512-WU6Vw8aa0TLs1COGHjCFS2gz6MkQzSNZYFejs7EtZOnlAkgG3TN8H55BHVoif1QGBlrhTQ6o6RxCdk5bI1WneQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-2019-09": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2019-09": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4043,14 +4028,14 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.12.0.tgz", - "integrity": "sha512-c+NYp+DZvoZ0+3hFgKhQn507kba0HUv8C60cJEqQPCpIKqKpWvpZ6YF8GsKVhJoML4PAr6Zl6SWjFVvOnDUbBg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.11.6.tgz", + "integrity": "sha512-kiYyTkUCsHBKS2EFTQgI0bF0yKjOlt+QMiPzsXKzHZ7+XqMmEeRBFn5wSfTF3h9aJ5JMgtmku4j3TtdlxEUvUA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.12.0", - "@swagger-api/apidom-core": "^1.12.0", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4058,15 +4043,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.12.0.tgz", - "integrity": "sha512-O8aM7l/+K2ej3AH6f7NbEVADAuZhulLWmAGFFwRBHvE022mUeOQiDrcA1cd5q/s8fY+UFE+GIrfVAq0aBPLvxA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.11.6.tgz", + "integrity": "sha512-vTlvnkmQ0UP0NeiPD8h6Kls2So8tNVruANah1n9BskJLUpvTVJV820HFDw88/+ocXUglAEg8aKENcPo0AWsrkw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-4": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4074,15 +4059,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.12.0.tgz", - "integrity": "sha512-gWTTBmz6CyBytc4v7tMcm0KJ/0xp8n0zyWE1VSBXvLjFylOfvlkzmPL/MD8sqiEqUCGXXSQAyUmyL7heU8uGzQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.11.6.tgz", + "integrity": "sha512-kddXboLPDceL0302icmJvltcg/LzwGPaK1W/35PiHbs0lCtgRg5bz8F0tzGbPa1zk6n2o5BSS8+YU0J6vGKf3g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-draft-6": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-6": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4090,16 +4075,16 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.12.0.tgz", - "integrity": "sha512-eISSf1rj7/HZAzOBGdZneUNp4M3FPwvqmKFL9ODBN+kpKFQ438TnYPdMVm60EoiinpvKa7hrKVKj/RubDfvcIQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.11.6.tgz", + "integrity": "sha512-eMlB/P7apYuiwiBPsCW5zc5cyb6T/ZY7VeMSLBBN/JDy8I4rtTpFQq3ltQKXJlFhU321CuCibRiZ6p7z45wWKw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-4": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4107,15 +4092,15 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-0": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.12.0.tgz", - "integrity": "sha512-8DqRCkRTxQPtFjRjWiFo5F/IZcBqFFcqjmtoVWbxkUNGyJpgfZjx5Mne/QCB9ZV5vqNeBPF2IYfjQZEvgOBKvA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.11.6.tgz", + "integrity": "sha512-RGngR9Daer9PtkPevSKgBqWZdXxCbDyjudadq0CImqFfjYd8OWhya20haKbPGpJuHDz7Rwmu2m92+0wZFjCcew==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-4": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4123,17 +4108,17 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.12.0.tgz", - "integrity": "sha512-6h4NJyCb/I9GkPYCma7SiEp3MlMNH9DFDntamb49AJDBNlibOG1VyR+y3R4+vzUlYOZayMDwe7Z1zC+qN0ulYw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.11.6.tgz", + "integrity": "sha512-mZe+3NgDxTjUmitxxgvlmLyK9mWSNkQajqA26j9JxcFdEvN/lcp/EsjxMX5kST4E0Qm4tQKD0i1QnJmjBSaHWw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.12.0", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-json-pointer": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-0": "^1.12.0", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2020-12": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4141,195 +4126,163 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.12.0.tgz", - "integrity": "sha512-kkrXfvRPpiJTcK6qr0dB5Jf80kD3jgTo1DU9W6Q5/n06WcyGQhtKTofY7YRHQ52r2Nfpuh9JbxtoD0PIHE0STQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.11.6.tgz", + "integrity": "sha512-TBDgIDAZodphp/1DS+mTilPLfTS84e/IXGEoRCE/MFrLKa4GZFY7bx8hXQmgGJfDWwxsNcQk5Q/X+SCi+WQg0g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.12.0", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-json-pointer": "^1.12.0", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-0": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.12.0", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2020-12": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-parser-adapter-a2a-json-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-a2a-json-1/-/apidom-parser-adapter-a2a-json-1-1.12.0.tgz", - "integrity": "sha512-RwUkRb92938NiTriueninR0MaeXYJRZKcZtYyEB5h2JRQ7nQGsCO6a6F1rNwehzLoPeRWLGMYr0wRWP1tTy7fw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-a2a-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-a2a-yaml-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-a2a-yaml-1/-/apidom-parser-adapter-a2a-yaml-1-1.12.0.tgz", - "integrity": "sha512-G2L1mRcuAJQICU18cjYH6FIZshYVJOCAmz5JSNknriIL0LYXr8xlbQ7kStTC5cmM9YfxDfD3eLb92vQ+yvpbCA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-a2a-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.12.0.tgz", - "integrity": "sha512-Co9BcoTlEAcrDIVTE/o0B6+HAlIuwB5hvKMs+w9+P+r6cSDRqx4NOi16iqBH6leCEtO6xU8H152ua0e8hKkQSQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.11.6.tgz", + "integrity": "sha512-vFwzMj6z5HBf8d8DM0eNjVkaI2hiewE4+RDkTYhpfydAUq8m8jTuzJCf7DDdQ0YdvHH7/Zwdj4EshYnzEAp8Aw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-api-design-systems": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-api-design-systems": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.12.0.tgz", - "integrity": "sha512-vBnmuMflgcxPkqQX8rXFIWU6GdC0OPMO73LU4iOOjG2kxycET6NTs0zu6mTKRwfCk7PmVR+MzaqLyORCkL3Rjw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.11.6.tgz", + "integrity": "sha512-bwRh9F9phloYhpQJImsFuY3e8M5uf9lf436zqU413UCNnbU0F1p4HzIxKjFeIwXxiTzHfN89tCpsrPRsmLySaA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-api-design-systems": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-api-design-systems": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.12.0.tgz", - "integrity": "sha512-5SHEWHdLXpbugsDN2NVYxqh87Sson4pDS+Yk9Zp00cJSl2dNtLZ+LHq3dtIgjFcKy9yOCnaiKjzqoZtpdQB1zQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.11.6.tgz", + "integrity": "sha512-opQc80CzT9buTKr3Q4k8R1DxVFJpHuJ4ej1Z6V4glGd7d2gEoabMkLKT9v9nKy50XLhJUTLkA86MLEyZPHxsfQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-arazzo-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-arazzo-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.12.0.tgz", - "integrity": "sha512-l0ocWS0IcWYx0ZFosEQguOS72dJXYnEC42KrQgYJMvZCfpMZPtP4HahAYryFxRZWiHZnhunkSChv8MRnBplcQA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.11.6.tgz", + "integrity": "sha512-7wMLd6yGGNW0UI1ourkMfGJaR79fav1HntVCC0IvmcaO5u8X6gUIwDpqMcIuX71Ipw1FHX2x9PKSakMiWy5LJQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-arazzo-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-arazzo-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.12.0.tgz", - "integrity": "sha512-oZt3H7qH4ttU+RILLQ8G8tgOLe+hRMQDh7q/W/Dfxc2xGGsLb6jYG4gl45ry/QHDpudCc2SKC3GxdXfQ3Dl4pg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.11.6.tgz", + "integrity": "sha512-jsx299Uconwq9oPHDNK51v3VPqm7JqkvovM2w/azW4aXQNv9u3ZcSqHMAbfC8KpYCbnx0LIuCU9OgRyH8dj8Nw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.12.0.tgz", - "integrity": "sha512-O4osrUo/q0ErGPqmCHfYEdRpeH894Dix0AQwLwuovgIS5OxWMa2r6Z8mzz3emRoyVi9ucF+0//OFm7WeU7NcGg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.11.6.tgz", + "integrity": "sha512-JePNK/AwnJk+U0yxUKDQynq6ER8Snx/zcZKxar+cBy/COMj1EV0Bif0vUdmF3RCEoPjoXNRq2HfBIbZ6yT+9AQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-3": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.12.0.tgz", - "integrity": "sha512-AfcdwWREQhBAEbVTwIVjFLvW/j5wTXmY8/VkKp1OmF7zXyyZDMA+xRAoGZlOlc+KNJ46O6yeN9CLfUaJ0IoRog==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.11.6.tgz", + "integrity": "sha512-SJqn9y4tPSCq6dfE5hHYGpl45oLwogbXtrsbd4IWmAFrxT9VnbSUSyGmLm+bBYycQiXsvg0SJ7QRuecCeEJvfw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.12.0.tgz", - "integrity": "sha512-+gAKV6dwr1neG3ngONPYWYRATrYD/ooRO+dY6kOgc2yy99YagtSZjtNNSosvoMdPL8vhI6OkZRyew41kyW+Vwg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.11.6.tgz", + "integrity": "sha512-gTDh14ge0xBHu+44I/oBsKFC24sFK18yaL3paEdi0LGdSVKYmAbPyOaQbX3JEJ5BkOAwLvXAEQBm2snZ75JcXw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-3": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-json": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.12.0.tgz", - "integrity": "sha512-Tftr63g71VwTsWtafYgI7xrmkp6lcs8p6hwo06UzZICTyEsfMrye6Hjvs2TM0KBO/e6Hw8cTdCIBVtEV10Knqw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.11.6.tgz", + "integrity": "sha512-LIbKkaydhviIJJ9VdMMmDrx96B2H21l1deH+osQt9QyrBr6Cdpo0NCv1xMr8TZi7CRjm6YD7+ec8mNmUNu54Bw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.12.0", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -4339,144 +4292,144 @@ } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.12.0.tgz", - "integrity": "sha512-2cMgq3NBAS7magAFqgtBZOoi3vRR7/txQOOA1nzEODiS4WV4ERbe2B59+uaQ7+yzJlkm0OPqzFhXy3IkOB6Z0g==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.11.6.tgz", + "integrity": "sha512-AeaRUdwEtQ1ThWQ3d47ZEH3WGLIHFR9Z5+uBhuYeT0jke0fHlLW6tnBWseZZ91Fk7Xo0sJR0fMnXRuCeBs4qTQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.12.0.tgz", - "integrity": "sha512-ws5vsTv8KtrwzPPGwyeNUrEGYLlsRSByTWHFXH8hU8eGbSAcmmKsENhNeEjslimzD2Cev5vJzmdat5oWpKY4fA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.11.6.tgz", + "integrity": "sha512-KJ9NyuwhRc17GKMMF9IS/wFbzJ2hRNssoza16v2mVhL+xs+1qYrUi5sOwb2vCWWfNtso8Wst/ILQWxYbhFfEHw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-0": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.12.0.tgz", - "integrity": "sha512-C2/oMuyGarGAPQj5Taob9MOI9Hme6D5Qm/ZyJkRgXBg50ZA3WeaFCr51St6dNU1lDhe8i++cZeWMsp4wegPllw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.11.6.tgz", + "integrity": "sha512-9CG6vfA5usCEYf18g7+SYVRhD/zds8vvwV8gnF6gyrJOK+RbaAeVp1/Oeg499uRXy8D1LvypY07eB8aeVsaw3w==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-2/-/apidom-parser-adapter-openapi-json-3-2-1.12.0.tgz", - "integrity": "sha512-kuSIq/Tht5LQ2lezsz6O1x+PM1U5DWeZBQKqHkTgQokhsbxM9dmO6xmSW/aQUk+0jUWf8n6ROrGmscrw6AawWg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-2/-/apidom-parser-adapter-openapi-json-3-2-1.11.6.tgz", + "integrity": "sha512-2EGjNq/tSFDJLVw46ApAaG2cRTxHF0Hhba5jEe5JiHTf52qZi0aPoi+YDxNnMvDKIzTrcEw34ZQddACu/JvOhQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.12.0.tgz", - "integrity": "sha512-QR0XdBWzjN/8ZmgBrbswP+Bpj+BllUqFiJu43VZNm1M8u/AtbJnsdlTw3coXLVpVpV7ruAKjp0GtsqQyOKNA1w==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.11.6.tgz", + "integrity": "sha512-ohy7FFWM0DN0bhIqHUZJrx8qCN0qGnvjf8xqJGx/cRfxmqToEeukAXvrBBhBhtz4eqLKiSeE9TkdgHPYRRDZWg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.12.0.tgz", - "integrity": "sha512-2Mzft9tXXodoRBqEEXQ1p5MRLrzIDt7SCqZyXF9cBF5ukIdHfgq7vDoX7XUECELzhCf3mMRxLaxH+JcPC26izg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.11.6.tgz", + "integrity": "sha512-6U+yTLDTdlMs68YLVKsOOpN3eFlFWeo0wbkkSSDQw7ugfpUi2PEEsjrQ+C6cviPYH3ZfZNchlj4EZaNFtMZ1/Q==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-0": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.12.0.tgz", - "integrity": "sha512-dYrUHnD7r6uhsh2H+gMwxFTSaqSADFHmpG1tFi7b7m/aFbNFCppB63iJ7LYg9Ew6IBej9WrYkr2jXDLKBgsMaA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.11.6.tgz", + "integrity": "sha512-P3t3Q4uUt92ZUrm0zPlWacPRWDHfMFqlFXdzH5dJN5phyQMMu08vdPFMaWFPv+w48wcZFXbrT/GeilorAoPlDw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2/-/apidom-parser-adapter-openapi-yaml-3-2-1.12.0.tgz", - "integrity": "sha512-KrNSQmsmp6iO2o+AxJ9jpwKd4lVmPIaDcWrbp4se5b1MnHUmtf7d7h9kvI5pXhGFGuyA59HMp6qQzNfuS5SMEA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2/-/apidom-parser-adapter-openapi-yaml-3-2-1.11.6.tgz", + "integrity": "sha512-bty6P85OHEV4YCdbVlNTsJRXZPs4ii3ynzTISURoIuyn08Y28C/Pm9H484Pl4iC9t16d1TKGngTAWc5RX0ve1Q==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.12.0.tgz", - "integrity": "sha512-vydWYSj1I0t4fsBeu7nAieivBKsW2A7KW9keS+osXLuyYWs1GLHdby5AXRHPv6ktR507vAq8RxQUXkODRxyRCg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.11.6.tgz", + "integrity": "sha512-voxU8HMUypS36DYmZX8OsZ9pHz0hoT66GiBXjGKb1ETkXQR/8rVIYJnbxvVqtyXUI82aZy3dRTcXd6C2BHqH/A==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.12.0", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@tree-sitter-grammars/tree-sitter-yaml": "=0.7.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", @@ -4518,14 +4471,14 @@ } }, "node_modules/@swagger-api/apidom-reference": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.12.0.tgz", - "integrity": "sha512-GApJUvwLROSyM2KfbdvuV/zC6ccb54khvhy2eJ7jPhAk5FU0zZE5Ijt0NmY5nSf97sET87MjrWgCCMszdqPXfw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.11.6.tgz", + "integrity": "sha512-wAU/b6fBw0zS2KLvbiwY13apXbXO43N+CnlnHvGuOf4WjV+msdcb31OPUAehMENsC1BUcrf/vroQj+lJqz77dA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "axios": "^1.18.0", "minimatch": "^10.2.6", @@ -4533,34 +4486,31 @@ "ramda-adjunct": "^5.0.0" }, "optionalDependencies": { - "@swagger-api/apidom-json-pointer": "^1.12.0", - "@swagger-api/apidom-ns-a2a-1": "^1.12.0", - "@swagger-api/apidom-ns-arazzo-1": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-2": "^1.12.0", - "@swagger-api/apidom-ns-openapi-2": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-0": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-a2a-json-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-a2a-yaml-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.12.0", - "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.12.0", - "@swagger-api/apidom-parser-adapter-arazzo-json-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0" + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-arazzo-1": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-json-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6" } }, "node_modules/@swagger-api/apidom-reference/node_modules/balanced-match": { @@ -5027,6 +4977,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.10.tgz", + "integrity": "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -5040,6 +5007,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", + "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -7517,9 +7494,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -8550,9 +8527,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -9174,9 +9151,9 @@ } }, "node_modules/immutable": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", - "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "license": "MIT" }, "node_modules/import-fresh": { @@ -12582,15 +12559,6 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, - "node_modules/redux-immutable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz", - "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==", - "license": "BSD-3-Clause", - "peerDependencies": { - "immutable": "^3.8.1 || ^4.0.0-rc.1" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13352,9 +13320,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -13747,23 +13715,23 @@ } }, "node_modules/swagger-client": { - "version": "3.38.0", - "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.38.0.tgz", - "integrity": "sha512-n7aykm1BEdQ3fKePJJx63UGjYe8/5fuxFMi3qZP4OJGZvzljKvmhxNwIF/MB71sF/lop9NeWZReKvPib9CY+2g==", + "version": "3.38.2", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.38.2.tgz", + "integrity": "sha512-MrMYADBRg7nY5Y+CY9E76/Zm2LSghhln4wNNU6jGn3bNZ76BRQ3hQRyCHCyJK9tVgTDUmYCcYgYWrwGruL1mIw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.22.15", "@scarf/scarf": "=1.4.0", - "@swagger-api/apidom-core": "^1.12.0", - "@swagger-api/apidom-error": "^1.12.0", - "@swagger-api/apidom-json-pointer": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.12.0", - "@swagger-api/apidom-ns-openapi-3-2": "^1.12.0", - "@swagger-api/apidom-reference": "^1.12.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-reference": "1.11.6", "@swaggerexpert/cookie": "^2.0.2", "deepmerge": "~4.3.0", "fast-json-patch": "^3.0.0-1", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.2", "neotraverse": "=1.0.1", "node-abort-controller": "^3.1.1", "openapi-path-templating": "^2.2.1", @@ -13775,37 +13743,38 @@ "node": ">=22" }, "optionalDependencies": { - "@swagger-api/apidom-ns-asyncapi-2": "^1.12.0", - "@swagger-api/apidom-ns-asyncapi-3": "^1.12.0", - "@swagger-api/apidom-ns-openapi-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.12.0", - "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.12.0", - "@swagger-api/apidom-parser-adapter-arazzo-json-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "^1.12.0", - "@swagger-api/apidom-parser-adapter-json": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.12.0", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "^1.12.0", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0" + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-3": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-json-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6" } }, "node_modules/swagger-ui-react": { - "version": "5.32.14", - "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.14.tgz", - "integrity": "sha512-6LAVBeC78DplbJ7kutm/YeBYo22nPzGOca4bIZAvQG4w2eSetnYDdazaUfY0qzQUlg/H90HnYZX3rg67EmENOw==", + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.33.1.tgz", + "integrity": "sha512-AvfsWJrKsiSxiE/GMvya4z4RRNyHAiGM3LAezdJzPe8pY5MP6jAyCB+2/7Bq+YYd+XcnsNxt6GaottJMj5CRzA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", + "@tanstack/react-virtual": "=3.14.10", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", @@ -13813,9 +13782,9 @@ "deep-extend": "0.6.0", "dompurify": "^3.4.13", "ieee754": "^1.2.1", - "immutable": "^4.3.9", + "immutable": "^5.1.9", "js-file-download": "^0.4.12", - "js-yaml": "=4.3.1", + "js-yaml": "=4.3.2", "lodash": "^4.18.1", "prop-types": "^15.8.1", "randexp": "^0.5.3", @@ -13828,12 +13797,11 @@ "react-redux": "^9.3.0", "react-syntax-highlighter": "^16.0.0", "redux": "^5.0.1", - "redux-immutable": "^4.0.0", "remarkable": "^2.0.1", "reselect": "^5.1.1", "serialize-error": "^8.1.0", "sha.js": "^2.4.12", - "swagger-client": "^3.38.0", + "swagger-client": "^3.38.2", "url-parse": "^1.5.10", "xml": "=1.0.1", "xml-but-prettier": "^1.0.1", diff --git a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts new file mode 100644 index 000000000..dc856e87c --- /dev/null +++ b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts @@ -0,0 +1,187 @@ +import { getEndpoints } from "codefly"; + +import { + duplicatedOAuthParameter, + oauthQueryString, + oauthRedirect, + oauthWireFormat, + parseOAuthRefusal, + readOAuthAuthorizationRequest, +} from "@/features/auth/model/oauth-authorization"; +import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context"; +import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; +import { requestPublicOrigin } from "@/lib/public-origin"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const PUBLIC_ORIGIN_HEADER = "X-Codefly-Public-Origin"; + +/** + * `GET /oauth2/authorize` — the RFC 6749 authorization endpoint (issue #1003). + * + * It lives in this app because the host is the only sign-in UI: an + * authorization request ends in a login page and, where the client is not one + * the operator declared or the request names a resource, a consent screen. + * Neither is a response body, so neither can live in accounts. + * + * What this handler does and does not do matters: + * + * - It validates the whole request against accounts BEFORE redirecting + * anywhere. A request that cannot succeed is refused while the browser is + * still on the host and the person has typed nothing. + * - It delivers a refusal to the client's redirect URI only once accounts + * has confirmed that URI belongs to that client (RFC 6749 §4.1.2.1). Before + * that, the refusal is rendered here. Redirecting to an unvalidated + * redirect_uri would make this an open redirector that any site could use. + * - It never issues the code. That happens after sign-in and consent, from + * the person's own session (see /v1/oauth2/authorize/grant). + */ +export async function GET(request: Request): Promise { + const url = new URL(request.url); + // A duplicated singleton parameter is refused before anything is read from + // it: `get` would return the first and discard the rest, which is the host + // choosing between two values the caller sent. Refused HERE rather than at + // accounts, because by the time the request is reduced to a JSON object the + // duplicate no longer exists to notice. + const duplicated = duplicatedOAuthParameter(url.searchParams); + if (duplicated) { + return invalidRequestPage( + "This sign-in link repeats a parameter that may appear only once.", + ); + } + const authorization = readOAuthAuthorizationRequest(url.searchParams); + if (!authorization) { + return invalidRequestPage( + "This sign-in link is incomplete: client_id, redirect_uri and code_challenge are all required.", + ); + } + + const base = gatewayBase(); + if (!base) { + return invalidRequestPage( + "Sign-in is temporarily unavailable. Please try again in a few minutes.", + 503, + ); + } + + const headers = new Headers({ "content-type": "application/json" }); + // The authorization server needs to know which origin it is being reached + // at, because every URL it publishes names it. That claim is only trusted + // beside the cluster-internal token, which this process holds and a browser + // does not — the same pair src/proxy.ts stamps on product API traffic. + const gatewayContext = resolveCodeflyGatewayContext( + requestPublicOrigin(request), + ); + if (gatewayContext) { + headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken); + headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin); + } + + let response: Response; + try { + response = await fetch(`${base}/v1/oauth2/authorize/validate`, { + method: "POST", + headers, + body: JSON.stringify(oauthWireFormat(authorization)), + cache: "no-store", + redirect: "manual", + }); + } catch (err) { + console.error("oauth authorize: validation call failed", err); + return invalidRequestPage( + "Sign-in is temporarily unavailable. Please try again in a few minutes.", + 503, + ); + } + + if (!response.ok) { + const refusal = parseOAuthRefusal(await response.json().catch(() => null)); + if (refusal?.redirectable) { + // The redirect URI is this client's own, so the client — which is + // sitting on a loopback listener waiting — is told what went wrong + // rather than being left on a browser tab that never comes back. + return redirectTo( + oauthRedirect(authorization.redirectUri, { + error: refusal.error, + error_description: refusal.errorDescription, + state: authorization.state || undefined, + // RFC 9207 §2 covers error responses, and this host advertises + // support for the parameter. The issuer comes from the host's + // own answer, never from the request, so a caller cannot choose + // what its client is told about who refused it. + iss: refusal.issuer, + }), + ); + } + return invalidRequestPage( + refusal?.errorDescription ?? + "This application is not registered to sign in here, or asked to be returned to an address it has not registered.", + response.status === 503 ? 503 : 400, + ); + } + + // Accepted. Hand the browser to the host's own login page, carrying the + // request verbatim: the page signs the person in however this deployment is + // configured, shows consent when the host said to, and completes through the + // grant call. The parameters are re-rendered from the parsed request rather + // than forwarded as the original query string, so anything this host does + // not read cannot ride along into the page's URL. + const login = new URL("/auth/login", url); + login.search = oauthQueryString(authorization); + return redirectTo(login.toString()); +} + +/** + * A 302 built explicitly rather than with `Response.redirect`, whose headers + * are immutable — the referrer policy is load-bearing here. The authorization + * request carries the client's PKCE challenge and `state` in the query, and a + * referrer would leak both to every subresource of the page being redirected + * to. + */ +function redirectTo(location: string): Response { + return new Response(null, { + status: 302, + headers: { + location, + "referrer-policy": "no-referrer", + "cache-control": "no-store", + }, + }); +} + +/** + * Resolve the auth-gateway REST base from the Codefly SDK, normalised the way + * every other server-side caller here normalises it: the whole address minus a + * trailing slash, never its origin, so a base path on the endpoint survives. + */ +function gatewayBase(): string | null { + const endpoint = getEndpoints().find( + (candidate) => + candidate.service === "auth-gateway" && candidate.name === "rest", + ); + if (!endpoint?.address) return null; + try { + return new URL(endpoint.address).toString().replace(/\/$/, ""); + } catch { + return null; + } +} + +/** + * A refusal the person has to read, because it cannot be delivered to the + * client. Plain text with no markup and no echo of anything the caller sent: + * this page is reachable by any site that can make a browser follow a link, so + * reflecting a parameter here would be a cross-site scripting surface on the + * host's own origin. + */ +function invalidRequestPage(message: string, status = 400): Response { + return new Response(message, { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + }, + }); +} diff --git a/module/services/frontend/code/src/app/(dashboard)/oauth2/consent/page.tsx b/module/services/frontend/code/src/app/(dashboard)/oauth2/consent/page.tsx new file mode 100644 index 000000000..06c97ff21 --- /dev/null +++ b/module/services/frontend/code/src/app/(dashboard)/oauth2/consent/page.tsx @@ -0,0 +1,22 @@ +import { ConsentPage } from "@/features/auth/ui/consent-page"; + +export const dynamic = "force-dynamic"; + +/** + * Where a person approves a client by name (issue #1003). + * + * It sits in the `(dashboard)` group, which is what makes the generated page + * catalog record it as AUTHENTICATED — and that is the truth: approving a + * client is an act of the person, and there is nobody to attribute it to before + * they have signed in. The `(auth)` group would have recorded it as public, + * which would be a false statement in a generated authorization artifact about + * a page the middleware in fact gates. + * + * Rendering inside the product shell is deliberate rather than incidental. The + * person is signed in by the time they arrive, and seeing WHICH account and + * organization they are signed in as is part of the decision they are being + * asked to make. + */ +export default function Page() { + return ; +} diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts index 3409b7008..d3c7be853 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts @@ -615,6 +615,60 @@ describe("solution proxy passthrough", () => { expect(res.headers.get("x-internal-trace")).toBeNull(); }); + // A1007B-01. This route is the only public way to a solution's backend, so a + // challenge it drops is a challenge nobody can read: an MCP client sees a + // bare 401 and reports "unauthorized" with nowhere to go. Probed on a live + // cell before this fix: `POST /api/solutions//proxy/mcp` answered 401 + // with NO WWW-Authenticate, while the gateway had emitted one. + it("passes the gateway's authentication challenge through on a 401", async () => { + withGateway(); + registerAudit(); + const challenge = + 'Bearer error="invalid_token", ' + + 'resource_metadata="https://host.example.com/api/solutions/audit/proxy/.well-known/oauth-protected-resource"'; + fetchMock.mockResolvedValueOnce( + new Response("authentication required", { + status: 401, + headers: { + "content-type": "text/plain", + "www-authenticate": challenge, + // Still not forwarded: the allowlist stays an allowlist. + "set-cookie": "upstream=leak", + }, + }), + ); + + const res = await POST( + proxyRequest("http://frontend/api/solutions/audit/proxy/mcp", { + method: "POST", + }), + context("audit", ["mcp"]), + ); + + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBe(challenge); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + // And nothing invents one: a response the gateway sent without a challenge + // must not acquire one here, or a client would chase metadata for a refusal + // that was never about its credential. + it("adds no challenge the gateway did not send", async () => { + withGateway(); + registerAudit(); + fetchMock.mockResolvedValueOnce(new Response("forbidden", { status: 403 })); + + const res = await POST( + proxyRequest("http://frontend/api/solutions/audit/proxy/mcp", { + method: "POST", + }), + context("audit", ["mcp"]), + ); + + expect(res.status).toBe(403); + expect(res.headers.get("www-authenticate")).toBeNull(); + }); + it("returns 502 when the resolved gateway is unreachable", async () => { withGateway(); registerAudit(); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts index 6dd4e48e2..a109704f0 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts @@ -1,7 +1,8 @@ // @vitest-environment node -import { createServer, type Server, type RequestListener } from "node:http"; + import { once } from "node:events"; -import { beforeAll, afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type RequestListener, type Server } from "node:http"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); const { getEndpoints, findSolution } = vi.hoisted(() => ({ @@ -15,7 +16,9 @@ vi.mock("codefly", () => ({ getWorkspaceSecret: () => "fixture-internal", })); vi.mock("@/solutions/registry", () => ({ findSolution })); + import { server as mockServer } from "@/test/setup"; + // Exercise native fetch/socket cancellation without MSW response cloning. beforeAll(() => mockServer.close()); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts index df799e60a..a2b6228b8 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts @@ -192,6 +192,23 @@ async function handler( if (upstreamRequestID) { responseHeaders.set("x-request-id", upstreamRequestID); } + // The gateway's authentication challenge, passed through verbatim. + // + // This is how an MCP client starts discovery: a 401 names where the resource + // describes itself, that document names the authorization server, and the + // client runs the authorization-code flow. This route is the only public way + // to a solution's backend, so a challenge it drops is a challenge nobody can + // ever read — the client sees a bare 401 and reports "unauthorized" with + // nowhere to go. RFC 6750 §3 and RFC 9728 §5.1 both require the header to + // reach the caller. + // + // Forwarded on any status that carries it rather than only on 401: a 403 may + // legitimately carry one too, and the header is the gateway's statement + // about the credential, not this route's to interpret. + const challenge = upstream.headers.get("www-authenticate"); + if (challenge) { + responseHeaders.set("www-authenticate", challenge); + } // A forwarded error keeps its upstream status and body (the solution's remote // owns how it renders them), but the host categorizes it so an operator can diff --git a/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts index c2cec3bfe..060002875 100644 --- a/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts @@ -42,7 +42,10 @@ function base64url(value: object): string { } /** A credential accounts would mint: EdDSA, bound to one id and one publisher. */ -function credential(solution = "audit", subject = `solution:${solution}`): string { +function credential( + solution = "audit", + subject = `solution:${solution}`, +): string { const now = Math.floor(Date.now() / 1000); const head = base64url({ alg: "EdDSA", typ: "JWT", kid: KEY_ID }); const payload = base64url({ @@ -387,13 +390,19 @@ describe("solutions register route auth", () => { it("relays a registry conflict rather than reporting success", async () => { getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal("fetch", registryAnswering(new Response("conflict", { status: 409 }))); + vi.stubGlobal( + "fetch", + registryAnswering(new Response("conflict", { status: 409 })), + ); expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(409); }); it("relays a foreign-publisher refusal", async () => { getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal("fetch", registryAnswering(new Response("forbidden", { status: 403 }))); + vi.stubGlobal( + "fetch", + registryAnswering(new Response("forbidden", { status: 403 })), + ); expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(403); }); @@ -487,8 +496,7 @@ describe("solutions register route auth", () => { expect(res.status).toBe(422); expect( gateway.mock.calls.some( - ([input]) => - new URL(String(input)).pathname === "/solutions/_frontend", + ([input]) => new URL(String(input)).pathname === "/solutions/_frontend", ), ).toBe(false); }); @@ -551,9 +559,7 @@ describe("solutions register route auth", () => { { id: "activity", layout: "grid", - widgets: [ - { id: "logins", metric: "logins", visualization: "line" }, - ], + widgets: [{ id: "logins", metric: "logins", visualization: "line" }], }, ], }; diff --git a/module/services/frontend/code/src/features/auth/model/__tests__/wire-contract.test.ts b/module/services/frontend/code/src/features/auth/model/__tests__/wire-contract.test.ts new file mode 100644 index 000000000..6d3e0fc52 --- /dev/null +++ b/module/services/frontend/code/src/features/auth/model/__tests__/wire-contract.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + completePendingClientAuthorization, + rememberClientAuthorization, + validateClientAuthorization, +} from "../client-authorization"; +import { + parseOAuthGrant, + parseOAuthRefusal, + parseOAuthResolution, +} from "../oauth-authorization"; + +/** + * The accounts↔frontend wire contract, from the browser's side. + * + * A1007-01: accounts serialises OAuth snake_case and this code used to cast + * that JSON to a camelCase interface. It compiled, it type-checked, every unit + * test passed — and `requiresConsent` was `undefined`, which is falsy, which + * meant consent was SKIPPED and a code was issued for a client the person never + * approved. The tests that should have caught it instead mocked the camelCase + * object they wished for. + * + * So every fixture below is the real serialised shape: snake_case, exactly the + * `json:"..."` names in accounts/pkg/adapters/oauth_http.go. The Go side holds + * the same contract from its end in oauth_wire_contract_test.go. + */ + +// The literal bytes accounts sends. Written out rather than built from a +// helper, because a helper shared with the decoder would agree with it by +// construction and prove nothing. +const VALIDATE_RESPONSE = { + client_name: "Claude Code", + client_origin: "https://claude.ai", + client_source: "metadata_document", + resource: "https://host.example.com/solutions/example/mcp", + resource_name: "example", + scope: "offline_access", + requires_consent: true, + issuer: "https://host.example.com", +}; + +const request = { + responseType: "code", + clientId: "https://claude.ai/oauth/claude-code-client-metadata", + redirectUri: "http://localhost:54321/callback", + state: "opaque-state", + codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + codeChallengeMethod: "S256", + scope: "offline_access", + resource: "https://host.example.com/solutions/example/mcp", +}; + +const replace = vi.fn(); + +beforeEach(() => { + sessionStorage.clear(); + replace.mockReset(); + vi.stubGlobal("location", { replace }); + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("decoding the real wire shape", () => { + it("reads the snake_case response accounts actually sends", () => { + const resolved = parseOAuthResolution(VALIDATE_RESPONSE); + expect(resolved).not.toBeNull(); + expect(resolved?.requiresConsent).toBe(true); + expect(resolved?.clientName).toBe("Claude Code"); + expect(resolved?.clientOrigin).toBe("https://claude.ai"); + expect(resolved?.clientSource).toBe("metadata_document"); + expect(resolved?.resourceName).toBe("example"); + expect(resolved?.issuer).toBe("https://host.example.com"); + }); + + // The regression in its original form: the camelCase shape this code used to + // believe in must NOT decode, or a future cast would look like it works. + it("refuses a camelCase body, which is what the bug looked like", () => { + expect( + parseOAuthResolution({ + clientName: "Claude Code", + clientSource: "metadata_document", + requiresConsent: true, + }), + ).toBeNull(); + }); + + // Fail closed on anything whose consent requirement cannot be read. A + // missing or non-boolean `requires_consent` is not "no consent needed". + it("refuses a response whose consent requirement is unreadable", () => { + for (const body of [ + null, + undefined, + "a string", + {}, + { ...VALIDATE_RESPONSE, requires_consent: undefined }, + { ...VALIDATE_RESPONSE, requires_consent: "true" }, + { ...VALIDATE_RESPONSE, requires_consent: 1 }, + { ...VALIDATE_RESPONSE, client_source: "invented" }, + { ...VALIDATE_RESPONSE, client_origin: "" }, + ]) { + expect(parseOAuthResolution(body), JSON.stringify(body)).toBeNull(); + } + }); + + it("reads the RFC 6749 error object and the grant response", () => { + const refusal = parseOAuthRefusal({ + error: "invalid_target", + error_description: "not one this host issues tokens for", + redirectable: true, + issuer: "https://host.example.com", + }); + expect(refusal?.error).toBe("invalid_target"); + expect(refusal?.errorDescription).toBe( + "not one this host issues tokens for", + ); + expect(refusal?.redirectable).toBe(true); + expect(refusal?.issuer).toBe("https://host.example.com"); + // `redirectable` decides whether a browser may be sent to a client's + // redirect URI, so anything other than true must read as false — a + // truthy string must not open a redirector. + expect( + parseOAuthRefusal({ error: "x", redirectable: "yes" })?.redirectable, + ).toBe(false); + + expect(parseOAuthGrant({ code: "one-time", issuer: "https://h" })).toEqual({ + code: "one-time", + issuer: "https://h", + }); + expect(parseOAuthGrant({ expires_in: 60 })).toBeNull(); + }); +}); + +describe("the flow over the real wire shape", () => { + it("requires consent when the real response says so", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => VALIDATE_RESPONSE, + } as Response); + + const resolution = await validateClientAuthorization(request); + expect(resolution.requiresConsent).toBe(true); + + rememberClientAuthorization(request, resolution); + vi.mocked(fetch).mockClear(); + + // The assertion the review's cross-language reproducer made and this head + // now satisfies: no grant request is issued before the person approves. + await expect(completePendingClientAuthorization("token")).resolves.toBe( + true, + ); + expect(fetch).not.toHaveBeenCalled(); + expect(replace).toHaveBeenCalledWith("/oauth2/consent"); + }); + + // And it fails closed: a stored resolution that cannot be read sends the + // person to consent rather than silently granting. + it("sends the person to consent when the resolution is unreadable", async () => { + rememberClientAuthorization(request); + sessionStorage.setItem( + "client_authorization_resolution", + JSON.stringify({ clientName: "Claude Code", requiresConsent: false }), + ); + + await expect(completePendingClientAuthorization("token")).resolves.toBe( + true, + ); + expect(fetch).not.toHaveBeenCalled(); + expect(replace).toHaveBeenCalledWith("/oauth2/consent"); + }); + + it("carries the host's error description, not a generic one", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: "invalid_target", + error_description: + "that resource is not one this host issues tokens for", + redirectable: true, + }), + } as Response); + + await expect(validateClientAuthorization(request)).rejects.toThrow( + /not one this host issues tokens for/, + ); + }); +}); diff --git a/module/services/frontend/code/src/features/auth/model/client-authorization.test.ts b/module/services/frontend/code/src/features/auth/model/client-authorization.test.ts index 026fecd79..50cc59424 100644 --- a/module/services/frontend/code/src/features/auth/model/client-authorization.test.ts +++ b/module/services/frontend/code/src/features/auth/model/client-authorization.test.ts @@ -1,18 +1,39 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + CONSENT_PATH, completePendingClientAuthorization, + declineClientAuthorization, + grantClientAuthorization, + pendingClientResolution, readClientAuthorizationRequest, rememberClientAuthorization, takeClientAuthorization, validateClientAuthorization, } from "./client-authorization"; +import { + duplicatedOAuthParameter, + type OAuthAuthorizationResolution, + readOAuthAuthorizationRequest, +} from "./oauth-authorization"; const request = { + responseType: "code", clientId: "example-addin", redirectUri: "https://localhost:3000/auth/callback", state: "opaque-state", codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", codeChallengeMethod: "S256", + scope: "", + resource: "", +}; + +const declared: OAuthAuthorizationResolution = { + clientName: "Example Add-in", + clientOrigin: "example-addin", + clientSource: "registry", + scope: "offline_access", + requiresConsent: false, + issuer: "https://host.example.com", }; const replace = vi.fn(); @@ -32,6 +53,7 @@ describe("readClientAuthorizationRequest", () => { it("reads a client's request out of the login URL", () => { const parsed = readClientAuthorizationRequest( new URLSearchParams({ + response_type: "code", client_id: "example-addin", redirect_uri: "https://localhost:3000/auth/callback", state: "opaque-state", @@ -42,6 +64,72 @@ describe("readClientAuthorizationRequest", () => { expect(parsed).toEqual(request); }); + // A1007-07. The STANDARD endpoint repairs nothing: a request omitting + // response_type or code_challenge_method carries them EMPTY, so the host + // refuses it and names what the client owes. Defaulting them there made the + // host the only reason a non-conforming request worked, and would have + // silently upgraded a client that believed it was sending `plain`. + it("repairs no missing parameter on the standard endpoint", () => { + const parsed = readOAuthAuthorizationRequest( + new URLSearchParams({ + client_id: "example-addin", + redirect_uri: "https://localhost:3000/auth/callback", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }), + ); + expect(parsed?.responseType).toBe(""); + expect(parsed?.codeChallengeMethod).toBe(""); + }); + + // But the LOGIN PAGE keeps the two defaults it has always applied. The first + // registered client opens it directly with no response_type, against a + // contract that never had one; making that path strict would refuse a + // shipped client. Both defaults are the only values this host serves, so + // they cannot admit anything a strict read would have refused otherwise. + it("keeps the login page's historical defaults", () => { + const parsed = readClientAuthorizationRequest( + new URLSearchParams({ + client_id: "example-addin", + redirect_uri: "https://localhost:3000/auth/callback", + state: "opaque-state", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }), + ); + expect(parsed?.responseType).toBe("code"); + expect(parsed?.codeChallengeMethod).toBe("S256"); + }); + + // And a parameter given twice is named rather than silently resolved to its + // first value. + it("names a parameter that appears more than once", () => { + const query = new URLSearchParams(); + query.append("client_id", "example-addin"); + query.append("client_id", "someone-else"); + expect(duplicatedOAuthParameter(query)).toBe("client_id"); + expect( + duplicatedOAuthParameter(new URLSearchParams({ client_id: "x" })), + ).toBeNull(); + }); + + // The two parameters an MCP client adds. Neither may be invented here: a + // resource the page supplied rather than received would bind a token's + // audience to something the client never asked for and the person never saw. + it("carries the resource indicator and the scope", () => { + const parsed = readClientAuthorizationRequest( + new URLSearchParams({ + client_id: "https://claude.ai/oauth/claude-code-client-metadata", + redirect_uri: "http://localhost:54321/callback", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + resource: "https://host.example.com/solutions/example/mcp", + scope: "offline_access", + }), + ); + expect(parsed?.resource).toBe( + "https://host.example.com/solutions/example/mcp", + ); + expect(parsed?.scope).toBe("offline_access"); + }); + // A request with no PKCE challenge is not a request: the code it would earn // could be redeemed by whoever intercepted the redirect. it("is absent for an ordinary sign-in and for a half-formed request", () => { @@ -64,20 +152,38 @@ describe("readClientAuthorizationRequest", () => { describe("validateClientAuthorization", () => { it("reports the host's refusal rather than proceeding", async () => { - vi.mocked(fetch).mockResolvedValue({ ok: false, status: 403 } as Response); + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_client", redirectable: false }), + } as Response); await expect(validateClientAuthorization(request)).rejects.toThrow( /not registered/, ); }); - it("falls back to the client id when the host names no client", async () => { + it("returns what the host says about the client and the resource", async () => { + // The REAL serialised shape: snake_case, as accounts sends it. A + // camelCase fixture here is what let the wire mismatch ship. vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({}), + json: async () => ({ + client_name: "Claude Code", + client_origin: "https://claude.ai", + client_source: "metadata_document", + resource: "https://host.example.com/solutions/example/mcp", + resource_name: "example", + scope: "offline_access", + requires_consent: true, + issuer: "https://host.example.com", + }), } as Response); - await expect(validateClientAuthorization(request)).resolves.toBe( - "example-addin", - ); + await expect(validateClientAuthorization(request)).resolves.toMatchObject({ + clientName: "Claude Code", + clientOrigin: "https://claude.ai", + clientSource: "metadata_document", + requiresConsent: true, + }); }); }); @@ -90,29 +196,55 @@ describe("completePendingClientAuthorization", () => { expect(replace).not.toHaveBeenCalled(); }); - it("returns the browser to the registered URI with the code and state", async () => { - rememberClientAuthorization(request); + it("returns the browser to the registered URI with the code, state and issuer", async () => { + rememberClientAuthorization(request, declared); vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ code: "one-time-code" }), + json: async () => ({ + code: "one-time-code", + issuer: "https://host.example.com", + }), } as Response); await expect(completePendingClientAuthorization("token")).resolves.toBe( true, ); expect(replace).toHaveBeenCalledWith( - "https://localhost:3000/auth/callback?code=one-time-code&state=opaque-state", + "https://localhost:3000/auth/callback?code=one-time-code&state=opaque-state&iss=https%3A%2F%2Fhost.example.com", ); }); + // A client the operator never declared, or a request narrowing the token to + // one resource, is a decision the person makes. The browser goes to the + // consent page and NO code is issued on the way — a code minted before the + // approval would make the approval decorative. + it("sends the person to consent instead of issuing a code", async () => { + rememberClientAuthorization(request, { + ...declared, + clientSource: "metadata_document", + requiresConsent: true, + }); + + await expect(completePendingClientAuthorization("token")).resolves.toBe( + true, + ); + // replace, not assign: Back must not return the person to a half-finished + // sign-in. + expect(replace).toHaveBeenCalledWith(CONSENT_PATH); + expect(fetch).not.toHaveBeenCalled(); + // Still pending: the consent page is what completes it. + expect(takeClientAuthorization()).toEqual(request); + }); + it("consumes the pending request, so one sign-in yields one code", async () => { - rememberClientAuthorization(request); + rememberClientAuthorization(request, declared); expect(takeClientAuthorization()).toEqual(request); expect(takeClientAuthorization()).toBeNull(); + expect(pendingClientResolution()).toBeNull(); }); it("does not move the browser when the host refuses to issue a code", async () => { - rememberClientAuthorization(request); + rememberClientAuthorization(request, declared); vi.mocked(fetch).mockResolvedValue({ ok: false, status: 403 } as Response); await expect(completePendingClientAuthorization("token")).rejects.toThrow( @@ -124,7 +256,7 @@ describe("completePendingClientAuthorization", () => { // A request consumed before the code is in hand is unrecoverable: nothing // can retry it, and the client waits on a redirect that never comes. it("leaves the request pending when the call fails", async () => { - rememberClientAuthorization(request); + rememberClientAuthorization(request, declared); vi.mocked(fetch).mockRejectedValue(new Error("network down")); await expect(completePendingClientAuthorization("token")).rejects.toThrow(); @@ -132,7 +264,7 @@ describe("completePendingClientAuthorization", () => { }); it("clears the request once the code is in hand", async () => { - rememberClientAuthorization(request); + rememberClientAuthorization(request, declared); vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ code: "one-time-code" }), @@ -142,3 +274,47 @@ describe("completePendingClientAuthorization", () => { expect(takeClientAuthorization()).toBeNull(); }); }); + +describe("grantClientAuthorization", () => { + it("sends the OAuth parameter names the host validates on", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ code: "one-time-code" }), + } as Response); + await grantClientAuthorization( + { + ...request, + resource: "https://host.example.com/solutions/example/mcp", + }, + "token", + ); + const [path, init] = vi.mocked(fetch).mock.calls[0] as [ + string, + RequestInit, + ]; + expect(path).toBe("/v1/oauth2/authorize/grant"); + expect(JSON.parse(String(init.body))).toMatchObject({ + client_id: "example-addin", + redirect_uri: "https://localhost:3000/auth/callback", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + resource: "https://host.example.com/solutions/example/mcp", + }); + }); +}); + +describe("declineClientAuthorization", () => { + // The client is sitting on a loopback listener. Told `access_denied` it can + // say so; left waiting it hangs until the person kills it. + it("tells the client the person declined", () => { + rememberClientAuthorization(request, declared); + declineClientAuthorization(request); + expect(replace).toHaveBeenCalledWith( + expect.stringContaining("error=access_denied"), + ); + expect(replace).toHaveBeenCalledWith( + expect.stringContaining("state=opaque-state"), + ); + expect(takeClientAuthorization()).toBeNull(); + }); +}); diff --git a/module/services/frontend/code/src/features/auth/model/client-authorization.ts b/module/services/frontend/code/src/features/auth/model/client-authorization.ts index 0038cbddd..c162cb4af 100644 --- a/module/services/frontend/code/src/features/auth/model/client-authorization.ts +++ b/module/services/frontend/code/src/features/auth/model/client-authorization.ts @@ -1,94 +1,122 @@ /** - * A registered first-party client (an add-in, a CLI, a mobile app) sends the - * person here rather than to an identity provider: the host is the only - * sign-in UI. The client puts its authorization request in the login page's - * query string, the host signs the person in however it is configured, and the + * A public client (an add-in, a CLI, a mobile app, an MCP client) sends the + * person here rather than to an identity provider: the host is the only sign-in + * UI. The client puts its authorization request in the login page's query + * string, the host signs the person in however it is configured, and the * browser is handed back to the client with a one-time authorization code. * * Nothing here decides whether a request is allowed. The host does, before any - * sign-in UI is rendered — see validateClientAuthorization. + * sign-in UI is rendered (`validateClientAuthorization`) and again before it + * mints a code (`/v1/oauth2/authorize/grant`). + * + * Both halves talk to the standard authorization-server endpoints (issue + * #1003), which carry the two parameters the older `/v1/auth/clients/*` RPCs + * did not: `resource` (RFC 8707), which binds the token's audience, and + * `scope`. Those RPCs still exist for the first registered client, which calls + * the token endpoint directly; this page does not use them, because a page that + * could not carry a resource could not sign anyone in to an MCP client. */ -export interface ClientAuthorizationRequest { - clientId: string; - redirectUri: string; - state: string; - codeChallenge: string; - codeChallengeMethod: string; -} -const PENDING_KEY = "client_authorization_request"; +import { + type OAuthAuthorizationRequest, + type OAuthAuthorizationResolution, + oauthRedirect, + oauthWireFormat, + parseOAuthGrant, + parseOAuthRefusal, + parseOAuthResolution, + readLegacyLoginAuthorizationRequest, +} from "./oauth-authorization"; -type QueryReader = { get(name: string): string | null }; +export type ClientAuthorizationRequest = OAuthAuthorizationRequest; -/** Reads a client's authorization request out of the login page's URL. */ -export function readClientAuthorizationRequest( - query: QueryReader, -): ClientAuthorizationRequest | null { - const clientId = query.get("client_id"); - const redirectUri = query.get("redirect_uri"); - const codeChallenge = query.get("code_challenge"); - if (!clientId || !redirectUri || !codeChallenge) return null; - return { - clientId, - redirectUri, - state: query.get("state") ?? "", - codeChallenge, - codeChallengeMethod: query.get("code_challenge_method") ?? "S256", - }; -} +/** + * Where the pending request and the host's answer about it live across the + * sign-in itself, which may bounce through an identity provider and back, and + * across the navigation to the consent page. sessionStorage, not a cookie or + * the URL: it dies with the tab and never reaches a server. + * + * Exported because the consent page reads the store through + * `useSyncExternalStore` rather than through the accessors below — React's own + * way to read an external store, which needs the key and a stable raw snapshot + * rather than a freshly parsed object on every call. + */ +export const PENDING_REQUEST_KEY = "client_authorization_request"; +export const PENDING_RESOLUTION_KEY = "client_authorization_resolution"; -function wireFormat(request: ClientAuthorizationRequest) { - return { - client_id: request.clientId, - redirect_uri: request.redirectUri, - code_challenge: request.codeChallenge, - code_challenge_method: request.codeChallengeMethod, - }; -} +/** + * Reads a client's authorization request out of the LOGIN PAGE's URL, with the + * two defaults that path has always applied — see + * readLegacyLoginAuthorizationRequest for why they stay here and not on the + * standard `/oauth2/authorize` endpoint. + */ +export const readClientAuthorizationRequest = + readLegacyLoginAuthorizationRequest; /** * Asks the host whether this request may proceed. A client id nobody - * registered, or a redirect URI that client did not register, is refused here — - * while the browser is still on the host and before the person has been shown - * anywhere to type a credential. Resolves to the client's display name. + * registered, a client whose metadata document does not validate, or a redirect + * URI that client did not register is refused here — while the browser is still + * on the host and before the person has been shown anywhere to type a + * credential. Resolves to what the host says about the client and the resource. */ export async function validateClientAuthorization( request: ClientAuthorizationRequest, -): Promise { - const response = await fetch("/v1/auth/clients/validate", { +): Promise { + const response = await fetch("/v1/oauth2/authorize/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ authorization: wireFormat(request) }), + body: JSON.stringify(oauthWireFormat(request)), }); + const body = await response.json().catch(() => null); if (!response.ok) { throw new Error( - "This application is not registered to sign in here, or asked to be returned to an address it has not registered.", + parseOAuthRefusal(body)?.errorDescription ?? + "This application is not registered to sign in here, or asked to be returned to an address it has not registered.", ); } - const data = await response.json(); - return typeof data.clientName === "string" && data.clientName.length > 0 - ? data.clientName - : request.clientId; + // DECODED, not cast. The host serialises OAuth snake_case and this side is + // camelCase, so a cast type-checks and produces `undefined` for every + // field — and a falsy `requiresConsent` is indistinguishable from a host + // that said no approval was required. A response this cannot decode is one + // whose meaning is unknown, and the only safe reading of that is to refuse. + const resolved = parseOAuthResolution(body); + if (!resolved) { + throw new Error("This application cannot sign in here."); + } + return resolved; } /** * Holds the validated request across the sign-in itself, which may bounce - * through an identity provider and back. sessionStorage, not a cookie or the - * URL: it dies with the tab and never reaches a server. + * through an identity provider and back, and across the navigation to the + * consent page. sessionStorage, not a cookie or the URL: it dies with the tab + * and never reaches a server. */ export function rememberClientAuthorization( request: ClientAuthorizationRequest, + resolution?: OAuthAuthorizationResolution, ): void { - sessionStorage.setItem(PENDING_KEY, JSON.stringify(request)); + sessionStorage.setItem(PENDING_REQUEST_KEY, JSON.stringify(request)); + if (resolution) { + sessionStorage.setItem(PENDING_RESOLUTION_KEY, JSON.stringify(resolution)); + } } export function forgetClientAuthorization(): void { - sessionStorage.removeItem(PENDING_KEY); + sessionStorage.removeItem(PENDING_REQUEST_KEY); + sessionStorage.removeItem(PENDING_RESOLUTION_KEY); } -/** Returns the pending request without consuming it. */ -export function pendingClientAuthorization(): ClientAuthorizationRequest | null { - const raw = sessionStorage.getItem(PENDING_KEY); +/** + * Parses a stored request. A half-formed one is no request: without all three + * of client, redirect and challenge there is nothing the host would accept. + * Separated from the read so the consent page can parse a snapshot React gave + * it rather than touching the store during render. + */ +export function parsePendingClientAuthorization( + raw: string | null, +): ClientAuthorizationRequest | null { if (!raw) return null; try { const parsed = JSON.parse(raw) as ClientAuthorizationRequest; @@ -100,17 +128,84 @@ export function pendingClientAuthorization(): ClientAuthorizationRequest | null } } +/** + * What the host said about the pending request — the client's validated name + * and origin, the resource, and whether the person must approve it. The consent + * page renders these facts and nothing it derived itself. + */ +export function parsePendingClientResolution( + raw: string | null, +): OAuthAuthorizationResolution | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + // Validated, not cast — the same discipline as the wire decoder, for the + // same reason. This value has been outside this code's hands: it crossed a + // sign-in, possibly a provider round trip, and a navigation, in storage the + // page's own origin can write. A cast here would let a stored + // `requiresConsent: false` skip the consent screen, which is the first + // defect over again with a different carrier. + if (typeof parsed !== "object" || parsed === null) return null; + const stored = parsed as Record; + if (typeof stored.requiresConsent !== "boolean") return null; + if ( + stored.clientSource !== "registry" && + stored.clientSource !== "metadata_document" + ) { + return null; + } + if (typeof stored.clientOrigin !== "string" || stored.clientOrigin === "") { + return null; + } + return { + clientName: typeof stored.clientName === "string" ? stored.clientName : "", + clientOrigin: stored.clientOrigin, + clientSource: stored.clientSource, + resource: typeof stored.resource === "string" ? stored.resource : undefined, + resourceName: + typeof stored.resourceName === "string" ? stored.resourceName : undefined, + scope: typeof stored.scope === "string" ? stored.scope : "", + requiresConsent: stored.requiresConsent, + issuer: typeof stored.issuer === "string" ? stored.issuer : "", + }; +} + +/** Returns the pending request without consuming it. */ +export function pendingClientAuthorization(): ClientAuthorizationRequest | null { + return parsePendingClientAuthorization( + sessionStorage.getItem(PENDING_REQUEST_KEY), + ); +} + +export function pendingClientResolution(): OAuthAuthorizationResolution | null { + return parsePendingClientResolution( + sessionStorage.getItem(PENDING_RESOLUTION_KEY), + ); +} + /** Returns the pending request and clears it, so one sign-in yields one code. */ export function takeClientAuthorization(): ClientAuthorizationRequest | null { const pending = pendingClientAuthorization(); - sessionStorage.removeItem(PENDING_KEY); + forgetClientAuthorization(); return pending; } +/** Where the person approves a client by name. */ +export const CONSENT_PATH = "/oauth2/consent"; + /** * Completes a pending handoff, if there is one, and reports whether it took the * browser away. Called wherever a sign-in has just produced a session — the * host's own session, which is what authorizes the code the client receives. + * + * When the host said the request needs consent, this navigates to the consent + * page instead of issuing a code, and still reports true: the browser HAS been + * moved, just not to the client yet. Returning false there would navigate the + * person into the product on top of a client still waiting for its redirect. */ export async function completePendingClientAuthorization( accessToken: string | null, @@ -121,22 +216,60 @@ export async function completePendingClientAuthorization( // is in hand. const request = pendingClientAuthorization(); if (!request) return false; - const response = await fetch("/v1/auth/clients/authorize", { + // Fail closed. A pending request whose resolution did not survive — the host + // never answered, the browser cleared it, a response shape changed — must not + // be completed silently: that would be a code issued for a client whose + // approval requirement nobody could read. Send the person to consent, where + // the absence is visible and nothing is granted without them. + const resolution = pendingClientResolution(); + if (!resolution || resolution.requiresConsent) { + // replace, not assign: Back must not return the person to a half-finished + // sign-in. It is a full navigation rather than a router push because this + // runs inside the auth provider, which has no router — and the session it + // just established lives in the refresh cookie, which the consent page + // bootstraps from exactly as any other entry to the app does. + window.location.replace(CONSENT_PATH); + return true; + } + await grantClientAuthorization(request, accessToken); + return true; +} + +/** + * Issues the code and sends the browser back to the client. Separated from the + * decision above so the consent page can call it on approval. + */ +export async function grantClientAuthorization( + request: ClientAuthorizationRequest, + accessToken: string | null, + options: { consentGranted?: boolean } = {}, +): Promise { + const response = await fetch("/v1/oauth2/authorize/grant", { method: "POST", headers: { "Content-Type": "application/json", ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, credentials: "include", - body: JSON.stringify({ authorization: wireFormat(request) }), + body: JSON.stringify({ + ...oauthWireFormat(request), + // Stated only by the consent page, after the person pressed Allow. The + // host refuses a request that needs consent and does not carry it, so a + // client-side defect — a mis-decoded `requires_consent`, a dropped + // navigation — can no longer skip the screen silently. It is not a + // security boundary against a hostile browser (a browser that holds the + // session can say anything); it is what stops a bug in this file from + // quietly issuing credentials nobody approved. + consent_granted: options.consentGranted === true, + }), }); if (!response.ok) { throw new Error( "Sign-in succeeded but the application could not be authorized.", ); } - const data = await response.json(); - if (typeof data.code !== "string" || data.code.length === 0) { + const granted = parseOAuthGrant(await response.json().catch(() => null)); + if (!granted) { throw new Error( "Sign-in succeeded but the application could not be authorized.", ); @@ -145,9 +278,37 @@ export async function completePendingClientAuthorization( // it is used verbatim; a URL assembled from anything the page still holds // would be assembling it from the caller's own input again. forgetClientAuthorization(); - const target = new URL(request.redirectUri); - target.searchParams.set("code", data.code); - if (request.state) target.searchParams.set("state", request.state); - window.location.replace(target.toString()); - return true; + window.location.replace( + oauthRedirect(request.redirectUri, { + code: granted.code, + state: request.state || undefined, + // RFC 9207: naming the issuer lets a client with more than one + // configured authorization server refuse a code that came back from + // the wrong one. + iss: granted.issuer, + }), + ); +} + +/** + * The person declined. The client is told so in the terms it understands + * (`access_denied`) rather than being left on a redirect that never arrives. + */ +export function declineClientAuthorization( + request: ClientAuthorizationRequest, +): void { + // RFC 9207 §2 covers error responses too, and the host advertises support for + // it: a client enforcing that protection cannot authenticate a decline that + // carries no `iss`. The issuer is what the HOST answered, never anything from + // the request, so a caller cannot choose what the client is told. + const issuer = pendingClientResolution()?.issuer; + forgetClientAuthorization(); + window.location.replace( + oauthRedirect(request.redirectUri, { + error: "access_denied", + error_description: "the person declined this authorization", + state: request.state || undefined, + iss: issuer, + }), + ); } diff --git a/module/services/frontend/code/src/features/auth/model/oauth-authorization.ts b/module/services/frontend/code/src/features/auth/model/oauth-authorization.ts new file mode 100644 index 000000000..802202425 --- /dev/null +++ b/module/services/frontend/code/src/features/auth/model/oauth-authorization.ts @@ -0,0 +1,285 @@ +/** + * The OAuth 2.1 authorization request, as this app handles it (issue #1003). + * + * The host is the only sign-in UI, so the standard authorization endpoint lives + * here rather than in accounts: it has to show a login page and, for a client + * the operator never declared or a request that names a resource, a consent + * screen. Nothing here decides whether a request is allowed — accounts does, + * before any sign-in UI renders, and again before it mints a code. + * + * Shared by the route handler at /oauth2/authorize (server) and the login page + * (browser), which is why the parameter names and their parsing live in one + * place: a parameter spelled differently on the two halves is a parameter the + * person approves and the host does not bind. + */ + +/** The OAuth parameters this host reads from an authorization request. */ +export interface OAuthAuthorizationRequest { + responseType: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + codeChallengeMethod: string; + state: string; + scope: string; + resource: string; +} + +type QueryReader = { get(name: string): string | null }; + +/** + * Every parameter the standard authorization endpoint reads. Each is a + * singleton: RFC 6749 §3.1 says a request parameter MUST NOT appear more than + * once, and `URLSearchParams.get` returns the first and discards the rest — + * which would make the host silently pick one of two values a caller sent. + */ +const OAUTH_SINGLETON_PARAMETERS = [ + "response_type", + "client_id", + "redirect_uri", + "code_challenge", + "code_challenge_method", + "state", + "scope", + "resource", +] as const; + +/** + * Reads an authorization request out of a query string, repairing nothing. + * + * Returns null when the three parameters without which there is no request at + * all are absent — a plain visit to the login page rather than a malformed + * authorization request. Everything else is carried EXACTLY as sent, including + * absent `response_type` and `code_challenge_method`. Neither is defaulted + * here: a default would make this page the reason a non-conforming request + * worked, and would answer a client's stated challenge method with a different + * one. The host refuses an incomplete request instead, and tells the client + * which parameter it owes. + */ +export function readOAuthAuthorizationRequest( + query: QueryReader, +): OAuthAuthorizationRequest | null { + const clientId = query.get("client_id"); + const redirectUri = query.get("redirect_uri"); + const codeChallenge = query.get("code_challenge"); + if (!clientId || !redirectUri || !codeChallenge) return null; + return { + responseType: query.get("response_type") ?? "", + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod: query.get("code_challenge_method") ?? "", + state: query.get("state") ?? "", + scope: query.get("scope") ?? "", + resource: query.get("resource") ?? "", + }; +} + +/** + * Reads a request that arrived at the LOGIN PAGE rather than at the standard + * authorization endpoint, applying the two defaults that path has always + * applied. + * + * The first registered client (and anything built against the shape the + * registered-clients decision documents) opens + * `/auth/login?client_id=…&redirect_uri=…&state=…&code_challenge=…` directly. + * It sends no `response_type`, and `code_challenge_method` has always been + * assumed. Making the login page strict would refuse a shipped client for a + * parameter the contract it was written against never had. + * + * Both defaults are safe here precisely because they are the only values this + * host serves: `code` is the only response type and S256 the only challenge + * method, so defaulting them cannot admit anything a strict read would have + * refused for a different reason. What the standard endpoint must not do — and + * no longer does — is repair a request from a client that DOES speak the + * specification, because there the omission is the client's own bug and + * silently fixing it hides a `plain` challenge the client thought it sent. + */ +export function readLegacyLoginAuthorizationRequest( + query: QueryReader, +): OAuthAuthorizationRequest | null { + const request = readOAuthAuthorizationRequest(query); + if (!request) return null; + return { + ...request, + responseType: request.responseType || "code", + codeChallengeMethod: request.codeChallengeMethod || "S256", + }; +} + +/** + * The first parameter given more than once, or null. The host refuses such a + * request rather than choosing between the values for the caller. + */ +export function duplicatedOAuthParameter( + query: URLSearchParams, +): string | null { + for (const name of OAUTH_SINGLETON_PARAMETERS) { + if (query.getAll(name).length > 1) return name; + } + return null; +} + +/** The wire shape accounts reads: the OAuth parameter names, unchanged. */ +export function oauthWireFormat(request: OAuthAuthorizationRequest) { + return { + response_type: request.responseType, + client_id: request.clientId, + redirect_uri: request.redirectUri, + code_challenge: request.codeChallenge, + code_challenge_method: request.codeChallengeMethod, + state: request.state, + scope: request.scope, + resource: request.resource, + }; +} + +/** Renders the request back into a query string, for the login-page handoff. */ +export function oauthQueryString(request: OAuthAuthorizationRequest): string { + const query = new URLSearchParams(); + for (const [name, value] of Object.entries(oauthWireFormat(request))) { + if (value) query.set(name, value); + } + return query.toString(); +} + +/** + * What accounts answers a validate call with. + * + * This is a DECODED value, never a cast one. + * + * The host serialises OAuth-style snake_case (`requires_consent`, + * `client_name`) and this interface is camelCase, so the two spellings are not + * the same string. A cast asserts a shape rather than checking it: it compiles, + * type-checks, and produces `undefined` for every field — and an undefined + * `requiresConsent` is falsy, indistinguishable from a host that said no + * approval was required. The invariant is that a response this cannot decode is + * refused rather than interpreted; `parseOAuthResolution` is where that is + * enforced. + * + * A unit test on either side of this seam cannot establish the agreement, + * because each side's test fixes the shape that side expects. The seam is held + * by tests spelled against the other side's own serialisation. + */ +export interface OAuthAuthorizationResolution { + /** Untrusted presentation, from the client's own document. */ + clientName: string; + /** + * The origin the host VERIFIED — derived from the client_id URL it fetched + * the document from, never from anything the document claims. Always shown. + */ + clientOrigin: string; + clientSource: "registry" | "metadata_document"; + resource?: string; + resourceName?: string; + scope: string; + requiresConsent: boolean; + /** The authorization server's own issuer, for the RFC 9207 `iss` echo. */ + issuer: string; +} + +/** What accounts answers a refusal with. */ +export interface OAuthAuthorizationRefusal { + error: string; + errorDescription?: string; + /** + * Whether the redirect URI was validated before the refusal, and so whether + * the error may be delivered to it. Sending a person to an unvalidated + * redirect URI is how an authorization endpoint becomes an open redirector, + * so this flag — not the presence of a redirect_uri parameter — is what + * decides. + */ + redirectable: boolean; + /** Echoed on the error redirect (RFC 9207), when the host could name it. */ + issuer?: string; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * Decodes a validate response. Returns null for anything that is not a + * well-formed resolution, so a caller FAILS CLOSED rather than reading a + * missing `requires_consent` as "no consent needed". + */ +export function parseOAuthResolution( + body: unknown, +): OAuthAuthorizationResolution | null { + if (typeof body !== "object" || body === null) return null; + const wire = body as Record; + // requires_consent decides whether a person is asked before a credential is + // issued, so it must be present and boolean. Absent or any other type is a + // response this code does not understand, and the safe reading of a response + // it does not understand is "do not issue anything". + if (typeof wire.requires_consent !== "boolean") return null; + const source = asString(wire.client_source); + if (source !== "registry" && source !== "metadata_document") return null; + const clientOrigin = asString(wire.client_origin); + if (clientOrigin === "") return null; + return { + clientName: asString(wire.client_name), + clientOrigin, + clientSource: source, + resource: asString(wire.resource) || undefined, + resourceName: asString(wire.resource_name) || undefined, + scope: asString(wire.scope), + requiresConsent: wire.requires_consent, + issuer: asString(wire.issuer), + }; +} + +/** Decodes an RFC 6749 §5.2 error object. */ +export function parseOAuthRefusal( + body: unknown, +): OAuthAuthorizationRefusal | null { + if (typeof body !== "object" || body === null) return null; + const wire = body as Record; + if (typeof wire.error !== "string" || wire.error === "") return null; + return { + error: wire.error, + errorDescription: asString(wire.error_description) || undefined, + redirectable: wire.redirectable === true, + issuer: asString(wire.issuer) || undefined, + }; +} + +/** + * True when a body is a decodable OAuth error object. Kept as a predicate for + * call sites that only need to branch; anything reading its FIELDS must use + * parseOAuthRefusal, so a mistyped field cannot read as absent. + */ +export function isOAuthRefusal(value: unknown): boolean { + return parseOAuthRefusal(value) !== null; +} + +/** What accounts answers a grant call with. */ +export interface OAuthAuthorizationGrant { + code: string; + issuer: string; +} + +export function parseOAuthGrant(body: unknown): OAuthAuthorizationGrant | null { + if (typeof body !== "object" || body === null) return null; + const wire = body as Record; + const code = asString(wire.code); + if (code === "") return null; + return { code, issuer: asString(wire.issuer) }; +} + +/** + * Builds the redirect back to the client. The redirect URI is the one the host + * validated against the registry or the client's own metadata document, so it + * is used verbatim; assembling one from anything the page still holds would be + * assembling it from the caller's input again. + */ +export function oauthRedirect( + redirectUri: string, + parameters: Record, +): string { + const target = new URL(redirectUri); + for (const [name, value] of Object.entries(parameters)) { + if (value) target.searchParams.set(name, value); + } + return target.toString(); +} diff --git a/module/services/frontend/code/src/features/auth/ui/consent-page.test.tsx b/module/services/frontend/code/src/features/auth/ui/consent-page.test.tsx new file mode 100644 index 000000000..8f5be4ff3 --- /dev/null +++ b/module/services/frontend/code/src/features/auth/ui/consent-page.test.tsx @@ -0,0 +1,243 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + accessToken: "access-token" as string | null, + isLoading: false, + isAuthenticated: true, + grantClientAuthorization: vi.fn(), + declineClientAuthorization: vi.fn(), +})); + +vi.mock("@/features/auth/model/client-authorization", async (original) => ({ + ...(await original>()), + grantClientAuthorization: h.grantClientAuthorization, + declineClientAuthorization: h.declineClientAuthorization, +})); + +vi.mock("@/lib/auth", () => ({ + useAuth: () => ({ + accessToken: h.accessToken, + isLoading: h.isLoading, + isAuthenticated: h.isAuthenticated, + }), +})); + +vi.mock("@/lib/appearance-provider", () => ({ + useAppearance: () => ({ branding: { name: "Example" } }), +})); + +vi.mock("@/components/brand-mark", () => ({ + BrandMark: () =>
, +})); + +import { + PENDING_REQUEST_KEY, + PENDING_RESOLUTION_KEY, +} from "@/features/auth/model/client-authorization"; +import { ConsentPage } from "./consent-page"; + +const request = { + responseType: "code", + clientId: "https://claude.ai/oauth/claude-code-client-metadata", + redirectUri: "http://localhost:54321/callback", + state: "opaque-state", + codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + codeChallengeMethod: "S256", + scope: "offline_access", + resource: "https://host.example.com/solutions/example/mcp", +}; + +const resolution = { + clientName: "Claude Code", + // The origin the host VERIFIED by fetching the document, never the + // document's own `client_uri` claim. + clientOrigin: "https://claude.ai", + clientSource: "metadata_document", + resource: "https://host.example.com/solutions/example/mcp", + resourceName: "example", + scope: "offline_access", + requiresConsent: true, + issuer: "https://host.example.com", +}; + +function storePending( + pending: unknown = request, + resolved: unknown = resolution, +): void { + if (pending) { + sessionStorage.setItem(PENDING_REQUEST_KEY, JSON.stringify(pending)); + } + if (resolved) { + sessionStorage.setItem(PENDING_RESOLUTION_KEY, JSON.stringify(resolved)); + } +} + +beforeEach(() => { + sessionStorage.clear(); + h.accessToken = "access-token"; + h.isLoading = false; + h.isAuthenticated = true; + h.grantClientAuthorization.mockReset().mockResolvedValue(undefined); + h.declineClientAuthorization.mockReset(); +}); + +afterEach(cleanup); + +// Everything the page shows about the client comes from what the HOST said — +// the name and origin it resolved from the registry or from the document it +// fetched and validated. A page that rendered a name from the query string +// would let any site put any name in front of the person. +it("names the client, its origin and the resource from the host's own answer", () => { + storePending(); + render(); + + expect(screen.getByText(/Allow Claude Code\?/)).toBeTruthy(); + expect(screen.getByText(/use example as you/)).toBeTruthy(); + expect( + screen.getByText("https://host.example.com/solutions/example/mcp"), + ).toBeTruthy(); + // A client the operator never installed is said to be one, with the origin + // the host verified. + expect(screen.getByText(/not installed by your administrator/)).toBeTruthy(); + // The verified origin, shown in both places: as the identity under the + // client's own (unverified) name, and in the warning. + expect(screen.getAllByText("https://claude.ai").length).toBeGreaterThan(0); +}); + +// A1007-06. A document claiming a trusted-looking `client_uri` must not be able +// to put that origin in front of the person: the host never reads the claim, so +// the page has only the verified origin to show. Adopted from the Astra review. +it("shows only the origin the host verified, never a claimed one", () => { + storePending(request, { + ...resolution, + clientName: "Totally Trusted", + clientOrigin: "https://client.example.com", + }); + render(); + + expect( + screen.getAllByText("https://client.example.com").length, + ).toBeGreaterThan(0); + expect(screen.queryByText(/trusted\.example\.com/)).toBeNull(); +}); + +it("issues the code only when the person allows it", () => { + storePending(); + render(); + + expect(h.grantClientAuthorization).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /Allow/ })); + // And it states that the person approved — the one call site allowed to. + // The host refuses a grant that needs consent without it, so this argument + // is what makes the screen load-bearing rather than decorative. + expect(h.grantClientAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ clientId: request.clientId }), + "access-token", + { consentGranted: true }, + ); +}); + +// The client is sitting on a loopback listener. Told `access_denied` it can say +// so; left waiting it hangs until the person kills it. +it("tells the client when the person cancels", () => { + storePending(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Cancel/ })); + expect(h.declineClientAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ clientId: request.clientId }), + ); + expect(h.grantClientAuthorization).not.toHaveBeenCalled(); +}); + +// An operator-declared client is not flagged as uninstalled, and a request that +// narrows nothing names no resource. +it("says nothing about installation for a client the operator declared", () => { + storePending( + { ...request, clientId: "example-addin", resource: "" }, + { + clientName: "Example Add-in", + clientOrigin: "example-addin", + clientSource: "registry", + scope: "offline_access", + requiresConsent: true, + issuer: "https://host.example.com", + }, + ); + render(); + + expect(screen.getByText(/Allow Example Add-in\?/)).toBeTruthy(); + expect(screen.getByText(/asking to act as you/)).toBeTruthy(); + expect(screen.queryByText(/not installed by your administrator/)).toBeNull(); +}); + +// Nothing to approve, or nobody to attribute the approval to. Both are the same +// thing from here, and saying so beats rendering an Allow button that cannot +// work. +it("offers no decision with no pending request", () => { + render(); + expect(screen.getByText(/no sign-in request to approve/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Allow/ })).toBeNull(); +}); + +it("offers no decision to a caller with no session", () => { + storePending(); + h.isAuthenticated = false; + render(); + expect(screen.getByText(/no sign-in request to approve/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Allow/ })).toBeNull(); +}); + +it("waits while the session is still being established", () => { + storePending(); + h.isLoading = true; + render(); + expect(screen.getByText(/Loading/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Allow/ })).toBeNull(); +}); + +// A failed grant leaves the person on the page with the reason, not navigated +// away with a client still waiting. +it("surfaces a failed grant instead of moving on", async () => { + storePending(); + h.grantClientAuthorization.mockRejectedValue( + new Error("could not be authorized"), + ); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Allow/ })); + expect(await screen.findByText(/could not be authorized/)).toBeTruthy(); + expect(screen.getByRole("button", { name: /Allow/ })).toBeTruthy(); +}); + +// A1007B-06. A pending request whose resolution is absent or no longer +// decodable — an in-flight sessionStorage record written by an older response +// shape across a deployment — must NOT render Allow. Without the resolution the +// page has no verified origin, no resource and no uninstalled-client warning to +// show, and an approval given against a blank presentation approves nothing. +// +// Adopted from the Astra review (missing resolution consent page continuation). +it("offers no decision without a validated resolution", () => { + storePending(request, null); + render(); + + expect(screen.queryByRole("button", { name: /Allow/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /Cancel/ })).toBeNull(); + expect(screen.getByText(/could not be confirmed/)).toBeTruthy(); + expect(h.grantClientAuthorization).not.toHaveBeenCalled(); +}); + +// Including one that is present but undecodable, which is the shape an older +// deployment's record actually takes. +it("offers no decision when the stored resolution cannot be decoded", () => { + storePending(request, null); + sessionStorage.setItem( + "client_authorization_resolution", + JSON.stringify({ clientName: "Claude Code", requiresConsent: true }), + ); + render(); + + expect(screen.queryByRole("button", { name: /Allow/ })).toBeNull(); + expect(screen.getByText(/could not be confirmed/)).toBeTruthy(); +}); diff --git a/module/services/frontend/code/src/features/auth/ui/consent-page.tsx b/module/services/frontend/code/src/features/auth/ui/consent-page.tsx new file mode 100644 index 000000000..a4a1fea26 --- /dev/null +++ b/module/services/frontend/code/src/features/auth/ui/consent-page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { AlertCircle, Check, Loader2, ShieldCheck, X } from "lucide-react"; +import { useMemo, useState, useSyncExternalStore } from "react"; +import { BrandMark } from "@/components/brand-mark"; +import { + declineClientAuthorization, + grantClientAuthorization, + PENDING_REQUEST_KEY, + PENDING_RESOLUTION_KEY, + parsePendingClientAuthorization, + parsePendingClientResolution, +} from "@/features/auth/model/client-authorization"; +import { useAppearance } from "@/lib/appearance-provider"; +import { useAuth } from "@/lib/auth"; + +// sessionStorage is an external store, and this page's whole job is to render +// what is in it. useSyncExternalStore is how React reads one: it takes a server +// snapshot (nothing is readable there) and the client's, so the value arrives +// without an effect that sets state — which the react-hooks rule rejects, and +// rightly: a setState in an effect body is a second render for a value that was +// already available. +// +// The store does not change under this page — one pending request is written +// before the browser arrives and cleared when it leaves — so there is nothing +// to subscribe to. The unsubscribe is still returned, because useSyncExternalStore +// calls it. +const neverChanges = () => () => {}; + +function useSessionItem(key: string): string | null { + return useSyncExternalStore( + neverChanges, + // Returns a string or null, both compared by value, so the snapshot is + // referentially stable and cannot loop. The read is guarded because + // sessionStorage access throws outright in some privacy modes. + () => { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } + }, + () => null, + ); +} + +/** + * The consent screen. It names the client and, when the request bound one, the + * resource — "this client, for that one solution" is a different sentence from + * "this client", and the difference is what the person is being asked to + * decide. + * + * Everything it displays comes from what the HOST said about the request + * (`pendingClientResolution`), never from the query string: a page that + * rendered the client's own name from a URL parameter would let any site put + * any name in front of the person. The host resolved the name from the operator + * registry, or from the metadata document it fetched and validated itself. + * + * The host asks for consent in two cases, and says which applies: + * - a client the operator never declared, which registered itself by + * publishing a metadata document — nobody but the person can vouch for it; + * - a request that narrows the credential to one named resource. + * An operator-declared client asking for nothing in particular never reaches + * this page; the deployment already vouched for it. + */ +export function ConsentPage() { + const { accessToken, isLoading, isAuthenticated } = useAuth(); + const { branding } = useAppearance(); + const [deciding, setDeciding] = useState<"allow" | "deny" | null>(null); + const [error, setError] = useState(null); + + const storedRequest = useSessionItem(PENDING_REQUEST_KEY); + const storedResolution = useSessionItem(PENDING_RESOLUTION_KEY); + // Parsed from the stable raw snapshots, so the objects below are rebuilt only + // when what is stored actually changes. + const request = useMemo( + () => parsePendingClientAuthorization(storedRequest), + [storedRequest], + ); + const resolution = useMemo( + () => parsePendingClientResolution(storedResolution), + [storedResolution], + ); + + const clientName = resolution?.clientName ?? request?.clientId ?? ""; + // The only identity fact the host verified. Never the document's claim. + const verifiedOrigin = resolution?.clientOrigin ?? ""; + const unvetted = resolution?.clientSource === "metadata_document"; + const resourceName = useMemo(() => { + if (!resolution?.resource) return null; + return resolution.resourceName || resolution.resource; + }, [resolution]); + + async function allow() { + if (!request) return; + setError(null); + setDeciding("allow"); + try { + // The person pressed Allow: this is the one call site that may say so. + await grantClientAuthorization(request, accessToken, { + consentGranted: true, + }); + } catch (err) { + setDeciding(null); + setError( + err instanceof Error + ? err.message + : "This application could not be authorized.", + ); + } + } + + function deny() { + if (!request) return; + setDeciding("deny"); + declineClientAuthorization(request); + } + + // The hydration render has the server snapshot (no request) and the auth + // provider has not finished exchanging the refresh cookie, so both reduce to + // the same honest answer: this page does not know yet. + if (isLoading) { + return ( +
+ + Loading… +
+ ); + } + + // No pending request, no validated resolution, or no session to attribute + // the approval to. All three are the same thing from here: there is nothing + // this page can put in front of the person to decide about. + // + // The resolution is what the HOST said about this client — its verified + // origin, the resource, whether it is one the operator installed. Without + // it the page would render Allow next to a name and nothing else: no + // verified origin, no resource, no uninstalled-client warning, and clicking + // it would still produce a code. An approval given against a blank + // presentation is not an approval of anything, so it is refused and the + // person is told to start again. + if (!request || !resolution || !isAuthenticated) { + return ( +
+
+ + + There is no sign-in request to approve here, or its details could + not be confirmed. Start again from the application you were using. + +
+
+ ); + } + + return ( +
+
+
+
+ + + {branding.name} + +
+ +
+

+ Allow {clientName}? +

+ {/* The name above comes from the client's own document and is + not verified. The origin here is: it is where the host + actually fetched that document from, and it is shown + unconditionally so a trusted-looking name can never be the + only thing the person sees. CIMD draft-02 §8.5. */} + {verifiedOrigin && ( +

+ {verifiedOrigin} +

+ )} +

+ {resourceName + ? `It is asking to use ${resourceName} as you.` + : "It is asking to act as you."} +

+
+ +
+
+ + + It will act with your own permissions — never more — and + everything it does is recorded against both you and it. + +
+ {resolution?.resource && ( +
+ + + Its access is limited to{" "} + + {resolution.resource} + + . + +
+ )} + {unvetted && ( +
+ + + This application was not installed by your administrator. The + host verified only that its registration is published at{" "} + + {resolution?.clientOrigin ?? request.clientId} + + . Allow it only if you started this yourself. + +
+ )} +
+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/module/services/frontend/code/src/features/auth/ui/login-page.test.tsx b/module/services/frontend/code/src/features/auth/ui/login-page.test.tsx index eeae86676..5ea1c0344 100644 --- a/module/services/frontend/code/src/features/auth/ui/login-page.test.tsx +++ b/module/services/frontend/code/src/features/auth/ui/login-page.test.tsx @@ -126,14 +126,25 @@ describe("LoginPage", () => { it("names the client once the host has accepted its request", async () => { h.query = clientQuery(); - h.validateClientAuthorization.mockResolvedValue("Example Add-in"); + h.validateClientAuthorization.mockResolvedValue({ + clientName: "Example Add-in", + clientSource: "registry", + scope: "offline_access", + requiresConsent: false, + }); render(); await waitFor(() => expect(screen.getByText(/to continue to Example Add-in/)).toBeTruthy(), ); - expect(h.rememberClientAuthorization).toHaveBeenCalledTimes(1); + // The host's whole answer is remembered, not just the name: the consent + // page renders what the HOST said about this client, never what the query + // string claimed. + expect(h.rememberClientAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "example-addin" }), + expect.objectContaining({ clientName: "Example Add-in" }), + ); expect( screen.getByRole("button", { name: /Continue with Provider/ }), ).toBeTruthy(); diff --git a/module/services/frontend/code/src/features/auth/ui/login-page.tsx b/module/services/frontend/code/src/features/auth/ui/login-page.tsx index 59c83d9c7..df3795422 100644 --- a/module/services/frontend/code/src/features/auth/ui/login-page.tsx +++ b/module/services/frontend/code/src/features/auth/ui/login-page.tsx @@ -71,10 +71,14 @@ export function LoginPage({ identity }: { identity: IdentityConfig }) { } let cancelled = false; validateClientAuthorization(clientRequest) - .then((name) => { + .then((resolution) => { if (cancelled) return; - rememberClientAuthorization(clientRequest); - setClientName(name); + // The host's answer is remembered alongside the request, because + // the consent page must render what the HOST said about this + // client — its validated name and origin, the resource — and never + // what the query string claimed. + rememberClientAuthorization(clientRequest, resolution); + setClientName(resolution.clientName || clientRequest.clientId); }) .catch((err) => { if (cancelled) return; diff --git a/module/services/frontend/code/src/gen/saas/frontend/v1/plugin_catalog.ts b/module/services/frontend/code/src/gen/saas/frontend/v1/plugin_catalog.ts index 219bcd9dc..739644568 100644 --- a/module/services/frontend/code/src/gen/saas/frontend/v1/plugin_catalog.ts +++ b/module/services/frontend/code/src/gen/saas/frontend/v1/plugin_catalog.ts @@ -84,6 +84,7 @@ export const FRONTEND_ROUTES = [ { path: "/legal/privacy", sourcePath: "src/app/(auth)/legal/privacy/page.tsx", match: "exact", access: "public" }, { path: "/legal/terms", sourcePath: "src/app/(auth)/legal/terms/page.tsx", match: "exact", access: "public" }, { path: "/notifications", sourcePath: "src/app/(dashboard)/notifications/page.tsx", match: "exact", access: "authenticated" }, + { path: "/oauth2/consent", sourcePath: "src/app/(dashboard)/oauth2/consent/page.tsx", match: "exact", access: "authenticated" }, { path: "/onboarding", sourcePath: "src/app/(dashboard)/onboarding/page.tsx", match: "exact", access: "authenticated" }, { path: "/s/{solutionId}", sourcePath: "src/app/(dashboard)/s/[solutionId]/page.tsx", match: "parameter", access: "authenticated" }, { path: "/security", sourcePath: "src/app/(dashboard)/security/page.tsx", match: "exact", access: "authenticated" }, diff --git a/module/services/frontend/code/src/lib/__tests__/proxy-product-api.test.ts b/module/services/frontend/code/src/lib/__tests__/proxy-product-api.test.ts index 250d24f15..409af9ab8 100644 --- a/module/services/frontend/code/src/lib/__tests__/proxy-product-api.test.ts +++ b/module/services/frontend/code/src/lib/__tests__/proxy-product-api.test.ts @@ -114,6 +114,80 @@ describe("proxy product API forwarding", () => { ); }); + // The OAuth 2.1 / MCP surface at the paths the specifications put it (issue + // #1003). This app is the module's public entry, so the standard paths have + // to exist here; the behaviour behind each one stays with the service that + // owns it, which is what these rewrites carry. + it("serves the standard OAuth paths from the services that own them", async () => { + const cases: Array<[string, string]> = [ + // The RFC 8414 document, published by accounts — the server whose + // behaviour it describes. + [ + "/.well-known/oauth-authorization-server", + "/v1/oauth2/authorization-server", + ], + // The RFC 6749 token endpoint, also accounts. + ["/oauth2/token", "/v1/oauth2/token"], + ]; + for (const [publicPath, upstreamPath] of cases) { + const response = await proxy( + productRequest(`https://app.example${publicPath}`), + ); + expect(rewrittenTo(response)).toBe(`${GATEWAY}${upstreamPath}`); + } + }); + + // /oauth2/authorize is this app's own page: it shows a login page and a + // consent screen, neither of which is a response body. Forwarding it to the + // gateway would 404 the whole flow. + it("keeps the authorization endpoint in this app", async () => { + const response = await proxy( + productRequest("https://app.example/oauth2/authorize?client_id=x"), + ); + expect(rewrittenTo(response)).toBeNull(); + }); + + // An MCP client reaches a solution's backend through + // `/api/solutions//proxy/*`, a route of this app that needs no rewrite. + // NO `/solutions/*` path is forwarded: on a deployed cell that prefix is a + // page of this app, and the gateway's own `/solutions/*` surface carries the + // cluster-internal registration endpoints that decide where authenticated + // traffic goes and what the host loads as an in-origin remote. + it("forwards no /solutions path at all", async () => { + for (const path of [ + "/solutions/_register", + "/solutions/_frontend", + "/solutions/_registry", + "/solutions/example/mcp", + "/solutions/example/mcp/tools", + "/solutions/example/detail", + "/solutions/Example/mcp", + "/.well-known/oauth-protected-resource/solutions/example/mcp", + ]) { + const response = await proxy( + productRequest(`https://app.example${path}`), + ); + expect(rewrittenTo(response), path).toBeNull(); + } + }); + + // An MCP client POSTing its token request must reach the backend, not a 307 + // to a login page. These paths carry their own credential (a bearer, a PKCE + // verifier) or none at all by specification. + it("does not send the OAuth surface through the login redirect", async () => { + for (const path of [ + "/.well-known/oauth-authorization-server", + "/oauth2/token", + "/oauth2/authorize", + ]) { + const request = new NextRequest(`https://app.example${path}`, { + headers: { "sec-fetch-dest": "empty", accept: "*/*" }, + }); + const response = await proxy(request); + expect(response.headers.get("location"), path).toBeNull(); + } + }); + it("follows the running composition when the gateway address changes", async () => { // The regression guard for the real defect: a next.config rewrite compiles // its destination into the build manifest, so an image built outside the diff --git a/module/services/frontend/code/src/lib/__tests__/proxy-well-known.test.ts b/module/services/frontend/code/src/lib/__tests__/proxy-well-known.test.ts new file mode 100644 index 000000000..8fe8ae086 --- /dev/null +++ b/module/services/frontend/code/src/lib/__tests__/proxy-well-known.test.ts @@ -0,0 +1,57 @@ +/** + * The middleware's own answer on a well-known path. + * + * `isPublic` is unit-tested beside the predicate, but the condition that + * matters to a client is the RESPONSE: a discovery request must not be answered + * with a redirect to a login page. A non-browser client follows that redirect, + * parses an HTML page as JSON, and reports that this host is not an + * authorization server — so this drives the middleware and asserts there is no + * 307, wherever in the tree the document sits. + */ + +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { proxy } from "@/proxy"; + +describe("well-known paths are never redirected to login", () => { + beforeEach(() => { + vi.stubEnv( + "SOLUTION_CSP_INPUTS", + JSON.stringify({ + solutionOrigins: [], + analyticsOrigin: null, + turnstile: false, + }), + ); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + // The RFC 8414 document a client reads to find the authorization server. + "/.well-known/oauth-authorization-server", + // RFC 9728 §3.1's constructed location, at the host root. + "/.well-known/oauth-protected-resource/api/solutions/example/proxy/mcp", + // Beneath a solution's own route, which is a page of this app — the case + // a prefix test on "/.well-known/" leaves behind the login redirect. + "/solutions/example/.well-known/oauth-protected-resource", + // One this host does not serve at all. It must reach the handler that + // answers 404: "no document here" is a true answer a client can act on. + "/.well-known/openid-configuration", + ])("does not redirect %s", async (pathname) => { + const response = await proxy( + new NextRequest(`https://app.example${pathname}`), + ); + + expect(response.status).not.toBe(307); + expect(response.headers.get("location")).toBeNull(); + }); + + it("still redirects a protected page, so the assertion above is not vacuous", async () => { + const response = await proxy(new NextRequest("https://app.example/settings")); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toContain("/auth/login"); + }); +}); diff --git a/module/services/frontend/code/src/proxy.test.ts b/module/services/frontend/code/src/proxy.test.ts new file mode 100644 index 000000000..73766f041 --- /dev/null +++ b/module/services/frontend/code/src/proxy.test.ts @@ -0,0 +1,58 @@ +/** + * The login-redirect decision, tested directly. + * + * Everything here is about one measured failure: a discovery request answered + * with a 307 to `/auth/login`. A client that cannot read metadata cannot + * authenticate, and the symptom it reports — "this host is not an authorization + * server" — points nowhere near the middleware that caused it. + */ + +import { describe, expect, it } from "vitest"; +import { isPublic } from "./proxy"; + +describe("public paths", () => { + it("exempts every well-known path, including ones this host does not serve", () => { + for (const pathname of [ + // The RFC 8414 document a client reads to find the authorization + // server. This is the one that was measured redirecting to login. + "/.well-known/oauth-authorization-server", + // RFC 9728 builds the protected-resource URL by INSERTING the + // well-known segment before the resource's own path, so a client that + // constructs it rather than reading the challenge arrives here. + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/api/solutions/example/proxy/mcp", + // Not served by this host at all. It must still reach the handler that + // answers 404: "no document here" is a true answer a client can act + // on, and a login page is not. Being public is not the same as + // existing, and conflating them is what produced the redirect. + "/.well-known/openid-configuration", + "/.well-known/anything-at-all", + // Not at the root. A solution's protected-resource metadata sits + // beneath its own route, and `/solutions//` is a page of this + // app — so a prefix test on "/.well-known/" would leave exactly the + // namespace a client reads during discovery behind the redirect. + "/solutions/example/.well-known/oauth-protected-resource", + "/solutions/example/.well-known/anything", + ]) { + expect(isPublic(pathname), pathname).toBe(true); + } + }); + + it("still protects the pages it protected before", () => { + // Without this the test above would pass against a predicate that simply + // returned true, which would be the same defect with the opposite sign. + // Not "/" — the landing page is deliberately public. + for (const pathname of ["/settings", "/organizations", "/oauth2/consent"]) { + expect(isPublic(pathname), pathname).toBe(false); + } + }); + + it("keeps the authorization endpoint public and the consent page private", () => { + // `/oauth2/authorize` is where an unauthenticated person is SUPPOSED to + // arrive, so it is public and renders its own sign-in. Consent is the + // opposite: it may only be reached by someone already signed in, so it + // stays behind the redirect. + expect(isPublic("/oauth2/authorize")).toBe(true); + expect(isPublic("/oauth2/consent")).toBe(false); + }); +}); diff --git a/module/services/frontend/code/src/proxy.ts b/module/services/frontend/code/src/proxy.ts index 10ca4566b..96b7eeb4f 100644 --- a/module/services/frontend/code/src/proxy.ts +++ b/module/services/frontend/code/src/proxy.ts @@ -12,6 +12,7 @@ // // Public pages (login, callback, landing, health) bypass the check. +import { getWorkspaceConfiguration } from "codefly"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { @@ -22,7 +23,6 @@ import { expectedInternalToken, INTERNAL_TOKEN_HEADER, } from "@/lib/internal-token"; -import { getWorkspaceConfiguration } from "codefly"; import { resolveAccountsBindings } from "../server/accounts-bindings.mjs"; import { contentSecurityPolicyFromInputs, @@ -32,8 +32,71 @@ import { const PRODUCT_API_PREFIXES = ["/v1/", "/saas.accounts.v1."] as const; const PUBLIC_ORIGIN_HEADER = "X-Codefly-Public-Origin"; +/** + * The host's OAuth 2.1 / MCP surface, at the paths the specifications put it + * (issue #1003). This app is the module's public entry, so the standard paths + * have to exist here; the behaviour behind each one belongs to the service that + * owns it, so each is forwarded rather than reimplemented. + * + * /.well-known/oauth-authorization-server the RFC 8414 document, published by + * accounts — the server whose + * behaviour it describes + * /oauth2/token the RFC 6749 token endpoint, in + * accounts + * /.well-known/oauth-protected-resource/… the RFC 9728 document for a + * solution's MCP endpoint, served by + * the gateway, which is the component + * that enforces what it says + * /solutions//mcp the resource itself, proxied by the + * gateway to the solution's runtime + * + * `/oauth2/authorize` is deliberately NOT here: the authorization endpoint shows + * the person a login page and a consent screen, which is this app's job. It is + * a route handler (src/app/oauth2/authorize/route.ts). + * + * Each entry maps a public path to the gateway path it is forwarded to. The + * mapping is explicit per path rather than a prefix, because `/solutions/` at + * the gateway also carries the registration endpoints, which are + * cluster-internal and must not become reachable from the edge by widening a + * prefix here. + */ +const OAUTH_SURFACE_REWRITES: ReadonlyArray = [ + [ + "/.well-known/oauth-authorization-server", + "/v1/oauth2/authorization-server", + ], + ["/oauth2/token", "/v1/oauth2/token"], +] as const; + +/** + * The gateway path a public OAuth path is served from, or undefined when the + * path is not part of that surface. + * + * Deliberately only the two authorization-server paths. An MCP client reaches a + * solution's backend — and the protected-resource metadata its runtime serves — + * through `/api/solutions//proxy/*`, which is a route of this app and needs + * no rewrite. + * + * An earlier revision also forwarded `/solutions//mcp` and a constructed + * `/.well-known/oauth-protected-resource/...`. Both were public paths that do + * not exist on a deployed cell, where `/solutions/*` on the public origin is a + * page of this app that redirects to login — so a client following the + * advertised metadata URL reached a login page instead of a JSON document. + * Adding a second public route to a solution's backend would also mean two + * paths to keep identical forever. + */ +export function oauthSurfaceUpstreamPath(pathname: string): string | undefined { + for (const [publicPath, upstreamPath] of OAUTH_SURFACE_REWRITES) { + if (pathname === publicPath) return upstreamPath; + } + return undefined; +} + function isProductAPI(pathname: string): boolean { - return PRODUCT_API_PREFIXES.some((prefix) => pathname.startsWith(prefix)); + if (PRODUCT_API_PREFIXES.some((prefix) => pathname.startsWith(prefix))) { + return true; + } + return oauthSurfaceUpstreamPath(pathname) !== undefined; } // Next derives `nextUrl`'s protocol and host from the internal request URL and @@ -91,7 +154,10 @@ export function trustedGatewayRequestHeaders( */ export function productAPIDestination(pathname: string, search: string): URL { const { rest } = resolveAccountsBindings(); - return new URL(`${rest}${pathname}${search}`); + // A public OAuth path is served from its own gateway path; everything else + // is forwarded unchanged. + const upstreamPath = oauthSurfaceUpstreamPath(pathname) ?? pathname; + return new URL(`${rest}${upstreamPath}${search}`); } const PUBLIC_PATHS = [ @@ -533,7 +599,17 @@ function reportMissingGateway(err: unknown): void { ); } -function isPublic(pathname: string): boolean { +/** + * Whether a path bypasses the page middleware's login redirect. + * + * Exported for its own test. This one predicate decides whether an + * unauthenticated request is answered or sent to a login page, and getting it + * wrong is invisible from inside the app: a browser follows the 307 and looks + * fine, while a non-browser client parses a login page as JSON and reports that + * the endpoint does not exist. That failure mode is worth a direct test rather + * than one inferred through `proxy()`. + */ +export function isPublic(pathname: string): boolean { if (PUBLIC_PATHS.includes(pathname)) return true; // Next internals must always pass through. if (pathname.startsWith("/_next/")) return true; @@ -544,6 +620,35 @@ function isPublic(pathname: string): boolean { // response semantics. if (pathname.startsWith("/v1/")) return true; if (pathname.startsWith("/saas.accounts.v1.")) return true; + // The OAuth 2.1 / MCP surface. These carry their own credentials (a bearer, + // a PKCE verifier) or none at all by specification, and are answered by the + // backend, so the page middleware's login redirect must not intercept them: + // an MCP client POSTing its token request would get a 307 to a login page. + // /oauth2/authorize is a page of this app and belongs here too — it is where + // an unauthenticated person is SUPPOSED to arrive. + if (pathname === "/oauth2/authorize") return true; + // EVERY well-known path, wherever it sits in the tree and whether or not + // this host serves a document there. + // + // `.well-known` is a reserved metadata namespace (RFC 8615) and every + // discovery chain begins by GETting one with no credential at all. + // Answering one with a 307 to a login page does not merely inconvenience a + // client: a non-browser client follows the redirect, parses an HTML login + // page as JSON, and concludes the metadata does not exist — so the flow ends + // before it has begun, and the symptom it reports is "this host is not an + // authorization server" rather than "you are not signed in". + // + // Matched as a path SEGMENT rather than a prefix, because these documents + // are not all at the root. A solution's protected-resource metadata sits + // beneath its own route, and `/solutions//` is a page of this app — so a + // prefix test on "/.well-known/" leaves exactly the namespace a client reads + // during discovery behind the login redirect. + // + // A well-known path this host does NOT serve must reach the handler that + // answers 404. "No document here" is a true answer a client can act on; a + // login page is not. + if (pathname.split("/").includes(".well-known")) return true; + if (oauthSurfaceUpstreamPath(pathname) !== undefined) return true; if (pathname === "/monitoring") return true; if (pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|avif|css|js|woff2?)$/)) return true; diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 663512a51..ea4d9708c 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -1885,6 +1885,18 @@ "coverage": "missing", "remaining": "Owner stories or a reviewed nonvisual exclusion still required." }, + { + "source": "src/app/(dashboard)/oauth2/consent/page.tsx", + "imports": [ + "@/features/auth/ui/consent-page" + ], + "owner": "SaaS application", + "disposition": "keep feature-local", + "reason": "Next.js route or layout entry; owns host routing and server/provider composition. Full-page preview coverage is still pending.", + "stories": [], + "coverage": "missing", + "remaining": "Owner stories or a reviewed nonvisual exclusion still required." + }, { "source": "src/app/(dashboard)/onboarding/page.tsx", "imports": [ @@ -3440,6 +3452,23 @@ "coverage": "missing", "remaining": "Owner stories or a reviewed nonvisual exclusion still required." }, + { + "source": "src/features/auth/ui/consent-page.tsx", + "imports": [ + "lucide-react", + "react", + "@/components/brand-mark", + "@/features/auth/model/client-authorization", + "@/lib/appearance-provider", + "@/lib/auth" + ], + "owner": "SaaS application", + "disposition": "keep feature-local", + "reason": "Owns the auth workflow, data binding or route composition. Lower-level presentation consumes the shared kit.", + "stories": [], + "coverage": "missing", + "remaining": "Owner stories or a reviewed nonvisual exclusion still required." + }, { "source": "src/features/auth/ui/login-page.tsx", "imports": [ diff --git a/module/services/frontend/generated/plugin-catalog.json b/module/services/frontend/generated/plugin-catalog.json index 19ee66f86..e962a4143 100644 --- a/module/services/frontend/generated/plugin-catalog.json +++ b/module/services/frontend/generated/plugin-catalog.json @@ -275,6 +275,12 @@ "match": "FRONTEND_ROUTE_MATCH_EXACT", "access": "FRONTEND_ROUTE_ACCESS_AUTHENTICATED" }, + { + "path": "/oauth2/consent", + "source_path": "src/app/(dashboard)/oauth2/consent/page.tsx", + "match": "FRONTEND_ROUTE_MATCH_EXACT", + "access": "FRONTEND_ROUTE_ACCESS_AUTHENTICATED" + }, { "path": "/onboarding", "source_path": "src/app/(dashboard)/onboarding/page.tsx", diff --git a/module/services/marketing/code/package-lock.json b/module/services/marketing/code/package-lock.json index 8eb92af08..bde1e3e98 100644 --- a/module/services/marketing/code/package-lock.json +++ b/module/services/marketing/code/package-lock.json @@ -1516,9 +1516,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/module/services/store/migrations/18_oauth_resource_indicators.down.sql b/module/services/store/migrations/18_oauth_resource_indicators.down.sql new file mode 100644 index 000000000..9ff077c69 --- /dev/null +++ b/module/services/store/migrations/18_oauth_resource_indicators.down.sql @@ -0,0 +1,9 @@ +-- Reverses 17_oauth_resource_indicators. Dropping the columns drops every +-- audience binding with them: tokens already minted keep the audience in their +-- signed claims, so a rotation after this ran would reissue them unbound and +-- the gateway would stop refusing one solution's token at another's. A downgrade +-- past this migration therefore wants those sessions revoked, which is what +-- the forward direction's own rotation does within one refresh lifetime. +ALTER TABLE public.client_authorization_codes DROP COLUMN scope; +ALTER TABLE public.client_authorization_codes DROP COLUMN resource; +ALTER TABLE public.sessions DROP COLUMN resource; diff --git a/module/services/store/migrations/18_oauth_resource_indicators.up.sql b/module/services/store/migrations/18_oauth_resource_indicators.up.sql new file mode 100644 index 000000000..771b39bd1 --- /dev/null +++ b/module/services/store/migrations/18_oauth_resource_indicators.up.sql @@ -0,0 +1,24 @@ +-- RFC 8707 resource indicators on the client sign-in flow (issue #1003). +-- +-- An MCP client asks for a token for one named resource — a solution's MCP +-- endpoint — and the token it gets carries that URL as a second audience, so it +-- is refused at any other solution's surface. Two columns carry that binding: +-- the authorization code records what the person approved, and the session +-- records what was minted, so every refresh rotation reissues the same audience +-- instead of re-reading it from a request the client controls. +-- +-- Both are nullable with no default. Every session and every code that exists +-- today named no resource, which is exactly what NULL means here, so there is +-- nothing to backfill and no invariant that pre-existing rows now break. + +-- The resource indicator this session's access tokens are bound to. NULL is a +-- session with no resource binding: the host's own web session, and every +-- registered-client session minted before resource indicators existed. +ALTER TABLE public.sessions ADD COLUMN resource text; + +-- What the person approved, read back at redemption. A token request may repeat +-- `resource` (RFC 8707 §2.2) and it must match this; it can never set it. +ALTER TABLE public.client_authorization_codes ADD COLUMN resource text; + +-- The scope granted, echoed on the token response (RFC 6749 §5.1). +ALTER TABLE public.client_authorization_codes ADD COLUMN scope text;