Authentication — OAuth 2.1 + PKCE¶
The Contio MeetingOS User API implements the OAuth 2.1 authorization-code flow with PKCE for Contio-owned first-party clients. This is the recommended way for native/desktop apps and CLIs built or approved by Contio to act on behalf of the signed-in user.
When to use OAuth vs. a PAT vs. an SLT
Use this flow for Contio-owned first-party clients — native/desktop apps and CLIs that can open a system browser and listen on a loopback address. The first-party client_id should only be used by Contio-owned clients.
For third-party scripts, agents, MCP clients, and unattended automation, issue a Personal Access Token instead. PATs are a paid feature and the supported way to authorize non-Contio clients until Dynamic Client Registration is available.
For ad hoc queries and quick experimentation without setting up OAuth or a PAT, issue a short-lived access token from the Contio UI. Short-lived tokens are available on all plans, expire within hours, and are shown once at creation.
The browser session is not an API credential
The /v1 resource endpoints (e.g. /v1/meetings, /v1/workspace/*) accept only scoped first-party credentials — an OAuth access token (cto_at_v1_), a short-lived access token (cto_slt_v1_), or a Personal Access Token (cto_pat_v1_). The browser session that signs you in to the Contio app is not a general-purpose API token: it grants your full role permissions rather than a consented subset, and it is not designed for programmatic use. Always authenticate resource calls with a scoped token.
The only exceptions are /v1/oauth/authorize and /v1/oauth/revoke, which use the signed-in session to identify the user (see Revoking tokens). If a browser hits /v1/oauth/authorize without a session, the API returns a 302 redirect to the Contio app sign-in page with the original authorize URL in a continue parameter. After sign-in, the user is redirected back to /v1/oauth/authorize to approve the request.
Short-lived access tokens¶
For ad hoc API queries or experimentation, you can issue a short-lived access token (cto_slt_v1_) directly from the Contio web app without setting up OAuth or a PAT.
- Open the Contio app and go to Settings > API Tokens.
- In the Short-lived Access Tokens (SLT) section, click Create SLT.
- Select the scopes you need and choose an expiry (1, 2, 4, or 6 hours).
- Copy the plaintext token immediately — it is shown only once.
Short-lived tokens are available on all plans, expire within hours, and cannot be refreshed. Use them for quick scripts, one-off curl calls, or trying out the API; for unattended automation, use a Personal Access Token (PRO and higher) or set up OAuth 2.1 + PKCE for longer-lived, refreshable access.
SLT scope limit
Short-lived tokens cannot include workspace scopes (workspace:read, workspace:write). For workspace-scoped admin tasks, use a workspace API key.
Key properties¶
- Authorization code + PKCE (
S256) — no client secret is used or required (token_endpoint_auth_methods_supported: ["none"]). - System browser + loopback redirect — the client opens the user's real browser and listens on a loopback address (
http://127.0.0.1:PORTorhttp://localhost:PORT) to receive the authorization code. - Browser-friendly authorization endpoint — the authorization endpoint is served from the app domain (
app.contio.ai) so it can share the Contio web session cookie. The token, revocation, and JWKS endpoints remain on the API domain (api.contio.ai). - Unauthenticated users are redirected to sign in — if the browser is not already signed in to the Contio app,
/v1/oauth/authorizereturns a302to/signin?continue=...on the app domain. After authentication the user is sent back to the original authorize URL to approve the request. This also supports MCP clients that initiate OAuth in a browser. - Opaque, versioned tokens — access tokens are prefixed
cto_at_v1_and refresh tokenscto_rt_v1_. Do not attempt to parse them. - Rotating refresh tokens — refreshing returns a new refresh token and invalidates the old one.
Token lifetimes¶
| Credential | Valid for | Notes |
|---|---|---|
Access token (cto_at_v1_) | 1 hour | Returned as expires_in: 3600. Use the refresh grant to get a new one. |
Refresh token (cto_rt_v1_) | 30 days | Rotates on every use — each refresh returns a new refresh token and invalidates the previous one, so always persist the newest one. Each rotation resets the 30-day window, so refreshing within the window keeps the session alive indefinitely. |
| Authorization code | 10 minutes | Single-use; exchange it promptly at the token endpoint. |
Keeping a session alive
Because each rotation issues a fresh 30-day refresh token, a client that refreshes at least once every 30 days can stay signed in without another authorization-code flow. If a refresh token goes unused for 30 days it expires, and the user must authorize again. For unattended automation that should not depend on this cadence, use a Personal Access Token instead.
Discovery¶
All endpoints are advertised by the OpenID Connect discovery document (RFC 8414, hyphenated spelling):
{
"issuer": "https://api.contio.ai/v1/oauth",
"authorization_endpoint": "https://app.contio.ai/v1/oauth/authorize",
"token_endpoint": "https://api.contio.ai/v1/oauth/token",
"revocation_endpoint": "https://api.contio.ai/v1/oauth/revoke",
"jwks_uri": "https://api.contio.ai/v1/oauth/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"id_token_signing_alg_values_supported": ["RS256"],
"subject_types_supported": ["public"],
"scopes_supported": ["meetings:read", "meetings:write", "action-items:read", "action-items:write", "calendar:read", "workspace:read", "workspace:write"]
}
Always treat the discovery document as the source of truth for endpoint URLs. Notice that the authorization_endpoint is on the app domain (app.contio.ai) while the issuer, token, revocation, and JWKS endpoints stay on the API domain (api.contio.ai). The app domain endpoint can use the existing Contio web session and redirect an unauthenticated browser to /signin with a continue parameter before returning to the authorize URL.
MCP clients
The Contio Public MCP Gateway (mcp.contio.ai) publishes the same authorization server metadata at https://mcp.contio.ai/.well-known/oauth-authorization-server and https://mcp.contio.ai/.well-known/openid-configuration. The issuer, authorization_endpoint, and token endpoints are identical to the values above, so MCP clients and first-party clients share one OAuth flow.
The flow¶
sequenceDiagram
participant App as Your App
participant Browser as System Browser
participant Loopback as Loopback Listener
participant Contio as Contio API
App->>App: Generate PKCE verifier, challenge, state
App->>Loopback: Start listener on a free port
App->>Browser: Open authorize URL (app.contio.ai)
Browser->>Contio: GET /v1/oauth/authorize
alt User is not signed in
Contio->>Browser: 302 /signin?continue=<authorize URL>
Browser->>Contio: Sign in (app session created)
Contio->>Browser: 302 /v1/oauth/authorize
end
Contio->>Browser: User approves, 302 redirect to loopback
Browser->>Loopback: GET callback with code and state
Loopback->>App: Deliver authorization code
App->>Contio: POST /v1/oauth/token (api.contio.ai) with code + verifier
Contio->>App: Return access_token + refresh_token 1. Generate PKCE parameters¶
import base64, hashlib, os
code_verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode()
2. Open the authorization URL¶
Open the following URL in the user's system browser. The base URL must match the authorization_endpoint from discovery (https://app.contio.ai/v1/oauth/authorize in production), and redirect_uri must be a loopback address your app is listening on.
https://app.contio.ai/v1/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=http://127.0.0.1:53123/callback
&response_type=code
&scope=meetings:read%20meetings:write
&state=STATE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
If the user is not already signed in to the Contio app, the browser is redirected to /signin?continue=<encoded-authorize-url>. After authentication the user returns to this URL to approve the request.
| Parameter | Required | Notes |
|---|---|---|
client_id | ✅ | Your first-party client ID |
redirect_uri | ✅ | Loopback URI: http://127.0.0.1:PORT or http://localhost:PORT |
response_type | ✅ | Must be code |
scope | ✅ | Space-delimited scopes (URL-encoded) |
code_challenge | ✅ | Base64URL-encoded SHA-256 of the verifier |
code_challenge_method | ✅ | Must be S256 |
state | recommended | Opaque value echoed back for CSRF protection |
After the user approves, Contio issues a 302 redirect back to your redirect_uri with code and state query parameters. Verify state matches the value you sent.
3. Exchange the code for tokens¶
Send an application/x-www-form-urlencoded request to the token endpoint:
curl -X POST https://api.contio.ai/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=AUTHORIZATION_CODE \
-d code_verifier=CODE_VERIFIER \
-d client_id=YOUR_CLIENT_ID \
-d redirect_uri=http://127.0.0.1:53123/callback
{
"access_token": "cto_at_v1_...",
"refresh_token": "cto_rt_v1_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "meetings:read meetings:write"
}
4. Call the API¶
Refreshing tokens¶
OAuth access tokens expire after one hour. Use the refresh_token grant to get a new access token. Refresh tokens rotate: each refresh returns a new refresh token and invalidates the previous one, so always persist the newest one.
curl -X POST https://api.contio.ai/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=cto_rt_v1_...
Revoking tokens¶
To revoke an access or refresh token (and its rotation chain), call the revocation endpoint (RFC 7009). It always responds 200, even for unknown tokens.
Requires a signed-in session
Unlike the resource endpoints, /v1/oauth/authorize and /v1/oauth/revoke require an authenticated user session — not just a bearer access token. Send the credential that represents the signed-in user (e.g. the session issued by the Contio app) in the Authorization header.
For /v1/oauth/authorize, an unauthenticated browser is redirected to the app sign-in page with the original authorize URL in a continue parameter, then returned to approve the request. A non-browser request or a deployment with no login host configured returns 401.
curl -X POST https://api.contio.ai/v1/oauth/revoke \
-H "Authorization: Bearer <user-session-token>" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=cto_rt_v1_... \
-d token_type_hint=refresh_token
Full loopback example (Python)¶
import base64, hashlib, http.server, os, secrets, threading, urllib.parse, webbrowser
import requests
CLIENT_ID = "YOUR_CLIENT_ID"
# Authorization endpoint is on the app domain so it can reuse the Contio web session.
AUTHZ_BASE = "https://app.contio.ai/v1/oauth"
# Token, refresh, revocation, and discovery endpoints are on the API domain.
API_BASE = "https://api.contio.ai/v1/oauth"
SCOPES = "meetings:read meetings:write"
# 1. PKCE + state
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
# 2. Loopback listener on an ephemeral port
received = {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
received.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.end_headers()
self.wfile.write(b"You can close this tab and return to the app.")
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
redirect_uri = f"http://127.0.0.1:{port}/callback"
threading.Thread(target=server.handle_request, daemon=True).start()
# 3. Open the system browser. If the user is not signed in, the app will
# redirect them through /signin?continue=... and then back to this URL.
params = {
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
webbrowser.open(f"{AUTHZ_BASE}/authorize?" + urllib.parse.urlencode(params))
# 4. Wait for the redirect, then exchange the code
while "code" not in received:
pass
assert received["state"][0] == state, "state mismatch"
resp = requests.post(f"{API_BASE}/token", data={
"grant_type": "authorization_code",
"code": received["code"][0],
"code_verifier": verifier,
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
})
resp.raise_for_status()
tokens = resp.json()
print("Access token:", tokens["access_token"][:16], "...")
Errors¶
OAuth endpoints return standard OAuth error responses:
| HTTP | Meaning |
|---|---|
302 | GET /v1/oauth/authorize only: unauthenticated browser redirected to /signin?continue=... on the app domain |
400 | Malformed request or invalid grant/PKCE verifier |
401 | Authentication required (e.g. authorize with no login host configured, or revoke, needs a signed-in session) |
500 | Server error |
See also¶
- Personal Access Tokens — for unattended automation
- API Reference — endpoint catalog and interactive spec