NorthlightSDK
Concepts

Connections

A connection is the stored token set for one person. How ConnectionStore keeps it, how it refreshes, and what to do when the directory refuses a refresh.

A connection is what your server stores for one person after they sign in: their user id, the URL of their runtime, a refresh token, and a short-lived access token. oauth.client(userId) loads it and gives you a client on that person's runtime. You keep connections in a ConnectionStore.

const connection = await oauth.exchangeCode({code, codeVerifier, state, expectedState, nonce});
await store.save(connection);

// Later, in any process that shares the store:
const runtime = await oauth.client(connection.userId);

Access tokens last 10 minutes. Refresh tokens rotate on every use and expire after 90 days without one. oauth.client(userId) refreshes the access token when it needs to. When nothing is stored for the id it throws CrouterError with code not_connected: send the person through sign-in again.

The store

ConnectionStore has load(userId), save(connection), lock(userId, fn) and remove(userId, refreshToken), and may have listStale(before).

  • MemoryConnectionStore keeps connections in one process. Use it for development and tests. Every sign-in is lost when the process restarts, and its lock does not reach other processes.
  • PostgresConnectionStore from @north-light/sdk/stores/postgres is the production store. Install pg in your app. It locks across every process that shares the database.
import {Pool} from 'pg';
import {PostgresConnectionStore} from '@north-light/sdk/stores/postgres';

const store = new PostgresConnectionStore(new Pool({connectionString: process.env.DATABASE_URL}), {table: 'connection'});

Create the table first:

CREATE TABLE connection (
  user_id text PRIMARY KEY,
  runtime_url text NOT NULL,
  refresh_token text NOT NULL,
  grant_id text NOT NULL,
  access_token text NOT NULL,
  access_token_expires_at timestamptz NOT NULL,
  id_token text,
  updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);

The table holds credentials. Restrict database access and encrypt backups. All workers must use the same database. The store holds an advisory lock for the whole refresh, so size the pool for concurrent refreshes and do not set a transaction timeout shorter than a token request.

Why the lock matters

Two processes refreshing one person at once would spend the same refresh token twice, and the directory ends the grant's refresh tokens when it sees that (refresh_token_reused). lock makes the SDK reload the connection, refresh only if no other process already did, and save the result while it holds the lock. If you write your own store, lock must hold across every process that shares it and commit what fn wrote when fn resolves.

When a refresh is refused

When the directory refuses to refresh a stored connection, the SDK runs your onRefusal(refusal) once the lock is released, then removes the stored connection, then rejects the call that needed the refresh with an AuthenticationError (status 401, userAction: 'reconnect'). Handle every dead connection in that one place:

const oauth = new OAuth2Client({
  clientId, privateKey, redirectUri, issuer, store,
  onRefusal: async ({kind, userId}) => {
    if (kind === 'removed') await forgetUser(userId);      // the runtime already deleted the app's runs
    else if (kind === 'paused') await markPaused(userId);  // keep everything
    else await askToSignIn(userId);                        // keep everything
  },
});

// In a request handler, answer the one request:
try { await (await oauth.client(userId)).runs.start({prompt}); }
catch (error) { if (refusalOf(error)) return showSignInAgain(); throw error; }

A Refusal is {kind, code, userId, grantId, error}. Delete what you keep for a person only on grant_removed (kind: 'removed'). On every other code, keep it.

codekindWhat happenedWhat your app does
grant_removedremovedThe person removed your app or deleted their account. The runtime has deleted your app's space, runs, schedules and shares.You may delete what you keep for that person. Do not call runs.delete; the runs are gone. If they connect again, they see the consent page and start fresh.
grant_suspendedpausedYour app's access for that person is paused. Nothing was deleted.Keep everything, stop calling for them, show the app as paused. If they connect again, the same grant comes back.
app_in_testingpausedYour app is in testing and this person is not a tester (they were removed, or their email or phone stopped matching). The grant is revoked, not removed.Keep everything, stop calling for them, and tell them the developer must add them. If you add them back and they sign in again, the same space returns.
refresh_token_expiredreconnectThe refresh token went 90 days unused. The grant is still active.Keep everything. Run sign-in again; the consent page is skipped. Prevent it with refreshIdleConnections.
refresh_token_reusedreconnectTwo processes raced on one refresh token.Keep everything. Fix the store's lock, then reconnect.
grant_revokedreconnectThe directory refused without naming a reason.Keep everything and reconnect. Never delete data on this code.

onRefusal runs at least once per dead connection, so make it idempotent. If it throws, the connection stays stored, the caller still receives the directory's refusal, and the next use calls the handler again. It must not call the runtime for refusal.userId: oauth.client(userId) for that user inside the handler throws CrouterError code refusal_handler_reentry.

refusalOf(error) returns a Refusal only for an error raised while refreshing a stored connection. A 429, cap_exhausted, scope_missing, or an invalid_client refusal of your app's key is not a refused connection, and refusalOf returns null for it.

Keep offline people connected

A refresh token expires after 90 days without use. Run a worker, for example daily, that calls:

await oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000});

It refreshes every connection last saved before the cutoff, through the store's lock, and returns {refreshed, refused, failures}. A refused connection goes through onRefusal and is removed. failures holds connections that could not be refreshed and are still stored; the next run retries them. The SDK starts no timer. The store needs listStale(before), which both MemoryConnectionStore and PostgresConnectionStore have.

Signing in a visitor

A person who only needs to be recognized, not connected, signs in with identity only: request openid, optionally with email and profile, and no runtime scope. Finish with exchangeIdentity, not exchangeCode. An Identity is not a connection: no runtime URL, grant or refresh token, nothing goes in the store, and you keep your own session. To act for that person later, send them through authorizeUrl again with the runtime scopes you need.

Checked and not checked

From the SDK source and tests. The refresh and refusal paths against account.northlight.ai are not yet checked on production.

On this page