Skip to content

Token Manager ​

Needs an access token

Demonstrates EsiTokenManager: the SSO login flow, persistent multi-character token storage, proactive refresh, a view for a character over the shared runtime (esi.as(tokens.identity(id))), and bulk refresh with a concurrency cap.

REQUIRES an SSO application — set ESI_SSO_CLIENT_ID (and, for a confidential client, ESI_SSO_CLIENT_SECRET) in your environment. Register one at https://developers.eveonline.com/ with callback http://localhost:8765/callback.

On first run this opens a login URL, waits for the SSO callback on localhost:8765, and stores the resulting token in ./tokens.example.json. Later runs reuse the stored token and refresh it as needed.

Calls: location.get

Run it ​

bash
git clone https://github.com/lgriffin/ESI.ts.git && cd ESI.ts
npm ci
npm run example:token-manager

examples/token-manager.ts on GitHub. The imports below are written as a consumer of the package writes them; the file in the repository imports from ../src.

Source ​

ts
import * as http from 'http';
import {
  createConsoleLogger,
  EsiTokenManager,
  FileTokenStorage,
  generatePkcePair,
  generateState,
  isTokenRevoked,
} from '@lgriffin/esi.ts';
import { createEsi } from '@lgriffin/esi.ts/client';

// The program's own output. The client logs through the same console sink
// at ESI_LOG_LEVEL (default warn), so its diagnostics stay out of the way.
const log = createConsoleLogger('info');
const esiLog = createConsoleLogger();

const esi = createEsi({
  logger: esiLog,
  userAgent: 'esi.ts-examples/1.0 (https://github.com/lgriffin/ESI.ts)',
});

const CALLBACK_PORT = 8765;
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
const SCOPES = ['esi-location.read_location.v1', 'esi-skills.read_skills.v1'];

const tokens = new EsiTokenManager({
  logger: esiLog,
  clientId: process.env.ESI_SSO_CLIENT_ID ?? '',
  clientSecret: process.env.ESI_SSO_CLIENT_SECRET, // undefined => PKCE public client
  callbackUrl: CALLBACK_URL,
  storage: new FileTokenStorage('./tokens.example.json'),
  onRefresh: (t) =>
    log.info(
      `  [refresh] ${t.characterName} now expires ${new Date(t.expiresAt).toISOString()}`,
    ),
  onRevoked: (id) => log.info(`  [revoked] character ${id} must log in again`),
});

/** Run the browser login flow once and store the character. */
async function loginNewCharacter(): Promise<number> {
  const state = generateState();
  const pkce = tokens.getSsoClient().isConfidential()
    ? undefined
    : generatePkcePair();

  const loginUrl = tokens.getAuthorizationUrl({
    scopes: SCOPES,
    state,
    codeChallenge: pkce?.codeChallenge,
  });

  log.info('\nOpen this URL in a browser to log in:\n');
  log.info(`  ${loginUrl}\n`);

  const code = await waitForCallback(state);
  const stored = await tokens.addCharacter(code, {
    codeVerifier: pkce?.codeVerifier,
  });
  log.info(
    `  Stored token for ${stored.characterName} (${stored.characterId})`,
  );
  log.info(`  Scopes: ${stored.scopes.join(', ')}`);
  return stored.characterId;
}

/** Tiny one-shot HTTP server that captures the SSO callback. */
function waitForCallback(expectedState: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const server = http.createServer((req, res) => {
      const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`);
      if (url.pathname !== '/callback') {
        res.writeHead(404).end();
        return;
      }
      const code = url.searchParams.get('code');
      const state = url.searchParams.get('state');
      if (!code || state !== expectedState) {
        res.writeHead(400).end('Missing code or state mismatch');
        server.close();
        reject(new Error('SSO callback missing code or state mismatch'));
        return;
      }
      res
        .writeHead(200, { 'Content-Type': 'text/plain' })
        .end('Logged in. You can close this tab.');
      server.close();
      resolve(code);
    });
    server.listen(CALLBACK_PORT, () =>
      log.info(`  Waiting for callback on ${CALLBACK_URL} ...`),
    );
  });
}

async function main() {
  log.info('Token Manager Demo');
  log.info('='.repeat(50));

  if (!process.env.ESI_SSO_CLIENT_ID) {
    log.info(
      'Set ESI_SSO_CLIENT_ID (and optionally ESI_SSO_CLIENT_SECRET) to run this example.',
    );
    return;
  }

  // --- 1. Find or add a character ---
  const characters = await tokens.listCharacters();
  const active = characters.filter((c) => !c.revoked);
  log.info(`\nStored characters: ${active.length}`);
  for (const c of active) {
    log.info(
      `  ${c.characterName} (${c.characterId}) expires ${new Date(c.expiresAt).toISOString()}`,
    );
  }
  const characterId = active[0]?.characterId ?? (await loginNewCharacter());

  // --- 2. A view for the character over the shared runtime ---
  // Before each request the view asks the manager for the token, which is
  // refreshed first if it is within 60 s of expiry; a 401 refreshes through
  // SSO and the request is retried once (guides/MULTI-CHARACTER.md).
  const view = esi.as(tokens.identity(characterId));
  try {
    const location = await view.character(characterId).location.get();
    log.info(`\nCurrent system: ${location.solar_system_id}`);
  } catch (err) {
    if (isTokenRevoked(err)) {
      log.info('\nToken revoked — remove the character and log in again:');
      log.info(`  await tokens.removeCharacter(${characterId})`);
    } else {
      log.error('Request failed', { error: err });
    }
  }

  // --- 3. Bulk refresh ---
  // Only tokens expiring in the next five minutes are refreshed; the rest
  // are reported as skipped. Each character settles independently.
  log.info('\nBulk refresh (tokens expiring within 5 minutes):');
  const results = await tokens.refreshAll({
    concurrency: 5,
    expiringWithinMs: 5 * 60_000,
  });
  for (const r of results) {
    const detail =
      r.status === 'failed'
        ? ` (${r.error?.message}${r.retryable ? ', retryable' : ''})`
        : '';
    log.info(`  ${r.characterId}: ${r.status}${detail}`);
  }

  log.info('\nDone.');
}

main().catch((err) => {
  log.error('Unexpected error', { error: err });
  process.exit(1);
});

Released under the GPL-3.0-or-later license.