# OAuth and OIDC endpoints

> Every endpoint of a tenant: discovery, authorize, token, userinfo, revoke, introspect, logout and device, with parameters and responses.

Source: https://9in.io/docs/reference/endpoints/

Every endpoint is on the tenant's issuer: `https://<tenant>.9in.io`, `https://<tenant>-sandbox.9in.io` or your custom domain. `<issuer>` below stands for it.

| Method | Path | What |
|---|---|---|
| GET | `/.well-known/openid-configuration` | Discovery document |
| GET | `/.well-known/jwks.json` | Public signing keys |
| GET, POST | `/oauth/authorize` | Start a sign-in |
| POST | `/oauth/token` | Exchange a code, refresh, client credentials, device code, password |
| POST | `/oauth/device/authorize` | Start a device sign-in (RFC 8628) |
| GET, POST | `/oauth/userinfo` | The user's claims, for an access token |
| POST | `/oauth/revoke` | Revoke a refresh or access token (RFC 7009) |
| POST | `/oauth/introspect` | Is a token live? (RFC 7662) |
| GET, POST | `/oauth/logout` | Sign out (RP-initiated logout) |
| | `/api/v1/…` | The [Management API](https://9in.io/docs/reference/management-api/) |

The hosted pages (`/login`, `/register`, `/password/recovery`, `/account`, `/device`, …) are for people, not for API calls; link to `/account` from your app if you like.

## Discovery

`GET <issuer>/.well-known/openid-configuration` returns the endpoints above and what's supported:

| Field | Value |
|---|---|
| `response_types_supported` | `code`, `id_token`, `id_token token` |
| `response_modes_supported` | `query`, `fragment`, `form_post` |
| `grant_types_supported` | `authorization_code`, `refresh_token`, `client_credentials`, `urn:ietf:params:oauth:grant-type:device_code`, `password`, `implicit` (the last two only for applications that enable them) |
| `code_challenge_methods_supported` | `S256` |
| `token_endpoint_auth_methods_supported` | `client_secret_basic`, `client_secret_post`, `none` (public clients) |
| `id_token_signing_alg_values_supported` | The algorithms of the tenant's keys |
| `prompt_values_supported` | `none`, `login`, `consent`, `select_account`, `create` |
| `ui_locales_supported` | The tenant's languages |
| `authorization_response_iss_parameter_supported` | `true` |
| `scopes_supported` | The standard scopes, plus every API's scopes |

## GET /oauth/authorize

Redirect the browser here to sign a user in. `POST` with the same parameters as a form also works.

| Parameter | Required | |
|---|---|---|
| `response_type` | Yes | `code`. (`id_token` and `id_token token` only for applications with the implicit flow turned on.) |
| `client_id` | Yes | |
| `redirect_uri` | Yes | Must exactly match one of the application's callback URLs |
| `scope` | | Space-separated. Include `openid` for an ID token, `offline_access` for a refresh token. Default `openid`. |
| `state` | Recommended | Returned unchanged |
| `code_challenge`, `code_challenge_method` | Public clients | PKCE: `S256` only |
| `nonce` | | Copied into the ID token |
| `audience` (or `resource`) | | An API identifier: the access token is issued for that API |
| `prompt` | | `login` (sign in again), `none` (no UI: fail with `login_required` or `consent_required`), `consent`, `select_account`, `create` (open the sign-up form) |
| `max_age` | | Seconds: ask the user to sign in again if their last sign-in is older |
| `login_hint` | | Pre-fills the identifier |
| `ui_locales` | | Preferred languages, e.g. `de en` |
| `response_mode` | | `query` (default for `code`), `fragment`, `form_post` |

`request` and `request_uri` (signed request objects) are not supported.

**Success:** a redirect to `redirect_uri?code=…&state=…&iss=<issuer>`. Check `state`, and `iss` if your library supports it.

**Errors:** with a valid client and redirect URI, a redirect to `redirect_uri?error=…&error_description=…&state=…`: `invalid_request`, `unauthorized_client`, `unsupported_response_type`, `invalid_scope`, `invalid_target` (unknown `audience`), `access_denied` (the user declined), `login_required`, `consent_required`, `request_not_supported`. An unknown client or redirect URI shows an error page instead of redirecting.

## POST /oauth/token

Form-encoded (`application/x-www-form-urlencoded`). Confidential applications authenticate with HTTP Basic (`client_id:client_secret`, URL-encoded) or `client_id` and `client_secret` in the form; public applications send `client_id`. Browsers may call it from an **allowed web origin** (CORS).

| `grant_type` | Parameters |
|---|---|
| `authorization_code` | `code`, `redirect_uri`, `code_verifier` (PKCE) |
| `refresh_token` | `refresh_token`, optional narrower `scope` |
| `client_credentials` | Optional `audience` and `scope` ([machine to machine](https://9in.io/docs/machine-to-machine/)) |
| `urn:ietf:params:oauth:grant-type:device_code` | `device_code` ([devices](https://9in.io/docs/devices/)) |
| `password` | `username`, `password`, `scope`, optional `audience` (first-party applications that enable it; refused for users with two-factor) |

```json
{
  "access_token": "eyJ…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email offline_access",
  "id_token": "eyJ…",
  "refresh_token": "…"
}
```

`id_token` comes with the `openid` scope; `refresh_token` with `offline_access` (when the application and the API allow refresh tokens). Refresh tokens **rotate**: each refresh returns a new one, and using an old one again revokes the whole family.

**Errors** (JSON `{ "error", "error_description" }`): `invalid_request`, `invalid_client` (401), `invalid_grant` (bad or used code, wrong verifier, expired or revoked refresh token, wrong password), `unauthorized_client` (grant not allowed for this application), `unsupported_grant_type`, `invalid_scope`, `invalid_target`, `access_denied`, and for device codes `authorization_pending`, `slow_down`, `expired_token`.

## POST /oauth/device/authorize

`client_id`, `scope`, optional `audience`. Returns `device_code`, `user_code` (`XXXX-XXXX`), `verification_uri` (`<issuer>/device`), `verification_uri_complete`, `expires_in` (600) and `interval` (5). See [TVs, CLIs and devices](https://9in.io/docs/devices/).

## GET /oauth/userinfo

`Authorization: Bearer <access token>` (a token with the `openid` scope). Returns `sub` plus the claims the token's scopes allow ([scopes and claims](https://9in.io/docs/reference/tokens/#scopes-and-claims)) and custom fields marked as claims. Revoked tokens get `401`.

## POST /oauth/revoke

`token`, optional `token_type_hint` (`refresh_token` or `access_token`), with client authentication (or `client_id` for public clients). Revoking a refresh token revokes its family; revoking a JWT access token puts its `jti` on a deny-list checked by introspection and hybrid verification. Always answers `200` for an unknown token, as RFC 7009 requires.

## POST /oauth/introspect

`token`, authenticated as a **confidential** application of the tenant. Live state: revoked tokens, ended sessions and blocked users are `{ "active": false }`.

```json
{
  "active": true,
  "token_type": "access_token",
  "scope": "orders:read",
  "client_id": "app_7f3k2m9q",
  "sub": "usr_8k2…",
  "aud": "https://api.acme.com",
  "iss": "https://acme.9in.io",
  "exp": 1794203600,
  "iat": 1794200000,
  "jti": "…",
  "sid": "…",
  "auth_time": 1794199000
}
```

## GET /oauth/logout

Ends the 9in session, then redirects to `post_logout_redirect_uri` (with `state`) or shows a "You're signed out" page.

| Parameter | |
|---|---|
| `id_token_hint` | An ID token the tenant issued (expired is fine): identifies the application |
| `client_id` | Identifies the application when there's no `id_token_hint` |
| `post_logout_redirect_uri` | One of the application's allowed logout URLs |
| `state` | Added to the redirect |

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

The public keys that verify tokens: every active and retired asymmetric key (RS, PS, ES, EdDSA). HMAC keys are never published. Cache it; fetch it again when a token names a `kid` you don't know.
