How-to library · Developer docs

How-to library

Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.

Connect a server integration with OAuth and keep its token fresh

Register an OAuth client, sign in once as a backend user made for the integration, and keep the refresh token on the server. Refresh shortly before the four-hour access token expires, or on the 403 that says it has, and store the refresh token from every answer.

Context

Context. A sync job, the server behind a headless front end, a CI task: a program that calls the site with nobody at the keyboard. OAuth is the preferred credential for it. The token sent with each request acts as one backend user, with that user's permissions, and stops working after four hours; what lasts is a refresh token, which is useless without the client's secret. Someone signs in as the integration's user once, and the program looks after itself from then on.

Before you start

A backend user made for the integration, with only the permissions it needs. Access to the admin to register an OAuth client. A redirect URI you control, a page on the integration's own domain for example, and a place on the server for secrets that the integration can also write to, because the refresh token changes.

Steps

  1. In the admin, register a client under Users › OAuth Authentication › Clients. Allow the authorization code and refresh token grants, add the redirect URI, and put the client id and secret in the server's secret store.
  2. Make a PKCE verifier and challenge, open the authorize URL with scope=offline in a private window, sign in as the integration's user, not as yourself, and approve.
  3. The browser lands on the redirect URI with ?code=…&state=…. The page does not need to serve anything for this: copy the code from the address bar. It is good for five minutes and one use.
  4. Exchange the code at /oauth/token with client_id and client_secret in the body, not in a Basic header, and the verifier. Store the refresh token, the access token and the time it expires.
  5. Before each call, refresh if the access token expires within a few minutes. If a call still answers 403 Token doesn't exist or is not valid, refresh once and send it again; the request was refused before anything ran, so repeating a write is safe.
  6. Store the refresh token from every refresh answer. On the fiftieth exchange it is a new one, and the old one stops working.
  7. Refresh in one place only. If several workers share the credential, one of them refreshes, under a lock, and the others read the stored token.
  8. When a refresh answers 400 invalid_grant, the refresh token has expired or been used up: alert a person to repeat steps 2 to 4. Retrying will not help.

Code

Shell Sign in once as the integration's user
# once: a PKCE pair and the URL to open in a private window
VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n')
echo "https://www.example.com/oauth/authorize?response_type=code&client_id=$COREDNA_CLIENT_ID&redirect_uri=https%3A%2F%2Fsync.example.org%2Foauth%2Fcallback&scope=offline&state=$(openssl rand -hex 16)&code_challenge=$CHALLENGE&code_challenge_method=S256"

# then, within five minutes of approving: exchange the code
curl https://www.example.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=https://sync.example.org/oauth/callback \
  -d client_id="$COREDNA_CLIENT_ID" \
  -d client_secret="$COREDNA_CLIENT_SECRET" \
  -d code_verifier="$VERIFIER"
JSON The token file the module below reads and writes (expires_at 0 forces a refresh)
{
  "access_token": "CDNA-A-…",
  "expires_at": 0,
  "refresh_token": "…"
}
JavaScript coredna.js
// coredna.js: the one module that holds the integration's credentials (Node 18 or later)
import { readFile, writeFile, rename } from 'node:fs/promises';

const SITE = process.env.COREDNA_URL;                 // https://www.example.com
const CLIENT_ID = process.env.COREDNA_CLIENT_ID;
const CLIENT_SECRET = process.env.COREDNA_CLIENT_SECRET;
const STORE = process.env.COREDNA_TOKEN_FILE;         // readable by this service only

let tokens = null;
let refreshing = null;                                // one refresh at a time in this process

async function load() {
  tokens ??= JSON.parse(await readFile(STORE, 'utf8'));
  return tokens;
}

async function save(next) {
  await writeFile(STORE + '.tmp', JSON.stringify(next), { mode: 0o600 });
  await rename(STORE + '.tmp', STORE);                // never a half-written file
  tokens = next;
}

async function refresh() {
  const { refresh_token } = await load();
  const response = await fetch(SITE + '/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,                   // in the body: a Basic header is refused
    }),
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error('OAuth refresh failed (' + (data.error ?? response.status) + '): sign in as the integration user again');
  }
  await save({
    access_token: data.access_token,
    expires_at: Date.now() + data.expires_in * 1000,
    refresh_token: data.refresh_token ?? refresh_token, // a new one on the fiftieth exchange
  });
}

async function accessToken(force = false) {
  const t = await load();
  if (force || t.expires_at - Date.now() < 5 * 60 * 1000) {
    refreshing ??= refresh().finally(() => { refreshing = null; });
    await refreshing;
  }
  return tokens.access_token;
}

export async function coredna(method, path, { query, body } = {}, retried = false) {
  const url = new URL(SITE + '/cdna-api/' + path);
  for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v);
  const response = await fetch(url, {
    method,
    headers: {
      Authorization: 'Bearer ' + await accessToken(retried),
      Accept: 'application/json',
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await response.json().catch(() => ({}));
  if (response.status === 403 && !retried && /Token doesn't exist or is not valid/.test(data.message ?? '')) {
    return coredna(method, path, { query, body }, true);   // refresh once and send it again
  }
  if (!response.ok) throw new Error(method + ' ' + path + ': ' + response.status + ' ' + (data.message ?? ''));
  return data;
}

What the failures look like:

ResponseMeaning
403, Token doesn't exist or is not valid, on an API callThe access token has expired: refresh and repeat the call
400 invalid_grant from /oauth/token on a refreshThe refresh token is past its sixty days, used up, or was replaced on its fiftieth exchange and the new one was not stored
400 invalid_grant on the code exchangeThe code is more than five minutes old or was used already; a second use also revokes the tokens the first one issued
400 invalid_clientThe client id or secret is wrong
403 Authentication failed from /oauth/tokenThe client's id and secret were sent as a Basic header, which the site reads as a user's username and password
403 on one endpoint onlyThe integration's user lacks permission for that module

Why this and not that

Not a person's account: when they leave, the integration breaks, and until then it can do everything they can. Not an API key for a new integration: keys are the older option and still work, but a key never expires, so a leaked key works until someone notices, while a leaked access token is dead within four hours and a refresh token cannot be used without the client secret. Not keeping only the first refresh token: it is replaced on its fiftieth exchange, which an integration that refreshes every four hours reaches in about eight days. Not refreshing before every call: refresh near expiry or on the 403, a handful of times a day. And not one client and user for development and production: give each environment its own, so a test run can never write to production.

Go deeper

Was this page helpful?

Your answer goes to the docs team.