On this page

SDKs and APIs

OAuth 2.1 endpoints

The gateway is its own authorization server: a client discovers the endpoints, registers itself and exchanges a code, all against one host. This page shows the token endpoint; the other routes are under More details. Writing a client? Read custom clients first.

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"
}

Parameters

POST /oauth/token is form-encoded, not JSON.

ParameterTypeRequiredNotes
grant_typestringRequiredauthorization_code or refresh_token. Nothing else, client_credentials included, is refused (400, unsupported_grant_type).
client_idstringRequiredIn the body or as HTTP Basic credentials; a body value wins.
codestringauthorization_codeLives five minutes and is single use. A missing, used or expired code is refused (400, invalid_grant: invalid or expired code). A client_id or redirect_uri that does not match the one it was issued for gives client_id/redirect_uri mismatch, and a wrong verifier PKCE verification failed, both invalid_grant with the text in error_description.
code_verifierstringauthorization_codeThe proof-key verifier; the only method is S256.
redirect_uristringauthorization_codeMatched exactly against the registered value.
refresh_tokenstringrefresh_tokenLives 30 days and rotates: each exchange returns a new one and invalidates the old. One rotated, expired or from another client is refused (400, invalid_grant: invalid refresh token).
Errors
StatusCodeRaised when
400invalid_requestThe form body could not be parsed.
More details
All the endpoints

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.

Registration and authorization

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 details

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.

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.

Related
  • Custom clients: The same flow as a walkthrough, with a working proof-key example.
  • Discovery: The unauthenticated documents a client reads before any of this.
  • Authentication: How an issued access token is resolved on a later call.
  • Identity: Confirming what the consent screen actually granted.

Every tool call works the same way: How every tool call works.