> Source: https://wexa.ai/docs/api/oauth

# 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](/docs/surfaces/mcp/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

```bash
curl -sS https://fabric.wexa.ai/.well-known/oauth-authorization-server
```

```json
{
  "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](/docs/api/api-keys) 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:

```json
{
  "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. |

```json
{
  "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:

```text
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:

```text
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:

```text
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

```bash
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'
```

```json
{
  "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](/docs/api/authentication).

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

### Refresh

```bash
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.

## Related

  <Card title="Custom clients" href="/docs/surfaces/mcp/custom-clients">
    The same flow as a walkthrough, with a working proof-key example.
  </Card>
  <Card title="Discovery" href="/docs/api/discovery">
    The unauthenticated documents a client reads before any of this.
  </Card>
  <Card title="Authentication" href="/docs/api/authentication">
    How an issued access token is resolved on a later call.
  </Card>
  <Card title="Identity" href="/docs/api/identity">
    Confirming what the consent screen actually granted.
  </Card>
