Example file
token-server.php
PHP: a small token endpoint for your pages, for PHP's built-in web server or your own.
<?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;
}