Example file

get-token.php

PHP: gets a token and keeps it in a small private file until shortly before it runs out.

get-token.php · PHP Download
<?php
// Gets an access token from Subcontext and keeps it until shortly before it
// runs out. Copy get_subcontext_token() into your own server code.
//
// An access token lets your code use the Subcontext API for two hours.
// You get one by sending your API application's client id
// and client secret to Subcontext (an OAuth "client credentials" request).
// Run this on your server, never in a web page, because it holds the secret.
//
//   SUBCONTEXT_CLIENT_ID=... SUBCONTEXT_CLIENT_SECRET=... php get-token.php
//
// SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET: from the Subcontext
//   dashboard, Integrations, API applications. The client secret is shown only
//   once, when you create the application (or rotate its secret).
// SUBCONTEXT_SCOPE (optional): what the token may do, separated by spaces.
//   Default "read". It can only include scopes ticked on the API application.
// SUBCONTEXT_BASE_URL (optional): the Subcontext address. Default
//   https://subcontext.com.
//
// PHP usually starts afresh for every web request, so the token is kept in a
// small file in the server's temporary folder, readable only by the user PHP
// runs as. Use your framework's cache instead if it has one.
//
// Needs PHP 8.1 or later and no extensions beyond the standard ones.

const RENEW_EARLY_SECONDS = 300; // ask for a new token this long before the old one runs out

function subcontext_base_url(): string
{
    return rtrim(getenv('SUBCONTEXT_BASE_URL') ?: 'https://subcontext.com', '/');
}

// Returns a token that is good for at least a few more minutes.
function get_subcontext_token(string $scope = 'read'): string
{
    $clientId = getenv('SUBCONTEXT_CLIENT_ID');
    $clientSecret = getenv('SUBCONTEXT_CLIENT_SECRET');
    if (!$clientId || !$clientSecret) {
        throw new RuntimeException('Set SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET (see the comment at the top of this file).');
    }

    // One cache file per application and scope.
    $cacheFile = sys_get_temp_dir() . '/subcontext-token-' . hash('sha256', $clientId . ' ' . $scope) . '.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) {
        return $cached['access_token'];
    }

    $baseUrl = subcontext_base_url();
    [$status, $body] = subcontext_http('POST', $baseUrl . '/oauth/token', ['Content-Type: application/x-www-form-urlencoded'], http_build_query([
        'grant_type' => 'client_credentials',
        'client_id' => $clientId,
        'client_secret' => $clientSecret,
        'scope' => $scope,
        // What the token is for (the Subcontext API). Required.
        'resource' => $baseUrl . '/api/v1',
    ]));
    if ($status !== 200) {
        throw new RuntimeException('Token request failed (' . $status . '): ' . ($body['error_description'] ?? $body['error'] ?? 'no details'));
    }

    $token = ['access_token' => $body['access_token'], 'expires_at' => time() + (int) $body['expires_in']];
    // Write the file so only this user can read it, then move it into place.
    $temporary = tempnam(sys_get_temp_dir(), 'sxt');
    chmod($temporary, 0600);
    file_put_contents($temporary, json_encode($token));
    rename($temporary, $cacheFile);
    return $token['access_token'];
}

// Forget the cached token, for example after the API answers 401.
function forget_subcontext_token(string $scope = 'read'): void
{
    @unlink(sys_get_temp_dir() . '/subcontext-token-' . hash('sha256', getenv('SUBCONTEXT_CLIENT_ID') . ' ' . $scope) . '.json');
}

// Sends a request and returns [HTTP status, decoded JSON body, response headers].
function subcontext_http(string $method, string $url, array $headers, ?string $content = null): array
{
    $context = stream_context_create(['http' => [
        'method' => $method,
        'header' => implode("\r\n", array_merge($headers, ['Accept: application/json'])) . "\r\n",
        'content' => $content ?? '',
        'timeout' => 20,
        'ignore_errors' => true, // read the body of error answers too
    ]]);
    $raw = @file_get_contents($url, false, $context);
    if ($raw === false) {
        throw new RuntimeException('Could not reach ' . $url);
    }
    $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;
    return [$status, json_decode($raw, true) ?: [], $responseHeaders];
}

// Run directly, it gets a token twice and shows the second one came from the cache.
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
    $scope = getenv('SUBCONTEXT_SCOPE') ?: 'read';
    try {
        forget_subcontext_token($scope);
        $first = get_subcontext_token($scope);
        echo 'Got a token: ', substr($first, 0, 8), "...\n";
        $second = get_subcontext_token($scope);
        echo $second === $first ? "Asked again: the same token came from the cache, no new request.\n" : "Asked again: got a new token.\n";
    } catch (RuntimeException $error) {
        fwrite(STDERR, $error->getMessage() . "\n");
        exit(1);
    }
}

Stuck, or something missing?

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

Contact us