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.
| Parameter | Type | Required | Notes |
|---|---|---|---|
grant_type | string | Required | authorization_code or refresh_token. Nothing else, client_credentials included, is refused (400, unsupported_grant_type). |
client_id | string | Required | In the body or as HTTP Basic credentials; a body value wins. |
code | string | authorization_code | Lives 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_verifier | string | authorization_code | The proof-key verifier; the only method is S256. |
redirect_uri | string | authorization_code | Matched exactly against the registered value. |
refresh_token | string | refresh_token | Lives 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
| Status | Code | Raised when |
|---|---|---|
| 400 | invalid_request | The form body could not be parsed. |
More details
All the endpoints
The endpoints
| Route | Credential | Purpose |
|---|---|---|
GET /.well-known/oauth-authorization-server | None | Authorization server metadata |
GET /.well-known/oauth-protected-resource | None | Protected resource metadata |
GET /.well-known/oauth-protected-resource/mcp/{projectId} | None | The project-scoped variant |
POST /oauth/register | None (optional x-server-key) | Dynamic client registration |
GET /oauth/authorize | None | Serves or redirects to the consent screen |
POST /oauth/authorize | User token, as a form field | Records the decision, issues a code |
POST /oauth/token | Client id, plus the proof key | Issues 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.
| Field | Notes |
|---|---|
client_name | Free label, echoed back. |
redirect_uris | Matched 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.
| Refusal | Cause |
|---|---|
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=st9Approval 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=st1Three 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.