On this page

OAuth 2.1 endpoints

The gateway is both the resource server and its own authorization server. There is no separate identity provider to configure and no developer portal to register in: a client discovers the endpoints, registers itself, and exchanges a code, all against the same host.

If you are writing a client, read custom clients first — it walks the flow end to end with a working verifier and challenge. This page is the endpoint reference: the parameters each route accepts, the exact refusal each one produces, and the server-side behaviour a walkthrough leaves out.

The endpoints

RouteCredentialPurpose
GET /.well-known/oauth-authorization-serverNoneAuthorization server metadata
GET /.well-known/oauth-protected-resourceNoneProtected resource metadata
GET /.well-known/oauth-protected-resource/mcp/{projectId}NoneThe project-scoped variant
POST /oauth/registerNone (optional x-server-key)Dynamic client registration
GET /oauth/authorizeNoneServes or redirects to the consent screen
POST /oauth/authorizeUser token, as a form fieldRecords the decision, issues a code
POST /oauth/tokenClient id, plus the proof keyIssues and refreshes access tokens

Authorization server metadata

curl -sS https://fabric.wexa.ai/.well-known/oauth-authorization-server
{
  "issuer": "https://fabric.wexa.ai",
  "authorization_endpoint": "https://fabric.wexa.ai/oauth/authorize",
  "token_endpoint": "https://fabric.wexa.ai/oauth/token",
  "registration_endpoint": "https://fabric.wexa.ai/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "scopes_supported": [
    "fabric:query.read", "fabric:ontology.write", "fabric:docs.read", "fabric:codesync.write",
    "fabric:agent.run", "fabric:orchestrate.read", "fabric:orchestrate.write"
  ]
}

Three absences are as informative as the contents. There is no client_credentials grant, so a caller with no person behind it uses an API key instead. There is no plain proof-key method. And there is no implicit or password grant to fall back to.

The seven advertised scopes are the grant names themselves, so what you request here is exactly what GET /v1/whoami will report later.

Protected resource metadata

Two paths serve the same document with one field different. resource on the generic path is the issuer; on the project-scoped path it is the exact connect URL:

{
  "resource": "https://fabric.wexa.ai/mcp/proj_abc123",
  "authorization_servers": ["https://fabric.wexa.ai"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["fabric:query.read", "…"]
}

That field is the whole mechanism by which a project survives a round trip through a browser. Send it back as the resource parameter on the authorization request and the gateway extracts the project from it, passes lock_project to the consent screen, and offers no project picker. Omit it and the reader picks a project by hand — which is how a connection made against one project ends up authorized against another.

Only a value shaped <issuer>/mcp/{projectId} (or <issuer>/sse/{projectId}) yields a project. The bare <issuer>/mcp deliberately yields nothing, so a stale link cannot silently lock a consent screen to a project it never named.

Dynamic client registration

POST /oauth/register, unauthenticated, JSON body.

FieldNotes
client_nameFree label, echoed back.
redirect_urisMatched exactly at authorization time. Register every one you will use, loopback ports included.
{
  "client_id": "fab_client_SflcBV5l8gJ26C2gq82JeQ8t",
  "client_secret": "fab_secret_kqdqXSYrBAPD6ybEmMww0TMg",
  "client_name": "docs-d15-client",
  "redirect_uris": ["http://127.0.0.1:9999/cb"]
}

The status is 201 Created. A secret is always issued, but a public client may ignore it and authenticate with none — the proof key is what actually binds the exchange, and the secret is not separately verified at the token endpoint. A malformed body is refused 400 {"error":"invalid_client_metadata"}.

Authorize

GET /oauth/authorize takes the standard query parameters: response_type, client_id, redirect_uri, state, code_challenge, code_challenge_method, scope, and optionally resource.

RefusalCause
400 {"error":"unsupported_response_type"}response_type is anything other than code. Checked before the client id.
400 {"error":"invalid_client"}The client_id is not registered — including after a restart.

What happens next depends on deployment configuration. With a branded consent screen configured, the gateway answers 302 to it, forwarding the OAuth parameters plus the resolved client_name, the gateway's own base URL, and project_id + lock_project=1 when a project-scoped resource was supplied. With none configured, the gateway serves a plain built-in HTML form itself, posting back to /oauth/authorize. The built-in form is a development affordance; treat the branded screen as the deployed path.

Recording the decision

POST /oauth/authorize is form-encoded, not JSON. It carries the OAuth parameters back as hidden fields, plus decision and user_token.

Denial redirects immediately, with no token work done:

HTTP/1.1 302 Found
Location: http://127.0.0.1:9999/cb?error=access_denied&state=st9

Approval requires a valid Wexa user token in the user_token field, falling back to the Authorization header. Without one:

HTTP/1.1 401 Unauthorized

{"error":"invalid_token","error_description":"consent requires a valid Fabric user token"}

With one, the gateway issues a code and redirects:

HTTP/1.1 302 Found
Location: http://127.0.0.1:9999/cb?code=fab_code_50Tqyzbbfw-60AbDW0VgCcp7&state=st1

Three server-side behaviours are worth knowing here. An empty scope defaults to query.read + docs.read rather than to everything the consenting person holds. Project membership is re-checked where the platform's membership records are available, because the consent screen's project list is org-wide and unfiltered: a project the person is not a member of is refused 403, a mismatched organization is refused 403, and a membership check that cannot complete fails closed with 502 identity_unavailable rather than minting on trust. And the role and organization are taken from identity's answer, not from the form. Where membership cannot be checked, a token with no role claim falls back to PROJECT_MEMBER.

Token

POST /oauth/token, form-encoded. The client id may arrive in the body or as HTTP Basic credentials; both are accepted, and a body value wins.

Authorization code

curl -sS -X POST https://fabric.wexa.ai/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code' \
  -d 'code=fab_code_50Tqyzbbfw-60AbDW0VgCcp7' \
  -d 'code_verifier=<verifier>' \
  -d 'client_id=fab_client_SflcBV5l8gJ26C2gq82JeQ8t' \
  -d 'redirect_uri=http://127.0.0.1:9999/cb'
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "fab_rt_cPzKiu0VGS_cPNBwIjhgSede",
  "scope": "fabric:query.read fabric:docs.read"
}

The access token is an HS256 JWT. Decoded, it carries sub, aud (the issuer), iat, exp, jti, org_id, dept_id, project_id, role, scope, client_id and token_use: "access" — which is the claim that distinguishes it from a user token during credential resolution.

Codes live five minutes and are single-use. Replaying one returns 400 {"error":"invalid_grant","error_description":"invalid or expired code"}.

Refresh

curl -sS -X POST https://fabric.wexa.ai/oauth/token \
  -d 'grant_type=refresh_token' -d 'refresh_token=fab_rt_…' -d 'client_id=fab_client_…'

Refresh tokens live 30 days and rotate: each exchange returns a new one and invalidates the one you sent. Replaying the old value immediately afterwards returned 400 {"error":"invalid_grant","error_description":"invalid refresh token"}. Persist the new token before using the new access token, or a crash between the two leaves the client with nothing to refresh with.

Refusals

StatusBodyCause
400unsupported_grant_typeAny grant_type other than the two — client_credentials included.
400invalid_grant + invalid or expired codeCode already used, expired, or issued to another client.
400invalid_grant + invalid refresh tokenRefresh token already rotated, expired, or from another client.
400invalid_requestThe form body could not be parsed.

invalid_grant is deliberately uniform: the same code covers a wrong verifier, a mismatched redirect URI and an expired code, so a caller cannot probe which of the three it got wrong.