OAuth apps
Knobs is an OAuth 2.1 authorization server. A third-party app can ask a Knobs user for scope-limited access to their mail, calendar and tasks, and then call the ordinary Knobs API as that user — the same routes documented on every other page, with an OAuth bearer instead of a session JWT.
This is the right credential when your users are Knobs users and you act on their behalf. If you only need to script your own account, use an API key instead — it is far less setup. If you are building a chat bot that acts as itself rather than as a person, use an integration.
How it differs from an API key
Section titled “How it differs from an API key”API key (knak_) |
OAuth app (knoa_) |
|
|---|---|---|
| Who creates it | the user, for themselves | the user, by consenting to your app |
| Scopes | none — full user authority | exactly what the user approved |
| Lifetime | until revoked | access token ~1 hour, refreshed |
| Workspace | all of the user’s workspaces | exactly one |
| Good for | your own scripts | software other people sign in to |
Registering a client
Section titled “Registering a client”A workspace admin registers your app in their workspace. In v1 a client belongs to the
workspace that registered it and can only ever obtain tokens for that workspace, so each customer
registers your app once and hands you the resulting client_id / client_secret.
Registration is session-only: neither an API key nor an OAuth token can create, rotate or delete an OAuth client. That is deliberate — it stops a leaked credential from minting a second, independently-scoped one.
POST /v1/workspaces/{wid}/oauth/clients
Section titled “POST /v1/workspaces/{wid}/oauth/clients”curl -X POST https://api.knobs.io/v1/workspaces/$WID/oauth/clients \ -H "Authorization: Bearer $SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "OppFlow", "description": "Keeps your CRM in sync with your mail", "homepageUrl": "https://oppflow.ai", "clientType": "confidential", "redirectUris": ["https://app.oppflow.ai/oauth/knobs/callback"], "scopes": ["profile:read", "mail:read", "calendar:read"] }'{ "client": { "id": "8mQ…", "name": "OppFlow", "clientType": "confidential", "redirectUris": ["https://app.oppflow.ai/oauth/knobs/callback"], "scopes": ["calendar:read", "mail:read", "profile:read"], "status": "active", "secretHint": "knos_…f31c" }, "clientSecret": "knos_8mQ…"}clientSecret appears here and nowhere else. Store it before you close the response; the
server keeps only a hash. If you lose it, rotate.
| Field | Notes |
|---|---|
name |
1–48 characters, unique within the workspace |
clientType |
confidential (default, holds a secret) or public (no secret, PKCE only) |
redirectUris |
1–10 entries. Matched by exact string comparison — see below |
scopes |
The ceiling: the most this client may ever request. A user can never consent past it |
logoUrl, homepageUrl, privacyUrl |
Optional https:// URLs shown on the consent screen |
webhookUrl |
Optional https:// receiver for agent-seat events. One endpoint per app, however many seats it operates. Changing it resets verification; it never rotates your signing key |
eventSubscriptions |
Which agent events that endpoint receives. An empty set means none — lifecycle notices still arrive |
Every app is issued an Ed25519 signing keypair at registration, whether or not it sets a webhook
URL, so attaching a receiver later never changes the key your verifier already trusts. The public
half is served, unauthenticated, at GET /v1/oauth/clients/{cid}/keys.
Deleting an app is refused (409) while agent seats are still bound to it. The delete would
take its webhook endpoint and its verification keyset with it, so unbind those seats first.
Redirect URIs are matched exactly
Section titled “Redirect URIs are matched exactly”There is no prefix, suffix or wildcard matching. https://app.example.com/cb will not accept
https://app.example.com/cb?next=… or https://app.example.com/cb/x; register every URI you
actually use. This closes the open-redirect class of attacks that pattern matching invites.
- Only
https://is accepted for internet-facing apps. - Loopback redirects (
http://127.0.0.1/cb,http://[::1]/cb) are accepted for native apps, and are the one case where the port is ignored — a desktop app binds an ephemeral port at runtime. Everything else about the URI still has to match. http://localhostis accepted only on a development server. Use the literal IP in production; a hostname can be redirected by DNS.- Fragments (
#…) and userinfo (user:pass@) are rejected. - The scheme and host must be lowercase. Because matching is exact,
https://App.Example.com/cbcould never match thehttps://app.example.com/cba browser actually sends, so it is rejected at registration rather than failing mysteriously at exchange time. The path and query keep their case — those are case-sensitive to begin with.
Scopes
Section titled “Scopes”Scopes are the whole point: a client sees exactly what the user approved and nothing else.
| Scope | Grants |
|---|---|
profile:read |
Who the user is, and which workspace the app is connected to |
mail:read |
Read threads, messages and attachments |
mail:send |
Send messages and replies |
mail:modify |
Mark read, star, move between folders — not message content |
mail:ai |
Draft replies, improve drafts, summarize threads (spends the workspace’s AI allowance) |
calendar:read |
Calendars, events, instances and free/busy |
calendar:write |
Create, change and delete events; respond to invitations |
calendar:ai |
Create events from natural language (spends the AI allowance) |
tasks:read |
Read tasks |
tasks:write |
Create, change, complete and delete tasks |
search:read |
Search content the user can already see |
No scope implies another. mail:send does not grant mail:read; calendar:write does not
grant calendar:read. Ask for both if you need both, and ask for neither if you do not — the
consent screen shows the user exactly this list, so a smaller request is a higher conversion rate
as well as better hygiene.
Anything not listed here is out of reach for an OAuth app in v1: Drive, Docs, Sheets, Chat and CRM have no OAuth scopes, and routes without a declared scope reject OAuth bearers outright.
GET /v1/oauth/scopes
Section titled “GET /v1/oauth/scopes”Returns the catalog with the exact wording shown on the consent screen. This is the source the consent UI itself renders from, so it never drifts from what the user actually agreed to.
{ "scopes": [ { "scope": "mail:read", "product": "mail", "title": "Read your mail", "description": "See your threads, messages and attachments in this workspace." } ]}The authorization flow
Section titled “The authorization flow”Standard OAuth 2.1 authorization code with PKCE. If you use an off-the-shelf OAuth client library, point it at these endpoints and it will work; the notes below cover where Knobs is stricter than OAuth 2.0.
1. Send the user to /oauth/authorize
Section titled “1. Send the user to /oauth/authorize”https://api.knobs.io/oauth/authorize ?client_id=8mQ… &redirect_uri=https://app.oppflow.ai/oauth/knobs/callback &response_type=code &scope=profile:read%20mail:read &state=<random, tied to the user's session> &code_challenge=<base64url(sha256(verifier))> &code_challenge_method=S256| Parameter | Required | Notes |
|---|---|---|
client_id |
yes | |
redirect_uri |
yes | Must match a registered URI exactly |
response_type |
yes | code only. The implicit grant does not exist here |
scope |
yes | Space-delimited. Must be within the client’s registered ceiling |
state |
recommended | Echoed back verbatim; use it for CSRF |
code_challenge |
yes | PKCE is mandatory for every client, confidential ones included |
code_challenge_method |
yes | S256 only. plain is rejected |
include_granted_scopes |
no | true issues a token carrying the user’s full existing grant, not just what you asked for |
workspace |
no | Must match the client’s workspace if given |
The user lands on the Knobs consent screen, which shows your name, logo, the exact scopes, and the
host of your redirect URI. On approval the browser returns to your redirect_uri with code,
state and iss.
Check iss. It is the issuer of the response (RFC 9207). If your app talks to more than one
authorization server, comparing it is what stops a mix-up attack redeeming a code at the wrong one.
It is always present: a deployment that cannot name its own origin refuses to start, so there is no
configuration in which the parameter is advertised and then quietly omitted.
If something goes wrong after your client and redirect URI have been validated, the error comes
back on your redirect URI as ?error=…&error_description=…&state=…. If the failure is the
client_id or redirect_uri themselves, the user sees an error page instead and nothing is
redirected — bouncing to an unvalidated URI would make Knobs an open redirect.
2. Exchange the code
Section titled “2. Exchange the code”POST /oauth/token, application/x-www-form-urlencoded. Codes are single-use and expire in 60
seconds.
curl -X POST https://api.knobs.io/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code=knoc_… \ -d redirect_uri=https://app.oppflow.ai/oauth/knobs/callback \ -d code_verifier=$VERIFIER{ "access_token": "knoa_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "knor_…", "scope": "mail:read profile:read", "workspace_id": "ws_8mQ…"}client_secret_basic (shown) and client_secret_post both work. redirect_uri must be
byte-identical to the one in the authorization request.
workspace_id is a Knobs extension and you need it. Every product route is
/v1/workspaces/{workspaceId}/…, so without it you cannot build a single request URL.
3. Call the API
Section titled “3. Call the API”curl https://api.knobs.io/v1/workspaces/$WORKSPACE_ID/mail/threads \ -H "Authorization: Bearer knoa_…"Routes that need a scope you were not granted return 403; routes with no OAuth scope at all
(Drive, Chat, anything under credential management) reject the token outright.
Token endpoint errors
Section titled “Token endpoint errors”The token endpoint returns the flat RFC 6749 error body, not the envelope used everywhere else in this API — that is what OAuth client libraries parse:
{ "error": "invalid_grant", "error_description": "authorization code is invalid or already used" }| Code | Status | Usually means |
|---|---|---|
invalid_client |
401 | Unknown client_id, wrong secret, or the client is disabled |
invalid_grant |
400 | Code expired, already used, issued to another client, redirect_uri mismatch, or a bad code_verifier |
invalid_request |
400 | A required parameter is missing |
unsupported_grant_type |
400 | Only authorization_code and refresh_token are supported |
invalid_scope |
400 | You asked for a scope outside your registered ceiling |
4. Refresh before the access token expires
Section titled “4. Refresh before the access token expires”Access tokens last about an hour. POST /oauth/token with grant_type=refresh_token:
curl -X POST https://api.knobs.io/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=refresh_token \ -d refresh_token=knor_…Refresh tokens rotate. Every refresh returns a new refresh token and retires the one you sent. Store the new one immediately — the old one is spent.
Replaying a spent refresh token revokes everything. If a token you already used comes back, we have to assume a copy is in circulation, and we cannot tell you from whoever else has it. The entire token family and the underlying grant are revoked, and your user has to re-consent. This is deliberate: it converts silent, indefinite token theft into an immediate, visible failure.
Practical consequences:
- Persist the new refresh token before you use the new access token. A crash in between leaves you holding a spent token, and the next refresh will look like theft.
- Do not refresh concurrently. Two parallel refreshes with the same token is exactly the pattern reuse detection fires on. Serialize per user.
- Rotation extends the idle deadline, not the absolute one. A family lives at most 90 days (14 for public clients), however often you rotate — refreshing keeps a session alive, it does not make it permanent.
5. Revoking
Section titled “5. Revoking”POST /oauth/revoke (RFC 7009), form-encoded, client-authenticated:
curl -X POST https://api.knobs.io/oauth/revoke \ -u "$CLIENT_ID:$CLIENT_SECRET" -d token=knor_…Always returns 200, even for a token that never existed — reporting otherwise would make this an
oracle, and your goal (the token stops working) holds either way. Revoking a refresh token
revokes the whole grant; revoking an access token kills just that token.
Users can also disconnect you from Knobs Settings → Security → Connected apps, and a workspace
admin can cut your access for any member. Handle a sudden 401 by starting the flow again.
POST /oauth/introspect
Section titled “POST /oauth/introspect”RFC 7662, for checking an access token you hold. A refresh token always reports
{"active": false} — introspection is for validating a bearer, and a knor_ is never a bearer.
You may only introspect your own tokens; anything else reports {"active": false} rather than
leaking another client’s state.
Discovery
Section titled “Discovery”| Endpoint | Contents |
|---|---|
GET /.well-known/oauth-authorization-server |
RFC 8414: endpoints, supported scopes, grant types, code_challenge_methods_supported |
GET /.well-known/oauth-protected-resource |
RFC 9728: which authorization server protects this API |
Most OAuth libraries can configure themselves from the first document given only the issuer URL
(https://api.knobs.io). The WWW-Authenticate header on a 401/403 points at the second.
Connected apps (session-only)
Section titled “Connected apps (session-only)”The user-facing and admin-facing views of what has been granted. Third-party apps never call these.
| Method · Path | Purpose |
|---|---|
GET /v1/users/me/connected-apps |
The user’s own list: app name, workspace, scopes, when granted, last used |
DELETE /v1/users/me/connected-apps/{gid} |
Disconnect an app — its tokens die immediately |
GET /v1/workspaces/{wid}/oauth/grants |
Admin: every app connected in this workspace |
DELETE /v1/workspaces/{wid}/oauth/grants/{gid} |
Admin kill switch: cut one app’s access for one member |
The consent API
Section titled “The consent API”The consent screen is part of the Knobs web app, not the gateway — Knobs keeps its session in
browser memory, so a top-level navigation to /oauth/authorize cannot tell who is browsing. These
three routes back that page and are session-only; third-party apps never call them.
| Method · Path | Purpose |
|---|---|
GET /v1/oauth/authorize-requests/{rid} |
Render a pending request: client, workspace, redirect host, scopes with their consent copy, and which are already granted |
POST /v1/oauth/authorize-requests/{rid}/approve |
Approve → {redirectTo} |
POST /v1/oauth/authorize-requests/{rid}/deny |
Deny → {redirectTo} carrying error=access_denied |
Managing clients
Section titled “Managing clients”| Method · Path | Purpose |
|---|---|
GET /v1/workspaces/{wid}/oauth/clients |
List the workspace’s clients |
GET /v1/workspaces/{wid}/oauth/clients/{cid} |
One client |
PATCH /v1/workspaces/{wid}/oauth/clients/{cid} |
Replace config; status toggles active/disabled |
POST /v1/workspaces/{wid}/oauth/clients/{cid}/rotate |
New secret, returned once; the previous one dies immediately |
DELETE /v1/workspaces/{wid}/oauth/clients/{cid} |
Remove the client |
All are admin-only and session-only. A secret hash is never returned by any of them.
clientType is immutable. Flipping a confidential client to public would silently strip secret
authentication from an app that may already hold live grants, so PATCH ignores it.
Disabling versus deleting
Section titled “Disabling versus deleting”Disabling stops a client obtaining new tokens, but access tokens already issued stay valid until they expire (about an hour). Delete the client when you need it dead now.
Using the CLI
Section titled “Using the CLI”knobsctl oauth scopesknobsctl oauth clients create --workspace $WID \ --name OppFlow --redirect https://app.oppflow.ai/oauth/knobs/callback \ --scope profile:read --scope mail:readknobsctl oauth clients rotate --workspace $WID --id $CLIENT_IDErrors
Section titled “Errors”Client-management routes use the standard error envelope.
| Status | Code | Meaning |
|---|---|---|
400 |
INVALID_ARGUMENT |
Bad name, unknown scope, or a redirect URI that fails the rules above |
403 |
PERMISSION_DENIED |
You are a member of the workspace but not an admin |
404 |
NOT_FOUND |
No such client — or you are not a member of that workspace at all |
409 |
CONFLICT |
A client with that name already exists in the workspace |
401 |
UNAUTHENTICATED |
Missing session, or you used an API key on a session-only route |