> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bbrands.io/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth / OIDC Endpoints

> Contract for the standard OAuth 2.1 / OpenID Connect endpoints: discovery, JWKS, authorize, consent, token, userinfo and revoke.

All endpoints live under the environment's canonical `auth.*` issuer origin. No
client-facing endpoint points at the underlying engine — even `/.well-known/*`
is served from the issuer. Examples below use the production base URL
`https://auth.bbrands.io`; substitute the per-environment base:

| Environment   | Base URL / issuer                       |
| ------------- | --------------------------------------- |
| Development   | `https://development.auth.bbrands.io`   |
| Certification | `https://certification.auth.bbrands.io` |
| Production    | `https://auth.bbrands.io`               |

## Conventions

* **Content-Type**: `application/json`, except OAuth endpoints that use
  `application/x-www-form-urlencoded`.
* **Transport**: HTTPS only (TLS 1.2+).
* **Caching**: `Cache-Control: no-store` on every OAuth endpoint.
* **Errors**: OAuth endpoints return `{"error": "...", "error_description": "..."}`.
* **Correlation**: every response carries `X-Request-Id`.

## Discovery

### GET `/.well-known/openid-configuration`

Returns the OIDC discovery document. **Public, no auth.** Point your client's
auto-configuration at this URL.

<CodeGroup>
  ```json Response 200 theme={null}
  {
    "issuer": "https://auth.bbrands.io",
    "authorization_endpoint": "https://auth.bbrands.io/api/v3/oauth/authorize",
    "token_endpoint": "https://auth.bbrands.io/api/v3/oauth/token",
    "userinfo_endpoint": "https://auth.bbrands.io/api/v3/oauth/userinfo",
    "revocation_endpoint": "https://auth.bbrands.io/api/v3/oauth/revoke",
    "jwks_uri": "https://auth.bbrands.io/.well-known/jwks.json",
    "response_types_supported": ["code"],
    "grant_types_supported": [
      "authorization_code",
      "refresh_token",
      "urn:ietf:params:oauth:grant-type:token-exchange"
    ],
    "scopes_supported": ["email", "openid", "profile"],
    "subject_types_supported": ["public"],
    "id_token_signing_alg_values_supported": ["ES256", "RS256"],
    "token_endpoint_auth_methods_supported": [
      "client_secret_basic",
      "client_secret_post"
    ]
  }
  ```
</CodeGroup>

<Note>
  The `iss` in discovery and in every issued access token is the canonical
  per-environment `auth.*` URL — never the underlying engine URL. The
  `registration_endpoint` is intentionally **not** exposed: dynamic client
  registration is off; clients are onboarded through the identity admin console.
</Note>

<Note>
  `id_token_signing_alg_values_supported` lists `["ES256"]` by default and
  `["ES256", "RS256"]` on environments where the RS256 id\_token lane is
  enabled. ES256 is always the default algorithm; RS256 only applies to
  clients registered with `id_token_alg: "RS256"` (e.g. Odoo).
</Note>

### GET `/.well-known/jwks.json`

