# OAuth 2.0

OAuth is how you build an app **many** Noé workspaces can connect to. Instead of
asking every customer to paste an API key, you send them to Noé, they approve
what you may access, and you receive tokens for their workspace.

Use an API key instead if you are integrating a single workspace you control —
see [Authentication](/authentication).

| | API key | OAuth |
| --- | --- | --- |
| Who creates the credential | The workspace owner, by hand | Your app, automatically |
| Scales to many customers | No | Yes |
| Credential lifetime | Until revoked | 10-minute access token + refresh token |
| Appears in the Noé Marketplace | No | Optional |

## Before you start

You need an approved app. Register one in the
[developer console](https://developers.wearenoe.fr), request the scopes it needs,
and submit it for review — the full sequence is in
[Registering an app](/apps).

From the console you will have:

- a **client id** (`noe_client_…`), public
- a **client secret**, shown once when you create it
- the **redirect URIs** you registered, matched exactly

## The flow

```
1. You redirect the customer to Noé's consent page
2. They approve the scopes
3. Noé redirects back to you with a one-time code
4. You exchange the code (+ PKCE verifier) for tokens
5. You call the API with the access token
6. You refresh when it expires
```

PKCE is **mandatory**, with `S256` only. A stolen authorization code is useless
without the verifier, and there is no reason to support the weaker flow.

### 1. Send the customer to Noé

Generate a `code_verifier` (43–128 random characters), derive the challenge, and
redirect the customer's browser:

```
https://<workspace>.app.wearenoe.fr/oauth/authorize
  ?client_id=noe_client_…
  &redirect_uri=https://yourapp.example/callback
  &response_type=code
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256
  &scope=properties:read calendar:read calendar:write
  &state=<random, tied to the user's session>
```

<Callout type="info">
`<workspace>` is your customer's Noé subdomain. Ask them for it before starting
the flow — the same way an integration asks for a Shopify shop or a Slack
workspace. A customer on a white-label domain uses that domain instead.
</Callout>

Deriving the challenge, in Node:

```js
import { createHash, randomBytes } from 'node:crypto';

const verifier = randomBytes(64).toString('base64url');       // keep in the session
const challenge = createHash('sha256').update(verifier).digest('base64url');
```

Always send `state`, and check it when the customer comes back. It is what stops
an attacker from having their own authorization code delivered into your
customer's session.

### 2. They approve

Noé validates your request before rendering anything, so a wrong `redirect_uri`
or an unapproved scope shows the customer an error rather than a consent screen
for a request that cannot complete.

They see your app's name and logo, tick the scopes to grant, and may restrict the
grant to a subset of their properties. Anything they untick, you do not get.

### 3. Noé redirects back

```
https://yourapp.example/callback?code=<one-time code>&state=<yours>
```

If they refuse:

```
https://yourapp.example/callback?error=access_denied&state=<yours>
```

### 4. Exchange the code

Within 5 minutes, and only once:

```bash
curl -X POST https://api.wearenoe.fr/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "noe_client_…",
    "client_secret": "…",
    "code": "…",
    "redirect_uri": "https://yourapp.example/callback",
    "code_verifier": "…"
  }'
```

```json
{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "…",
  "scope": "properties:read calendar:read calendar:write"
}
```

Store the refresh token encrypted, against the customer's workspace. Compare the
returned `scope` with what you asked for: the customer may have granted less.

<Callout type="caution">
Presenting the same code twice revokes **every** token for that grant. A replayed
code is evidence it leaked, so the safe response is to invalidate everything it
produced rather than to refuse just the second attempt. Exchange each code once.
</Callout>

### 5. Call the API

The access token is a bearer credential like any other:

```bash
curl https://api.wearenoe.fr/v1/properties \
  -H "Authorization: Bearer eyJhbGciOi…"
```

Everything else in these docs — pagination, filtering, idempotency, rate limits,
webhooks — behaves identically whether you authenticate with a key or a token.

### 6. Refresh

Access tokens last 10 minutes. Refresh tokens last 60 days and **rotate**: each
refresh returns a new one and invalidates the old.

```bash
curl -X POST https://api.wearenoe.fr/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "client_id": "noe_client_…",
    "client_secret": "…",
    "refresh_token": "…"
  }'
```

Persist the new refresh token before you use the new access token. Losing it
means the customer has to re-authorise.

<Callout type="caution">
Reusing a rotated refresh token revokes every token for that grant, for the same
reason as a replayed code. If two of your workers can refresh concurrently, take
a lock per workspace — otherwise one will present a token the other has already
rotated and disconnect the customer.
</Callout>

## Disconnecting

Revoke a refresh token when a customer disconnects your app:

```bash
curl -X POST https://api.wearenoe.fr/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{ "client_id": "…", "client_secret": "…", "token": "…" }'
```

This always returns `200`, so it cannot be used to probe whether a token existed.

A customer can also revoke you from their side, at any time, in
**Mon compte → API**. When they do, your next call fails with `grant_revoked` —
treat that as a disconnection and stop polling.

## Errors

Authorization-server errors use OAuth's own codes, not the
[data-API error format](/errors):

| Code | Meaning |
| --- | --- |
| `invalid_client` | Wrong client id or secret, or the app is not approved |
| `unauthorized_client` | The app is suspended or not approved |
| `invalid_redirect_uri` | Does not exactly match a registered URI |
| `invalid_scope` | None of the requested scopes are approved for your app |
| `invalid_grant` | Code or refresh token unknown, expired, already used, or the workspace revoked you |
| `unsupported_grant_type` | Only `authorization_code` and `refresh_token` |

On the data API, two token-specific codes join the usual set:

| Code | What to do |
| --- | --- |
| `token_expired` | Refresh and retry |
| `token_revoked` | Stop; the customer or Noé revoked this token |

## Security expectations

These are the rules we hold apps to, and what a review checks:

- **Never** put the client secret in a browser, mobile binary or public repo. The
  exchange happens on your server.
- Register exact `redirect_uri` values, over HTTPS. Cleartext HTTP on a public
  host is rejected at review.
- Store refresh tokens encrypted at rest.
- Request the narrowest scopes you need. Asking for `reservations:guest_read`
  without a reason that survives review will slow your approval down.
- Treat `grant_revoked` as final rather than retrying it.
