Skip to content

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.

  1. Open the Contio app and go to Settings > API Tokens.
  2. In the Short-lived Access Tokens (SLT) section, click Create SLT.
  3. Select the scopes you need and choose an expiry (1, 2, 4, or 6 hours).
  4. 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:PORT or http://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/authorize returns a 302 to /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 tokens cto_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):

curl https://api.contio.ai/v1/oauth/.well-known/openid-configuration
{
  "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

curl https://api.contio.ai/v1/meetings \
  -H "Authorization: Bearer cto_at_v1_..."

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:

{ "error": "invalid_grant", "error_description": "authorization code is invalid or expired" }
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