OAuth 2.1 Authentication API

This documentation describes how to integrate transitspotter authentication with external websites using OAuth 2.1 with Authorization Code + PKCE flow.

Important: Origin Header Required

All requests to the External Auth API must include a valid Origin header. This is used for security validation and CORS handling. Requests without a valid Origin header will be rejected.

Authentication Flow

1. Authorization Request with PKCE
2. User Authentication
3. Authorization Code
4. Token Exchange with PKCE
5. Access + Refresh Tokens

The authentication flow follows the OAuth 2.1 authorization code flow with PKCE:

  1. Your application generates a code verifier and code challenge
  2. Your application redirects the user to transitspotter's authorization endpoint with the code challenge
  3. User logs in with their transitspotter credentials
  4. User is redirected back to your application with an authorization code
  5. Your application exchanges the authorization code and code verifier for access and refresh tokens
  6. Your application uses the access token to access protected resources
  7. When the access token expires, your application can use the refresh token to obtain new tokens

Prerequisites

Your domain must be approved by transitspotter administrators before you can use this API. Please contact the transitspotter team to get your domain added to the allowed list.

OAuth 2.1 Endpoints

GET /external/authorize

Initiates the authentication flow. Redirect your users to this endpoint.

Query Parameters

Parameter Required Description
response_type Yes Must be set to code.
client_id Yes Your site identifier (typically your domain name)
redirect_uri Yes The URL to redirect to after successful authentication. Must be from an authorized domain.
code_challenge Yes The code challenge generated from a code verifier using SHA-256 and base64url encoding.
code_challenge_method Yes Must be set to S256.
state Recommended An opaque value used to maintain state between the request and callback. Helps prevent CSRF attacks.
scope No Space-separated list of scopes. Currently unused, but provided for OAuth 2.1 compliance.

Example

https://auth.transitspotter.com/external/authorize?response_type=code&client_id=example.com&redirect_uri=https://example.com/callback&code_challenge=CHALLENGE&code_challenge_method=S256&state=random_state_value
POST /external/token

Exchanges an authorization code for access and refresh tokens.

Required Headers

Header Description
Content-Type application/x-www-form-urlencoded or application/json
Origin The origin of your application (e.g., https://example.com). Must match the redirect_uri origin.

Request Parameters for Authorization Code Grant

Parameter Required Description
grant_type Yes Must be set to authorization_code.
code Yes The authorization code received from the authorize endpoint.
client_id Yes Your site identifier (same as used in the authorize request).
redirect_uri Yes The same redirect URI used in the authorize request.
code_verifier Yes The original code verifier that was used to generate the code challenge.

Request Parameters for Refresh Token Grant

Parameter Required Description
grant_type Yes Must be set to refresh_token.
refresh_token Yes The refresh token previously received.
client_id Yes Your site identifier.
scope No Space-separated list of scopes. Must be equal to or a subset of the original scopes.

Successful Response

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "8a57b597f8d842859a403d7c0294eae35d...",
  "scope": ""
}

Error Response

{
  "error": "invalid_grant",
  "error_description": "Authorization code has expired"
}
POST /external/introspect

Introspects a token to determine its state and metadata (per RFC 7662).

Required Headers

Header Description
Content-Type application/x-www-form-urlencoded or application/json
Origin The origin of your application. Must match the token's intended audience.

Request Parameters

Parameter Required Description
token Yes The token to introspect.
token_type_hint No A hint about the type of token: access_token or refresh_token.

Successful Response for Active Token

{
  "active": true,
  "client_id": "example.com",
  "scope": "",
  "token_type": "access_token",
  "exp": 1716317154,
  "iat": 1716313554,
  "sub": "user@example.com"
}

Response for Inactive Token

{
  "active": false
}
POST /external/revoke

Revokes a token (access or refresh token).

Required Headers

Header Description
Content-Type application/x-www-form-urlencoded or application/json
Origin The origin of your application. Must match the token's intended audience.

Request Parameters

Parameter Required Description
token Yes The token to revoke.
token_type_hint No A hint about the type of token: access_token or refresh_token.

Successful Response

