Authorization Code Flow
The standard OAuth 2.0 authorization code grant (with optional PKCE), plus scope discovery and token exchange.
The flow is: discover scopes, redirect the user to authorize, receive an
authorization code on the redirect, then exchange that code for tokens. The
authorize step is gated by the token API license feature; the token exchange
authenticates the client by its credentials rather than a user session.
GET /api/oauth/scopes
List the registered OAuth scopes a client may request.
Authentication: Required (no token API feature gate)
Response:
A HAL collection under _embedded["items"]. The registered set is read/write
for dataset, profile, report, and global, plus the special mcp scope
(never granted by default, and meaningful only alongside an RFC 8707
resource; see MCP Clients). Each scope's name and
description are i18n message keys (resolved by the client to the active
locale), and isDefault appears only on default scopes (it is not serialized
as false on the others).
{
"_links": {
"self": {
"href": "https://informer.example.com/api/oauth/scopes"
}
},
"start": 0,
"count": 9,
"total": 9,
"_embedded": {
"inf:oauth-scope": [
{
"_links": {
"self": {
"href": "https://informer.example.com/api/oauth/scopes/mcp"
}
},
"id": "mcp",
"name": "App Tools (MCP)",
"description": "The application can list and call the authorized app's MCP tools, acting as you.",
"isDefault": false
},
{
"_links": {
"self": {
"href": "https://informer.example.com/api/oauth/scopes/read%3Adataset"
}
},
"id": "read:dataset",
"name": "Datasets Read",
"description": "The application will have read access to Datasets and Dataset data.",
"isDefault": true
},
{
"_links": {
"self": {
"href": "https://informer.example.com/api/oauth/scopes/read%3Aglobal"
}
},
"id": "read:global",
"name": "Full Access Read",
"description": "The application will have full read access to your Informer account.",
"isDefault": false
},
"… 6 more items (9 total) — omitted from docs"
]
}
}
GET /oauth/authorize
The browser entry to the consent flow, and the URL the authorization server
metadata advertises as authorization_endpoint. It lives at the server root,
not under /api, and it is a page, not an API: send the user's browser here,
not an HTTP client.
Authentication: optional (mode: try). An unauthenticated visitor is
redirected to /go/login?redirectTo=<this URL> (with Cache-Control: no-store) and returns here after signing in. An authenticated visitor falls
through to the single-page app, which renders the consent screen. The consent
screen then submits POST /api/oauth/authorize.
POST /api/oauth/authorize
The user consent step. With authorize: true, issues a single-use
authorization code (60 second TTL, Redis-backed) and redirects to the client's
registered redirect_uri.
Authentication: Signed-in user + token API feature
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
authorize | boolean | No | true to grant, false (default) to deny |
response_type | string | Yes | Must be code |
client_id | string | Yes | The client's client_id |
redirect_uri | string | Yes | Must match a registered redirect URI |
scope | string | Yes | Space-delimited requested scopes |
state | string | No | CSRF token, echoed back on the redirect |
code_challenge | string | No | PKCE challenge (required for PKCE clients) |
code_challenge_method | string | No | PKCE method, plain or S256 |
resource | string | No | RFC 8707 resource indicator: the absolute URL of an App's MCP endpoint (/api/apps/{id}/mcp on this server). Binds the issued code, and the token exchanged for it, to that App. A resource on another origin, or one naming an App the consenting user cannot read, redirects with error=invalid_target. |
Example Request:
{
"authorize": true,
"response_type": "code",
"client_id": "your-client-id",
"redirect_uri": "https://myapp.example.com/callback",
"scope": "read:profile read:dataset",
"state": "csrf-state-value"
}
Response:
Answers 302 with an empty body. The authorization code (and state, when
supplied) ride on the Location header's query string, not the response body.
{
"authorize": true,
"response_type": "code",
"client_id": "1a2b3c4d5e6f7a8b9c0d",
"redirect_uri": "https://myapp.example.com/callback",
"scope": "read:profile read:dataset",
"state": "csrf-state-value"
}
""
state is omitted from the redirect when you do not supply it, so always pass a
CSRF state and verify it on the callback.
When the user denies consent (authorize: false, the default), the route still
answers 302 but redirects with error=access_denied instead of a code. Other
pre-validation failures (invalid scope, unsupported response_type, missing
PKCE parameters, an unresolvable resource as invalid_target, or an internal
fault as server_error) redirect the same way with their own error code. The
one non-redirect failure is an unregistered redirect_uri, which answers
400. A scope outside the client's allowedScopes cap (dynamically registered
clients are capped at mcp) also redirects with error=invalid_scope, even if
the user consents.
{
"authorize": false,
"response_type": "code",
"client_id": "1a2b3c4d5e6f7a8b9c0d",
"redirect_uri": "https://myapp.example.com/callback",
"scope": "read:profile",
"state": "csrf-state-value"
}
""
POST /api/oauth/access_token
Exchange an authorization code (or a refresh token) for an access token.
Authentication: Client credentials (client_id / client_secret in the
payload, or HTTP Basic). There is no user session, so callers send the
x-tenant header to identify the tenant.
Request Body (authorization_code grant):
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | authorization_code |
code | string | Yes | The single-use authorization code |
client_id | string | Yes | The client's client_id (or use HTTP Basic) |
client_secret | string | Conditional | A client secret (or use HTTP Basic); not required for PKCE clients |
redirect_uri | string | No | Must match the authorize request |
code_verifier | string | PKCE | Required when code_challenge was sent on authorize |
resource | string | No | Must match the resource sent on authorize. A different value answers 400 with invalid_target; omitting it keeps the binding the code already carries. |
Example Request:
{
"grant_type": "authorization_code",
"code": "the-authorization-code",
"client_id": "your-client-id",
"client_secret": "your-client-secret",
"redirect_uri": "https://myapp.example.com/callback"
}
Response:
Answers 200 (a plain object, not 201) with access_token, token_type, and
scope. refresh_token and expires_in (900 seconds) are present only when the
client has enableRefreshTokens. The tokens are JWTs.
{
"grant_type": "authorization_code",
"code": "0f1e2d3c4b5a69788796",
"client_id": "1a2b3c4d5e6f7a8b9c0d",
"client_secret": "example-client-secret-shown-once",
"redirect_uri": "https://myapp.example.com/callback"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/oauth/access_token"
}
},
"access_token": "eyJhbGciOiJIUzI1NiJ9.example-token-1.signature",
"token_type": "bearer",
"scope": "read:profile",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.example-token-2.signature",
"expires_in": 900
}
Failures answer the RFC 6749 error shape, {"error", "error_description"},
with error drawn from the spec's code set (invalid_grant, invalid_client,
invalid_request, unsupported_grant_type, ...). Every failure is 400
except bad client credentials, which answer 401 (invalid_client) with a
WWW-Authenticate: Basic header.
The authorization code is single-use and expires after 60 seconds. Reusing it
or sending a mismatched redirect_uri answers 400 (invalid_grant); bad
client credentials answer 401 (invalid_client).
Refresh token grant
Send grant_type=refresh_token with the stored refresh_token to obtain a
fresh access token.
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | refresh_token |
refresh_token | string | Yes | The refresh token from a prior exchange |
client_id | string | Yes | The client's client_id (or use HTTP Basic) |
client_secret | string | Conditional | A client secret (or use HTTP Basic) |
{
"grant_type": "refresh_token",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.example-token-1.signature",
"client_id": "1a2b3c4d5e6f7a8b9c0d",
"client_secret": "example-client-secret-shown-once"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/oauth/access_token"
}
},
"access_token": "eyJhbGciOiJIUzI1NiJ9.example-token-2.signature",
"token_type": "bearer",
"scope": "read:profile",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.example-token-3.signature",
"expires_in": 900
}
Each refresh issues a new refresh_token that differs from the one presented.
Replaying a spent refresh token answers 400 with error: "invalid_grant"
(replay detection).
Client credentials can be sent as payload fields (client_id, client_secret)
or via HTTP Basic authentication (Authorization: Basic base64(client_id:client_secret)).