Skip to content

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:

  1. See a login page and enter their email
  2. Receive a one-time password (OTP) via email
  3. Enter the OTP to verify their identity
  4. See a consent screen showing requested permissions
  5. 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

  1. Always verify state parameter to prevent CSRF attacks
  2. Use HTTPS for all OAuth endpoints and redirects
  3. Store tokens encrypted at rest in your database
  4. Implement token refresh before expiration
  5. Handle revocation - tokens may be revoked by users
  6. Use PKCE for public clients or additional security
  7. 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, sameSite flags
  • Secure key management service for encryption keys

Troubleshooting

Common Issues

  1. Invalid redirect_uri

    • Ensure redirect URI exactly matches registered URI
    • Check for trailing slashes and protocol (http vs https)
  2. State mismatch

    • Verify session configuration
    • Check for session persistence issues
    • Ensure cookies are enabled
  3. Token expiration

    • Implement automatic token refresh
    • Handle refresh token expiration gracefully
    • Provide clear re-authentication flow
  4. CORS issues

    • OAuth flow should use redirects, not AJAX
    • For SPA apps, use PKCE

Next Steps