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
| 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.
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=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
| Status | Body | Cause |
|---|---|---|
400 | unsupported_grant_type | Any grant_type other than the two — client_credentials included. |
400 | invalid_grant + invalid or expired code | Code already used, expired, or issued to another client. |
400 | invalid_grant + invalid refresh token | Refresh token already rotated, expired, or from another client. |
400 | invalid_request | The 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.