# User-authorized OAuth client (PKCE)

Standalone Node example that completes an **authorization-code + PKCE S256**
login against a Bex installation, performs one least-privileged API read, and
demonstrates refresh and disconnect. It does **not** use a workspace machine
API key (`client_credentials`).

**Source pin (Bex):** `0eb2036e2` —
`deploy/gitops/base/values/hydra.values.yaml`,
`dashboard/src/common/server-fn/hydra-consent.ts`,
`dashboard/src/common/lib/oauth-scopes.ts`,
`lego/backend/internal/api/oauth_discovery_test.go`.

Runnable commands below land with `client.mjs` in later tasks. Until then, use
this file as the registration and consent contract.

## Credential classes (do not mix)

| Kind | How you get it | Typical use |
| --- | --- | --- |
| Dashboard browser session | Human login on the dashboard | Interactive admin UI |
| Machine API key | Operator-minted Hydra client + `client_credentials` | Workspace-scoped automation (developer role) |
| **User-consented public client** (this example) | Registered OAuth public client + human consent | Integration you control that acts as the signed-in user |

Scopes (`bex.read`, `bex.write`, `bex.sensitive`) bound API capability; they do
**not** elevate workspace role. Admin-only surfaces still need an administrator
user. Machine keys and user tokens are different permissions — never reuse a
first-party / desktop / CLI client ID here.

`readServices` walks every `{ service, cursor }` page (see `services.mjs`). For a standalone inventory command, use [`../service-inventory/`](../service-inventory/README.md).

## Prerequisites

1. **Node.js 22+** (matches the CMS engines field; plain `node`, no npm deps).
2. **Installation origins** (hosted defaults; replace for self-hosted):
   - Issuer: `https://oauth.bex.co`
   - API: `https://api.bex.co`
   - Resource / audience: `https://api.bex.co/mcp` (from
     `/.well-known/oauth-protected-resource`)
3. **A registered public OAuth client** for your redirect URI (see below).
4. **A human account** that can consent in that installation’s workspace.

## Registered public client (operator vs customer)

Bex reference Hydra config enables dynamic client registration, but **hosted
discovery may omit `registration_endpoint`**. Live checks on 2026-09-15 saw
authorization/token/refresh/revoke advertised without a registration endpoint,
and `GET /oauth2/register` returned 404. That alone does not prove POST
registration is disabled, and it also does **not** authorize documenting
self-service hosted enrollment.

**Supported path for this example:** an operator of the installation provides a
suitably registered **public** client:

- `token_endpoint_auth_method`: `none`
- Grant types: `authorization_code`, `refresh_token`
- Redirect URIs: exact match for the loopback callback you will run (for
  example `http://127.0.0.1:8765/callback`)
- Not a platform / first-party client ID

Ordinary customers then receive only: `CLIENT_ID`, issuer URL, API origin,
resource audience, and the exact redirect URI string. They do not need admin
API access to register clients when the operator already did.

If your installation **does** advertise `registration_endpoint` in
`/.well-known/oauth-authorization-server`, follow that installation’s operator
docs — do not invent a DCR URL from Hydra defaults alone.

## Authorization contract the client must implement

Consent enforcement in Bex requires:

- `response_type=code`
- `code_challenge_method=S256` (plain is rejected)
- Single `code_challenge` / `code_challenge_method`
- `state` bound to the pending login
- Exact redirect URI match
- For API access: request at least one of `bex.read`, `bex.write`,
  `bex.sensitive` (identity scopes alone do not authorize the API)
- Audience via Hydra `audience` (installation resource), not an invented
  `resource` parameter unless the installation documents otherwise

Token endpoint: `{issuer}/oauth2/token` — authorization-code exchange, then
`grant_type=refresh_token` when `offline_access` was granted. Access tokens are
short-lived (~15 minutes in reference config); honor `expires_in`.

Disconnect: `POST {issuer}/oauth2/revoke` for local tokens. Dashboard
“Connected agents” revocation is a separate operator/user UI path.

## Harmless demo read

After consent, call:

```http
GET {API}/v1/services
Authorization: Bearer {access_token}
```

Requires `bex.read` (and a workspace the user can view). This is a list read,
not an admin or mutation call. Do not mint API keys or call webhook admin APIs
from this sample.

## Environment (placeholders only)

```bash
export BEX_OAUTH_ISSUER=https://oauth.bex.co
export BEX_API_ORIGIN=https://api.bex.co
export BEX_OAUTH_AUDIENCE=https://api.bex.co/mcp
export BEX_OAUTH_CLIENT_ID=replace-me
export BEX_OAUTH_REDIRECT_URI=http://127.0.0.1:8765/callback
export BEX_OAUTH_SCOPES="openid offline_access bex.read"
# Optional offline fixture mode (CI): BEX_OAUTH_FIXTURE=1
```

Never commit real client IDs from production, access tokens, refresh tokens, or
PKCE verifiers. Do not put secrets on the command line in shared transcripts.

## Run (live installation)

```bash
cp .env.example .env   # fill BEX_OAUTH_CLIENT_ID and confirm redirect URI
set -a && source .env && set +a
node client.mjs
```

Open the printed authorization URL, consent, then return to the terminal. The
program lists `GET /v1/services`, refreshes when a refresh token is present,
and revokes tokens before exit.

## Run (offline fixture)

```bash
BEX_OAUTH_FIXTURE=1 node client.mjs
```

## Public download

Published at [https://bex.co/examples/oauth-user-client/](https://bex.co/examples/oauth-user-client/)
(same files). Download `client.mjs`, `README.md`, `.env.example`, and
`fixtures/stack.mjs` into one directory.

## Status

- [x] Setup / registration / consent contract (this README)
- [x] `client.mjs` — PKCE login, authorized read, refresh, revoke
- [x] Offline fixture stack for CI
- [x] Public download under `/examples/oauth-user-client/`
- [ ] Platform docs page + translations
