Example file

token-server.php

PHP: a small token endpoint for your pages, for PHP's built-in web server or your own.

token-server.php · PHP Download
<?php
// A small token endpoint for your web pages, in PHP. It swaps a browser API
// application's client id and secret for a short-lived access token (an OAuth
// "client credentials" request) and gives the page only
// { access_token, expires_in }. The secret never reaches the browser.
//
// Works with quote-form-with-your-token.html and call-the-api-from-the-browser.html.
// To try it with PHP's built-in web server:
//
//   SUBCONTEXT_CLIENT_ID=... SUBCONTEXT_CLIENT_SECRET=... php -S localhost:8081 token-server.php
//
// It answers GET (or POST) http://localhost:8081/subcontext-token.
//
// SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET: from the Subcontext
//   dashboard, Integrations, API applications. Use an application with your
//   website under "Allowed origins" and only the read and write scopes ticked.
//   The client secret is shown only once, when you create the application.
// ALLOWED_ORIGIN (optional): the website whose pages may ask for a token,
//   like https://www.example.com. Default http://localhost:4100 (the examples
//   on your computer). It must also be in the API application's allowed origins.
// SUBCONTEXT_BASE_URL (optional): the Subcontext address. Default
//   https://subcontext.com.
//
// On a real website, put this at /subcontext-token on the same website as the
// page (with your web server or framework's routing), so the page can call
// '/subcontext-token' and the cross-origin (CORS) headers below aren't needed.
// Keep the client id and secret in your server's environment, not in this file.
//
// Needs PHP 8.1 or later and no extensions beyond the standard ones.

const RENEW_EARLY_SECONDS = 300; // get a new token when the cached one has less than this left

$baseUrl = rtrim(getenv('SUBCONTEXT_BASE_URL') ?: 'https://subcontext.com', '/');
$clientId = getenv('SUBCONTEXT_CLIENT_ID');
$clientSecret = getenv('SUBCONTEXT_CLIENT_SECRET');
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: 'http://localhost:4100';

// Only your website's pages may read the answer.
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Vary: Origin');

$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'OPTIONS') {
    http_response_code(204);
    exit;
}
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/subcontext-token' || !in_array($method, ['GET', 'POST'], true)) {
    http_response_code(404);
    exit;
}

// A page from another website gets nothing. This only stops browsers.
// Anyone can still call this endpoint from a script, which is why a browser
// token must only be able to do what a visitor may do anyway.
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
if ($origin !== null && $origin !== $allowedOrigin) {
    send_json(403, ['error' => 'origin not allowed']);
}

if (!$clientId || !$clientSecret) {
    error_log('Set SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET (see the comment at the top of token-server.php).');
    send_json(500, ['error' => 'the token server is not set up']);
}

// One token is shared by every visitor until it nearly runs out, kept in a
// small file only this user can read. Asking Subcontext for a new token per
// visitor would soon hit the rate limit.
$cacheFile = sys_get_temp_dir() . '/subcontext-browser-token-' . hash('sha256', $clientId) . '.json';
$cached = is_readable($cacheFile) ? json_decode((string) file_get_contents($cacheFile), true) : null;

if (!is_array($cached) || time() > $cached['expires_at'] - RENEW_EARLY_SECONDS) {
    $context = stream_context_create(['http' => [
        'method' => 'POST',
        'header' => "Content-Type: application/x-www-form-urlencoded\r\nAccept: application/json\r\n",
        'content' => http_build_query([
            'grant_type' => 'client_credentials',
            'client_id' => $clientId,
            'client_secret' => $clientSecret,
            // What the quote form needs. Never give a browser token the crm scope.
            'scope' => 'read write',
            // What the token is for (the Subcontext API). Required.
            'resource' => $baseUrl . '/api/v1',
        ]),
        'timeout' => 20,
        'ignore_errors' => true, // read the body of error answers too
    ]]);
    $raw = @file_get_contents($baseUrl . '/oauth/token', false, $context);
    $responseHeaders = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : ($http_response_header ?? []);
    $status = preg_match('#^HTTP/\S+ (\d{3})#', $responseHeaders[0] ?? '', $match) ? (int) $match[1] : 0;
    $body = $raw === false ? [] : (json_decode($raw, true) ?: []);
    if ($status !== 200) {
        error_log('token request failed (' . $status . '): ' . ($body['error_description'] ?? $body['error'] ?? 'no details'));
        send_json(502, ['error' => 'could not get a token']);
    }
    $cached = ['access_token' => $body['access_token'], 'expires_at' => time() + (int) $body['expires_in']];
    $temporary = tempnam(sys_get_temp_dir(), 'sxt');
    chmod($temporary, 0600);
    file_put_contents($temporary, json_encode($cached));
    rename($temporary, $cacheFile);
}

header('Cache-Control: no-store');
send_json(200, [
    'access_token' => $cached['access_token'],
    // Seconds left on this token, which may be less than two hours.
    'expires_in' => $cached['expires_at'] - time(),
]);

function send_json(int $status, array $body): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($body);
    exit;
}

Stuck, or something missing?

Get in touch and a person on the team will help.

Contact us