NorthlightSDK
Concepts

Apps and sign-in

What registering an app creates, how an app proves who it is with a key pair, and the authorization-code flow with PKCE that OAuth2Client runs.

An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it in the console at https://account.northlight.ai/console/new. Your server proves it is that app by signing its requests with the matching private key.

What registration holds

PartWhat it is
Client IDThe app's id. The console shows it after you register. Your code passes it as clientId.
Origin URLWhere your app runs. People see its domain as your app's maker. http://localhost:<port> is accepted for development.
Redirect URIsThe only places sign-in may return to. A URI must match exactly: localhost and 127.0.0.1 are different, and the port counts. http://localhost, http://127.0.0.1 and http://[::1] are accepted over plain http; every other URI is https.
Public keyThe half of your key pair the directory keeps.
ScopesWhat the app may ask for. See Scopes and grants.

Name, logo and "Why your app needs access" appear on the consent page.

A key pair, not a client secret

An app has no client secret. It signs a short assertion with its private key (private_key_jwt, the only token authentication method the directory supports) each time it exchanges a code or refreshes a token. The directory checks the signature against the public keys it holds.

  • npx @north-light/sdk keygen writes the private key to local/app-key.json (mode 0600) and prints the public key as JSON. init runs it for you. Paste the public key into Public key when you register, or under Add key on the app's Sign-in tab.
  • The private key stays on your server. Never paste it, log it or commit it. The console refuses a pasted private key.
  • An app holds at most 2 active keys, so you can add a new one beside the old one, switch, and retire the old one without downtime.
  • Instead of pasting keys you can give the directory a JWKS URL (Use a JWKS URL instead on the Sign-in tab).
  • Until a key is added, exchangeCode throws an AuthenticationError.

The sign-in flow

OAuth2Client runs the OAuth 2.0 authorization-code flow with PKCE (S256) and an OpenID Connect nonce. Your two routes are short:

if (url.pathname === '/auth/login') {
  const {url: authorizeUrl, codeVerifier, state, nonce} = await oauth.authorizeUrl({
    scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
    state: randomBytes(16).toString('base64url'),
  });
  pending.set(state, {codeVerifier, nonce});
  return void res.writeHead(303, {location: authorizeUrl}).end();
}

authorizeUrl returns the URL to redirect to, plus three values to keep for this sign-in: codeVerifier, state and nonce. The example keeps them in a Map keyed by state. Use your own session store.

if (url.pathname === '/auth/callback') {
  if (authorizationRefusalOf(url)?.code === 'app_in_testing') {
    return page(res, 403, '<h1>This app is still in testing</h1><p>Ask its owner to add you on the Testers tab in the console.</p>');
  }
  const state = url.searchParams.get('state') ?? '';
  const code = url.searchParams.get('code');
  const saved = pending.get(state);
  if (!saved || !code) return page(res, 400, '<p>Start sign-in again at <a href="/auth/login">/auth/login</a>.</p>');
  pending.delete(state);
  const connection = await oauth.exchangeCode({code, codeVerifier: saved.codeVerifier, state, expectedState: state, nonce: saved.nonce});
  await store.save(connection);
  const sid = randomBytes(16).toString('base64url');
  sessions.set(sid, connection.userId);
  return void res.writeHead(303, {location: '/', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`}).end();
}

On the callback:

  1. Read state and code from the query. Find what you saved for that state. If nothing is there, send the person back to the login route.
  2. Call exchangeCode with the code, the saved codeVerifier and nonce, the state from the query as state, and the value you saved as expectedState. Never pass the callback's state for both. A missing or different expectedState throws CrouterError with code oauth_state_mismatch before the code is sent anywhere.
  3. exchangeCode verifies the ID token (ES256 signature, issuer, audience and nonce) when it has one, and returns a Connection. Save it in your ConnectionStore.

Then set your own session for the person. The SDK does not manage your sessions.

Scopes decide what comes back

A sign-in that asks only for openid, optionally with email and profile, creates no connection. Finish it with oauth.exchangeIdentity(...), which returns the verified Identity (sub, and email, name and picture when approved and present). A sign-in that asks for runtime scopes such as crtr:llm finishes with exchangeCode. Each method refuses the other's answer with oauth_exchange_mismatch.

When the directory refuses to sign someone in

If the app is in testing and the person is not a tester, the directory redirects to your callback with error=access_denied and error_description=app_in_testing instead of a code. authorizationRefusalOf(url) reads it. See Testing mode.

Keep sign-in secrets out of HTTP logs

The callback URL carries a one-use code and your state, and the response that follows carries a session cookie. Request loggers such as pino-http log full URLs and response headers by default. Log the method, the path without the query string, an id and the response status, for every route, not only the callback. Redacting only the authorization and cookie request headers does not cover these values.

LoggerModule.forRoot({
  pinoHttp: {
    serializers: {
      req: (req: { method?: string; url?: string; id?: unknown }) => ({
        method: req.method,
        path: req.url?.split('?')[0],
        id: req.id,
      }),
      res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
    },
    redact: ['req.headers.authorization', 'req.headers.cookie'],
  },
});

Test it: send a callback with a recognizable fake code and assert that the code, the state and the cookie never appear in the log output.

Checked and not checked

Checked on production: the discovery document lists S256 and private_key_jwt, and the login route of the quickstart server redirects to https://account.northlight.ai/authorize with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.

On this page