Skip to content

Authentication ​

Implements: SEC-02 · SEC-08 · ARCH-07. See CHARTER.md Part 6 and Part 2.

How a client gets an EVE SSO access token, keeps it fresh, and holds tokens for many characters. Construction and configuration are in USAGE.md. The error classes named here are described in ERRORS.md.

Three rules hold for every path on this page:

  • The library attaches a token only to calls whose endpoint declares SSO scopes. A public call never carries your token, even when the client holds one.
  • A token never appears in clear text in a log line, an error message or a cache key (SEC-02). URLs are redacted in every error and every log line, and cache and deduplication keys hash the Authorization header.
  • Local development credentials come from the PKCE script into a git-ignored .env. They never appear in a committed file, fixture or example (SEC-08).

1. One token ​

Three ways to give a client a token, in order of precedence:

SourceHowUse for
Constructor optionnew EsiClient({ accessToken })Apps that manage tokens themselves
Runtime updateclient.setAccessToken(newToken)Replacing a token you refreshed
Environment variableESI_ACCESS_TOKEN, read when the client is constructedScripts, examples, CI
typescript
import 'dotenv/config';
import { EsiClient } from '@lgriffin/esi.ts';

const fromEnv = new EsiClient(); // reads process.env.ESI_ACCESS_TOKEN
const explicit = new EsiClient({ accessToken: token });
explicit.setAccessToken(newToken);

To get a token, register an application at EVE Developers, choose the scopes it needs, and run the OAuth2 flow. For local work, npx ts-node scripts/auth/create-token.ts runs the PKCE flow and writes the result to .env. Copy .env.example first.

esiEndpointScopes (see USAGE.md) tells you which scopes a route needs before you ask the player for them.

2. Refresh on 401 ​

EVE SSO access tokens live for 20 minutes. Give the client a refresh callback and it handles expiry itself:

typescript
const refreshing = new EsiClient({
  accessToken: initialToken,
  onTokenRefresh: async () => {
    const response = await fetch('https://login.eveonline.com/v2/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: myRefreshToken,
        client_id: myClientId,
      }),
    });
    const { access_token } = await response.json();
    return access_token;
  },
});

const location = await refreshing.location.getCharacterLocation(characterId);

client.setTokenProvider(myRefreshFunction) sets or replaces the callback later, and setTokenProvider(undefined) removes it.

Behaviour, each pinned by a scenario in tests/bdd/features/core/:

  • One refresh per call. If the refreshed token also gets a 401, that error is thrown.
  • Coalesced. Concurrent 401s share one call to your callback.
  • Typed failure. A callback that throws anything other than an EsiError surfaces as an EsiTokenRefreshError (code TOKEN_REFRESH_FAILED) with what it threw, such as a TokenRevokedError, on cause. A callback that throws an EsiError, including CircuitOpenError, has that error propagate unchanged (src/core/RetryStrategy.ts).
  • No provider, no refresh. A 401 throws immediately.

3. Many characters: EsiTokenManager ​

The callback above is a hook. EsiTokenManager is the whole lifecycle for applications that hold tokens for one or many characters:

  • the SSO code exchange, with or without PKCE;
  • persistence through a storage adapter;
  • proactive refresh before expiry;
  • coalescing of concurrent refreshes;
  • persisting the rotated refresh token before it is used;
  • revocation tracking and bulk refresh.
typescript
import {
  EsiTokenManager,
  FileTokenStorage,
  generateState,
} from '@lgriffin/esi.ts';
import { createEsi } from '@lgriffin/esi.ts/client';

const esi = createEsi({ userAgent: 'my-app/1.0 (ops@my-app.example)' });
const tokens = new EsiTokenManager({
  clientId: process.env.ESI_SSO_CLIENT_ID!,
  clientSecret: process.env.ESI_SSO_CLIENT_SECRET, // omit for a public (PKCE) client
  callbackUrl: 'https://my-app.example/callback',
  storage: new FileTokenStorage('./tokens.json'), // or MemoryTokenStorage, or your own
});

// 1. Send the player to SSO, and keep loginState with this user's session
const loginState = generateState();
const loginUrl = tokens.getAuthorizationUrl({
  scopes: ['esi-wallet.read_character_wallet.v1'],
  state: loginState,
});

