# Auth.md — BanditPOS

BanditPOS is a private application for one business. Access is granted to
named staff accounts by an administrator. An agent can hold a credential of its
own, but only one a member of staff approved: every token acts as a person, and
no flow issues one without them.

## What is available

Authentication produces a session, held in an `httpOnly` cookie named
`session`. Two interactive paths mint one:

1. **Password** — `POST /login/username` with
   `{ "username", "password", "turnstileToken", "returnTo" }`. The request
   requires a valid Cloudflare Turnstile token, which is issued to a browser
   that completes the challenge on `/login`. Every failure answers `401` with
   the same body, and repeated failures for a username and address pair are
   throttled.
2. **Google** — `GET /login/google` starts an OAuth 2.0 authorization code
   flow with PKCE against Google. `GET /login/google/callback` completes it.
   The account must be in the `banditmachine.com` Google Workspace domain; the
   callback requires a verified email in that domain and rejects everything
   else. A new Google account arrives with no privileges until an administrator
   grants them.

Sessions last 30 days and roll forward on use. `POST /logout` ends one.

## Bearer tokens

There are two ways to get one, and they produce the same kind of token:

- **An agent asks for it**, through the OAuth 2.1 authorization server described
  below. A member of staff approves the scopes in their browser.
- **A member of staff mints it by hand** at
  [/settings/api-tokens](https://pos.banditmachine.com/settings/api-tokens), then hands it
  over.

Either way, present it as a bearer token:

```
Authorization: Bearer bpat_...
```

**A token is never more than the person who made it.** Two independent things
enforce that, and neither is relied on alone:

1. **Scopes, fixed when the token is issued.** A scope is
   `<resource>:<action>` — for example `parts:read` or `invoices:write`;
   write implies read on the same resource. One read-only route draws on
   several resources: `/attention`, what needs attention, which any one of
   `purchase-orders:read`, `cycle-counts:read` or `quickbooks:read` reaches
   and which answers only with the alerts whose resource the token holds. It
   gives no access to anything the token's own scopes do not already cover.
   The approving user is only shown
   scopes they themselves hold, and the server re-derives that cap when the
   token is created rather than trusting what was asked for. It re-derives it
   again at the moment of issue, so a capability lost between approval and
   redemption is not in the token.
2. **The route's own checks, unchanged.** A token request is authorized as the
   owning user, so every admin and capability check still runs. A token carrying
   `users:write` held by someone without `users:manage` still gets `403` from
   `/admin/users`.

Other properties worth knowing before you build against it:

- **Scopes only narrow.** They cannot grant anything the owner lacks.
- **Unmapped paths are refused.** A path no scope covers cannot be reached with
  a token at all, so a route added later is not silently exposed to tokens
  already in circulation.
- **Tokens cannot manage tokens.** Nothing under `/settings` is reachable with
  a token, so one cannot mint another, extend itself, or revoke anything.
- **Expiry and revocation.** A token may carry an expiry, and can be revoked at
  any moment either by its owner or by an administrator; revocation takes effect
  on the next request. Tokens are self-service to create, so an administrator
  can see every one pointing at the deployment and end any of them.
- **Actions are attributed to the credential.** Anything a token writes records
  the token as well as the account, so what an agent did is distinguishable from
  what its owner did by hand. Revoking keeps that history.
- **Storage.** Only a SHA-256 of the secret is kept. It is shown once at
  generation and cannot be recovered.
- **CSRF.** The origin check that guards cookie requests does not apply to a
  bearer token, which is never attached ambiently by a browser.

A failed token request answers per RFC 6750: `401` with
`error="invalid_token"` when the token is unknown, expired, or revoked, and
`403` with `error="insufficient_scope"` when it is valid but too narrow.

## Obtaining a token as an agent

BanditPOS runs its own OAuth 2.1 authorization server. Its metadata is at
[/.well-known/oauth-authorization-server](https://pos.banditmachine.com/.well-known/oauth-authorization-server)
([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)) — read it rather than
hard-coding the endpoints below.

1. **Register.** `POST /oauth/register` with at least
   `redirect_uris` ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)).
   Registration is open and needs no credential. A redirect URI must be
   `https`, or `http` on the loopback interface: `127.0.0.1`, `[::1]`
   or `localhost`. For a loopback redirect the port is ignored when matching
   ([RFC 8252](https://www.rfc-editor.org/rfc/rfc8252) section 7.3), so a
   native client can register once and bind a fresh port each run.
   You get a `client_id` and no secret: clients here are public and
   authenticate with PKCE.

   **Or skip registration** by using the `https` URL of your own
   [Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)
   as your `client_id`. The authorization server metadata advertises
   `client_id_metadata_document_supported`. The document must name that same
   URL as its `client_id`, list your `redirect_uris`, and carry no secret;
   it is fetched when a person opens the consent screen, which shows its host.

   A service that can only be configured with a client ID and **secret** —
   Gemini Enterprise, say — is given one by an administrator. Such a client
   authenticates with `client_secret_basic` or `client_secret_post` and
   may omit PKCE. It cannot be created by registration.
2. **Send a person to approve it.** Open
   `/oauth/authorize` in their browser with `response_type=code`,
   `client_id`, `redirect_uri`, `code_challenge`,
   `code_challenge_method=S256`, `state`, and the `scope` you want.
   `resource` ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) is honoured
   and must name this origin. They sign in if they have not, see exactly what is
   being asked for, and approve or refuse. The response carries `iss`
   ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)); check it.
   `offline_access` is accepted in `scope` and changes nothing: every
   approval already comes with a refresh token.
