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
| Part | What it is |
|---|---|
| Client ID | The app's id. The console shows it after you register. Your code passes it as clientId. |
| Origin URL | Where your app runs. People see its domain as your app's maker. http://localhost:<port> is accepted for development. |
| Redirect URIs | The 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 key | The half of your key pair the directory keeps. |
| Scopes | What 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 keygenwrites the private key tolocal/app-key.json(mode0600) and prints the public key as JSON.initruns 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,
exchangeCodethrows anAuthenticationError.
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:
- Read
stateandcodefrom the query. Find what you saved for thatstate. If nothing is there, send the person back to the login route. - Call
exchangeCodewith the code, the savedcodeVerifierandnonce, thestatefrom the query asstate, and the value you saved asexpectedState. Never pass the callback'sstatefor both. A missing or differentexpectedStatethrowsCrouterErrorwith codeoauth_state_mismatchbefore the code is sent anywhere. exchangeCodeverifies the ID token (ES256 signature, issuer, audience and nonce) when it has one, and returns aConnection. Save it in yourConnectionStore.
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.
Where your app runs
Your app runs on your server, people sign in at account.northlight.ai, and the agent work runs on each person's own Northlight runtime. Northlight does not host your app's code.
Scopes and grants
A scope is what your app may ask for, a grant is what one person allowed, and a run's scopes narrow it further.