# Delegated access with OAuth 2.1

By the end of this guide, your application lets a person decide instead of asking for a key: It registers itself, sends the person to give consent, exchanges the code with PKCE for an `oat_…` access token, and renews or revokes it. It never sees the person’s password.

> **Type:** guide
> **For:** Anyone who builds software that runs on other people’s machines and cannot hide a secret: a command-line tool, a desktop application, an AI assistant.
> **Prerequisite:** the base URL of your environment in `SZENERA_API` and a browser in which the consenting person is signed in
> **Where:** command line and browser

## When this approach is the right one

| Situation | Choice |
|---|---|
| Your own server talks to Szenera | an API key `ak_…`, which belongs to the organization and is attached to no account |
| Your application runs at other customers’ sites and should see their inventory | this approach: A person there consents, and your application gets a token |
| An AI assistant should work on your behalf | this approach, usually through the [MCP service](/api/mcp-service) |

Delegated access always has two sides: the application that acts and the account on whose behalf it acts. At runtime, the intersection of the two applies. If the person loses a permission, your application loses it at the same moment.

## The addresses

Your application has to hard-code exactly **one** address: the metadata document. Everything else is stated in it, as absolute URLs. Issuer and endpoints will in future be named by the metadata of `auth.<your-domain>`, if your environment has this host. So do not enter any endpoint by hand, read it from the document you fetch there. The addresses at the API base URL remain reachable until further notice.

| Route | Purpose |
|---|---|
| `GET /.well-known/oauth-authorization-server` | metadata document of the authorization server (RFC 8414) |
| `GET /.well-known/oauth-protected-resource` | metadata document of the protected resource (RFC 9728) |
| `POST /oauth/register` | registration of a public client (RFC 7591) |
| `GET /oauth/authorize` | validates the request and leads to the consent page |
| `POST /oauth/token` | exchange a code or refresh token for a new token |
| `POST /oauth/revoke` | revoke a token (RFC 7009) |
| `GET /v1/me/connected-apps` | the connected applications of your own account |
| `DELETE /v1/me/connected-apps/{clientId}` | take away from an application everything this account gave it |

The first six addresses carry **no** `/v1`: The standards prescribe their paths literally, and a third-party client looks there and nowhere else. What lies behind the consent lives under `/v1` as usual, see [Base URL, /v1, and stability](/api/base-url-and-stability).

## Steps

1. Fetch the metadata document. It names the endpoints, the supported methods, and under `scopes_supported` every permission that delegated access can carry.

   ```bash
   curl -sS "$SZENERA_API/.well-known/oauth-authorization-server" | jq '{issuer, authorization_endpoint, token_endpoint}'
   ```

2. Register your client, once, without authentication and without a secret. Only public clients exist: `token_endpoint_auth_method` is always `none`.

   ```bash
   curl -sS -X POST "$SZENERA_API/oauth/register" \
     -H "Content-Type: application/json" \
     -d '{ "client_name": "Warenwirtschaft-Brücke", "redirect_uris": ["http://127.0.0.1:8765/callback"] }'
   ```

   ```json
   { "client_id": "oac_01KQRWJRC0OAUTHCLIENT00001", "token_endpoint_auth_method": "none", "redirect_uris": ["http://127.0.0.1:8765/callback"] }
   ```

   Registration is limited to 10 calls per hour and sender address. Store the identifier and do not register anew on every start.

3. Generate a code verifier and its code challenge. The method is exclusively `S256`, and there is no `plain`.

   ```bash
   VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
   CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')
   ```

4. Send the person to the browser. They land on the consent page of the portal and see the name of your application, the redirect target, and the requested permissions, bundled into **areas** with one sentence and one toggle each.

   ```bash
   open "$SZENERA_API/oauth/authorize?response_type=code&client_id=oac_01KQRWJRC0OAUTHCLIENT00001&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=scene.read%20playlist.read&state=$(openssl rand -hex 16)&code_challenge=$CHALLENGE&code_challenge_method=S256"
   ```

   When the person returns, check `state` against the value you sent before you redeem the code.

5. Exchange the code. The body is **form-encoded**, not JSON.

   ```bash
   curl -sS -X POST "$SZENERA_API/oauth/token" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d grant_type=authorization_code \
     -d code=ocd_01KQRWJRC0OAUTHCODE0000001 \
     -d redirect_uri=http://127.0.0.1:8765/callback \
     -d client_id=oac_01KQRWJRC0OAUTHCLIENT00001 \
     -d code_verifier=$VERIFIER
   ```

   ```json
   { "access_token": "oat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "ort_…", "scope": "playlist.read scene.read" }
   ```

6. Work with it like with any other Bearer token:

   ```bash
   curl -sS "$SZENERA_API/v1/me" -H "Authorization: Bearer oat_…"
   ```

7. Renew in time. The refresh token **rotates**: After the exchange, the old one is used up, and the response carries a new one.

   ```bash
   curl -sS -X POST "$SZENERA_API/oauth/token" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d grant_type=refresh_token -d refresh_token=ort_… -d client_id=oac_01KQRWJRC0OAUTHCLIENT00001
   ```