3. **Redeem the code.** `POST /oauth/token` with
   `grant_type=authorization_code`, `code`, `client_id`,
   `redirect_uri` and `code_verifier`. Codes last 60 seconds and are
   single-use: presenting one long after the first redemption is refused and
   revokes the tokens that code produced. **Retrying is the exception, and you
   should retry.** Within 30 seconds the same code is served again rather than
   treated as a replay, because a client asking twice has almost always just
   lost the first answer — so if this call times out, the connection drops, or
   your callback runs twice, send it again with the same `code_verifier`:
   you get a working pair, and the one the first attempt issued stays good.
   Two workers redeeming at once likewise both get one. Each attempt returns a
   *new* pair rather than a repeat of the first — only digests are stored
   here, so an answer cannot be handed back twice — so keep whatever your last
   successful call returned. You get an access token valid for 8 hours and a
   refresh token.
4. **Renew, while the grant lasts.** `POST /oauth/token` with
   `grant_type=refresh_token`, `refresh_token` and `client_id`.
   Refresh tokens **rotate**: each use returns a new one and invalidates the
   one presented, so store what comes back. Presenting a spent refresh token
   destroys the whole grant — every token it ever minted — and you start again
   at step 2. **Retrying is the exception, and you should retry.** Within 30
   seconds of a rotation the same refresh token is served again rather than
   treated as a replay, because a client asking twice has almost always just
   lost the first answer. So if a refresh times out or the connection drops,
   send it again: you get a working pair and the grant survives. Two workers
   refreshing at once likewise both get one. Each attempt returns a *new*
   pair rather than a repeat of the first — only digests are stored here, so
   an answer cannot be handed back twice — so keep whatever your last
   successful call returned. The grant itself expires 30 days after it was
   approved and is not extended by refreshing; after that a person approves
   again.
5. **Revoke when done.** `POST /oauth/revoke` with `token`
   ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)). Either kind of token
   is accepted; revoking a refresh token ends the grant and every access token
   it produced.

PKCE is mandatory and `S256`-only: `plain` is refused, because a challenge
equal to its verifier protects nothing.

Refreshing never widens anything. The scopes are re-derived against the
approving user each time, so a capability they lost since consent is gone from
the next access token; and `scope` on a refresh request may only narrow what
was granted, never add to it.

