Example file
token-server.mjs
A small Node token server that swaps an API application's client id and secret for a short-lived token for your page.
// A tiny token endpoint for your web pages. 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.
//
// Used by 06-own-token-server.html. The same as the access tokens guide's
// token-server.mjs (https://subcontext.com/developers/examples/oauth/token-server.mjs).
//
// SUBCONTEXT_CLIENT_ID=... SUBCONTEXT_CLIENT_SECRET=... node token-server.mjs
//
// 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.
// PORT (optional): default 8081.
// SUBCONTEXT_BASE_URL (optional): the Subcontext address. Default
// https://subcontext.com.
//
// Needs Node 18 or later. Uses no packages.
//
// On a real website this is a route on your own server, on the same website as
// the page, so the page can call '/subcontext-token' and the cross-origin
// (CORS) headers below aren't needed.
import { createServer } from 'node:http'
const baseUrl = (process.env.SUBCONTEXT_BASE_URL || 'https://subcontext.com').replace(/\/+$/, '')
const clientId = process.env.SUBCONTEXT_CLIENT_ID
const clientSecret = process.env.SUBCONTEXT_CLIENT_SECRET
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'http://localhost:4100'
const port = Number(process.env.PORT || 8081)
// Get a new token when the cached one has less than this left, so a page
// always receives a token with at least a few minutes to run.
const RENEW_EARLY_SECONDS = 300
if (!clientId || !clientSecret) {
console.error('Set SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET (see the comment at the top of this file).')
process.exit(1)
}
let cached = null // { accessToken, expiresAt (milliseconds) }
let pending = null // a token request already on its way, shared by everyone waiting
// One token is shared by every visitor until it nearly runs out. Asking
// Subcontext for a new token per visitor would soon hit the rate limit.
async function currentToken() {
if (!cached || Date.now() > cached.expiresAt - RENEW_EARLY_SECONDS * 1000) {
pending ||= requestToken().finally(() => { pending = null })
cached = await pending
}
return {
access_token: cached.accessToken,
// What is left of it, in seconds, not the full two hours.
expires_in: Math.floor((cached.expiresAt - Date.now()) / 1000)
}
}
async function requestToken() {
const response = await fetch(baseUrl + '/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams({
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'
})
})
const body = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error('token request failed (' + response.status + '): ' + (body.error_description || body.error || 'no details'))
}
return { accessToken: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 }
}
createServer(async (request, response) => {
// Only your website's pages may read the answer.
response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
response.setHeader('Vary', 'Origin')
if (request.method === 'OPTIONS') return response.writeHead(204).end()
const path = new URL(request.url, 'http://localhost').pathname
if (path !== '/subcontext-token' || !['GET', 'POST'].includes(request.method)) {
return response.writeHead(404).end()
}
// A page from another website gets nothing. (This only stops browsers.
// Anyone can still call this endpoint from a script, so a browser token
// must only be able to do what a visitor may do anyway.)
const origin = request.headers.origin
if (origin && origin !== allowedOrigin) {
return response.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'origin not allowed' }))
}
try {
const token = await currentToken()
response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
response.end(JSON.stringify(token))
} catch (error) {
console.error(error.message)
response.writeHead(502, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ error: 'could not get a token' }))
}
}).listen(port, () => console.log('Token server on http://localhost:' + port + '/subcontext-token for pages on ' + allowedOrigin))