Example file

get-token.mjs

Node: gets a token and keeps it in memory until shortly before it runs out.

get-token.mjs · JavaScript (Node) Download
// 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)
  }
}

Stuck, or something missing?

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

Contact us