{
  "success": true
}

OAuth 2.1 Client Implementation

JavaScript Implementation

// OAuth 2.1 with PKCE Implementation

// 1. Generate code verifier and challenge
async function generateCodeVerifierAndChallenge() {
  // Generate a random code verifier
  const codeVerifier = generateRandomString(64);
  
  // Create a code challenge using SHA-256
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  
  // Base64url encode the hash
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
  
  return { codeVerifier, codeChallenge };
}

// Generate a random string for code verifier and state
function generateRandomString(length) {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  let result = '';
  const randomValues = new Uint8Array(length);
  crypto.getRandomValues(randomValues);
  for (let i = 0; i < length; i++) {
    result += charset[randomValues[i] % charset.length];
  }
  return result;
}

// 2. Start OAuth flow
async function startOAuthFlow() {
  // Generate PKCE values
  const { codeVerifier, codeChallenge } = await generateCodeVerifierAndChallenge();
  
  // Generate state for CSRF protection
  const state = generateRandomString(32);
  
  // Store in session storage for later use
  sessionStorage.setItem('code_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);
  
  // Build authorization URL
  const authUrl = new URL('https://auth.transitspotter.com/external/authorize');
  authUrl.searchParams.append('response_type', 'code');
  authUrl.searchParams.append('client_id', 'your-domain.com');
  authUrl.searchParams.append('redirect_uri', 'https://your-domain.com/oauth/callback');
  authUrl.searchParams.append('code_challenge', codeChallenge);
  authUrl.searchParams.append('code_challenge_method', 'S256');
  authUrl.searchParams.append('state', state);
  
  // Redirect to authorization endpoint
  window.location.href = authUrl.toString();
}

// 3. Handle the callback
async function handleOAuthCallback() {
  // Get URL parameters
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');
  const state = urlParams.get('state');
  const error = urlParams.get('error');
  
  // Handle errors
  if (error) {
    console.error('OAuth error:', error, urlParams.get('error_description'));
    return;
  }
  
  // Verify state to prevent CSRF attacks
  const storedState = sessionStorage.getItem('oauth_state');
  if (!state || state !== storedState) {
    console.error('Invalid state parameter');
    return;
  }
  
  // Get the code verifier
  const codeVerifier = sessionStorage.getItem('code_verifier');
  if (!codeVerifier) {
    console.error('Code verifier not found');
    return;
  }
  
  // Exchange code for tokens
  try {
    const response = await fetch('https://auth.transitspotter.com/external/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Origin': window.location.origin
      },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code: code,
        client_id: 'your-domain.com',
        redirect_uri: 'https://your-domain.com/oauth/callback',
        code_verifier: codeVerifier
      })
    });
    
    const tokenData = await response.json();
    
    if (tokenData.error) {
      console.error('Token error:', tokenData.error, tokenData.error_description);
      return;
    }
    
    // Store tokens securely
    localStorage.setItem('access_token', tokenData.access_token);
    localStorage.setItem('refresh_token', tokenData.refresh_token);
    localStorage.setItem('token_expiry', Date.now() + (tokenData.expires_in * 1000));
    
    // Clean up session storage
    sessionStorage.removeItem('code_verifier');
    sessionStorage.removeItem('oauth_state');
    
    // Redirect to protected area or show success
    console.log('Authentication successful!');
  } catch (error) {
    console.error('Token exchange error:', error);
  }
}

// 4. Use the access token for API calls
async function fetchUserInfo() {
  const accessToken = localStorage.getItem('access_token');
  
  const response = await fetch('https://auth.transitspotter.com/external/userinfo', {
    method: 'GET',
    headers: {
      'Authorization': 'Bearer ' + accessToken,
      'Origin': window.location.origin
    }
  });
  
  return await response.json();
}