Returns the public keys to validate OAuth JWTs. **Public.** Served by the
IdP (proxy/cache of the engine's JWKS); the client never sees the engine URL.

```bash Response headers theme={null}
Cache-Control: public, max-age=3600
ETag: "<hash>"
```

<Warning>
  JWKS applies only to **OAuth** tokens. The internal HS256 B Brands JWT is
  validated with a symmetric server-side secret and is never exposed here.
</Warning>

<Note>
  On environments with the RS256 lane enabled, the document contains the
  engine's EC P-256 key **plus** the IdP's RSA public key (both with `kid`).
  Verifiers resolve keys by `kid`, so the extra key is transparent for ES256
  clients.
</Note>

## Authorization endpoint

### GET `/api/v3/oauth/authorize`

Starts the authorization-code flow. **Called by the user's browser** after a
redirect from the client. The request is validated strictly, forwarded to the
engine, and the browser is redirected to the consent UI on the same `auth.*`
origin.

<ParamField query="response_type" type="string" required>
  Must be `code`.
</ParamField>

<ParamField query="client_id" type="string" required>
  The registered OAuth client identifier (UUID provided at onboarding).
</ParamField>

<ParamField query="redirect_uri" type="string" required>
  Must exactly match one of the client's registered redirect URIs.
</ParamField>

<ParamField query="scope" type="string" required>
  Space-separated list. Must include `openid`. Supported: `openid email profile`.
</ParamField>

<ParamField query="state" type="string" required>
  Client anti-CSRF value, echoed back on the redirect.
</ParamField>

<ParamField query="code_challenge" type="string" required>
  PKCE challenge. **PKCE is mandatory** for every client.
</ParamField>

<ParamField query="code_challenge_method" type="string" required>
  Must be `S256` (`plain` is rejected).
</ParamField>

<ParamField query="nonce" type="string">
  Recommended. ID-token anti-replay value.
</ParamField>

**Response**: `302` redirect to `https://auth.bbrands.io/oauth/consent?...` (the
consent UI). On a valid session and an enabled first-party client, consent
auto-approves and the browser is redirected back to the client with
`?code&state`.

**Validation errors** are returned as `400` JSON (no redirect, since the
`redirect_uri` cannot be trusted before validation):

| `error`               | Cause                                                                                                                                       |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request`     | Missing/malformed parameter (no PKCE, `scope` without `openid`, non-`S256` challenge, missing `state`, …) or an unregistered `redirect_uri` |
| `unauthorized_client` | Unknown, suspended or revoked `client_id`                                                                                                   |

## Consent

### Consent UI — `https://auth.bbrands.io/oauth/consent`

Served by the authentication app (not an API). It validates the B Brands session
(redirecting to login with `?next=` when missing), shows the client name and
requested OIDC scopes, and offers Allow / Deny. `first_party` + enabled users are
auto-approved.

### POST `/api/v3/oauth/decision`

Records the user's consent decision for **audit**. It does **not** continue the
OAuth code flow (the engine does, via the `authorize_url`).

<ParamField body="client_handle" type="string" required>
  Stable client handle, e.g. `erp-prod`.
</ParamField>

<ParamField body="decision" type="string" required>
  `approve` or `deny`.
</ParamField>

<ParamField body="scopes" type="string[]">
  Requested scopes.
</ParamField>

<ParamField body="user" type="string">
  User identifier.
</ParamField>

<CodeGroup>
  ```json Response 200 theme={null}
  {
    "message": "Decision recorded",
    "data": { "decision": "approve" },
    "meta": { "correlation": "...", "status": 200, "timestamp": "..." }
  }
  ```
</CodeGroup>

A missing `client_handle` or an invalid `decision` returns `400`.

## Token endpoint

### POST `/api/v3/oauth/token`

Exchanges an authorization code for tokens, or refreshes an access token. **Called
server-to-server** by the client. The gateway proxies the request to the engine,
which runs the access-token hook (enablement check + `iss`/`aud` rewrite).

**Headers**: `Content-Type: application/x-www-form-urlencoded` and
`Authorization: Basic <base64(client_id:client_secret)>` (or the secret in the
body for `client_secret_post`).

<CodeGroup>
  ```bash authorization_code theme={null}
  grant_type=authorization_code
  code=<code>
  redirect_uri=<uri>
  client_id=<id>
  code_verifier=<pkce_verifier>
  ```

  ```bash refresh_token theme={null}
  grant_type=refresh_token
  refresh_token=<token>
  client_id=<id>
  ```
</CodeGroup>

<CodeGroup>
  ```json Response 200 theme={null}
  {
    "access_token": "<ES256 identity JWT>",
    "token_type": "bearer",
    "expires_in": 86400,
    "refresh_token": "<rotated token>",
    "id_token": "<ES256 identity JWT>"
  }
  ```
</CodeGroup>

The **access token** carries the canonical identity claims — validate it against
the issuer and the JWKS:

```json theme={null}
{
  "iss": "https://auth.bbrands.io",
  "aud": "bbrands-idp",
  "sub": "<user uuid>",
  "email": "user@example.com",
  "jti": "<uuid>",
  "client_id": "<your client_id>",
  "https://bbrands.io/client_handle": "<client_handle, e.g. erp-prod>",
  "https://bbrands.io/platform_type": "<platform_type, e.g. erp>"
}
```

<Note>
  `aud` is the fixed IdP audience `bbrands-idp`; the requesting client is
  identified by `https://bbrands.io/client_handle` (and `client_id`). Validate
  `iss`, the ES256 signature via JWKS, and that `client_handle` matches your
  own handle. The `id_token` keeps the engine's issuer/audience and is intended
  for the `nonce` check and profile claims; use the **access token** as the
  identity assertion.
</Note>

<Note>
  **RS256 id\_token lane**: clients registered with `id_token_alg: "RS256"`
  (e.g. Odoo with the OCA `auth_oidc` module) receive the same `id_token`
  re-signed with the IdP's RSA key — header `alg: RS256` with a `kid` that
  resolves through `/.well-known/jwks.json`, every claim preserved (`nonce`,
  `at_hash`, `exp`, ...). The **access token is always ES256**.
</Note>

**Typical errors** (OAuth mapping):

| HTTP | `error`          | Cause                                                     |
| ---- | ---------------- | --------------------------------------------------------- |
| 400  | `invalid_grant`  | Expired code, invalid refresh, or user not enabled (hook) |
| 401  | `invalid_client` | Wrong `client_secret`                                     |
| 403  | `access_denied`  | User disabled between issuances                           |

## UserInfo endpoint

### GET `/api/v3/oauth/userinfo`

Returns the user's **identity** claims, enriched with the business-account
dimension. Proxied to the engine's userinfo.

**Headers**: `Authorization: Bearer <access_token>` (required; a missing header
returns `401 invalid_token` with `WWW-Authenticate: Bearer`).

<CodeGroup>
  ```json Response 200 theme={null}
  {
    "sub": "uuid-of-user",
    "email": "user@example.com",
    "email_verified": true,
    "name": "Jane Doe",
    "given_name": "Jane",
    "family_name": "Doe",
    "preferred_username": "jdoe",
    "https://bbrands.io/profile": {
      "account": "uuid-of-account",
      "odoo": "ODOO-84213",
      "user": "uuid-of-user"
    }
  }
  ```
</CodeGroup>

<Note>
  No permissions/roles claim. UserInfo is identity plus tenancy context: the
  `https://bbrands.io/profile` object carries the account linked to the user
  through the `primary_user` role (`account`), that account's external system
  id (`odoo`, `null` until the account is mapped by the migration API) and the
  subject (`user`, mirrors `sub`).
</Note>

## Revocation endpoint

### POST `/api/v3/oauth/revoke`

Invalidates a token (RFC 7009), typically on logout. Adds the token `jti` to the
local blacklist store and proxies revocation to the engine.

**Body** (`application/x-www-form-urlencoded`): `token`, `token_type_hint`
(`access_token` | `refresh_token`), `client_id`.

<CodeGroup>
  ```json Response 200 theme={null}
  { "revoked": true }
  ```
</CodeGroup>

<Note>
  Per RFC 7009 the response is always `200` and opaque, regardless of whether the
  token existed.
</Note>

## Custom error codes

Beyond standard OAuth errors, B Brands-specific endpoints may return these in
`error.code`:

| Code                                  | HTTP | Meaning                               |
| ------------------------------------- | ---- | ------------------------------------- |
| `bbrands.client_not_active`           | 400  | Client suspended or revoked           |
| `bbrands.user_not_provisioned`        | 403  | User not enabled for that platform    |
| `bbrands.token_blacklisted`           | 401  | Token individually revoked            |
| `bbrands.client_secret_grace_expired` | 401  | Rotated secret, grace period over     |
| `bbrands.invalid_subject_token`       | 400  | Invalid subject token in the exchange |

## Rate limiting

| Endpoint                   | Limit       | Granularity           |
| -------------------------- | ----------- | --------------------- |
| `/oauth/authorize`         | 30 req/min  | IP                    |
| `/oauth/token` (auth code) | 10 req/min  | IP + client\_id       |
| `/oauth/token` (refresh)   | 60 req/min  | client\_id + user\_id |
| `/oauth/revoke`            | 30 req/min  | client\_id            |
| `/oauth/exchange`          | 60 req/min  | client\_id + user\_id |
| `/idp/*` (management)      | 120 req/min | user\_id (admin)      |

Responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
`X-RateLimit-Reset`; `429` carries `Retry-After`.
