Skip to content

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.

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

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.

Terminal window
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.

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://localhost is 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/cb could never match the https://app.example.com/cb a 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 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.

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."
}
]
}

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.

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.

POST /oauth/token, application/x-www-form-urlencoded. Codes are single-use and expire in 60 seconds.

Terminal window
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.

Terminal window
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.

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:

Terminal window
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.

POST /oauth/revoke (RFC 7009), form-encoded, client-authenticated:

Terminal window
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.

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.

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.

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 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
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 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.

Terminal window
knobsctl oauth scopes
knobsctl oauth clients create --workspace $WID \
--name OppFlow --redirect https://app.oppflow.ai/oauth/knobs/callback \
--scope profile:read --scope mail:read
knobsctl oauth clients rotate --workspace $WID --id $CLIENT_ID

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