// 2. In your callback handler, reject a callback whose state is not the one
//    saved for this login (it may come from someone else's login), then
//    exchange the code. The character id, name and scopes are decoded from
//    the token.
async function onSsoCallback(requestUrl: string, savedState: string) {
  const params = new URL(requestUrl, 'https://my-app.example').searchParams;
  const code = params.get('code');
  if (!code || params.get('state') !== savedState) {
    throw new Error('SSO callback missing code or state mismatch');
  }
  const stored = await tokens.addCharacter(code);
  log.info(`Added ${stored.characterName} (${stored.characterId})`);

  // 3. A view of the shared runtime as that character, refreshed through the manager
  await esi
    .as(tokens.identity(stored.characterId))
    .character(stored.characterId)
    .wallet.get();

  // Or a fresh access token, or a TokenProvider for a client you build yourself
  const accessToken = await tokens.getToken(stored.characterId);
  const provider = tokens.tokenProviderFor(stored.characterId);
  return { stored, accessToken, provider };
}

Public clients, such as desktop and CLI tools that cannot keep a secret, use PKCE:

typescript
import { generatePkcePair } from '@lgriffin/esi.ts';

const pkce = generatePkcePair();
const url = tokens.getAuthorizationUrl({
  scopes,
  state,
  codeChallenge: pkce.codeChallenge,
});
// ...later, on a callback whose state matched (as above):
await tokens.addCharacter(code, { codeVerifier: pkce.codeVerifier });

listCharacters(), importToken() and removeCharacter(id, { revoke: true }) complete the lifecycle. examples/token-manager.ts (npm run example:token-manager) walks through it against real SSO.

Bulk refresh ​

Services that hold many characters refresh in bulk. A failure for one character never rejects the call: each character gets its own result. The one exception is a storage adapter that cannot list tokens, which rejects with the storage error.

typescript
const results = await tokens.refreshAll({
  concurrency: 5, // simultaneous SSO requests (default 5)
  expiringWithinMs: 5 * 60_000, // only tokens expiring within 5 minutes; omit for all
});

for (const r of results) {
  switch (r.status) {
    case 'refreshed':
      break;
    case 'skipped':
      break; // not stale, or the run was aborted
    case 'revoked':
      log.info(`${r.characterId} must log in again`);
      break;
    case 'failed':
      if (r.retryable) scheduleRetry(r.characterId);
      break;
  }
}

Storage adapters ​

ITokenStorage is four async methods keyed by character id: get, set, delete and list, plus an optional withLock for stores that several processes share.

AdapterUse for
MemoryTokenStorageTests, CLIs that log in on every run, a cache in front of a durable store
FileTokenStorageSingle-process apps. Writes go to a temp file then a rename, mode 0600
FileTokenStorage with lock: trueSeveral processes on one machine sharing one token file (workers, cron)

Implement the interface over Redis, Postgres or a keychain for anything else. set must be durable before it resolves. The manager persists the rotated refresh token before it returns the new access token, and SSO has already invalidated the previous one.

Sharing a store between processes ​

SSO rotates the refresh token on every use, so two processes that refresh the same character at once each get a different rotation, and whichever is saved second leaves the other holding a dead refresh token. A storage adapter that implements withLock(characterId, fn) prevents that. The manager runs every refresh inside the lock and reads the stored token again once it holds it. When another process rotated the token meanwhile, the manager returns that token and makes no SSO call.

typescript
import { EsiTokenManager, FileTokenStorage } from '@lgriffin/esi.ts';

const tokens = new EsiTokenManager({
  clientId: process.env.EVE_CLIENT_ID!,
  clientSecret: process.env.EVE_CLIENT_SECRET!,
  storage: new FileTokenStorage('./tokens.json', { lock: true }),
  ssoTimeoutMs: 10_000, // well below the lock's staleMs (30 s)
});

With lock on, FileTokenStorage takes an advisory lock file next to the token file for every write (tokens.json.lock) and one per character for every refresh (tokens.json.<characterId>.lock), so refreshes of different characters still run in parallel. It also reads the file on every get instead of keeping a copy in memory. The lock options are:

OptionDefaultMeaning
staleMs30 000A lock older than this is taken to belong to a crashed process and is broken. Keep it above one SSO call.
timeoutMs60 000How long a write or refresh waits for the lock, queueing in this process included, before it rejects.
retryMs25Pause between attempts to take the lock.