Asking for less is safe. A narrowed request mints one weaker access token and
changes nothing else: the token you are already using stays live until its own
expiry, and the grant keeps the breadth the approver approved, so a later
refresh with no `scope` comes back at full scope. The refresh token rotates as
it does on any refresh, so store the one that comes back.

## The MCP endpoint

[/mcp](https://pos.banditmachine.com/mcp) speaks the Model Context Protocol over Streamable HTTP, at
revision `2026-07-28` and at the `initialize`-based revisions before
it (`2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, `2024-10-07`). Send `MCP-Protocol-Version` and the endpoint
answers in the era you named; send none and open with `initialize` and it
answers as it always did.

Under `2026-07-28` there is no handshake and no session. Every request
carries its own protocol version, client identity and capabilities in `_meta`,
mirrored into the `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers —
which must agree with the body, or the request is refused with `-32020`. Start
with `server/discover` if you want the tool list and instructions up front; you
do not have to.

**Every request needs a token**, whichever revision you speak: the handshake,
`tools/list`, `resources/list`, `resources/read` and every tool, the
document relays included. The documents themselves stay public at their own
URLs, listed here. Called without a token, the endpoint answers `401` with

```
WWW-Authenticate: Bearer resource_metadata="https://pos.banditmachine.com/.well-known/oauth-protected-resource/mcp"
```

which is what an MCP client reads to start the flow above. Follow it, register,
have a person approve the scopes, and connect again with the token.

A token that *is* presented and cannot be used is refused outright, on every
request rather than only on the ones that need it: an expired or revoked
credential answers `401` with `error="invalid_token"`, so a client refreshes
instead of quietly dropping to the anonymous half.

The endpoint holds no authority of its own. It reads your token and calls the
same application routes any other client would, so the scope gate applies
there: a tool that reads parts needs `parts:read` exactly as `GET /parts`
does.

## What is not available

- **No client credentials grant.** `authorization_code` is the only grant that
  creates authority, and `refresh_token` only renews one a person already
  approved. Registration is open precisely because it confers nothing on its
  own — a registered client that no one has approved can reach exactly what an
  anonymous one can, which is the public documents and nothing else.
- **No non-interactive sign-in.** Both sign-in paths above require a human: one
  clears a Turnstile challenge, the other consents at Google. Approving a token
  requires having signed in one of those ways first.
- **No token that outlives its approver's authority.** Scopes are re-derived
  against the approving user when the token is minted, and every route still
  runs its own admin and capability checks on each request.

An autonomous agent therefore cannot authenticate to BanditPOS on its own
initiative. It can *ask*, through a documented flow — but it acts only with
authority a named person deliberately gave it, and that person can take it
back.

## Protected resource metadata

[/.well-known/oauth-protected-resource](https://pos.banditmachine.com/.well-known/oauth-protected-resource)
describes this resource per [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728).
`authorization_servers` lists this origin and nothing else, because RFC 9728
section 2 defines the field as servers that can issue a token *this resource
accepts*, and only this origin can. Google, the identity provider behind the
interactive sign-in, is not one of them: it issues tokens this API refuses.
`scopes_supported` lists the bearer token scopes and nothing else.

Metadata for a resource under a path lives at
`/.well-known/oauth-protected-resource/<path>` (RFC 9728 section 3.1), and that
is what a `401` points you at rather than the origin-wide document. The
`resource` it returns is `https://pos.banditmachine.com<path>` — the URL you requested.
Section 3.3 tells you to check those two are identical before using the
document; they will be.

## Error format

Refusals from the request pipeline are negotiated. The default is
`{"success":false,"message":"..."}`, matching the application's own routes.
Send `Accept: application/problem+json` and you get
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details, with the
`resource_metadata` URL as an extension member.

## Requesting access

Access is a business decision, not a technical one. Contact Bandit Machine.