8. Give back what you no longer need. Revocation always answers `200`, even for an unknown value.

   ```bash
   curl -sS -X POST "$SZENERA_API/oauth/revoke" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d token=ort_… -d client_id=oac_01KQRWJRC0OAUTHCLIENT00001
   ```

## Options

### Lifetimes

| Item | Valid |
|---|---|
| Access token `oat_…` | one hour |
| Refresh token `ort_…` | thirty days, rotates on every exchange |
| Authorization code `ocd_…` | two minutes, exactly one redemption |
| open consent request | ten minutes |

### Permissions

There is no catalog of its own: A scope **is** a permission key, the same one that an API key may carry as well: `scene.read`, `playlist.write`, and so on. The complete list appears as `scopes_supported` in both metadata documents. `GET /v1/permission-catalog` also tells you, through the field `appliesTo`, which permissions are released for machines.

Two cuts apply at consent: A permission that machines never carry cannot be granted, and neither can a permission that the consenting person does not hold themselves. Both are listed with their reason in their area. Ask only for what you need: A request that is too broad makes the decision harder and is rejected instead of being silently trimmed.

### Areas and partial consent

The consent page does not show the permissions as a list but bundled by the **category** of the permission catalog: “Scenes”, “Playlists & schedules”, “Media”, “Displays & locations”, “Reports”. Each area carries one sentence about what your application can do in it, one toggle, and the individual permissions in an expandable list. All requested areas are switched on at the start. The same bundling appears later in the **Connected applications** card, collapsed into a tally (“5 areas, 18 permissions”).

**Expect a subset.** The person may switch individual areas off, and `POST /v1/oauth/authorizations/{id}/approve` accepts any subset of what was requested. The access token then carries only the granted permissions, and the token response names them under `scope`:

```json
{ "access_token": "oat_…", "scope": "scene.read", "expires_in": 3600 }
```

Compare `scope` with what you asked for instead of assuming it. The platform answers what is missing with `403` at call time, not at the exchange. If you depend on an area, tell the person in plain language and send them through the round trip a second time. There is deliberately no way to extend an existing consent afterward.

### Limits

`GET /oauth/authorize`, `POST /oauth/token`, and `POST /oauth/revoke` are limited to 60 calls per minute and sender address, the two metadata documents to 120, and registration to 10 per hour. The realtime channel stays closed to delegated access, and you fetch events through [Webhooks](/webhooks).

The platform operator can block a client. Then no new round trip begins, and a running renewal fails. Existing access tokens run out instead of being revoked. Likewise, an organization can switch delegated access off entirely: The consent then fails with `OAuthAuthorization.DelegationDisabled`, at the point where the person decides rather than at the exchange.

## Check the result

- `GET /v1/me` with the token answers `200` and names the account and the organization on whose behalf you act.
- The token response names under `scope` the permissions that were actually granted. They can be fewer than those requested.
- The person sees your application in the portal under **Settings › My account › Security** in the **Connected applications** card, expandable by the same areas, and can revoke it there with one click.
- After a revocation, the same call answers `401`.

## If something goes wrong

The endpoints of the standard answer in the **format of the standard** (`{ "error": …, "error_description": … }`) and not in the [error format of this API](/api/error-format). The routes under `/v1` answer as usual.

| Symptom | Meaning | Solution |
|---|---|---|
| `400 invalid_grant` on the exchange | The code verifier does not match the challenge, or the code has expired. | Start the round trip again. The verifier and the challenge must come from the same run. |
| `400 invalid_grant`, and all tokens are gone | A code or a refresh token was redeemed a second time. The whole token family expires: Two holders of the same secret cannot be told apart. | Discard the old token, have the person consent again, and never run two processes on the same token. |
| `400` without a redirect | `redirect_uri` is not in the list from the registration. This very address is what could not be verified. | Enter the address at registration and send it word for word at the exchange. |
| `410 OAuthAuthorization.Expired` when opening the consent page | The request is older than ten minutes. Explicitly not “not found”. | Send the person there again. |
| `422` naming permission keys | A scope was confirmed that the person cannot hold or that is not intended for applications. | Remove the named key from the request. |
| `401` during operation | The access token is older than one hour, or the person has revoked it. | Renew once. If that fails too, have the person consent again. |

The `OAuthClient.*` and `OAuthAuthorization.*` codes are listed in full, with meaning, trigger, and status, under [Error codes: accounts and sessions](/api/error-codes-accounts). The two credential codes `Auth.OAuthTokenRevoked` and `Auth.OAuthClientBlocked` are listed under [Error codes: possible on any route](/api/error-codes-general).

## Related

- [Authentication: keys and sessions](/api/authentication)
- [The MCP service for AI assistants](/api/mcp-service)
- [Handbook: Allow and revoke connected applications](https://szenera.com/docs/organization/connected-applications)