Each timing must be a positive, finite number, or the constructor throws a RangeError. The lock file only works between processes that see the same file system with working hard links, so a local disk rather than a network share. A holder that keeps the lock longer than staleMs can lose it to a waiter, and a refresh then behaves as it did without the lock. SSO calls have no timeout unless you set one, and an SSO request that hangs holds that character's lock until it ends; if that outlasts staleMs, a waiter breaks the lock and may spend a refresh token the hung request already rotated. Set ssoTimeoutMs on the manager (or timeoutMs on an EveSsoClient you pass as ssoClient) well below staleMs. A request that SSO does not answer in time rejects with an SsoError whose statusCode is 0, errorCode is timeout and isRetryable() is true, and refreshAll reports it as a retryable failure. When SSO sends a status but the body is late, the error keeps that status: a late 2xx is not retryable, because SSO has probably rotated the refresh token already and the old one would be refused. For processes on different machines, implement withLock over the shared store itself, for example Redis SET key value NX PX with a token you check before deleting the key. Adapters without withLock behave exactly as before.

Verifying tokens from a third party ​

A token that reaches your code from anywhere but SSO's token endpoint (a reverse proxy, a browser login that hands the token to a backend) has not been vouched for by TLS. Verify it before trusting its sub, name, scp or owner claims. verifyAccessToken fetches SSO's key set from https://login.eveonline.com/oauth/jwks, checks the RS256 signature with Node's crypto, then checks iss (EVE SSO), aud (your client id and EVE Online) and exp:

typescript
import {
  SsoJwks,
  verifyAccessToken,
  isTokenVerificationError,
} from '@lgriffin/esi.ts';

// One instance per process: it caches the key set for an hour and refetches
// when a token names a key it has not seen (SSO rotated its key).
const jwks = new SsoJwks();

async function characterFromBearer(token: string): Promise<number | null> {
  try {
    const verified = await verifyAccessToken(token, {
      clientId: process.env.ESI_SSO_CLIENT_ID!,
      jwks,
    });
    return verified.characterId;
  } catch (err) {
    if (isTokenVerificationError(err)) return null; // err.reason names the failed check
    throw err;
  }
}

EsiTokenManager does the same for every token it is handed when it is built with verifyTokens: true. addCharacter and importToken then reject a token that fails with TokenVerificationError and store nothing. An expired access token fails too, so a migration that imports stale tokens leaves the flag off for the import. Refreshes are not re-verified: those tokens come straight from SSO.

Guarantees and limits ​

  • One token per character. Re-authorising replaces the stored token. A warning is logged if the new consent drops scopes.
  • Proactive refresh. getToken refreshes a token that is within refreshSkewMs of expiry (default 60 s).
  • Coalescing. Concurrent refreshes for one character share one SSO call. This matters because SSO rotates the refresh token on every use.
  • Revocation. An invalid_grant from SSO marks the character revoked. Later calls throw TokenRevokedError locally without calling SSO.
  • Hooks. onRefresh, onRefreshError and onRevoked are there for logging, metrics or prompting a re-login.
  • No JWT signature verification by default. Tokens are trusted because they arrive straight from SSO over TLS. Do not use decodeAccessToken to authenticate a token a third party hands you; use verifyAccessToken, or verifyTokens: true on the manager (see Verifying tokens from a third party).
  • One process per store, unless the store locks. Two processes sharing a store without withLock would each rotate refresh tokens the other cannot see. Use FileTokenStorage with lock: true, or an adapter of your own that implements withLock; see Sharing a store between processes.

4. What 11.0.0 changes ​

tokens.createClient(id) builds a complete EsiClient for each character. Each of those clients has its own rate limiter and cache, so ESI's per-IP error budget is tracked once per character. 11.0.0 fixes that without removing this API:

  • Phase 2 PR 10b (done) keys the cache and the deduplicator by the character the token names instead of the token, so ETags survive a refresh.
  • Phase 2 PR 11 (done) adds @lgriffin/esi.ts/client: esi.as(identity) is an immutable per-character view over one shared runtime, built from tokens.identity(characterId), a raw token (identityFromToken) or a TokenProvider (identityFromProvider). MULTI-CHARACTER.md is the guide. Deprecated in 11.0.0: tokens.createClient(id) still works, and is @deprecated in favour of esi.as(tokens.identity(id)). Removing it needs a future major release, and none is scheduled; the rest of the custom clients stay (CLIENT-ARCHITECTURE.md).

The design is in ROADMAP.md, Phase 2.

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