Example file
get-token.mjs
Node: gets a token and keeps it in memory until shortly before it runs out.
// Gets an access token from Subcontext and keeps it in memory until shortly
// before it runs out. Copy getToken() 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=... node get-token.mjs
//
// 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.
//
// Needs Node 18 or later and no packages.
import { pathToFileURL } from 'node:url'
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 scope = process.env.SUBCONTEXT_SCOPE || 'read'
// Ask for a new token this long before the current one runs out, so a token
// never expires in the middle of a request.
const RENEW_EARLY_SECONDS = 300
let cached = null // { accessToken, expiresAt (milliseconds) }
let pending = null // a request already on its way, shared by everyone waiting
// Returns a token that is good for at least a few more minutes.
export async function getToken() {
if (cached && Date.now() < cached.expiresAt - RENEW_EARLY_SECONDS * 1000) return cached.accessToken
if (!pending) {
pending = requestToken().finally(() => { pending = null })
}
return (await pending).accessToken
}
// Forget the cached token, for example after the API answers 401.
export function forgetToken() {
cached = null
}
async function requestToken() {
if (!clientId || !clientSecret) {
throw new Error('Set SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET (see the comment at the top of this file).')
}
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,
scope: scope,
// 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'))
}
cached = { accessToken: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 }
return cached
}
// Run directly, it gets a token twice and shows the second one came from memory.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const first = await getToken()
console.log('Got a token:', first.slice(0, 8) + '...')
console.log('It runs out at', new Date(cached.expiresAt).toISOString())
const second = await getToken()
console.log(second === first ? 'Asked again: the same token came from memory, no new request.' : 'Asked again: got a new token.')
} catch (error) {
console.error(error.message)
process.exit(1)
}
}