Files
netris-nestri/apps/api/test/install-token.test.ts
T
Wanjohi eee3727d0c feat: add a host by approving it in a browser, without pasting a token (#416)
The installer no longer needs a token copied from the dashboard. Run
bare, the host agent shows a link, a code and a QR; approving it in a
browser registers the machine.

**Issuer**
- New device client `installer`. Its access token lasts 15 minutes and
it gets no refresh token: a refresh lifetime of 0 now means none is
stored or returned.
- `clientTTL(clientID)` sets lifetimes per client and wins over what a
sign-in asks for. Desktop is unchanged.
- `/device/authorize` allows 10 requests per address per 10 minutes.
Past that it answers 429 `slow_down` with `Retry-After`.
- The confirm page for the installer reads "Add a computer as a Nestri
host?". The wording is fixed per client id.

**API**
- An `installer` token is accepted only on `POST
/machine/install-token`. Everything else answers 403
`insufficient_permissions`.
- Install tokens: at most 5 per person per hour, and at most 3 unspent
at once (429 `too_many_requests`).
- A team may own at most 10 machines. This is checked at mint and at
redeem under a row lock (429 `quota_exceeded`). A refused redeem leaves
the token unspent. Organisation hosts are not counted.

**install.sh**
- The token is optional. With none, `NESTRI_INSTALL_TOKEN` is left unset
and onboarding signs in.
- **Do not release this file until `DEFAULT_VERSION` points at a host
agent that can sign in.** Older agents need a token.

Tests: packages/core 256, apps/api 193, packages/auth 74, apps/auth 28,
all passing.
2026-10-16 20:49:02 +03:00

221 lines
7.1 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Env } from '@nestri/core/env';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { app } from '../app/index';
import { TEST_FRONTEND_URL } from './setup';
const sql = testDb();
const createdUserIds: string[] = [];
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from machine where owner_user_id in ${sql(createdUserIds)}`;
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
}
});
/**
* An issuer, reached the way the API reaches one in production: through a
* binding that serves its discovery document and its keys. Tokens are signed
* here with the claims the real issuer writes, `aud` being the client id.
*/
const ISSUER = 'https://auth.test';
const keys = await generateKeyPair('ES256');
const jwk = { ...(await exportJWK(keys.publicKey)), kid: 'test', alg: 'ES256', use: 'sig' };
const env = {
AUTH: {
fetch: async (request: Request) => {
const { pathname } = new URL(request.url);
if (pathname === '/.well-known/oauth-authorization-server') {
return Response.json({ issuer: ISSUER, jwks_uri: `${ISSUER}/.well-known/jwks.json` });
}
if (pathname === '/.well-known/jwks.json') return Response.json({ keys: [jwk] });
return new Response('not found', { status: 404 });
}
}
};
beforeEach(() => {
Env.init({ NODE_ENV: 'test', FRONTEND_URL: TEST_FRONTEND_URL, AUTH_ISSUER_URL: ISSUER });
});
/** An access token for `userId`, minted for `clientID` and so carrying it as `aud`. */
async function tokenFor(userId: string, clientID: string): Promise<string> {
return new SignJWT({
mode: 'access',
type: 'user',
properties: { userID: userId, linkedAccountID: '' }
})
.setProtectedHeader({ alg: 'ES256', kid: 'test', typ: 'JWT' })
.setIssuer(ISSUER)
.setAudience(clientID)
.setSubject(`user:${userId}`)
.setExpirationTime('15m')
.sign(keys.privateKey);
}
async function person(label: string) {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
return owner;
}
function call(method: string, path: string, token: string, body?: unknown) {
return app.request(
path,
{
method,
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
},
env
);
}
describe('a sign-in made by the installer', () => {
test('can ask for an install token', async () => {
const owner = await person('aud-ok');
const response = await call(
'POST',
'/machine/install-token',
await tokenFor(owner.userId, 'installer'),
{}
);
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.data.token).toStartWith('nit_');
});
test('can do nothing else', async () => {
const owner = await person('aud-refused');
const token = await tokenFor(owner.userId, 'installer');
const elsewhere: [string, string][] = [
['GET', '/user'],
['GET', '/machine'],
['POST', '/machine/register'],
['POST', '/access-token']
];
for (const [method, path] of elsewhere) {
const response = await call(method, path, token, method === 'POST' ? {} : undefined);
expect(response.status).toBe(403);
expect((await response.json()) as any).toEqual({
type: 'forbidden',
code: 'insufficient_permissions',
message: 'This sign-in can only add a host'
});
}
});
test('every other client is unaffected', async () => {
const owner = await person('aud-desktop');
const token = await tokenFor(owner.userId, 'desktop');
expect((await call('GET', '/user', token)).status).toBe(200);
expect((await call('POST', '/machine/install-token', token, {})).status).toBe(200);
});
});
describe('how many install tokens one person may have', () => {
test('three unspent at once, then a 429 a terminal can print', async () => {
const owner = await person('limit-outstanding');
const token = await tokenFor(owner.userId, 'installer');
for (let i = 0; i < 3; i++) {
expect((await call('POST', '/machine/install-token', token, {})).status).toBe(200);
}
const refused = await call('POST', '/machine/install-token', token, {});
expect(refused.status).toBe(429);
expect((await refused.json()) as any).toEqual({
type: 'rate_limit',
code: 'too_many_requests',
message:
'You have 3 install tokens that have not been used yet. Use one, or wait for them to expire.'
});
});
test('five an hour, even when every one was spent', async () => {
const owner = await person('limit-hourly');
const token = await tokenFor(owner.userId, 'installer');
for (let i = 0; i < 5; i++) {
const issued = await call('POST', '/machine/install-token', token, {});
expect(issued.status).toBe(200);
const { data } = (await issued.json()) as any;
const installed = await app.request('/machine/install', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: data.token, label: `host-${i}` })
});
expect(installed.status).toBe(200);
}
const refused = await call('POST', '/machine/install-token', token, {});
expect(refused.status).toBe(429);
expect((await refused.json()) as any).toEqual({
type: 'rate_limit',
code: 'too_many_requests',
message: 'You have made 5 install tokens in the last hour. Try again later.'
});
});
});
describe('how many machines a team may own', () => {
const full = {
type: 'rate_limit',
code: 'quota_exceeded',
message: 'Your team already has 10 machines. Remove one to add another.'
};
function install(token: string, label: string) {
return app.request('/machine/install', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, label })
});
}
test('a full team is not handed an install token', async () => {
const owner = await person('cap-mint');
for (let i = 0; i < 10; i++) await Fixtures.machine(owner, `box-${i}`);
const refused = await call(
'POST',
'/machine/install-token',
await tokenFor(owner.userId, 'installer'),
{}
);
expect(refused.status).toBe(429);
expect((await refused.json()) as any).toEqual(full);
});
test('a token minted with room left cannot add an eleventh', async () => {
const owner = await person('cap-redeem');
for (let i = 0; i < 9; i++) await Fixtures.machine(owner, `box-${i}`);
const issued = await call(
'POST',
'/machine/install-token',
await tokenFor(owner.userId, 'installer'),
{}
);
const { data } = (await issued.json()) as any;
await Fixtures.machine(owner, 'the-last-place');
const refused = await install(data.token, 'eleventh');
expect(refused.status).toBe(429);
expect((await refused.json()) as any).toEqual(full);
});
test('registering directly is held to the same limit', async () => {
const owner = await person('cap-register');
for (let i = 0; i < 10; i++) await Fixtures.machine(owner, `box-${i}`);
const refused = await call(
'POST',
'/machine/register',
await tokenFor(owner.userId, 'desktop'),
{ label: 'eleventh' }
);
expect(refused.status).toBe(429);
expect((await refused.json()) as any).toEqual(full);
});
});