OAuth 2.0 Flow Guide¶
This guide provides a detailed walkthrough of implementing OAuth 2.0 authentication with the Contio Partner API.
Flow Overview¶
The Contio Partner API uses the Authorization Code Grant flow with optional PKCE support.
sequenceDiagram
participant User
participant YourApp as Your Application
participant Contio as Contio Auth Server
User->>YourApp: Click "Connect to Contio"
YourApp->>Contio: Redirect to /oauth2/authorize
Note over Contio: User enters email
Contio->>User: Send OTP via email
User->>Contio: Enter OTP code
Note over Contio: Display consent screen
User->>Contio: Grant permission
Contio->>YourApp: Redirect with authorization code
YourApp->>Contio: POST /oauth2/token
Contio->>YourApp: Access token + Refresh token
YourApp->>Contio: GET /v1/partner/user/meetings
Contio->>YourApp: User's meeting data Step 1: Authorization Request¶
Redirect the user to the Contio authorization endpoint:
const { oauth } = ContioPartnerSDK.forUser({
clientId: process.env.CONTIO_CLIENT_ID!,
clientSecret: process.env.CONTIO_CLIENT_SECRET!,
redirectUri: 'https://your-app.com/callback'
});
// Generate authorization URL with state for CSRF protection
const state = crypto.randomUUID();
req.session.oauthState = state;
const authUrl = oauth.getAuthorizationUrl(state, [
'openid',
'profile',
'meetings:read',
'action-items:read'
]);
res.redirect(authUrl);
Authorization URL Parameters¶
| Parameter | Required | Description |
|---|---|---|
client_id | Yes | Your partner app's client ID |
redirect_uri | Yes | Must match registered redirect URI |
response_type | Yes | Must be code |
scope | No | Space-separated list of scopes |
state | Recommended | CSRF protection token |
code_challenge | Optional | PKCE code challenge |
code_challenge_method | Optional | Must be S256 if using PKCE |
login_hint | Optional | Pre-fill and lock the email address field |
Step 2: User Authentication¶
The user will:
- See a login page and enter their email
- Receive a one-time password (OTP) via email
- Enter the OTP to verify their identity
- See a consent screen showing requested permissions
- Grant or deny access
Important
Do not attempt to bypass the OTP verification step. The authentication session is only valid after successful OTP verification.
Scopes Are Granted During Consent
The access token only receives scopes when the user completes step 5 (granting consent). If the OAuth flow is interrupted before consent, or if you're testing with automated flows that skip the consent UI, the resulting token will have no scopes and API calls will fail with insufficient_scope.
See Troubleshooting: Access Token Has No Scopes if you encounter this issue.
Pre-filling Email with login_hint¶
If you already know the user's email address (e.g., from your own identity provider or SSO system), you can pre-fill and lock the email field on the Contio login page using the login_hint parameter:
// Generate authorization URL with login_hint for known email
const authUrl = oauth.getAuthorizationUrl(state, scopes, {
login_hint: 'user@example.com'
});
When login_hint is provided:
- The email field is pre-filled with the specified email address
- The email field is read-only - the user cannot change it
- The user proceeds directly to OTP verification for that email
This is useful when:
- Your application has already authenticated the user via SSO
- You're provisioning users on-demand and want to constrain their Contio account to match your system
- You want to ensure the OAuth flow uses the same email as your partner application
Email Validation
The login_hint email is not validated before display. Ensure you pass a valid email address to avoid confusing error messages during OTP verification.
Step 3: Handle Callback¶
After the user grants permission, Contio redirects back to your app:
app.get('/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
// Check for errors
if (error) {
console.error('OAuth error:', error, error_description);
return res.redirect('/error?message=' + encodeURIComponent(error_description as string));
}
// Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
try {
// Exchange code for tokens
const tokens = await oauth.exchangeCodeForToken(code as string);
// Store tokens securely (encrypted)
await saveUserTokens(req.session.userId, tokens);
res.redirect('/dashboard');
} catch (error) {
console.error('Token exchange failed:', error);
res.redirect('/error');
}
});
Token Format¶
Contio uses opaque tokens for improved security and privacy:
| Token Type | Format | Example |
|---|---|---|
| Access Token | cto_at_v1_<random> | cto_at_v1_7f3d8a2b... |
| Refresh Token | cto_rt_v1_<random> | cto_rt_v1_9e4c1f6a... |
| ID Token | Standard JWT | eyJhbGciOiJSUzI1NiI... |
Opaque Tokens
Access and refresh tokens are opaque - they contain no readable claims. Use token introspection to retrieve token metadata like scopes and expiration.
Do Not Parse Tokens
Never attempt to decode or parse access/refresh tokens. Their internal format may change without notice. Always use the introspection endpoint to get token information.
Step 4: Token Refresh¶
Access tokens expire after 24 hours. Refresh tokens are valid for 30 days and can be used to obtain new access tokens:
async function getValidTokens(userId: string): Promise<OAuthTokens> {
const tokens = await loadUserTokens(userId);
// Check if access token is expired (with 5 min buffer)
const expiresAt = tokens.issued_at + (tokens.expires_in * 1000);
const isExpired = Date.now() > expiresAt - (5 * 60 * 1000);
if (isExpired) {
const newTokens = await oauth.refreshToken(tokens.refresh_token);
await saveUserTokens(userId, newTokens);
return newTokens;
}
return tokens;
}
Step 5: Token Introspection¶
Token introspection (RFC 7662) allows you to validate tokens and retrieve their metadata. This is useful for:
- Debugging - Verify token contents and expiration
- Building resource servers - Validate tokens from other services
- Checking scopes - Confirm what permissions a token has
- Auditing - Log token usage and metadata
Introspecting Tokens¶
// Using the SDK
const introspection = await oauth.introspectToken(accessToken);
if (introspection.active) {
console.log('Token is valid');
console.log('Subject:', introspection.sub);
console.log('Scopes:', introspection.scope);
console.log('Expires:', new Date(introspection.exp * 1000));
} else {
console.log('Token is invalid or expired');
}
Or using a direct HTTP request:
curl -X POST https://api.contio.ai/oauth2/introspect \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=ACCESS_TOKEN_HERE"
Supported Token Types¶
| Token Type | Can Introspect? | Notes |
|---|---|---|
| Access Token | ✅ Yes | Returns scopes, client_id, username, expiration |
| ID Token | ✅ Yes | Returns user identity claims (sub, email, etc.) |
| Refresh Token | ❌ No | Encrypted JWE format - returns active: false |
Introspection Response¶
{
"active": true,
"sub": "user-uuid",
"client_id": "your-client-id",
"username": "user@example.com",
"scope": "openid profile meetings:read",
"token_type": "Bearer",
"exp": 1704672000,
"iat": 1704585600,
"iss": "https://auth.contio.ai",
"aud": ["your-client-id"]
}
| Field | Description |
|---|---|
active | Whether the token is valid and not expired |
sub | Subject - the user's unique identifier |
client_id | The OAuth client that requested this token |
username | The user's email or username |
scope | Space-separated list of granted scopes |
exp | Expiration time (Unix timestamp) |
iat | Issued-at time (Unix timestamp) |
iss | Token issuer URL |
aud | Intended audience(s) for this token |
Access Token vs ID Token
Access tokens contain authorization information (scopes, client_id) while ID tokens contain identity information (email, name). Use access tokens for API calls and ID tokens when you need user profile data.
Error Handling¶
Handle common OAuth errors gracefully:
try {
const tokens = await oauth.exchangeCodeForToken(code);
} catch (error) {
switch (error.code) {
case 'invalid_grant':
// Code expired or already used - restart flow
return res.redirect('/connect');
case 'invalid_client':
// Bad credentials - check configuration
console.error('Invalid client credentials');
break;
case 'session_expired':
// OTP session expired - user needs to re-authenticate
return res.redirect('/connect?error=session_expired');
}
}
Token Refresh Middleware¶
Implement middleware to automatically refresh expired tokens:
async function refreshTokenMiddleware(req: Request, res: Response, next: NextFunction) {
if (!req.session.refreshToken) {
return next();
}
const tokenExpiresAt = req.session.tokenExpiresAt || 0;
const bufferTime = 5 * 60 * 1000; // 5 minutes buffer
// Check if token is expired or about to expire
if (Date.now() >= tokenExpiresAt - bufferTime) {
try {
const tokenResponse = await oauth.refreshAccessToken(req.session.refreshToken);
// Update session with new tokens
req.session.accessToken = tokenResponse.access_token;
req.session.refreshToken = tokenResponse.refresh_token;
req.session.tokenExpiresAt = Date.now() + (tokenResponse.expires_in * 1000);
console.log('Token refreshed successfully');
} catch (error) {
console.error('Token refresh failed:', error);
// Clear invalid tokens
delete req.session.accessToken;
delete req.session.refreshToken;
delete req.session.tokenExpiresAt;
}
}
next();
}
// Apply to all authenticated routes
app.use(refreshTokenMiddleware);
Logout Implementation¶
Properly clean up tokens when users log out:
app.post('/auth/logout', async (req, res) => {
// Revoke token at Contio
if (req.session.accessToken) {
try {
await oauth.revokeToken(req.session.accessToken);
} catch (error) {
console.error('Token revocation failed:', error);
// Continue with logout even if revocation fails
}
}
// Destroy session
req.session.destroy((err) => {
if (err) {
console.error('Session destruction error:', err);
}
res.redirect('/');
});
});
PKCE (Proof Key for Code Exchange)¶
For enhanced security, implement PKCE:
import { createHash, randomBytes } from 'crypto';
function generatePKCE() {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// During authorization
const { verifier, challenge } = generatePKCE();
req.session.codeVerifier = verifier;
const authUrl = oauth.getAuthorizationUrl({
state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
// During token exchange
const tokenResponse = await oauth.exchangeCodeForToken(code, {
code_verifier: req.session.codeVerifier
});
Security Best Practices¶
- Always verify state parameter to prevent CSRF attacks
- Use HTTPS for all OAuth endpoints and redirects
- Store tokens encrypted at rest in your database
- Implement token refresh before expiration
- Handle revocation - tokens may be revoked by users
- Use PKCE for public clients or additional security
- Never store tokens in:
- URL parameters
- Local storage (for server-side apps)
- Cookies without proper security flags
Secure Token Storage¶
Recommended storage approaches:
- Server-side sessions (Redis, database)
- Encrypted cookies with
httpOnly,secure,sameSiteflags - Secure key management service for encryption keys
Troubleshooting¶
Common Issues¶
-
Invalid redirect_uri
- Ensure redirect URI exactly matches registered URI
- Check for trailing slashes and protocol (http vs https)
-
State mismatch
- Verify session configuration
- Check for session persistence issues
- Ensure cookies are enabled
-
Token expiration
- Implement automatic token refresh
- Handle refresh token expiration gracefully
- Provide clear re-authentication flow
-
CORS issues
- OAuth flow should use redirects, not AJAX
- For SPA apps, use PKCE
Next Steps¶
- Postman Collection - Test the OAuth flow interactively without code
- User Provisioning - Learn how users are auto-provisioned during OAuth
- SSO Integration - Add SSO login for connected users
- Webhook Events - Set up real-time notifications
- Workflow Setup - Configure automated action item handling
- API Guide - Make API calls with your tokens