Token Manager
Needs an access tokenDemonstrates 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
git clone https://github.com/lgriffin/ESI.ts.git && cd ESI.ts
npm ci
npm run example:token-managerexamples/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
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);
});