// 5. Refresh the token when it expires
async function refreshToken() {
  const refreshToken = localStorage.getItem('refresh_token');
  
  try {
    const response = await fetch('https://auth.transitspotter.com/external/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Origin': window.location.origin
      },
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: 'your-domain.com'
      })
    });
    
    const tokenData = await response.json();
    
    if (tokenData.error) {
      console.error('Refresh error:', tokenData.error, tokenData.error_description);
      // Handle failed refresh (e.g., redirect to login)
      return false;
    }
    
    // Update stored tokens
    localStorage.setItem('access_token', tokenData.access_token);
    localStorage.setItem('refresh_token', tokenData.refresh_token);
    localStorage.setItem('token_expiry', Date.now() + (tokenData.expires_in * 1000));
    
    return true;
  } catch (error) {
    console.error('Token refresh error:', error);
    return false;
  }
}

// 6. Check if token needs refreshing before API calls
async function ensureValidToken() {
  const expiryTime = parseInt(localStorage.getItem('token_expiry'), 10);
  
  // Refresh if token is expired or will expire in the next 5 minutes
  if (!expiryTime || Date.now() > expiryTime - 300000) {
    return await refreshToken();
  }
  
  return true;
}

// 7. Logout - revoke tokens
async function logout() {
  const refreshToken = localStorage.getItem('refresh_token');
  
  try {
    // Revoke the refresh token
    await fetch('https://auth.transitspotter.com/external/revoke', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Origin': window.location.origin
      },
      body: JSON.stringify({
        token: refreshToken,
        token_type_hint: 'refresh_token'
      })
    });
    
    // Clear local storage
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    localStorage.removeItem('token_expiry');
    
    // Redirect to login page
    window.location.href = '/login';
  } catch (error) {
    console.error('Logout error:', error);
  }
}

PHP Implementation

/**
 * OAuth 2.1 with PKCE Client for transitspotter
 */

// 1. Start the OAuth flow
function startOAuthFlow() {
  // Generate code verifier (random string)
  $codeVerifier = bin2hex(random_bytes(32));
  
  // Generate code challenge (base64url encoded SHA-256 hash)
  $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
  
  // Generate state for CSRF protection
  $state = bin2hex(random_bytes(16));
  
  // Store these in the session
  $_SESSION['code_verifier'] = $codeVerifier;
  $_SESSION['oauth_state'] = $state;
  
  // Build authorization URL
  $params = [
    'response_type' => 'code',
    'client_id' => 'your-domain.com',
    'redirect_uri' => 'https://your-domain.com/oauth/callback.php',
    'code_challenge' => $codeChallenge,
    'code_challenge_method' => 'S256',
    'state' => $state
  ];
  
  $authUrl = 'https://auth.transitspotter.com/external/authorize?' . http_build_query($params);
  
  // Redirect the user
  header('Location: ' . $authUrl);
  exit;
}

// 2. Handle the callback
function handleOAuthCallback() {
  // Check for errors
  if (isset($_GET['error'])) {
    die('OAuth Error: ' . $_GET['error'] . ' - ' . ($_GET['error_description'] ?? 'No description'));
  }
  
  // Get the authorization code
  $code = $_GET['code'] ?? null;
  $state = $_GET['state'] ?? null;
  
  if (!$code) {
    die('No authorization code received');
  }
  
  // Verify state to prevent CSRF
  if (!$state || $state !== $_SESSION['oauth_state']) {
    die('Invalid state parameter');
  }
  
  // Get the code verifier from session
  $codeVerifier = $_SESSION['code_verifier'] ?? null;
  
  if (!$codeVerifier) {
    die('Code verifier not found in session');
  }
  
  // Exchange code for tokens
  $tokenData = exchangeCodeForTokens($code, $codeVerifier);
  
  // Store tokens in session (or more securely in database)
  $_SESSION['access_token'] = $tokenData['access_token'];
  $_SESSION['refresh_token'] = $tokenData['refresh_token'];
  $_SESSION['token_expiry'] = time() + $tokenData['expires_in'];
  
  // Clear PKCE and state data
  unset($_SESSION['code_verifier']);
  unset($_SESSION['oauth_state']);
  
  // Redirect to protected area
  header('Location: /dashboard.php');
  exit;
}

// 3. Exchange code for tokens
function exchangeCodeForTokens($code, $codeVerifier) {
  // Prepare the token request
  $tokenUrl = 'https://auth.transitspotter.com/external/token';
  
  $tokenParams = [
    'grant_type' => 'authorization_code',
    'code' => $code,
    'client_id' => 'your-domain.com',
    'redirect_uri' => 'https://your-domain.com/oauth/callback.php',
    'code_verifier' => $codeVerifier
  ];
  
  // Create cURL request
  $ch = curl_init($tokenUrl);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_POST, true);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($tokenParams));
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Origin: https://your-domain.com'
  ]);
  
  $response = curl_exec($ch);
  $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);
  
  if ($status !== 200) {
    die('Token request failed with status ' . $status . ': ' . $response);
  }
  
  $tokenData = json_decode($response, true);
  
  if (isset($tokenData['error'])) {
    die('Token error: ' . $tokenData['error'] . ' - ' . ($tokenData['error_description'] ?? 'No description'));
  }
  
  return $tokenData;
}

// 4. Refresh tokens when they expire
function refreshTokens() {
  $refreshToken = $_SESSION['refresh_token'] ?? null;
  
  if (!$refreshToken) {
    return false;
  }
  
  $tokenUrl = 'https://auth.transitspotter.com/external/token';
  
  $tokenParams = [
    'grant_type' => 'refresh_token',
    'refresh_token' => $refreshToken,
    'client_id' => 'your-domain.com'
  ];
  
  // Create cURL request
  $ch = curl_init($tokenUrl);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_POST, true);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($tokenParams));
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Origin: https://your-domain.com'
  ]);
  
  $response = curl_exec($ch);
  $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);
  
  if ($status !== 200) {
    return false;
  }
  
  $tokenData = json_decode($response, true);
  
  if (isset($tokenData['error'])) {
    return false;
  }
  
  // Update stored tokens
  $_SESSION['access_token'] = $tokenData['access_token'];
  $_SESSION['refresh_token'] = $tokenData['refresh_token'];
  $_SESSION['token_expiry'] = time() + $tokenData['expires_in'];
  
  return true;
}

// 5. Ensure we have a valid token before API calls
function ensureValidToken() {
  if (!isset($_SESSION['token_expiry']) || time() > $_SESSION['token_expiry'] - 300) {
    // Token is expired or will expire in the next 5 minutes
    if (!refreshTokens()) {
      // Refresh failed, redirect to login
      header('Location: /login.php');
      exit;
    }
  }
  
  return $_SESSION['access_token'];
}

// 6. Make authenticated API request
function makeAuthenticatedRequest($url) {
  $accessToken = ensureValidToken();
  
  $ch = curl_init($url);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $accessToken,
    'Origin: https://your-domain.com'
  ]);
  
  $response = curl_exec($ch);
  $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);
  
  if ($status === 401) {
    // Try refreshing the token once
    if (refreshTokens()) {
      return makeAuthenticatedRequest($url); // Retry with new token
    } else {
      // Refresh failed, redirect to login
      header('Location: /login.php');
      exit;
    }
  }
  
  return json_decode($response, true);
}

// 7. Logout - revoke tokens
function logout() {
  $refreshToken = $_SESSION['refresh_token'] ?? null;
  
  if ($refreshToken) {
    $revokeUrl = 'https://auth.transitspotter.com/external/revoke';
    
    $revokeParams = [
      'token' => $refreshToken,
      'token_type_hint' => 'refresh_token'
    ];
    
    // Create cURL request
    $ch = curl_init($revokeUrl);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($revokeParams));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'Content-Type: application/json',
      'Origin: https://your-domain.com'
    ]);
    
    curl_exec($ch);
    curl_close($ch);
  }
  
  // Clear session data
  unset($_SESSION['access_token']);
  unset($_SESSION['refresh_token']);
  unset($_SESSION['token_expiry']);
  
  // Redirect to login page
  header('Location: /login.php');
  exit;
}

Rate Limiting and Security

The API implements rate limiting to prevent abuse. If you exceed the rate limits, you'll receive a 429 Too Many Requests response with a Retry-After header.

Common rate limits:

Security Considerations

Support

For questions or to request domain approval, please contact the transitspotter team at support@transitspotter.com.