diff --git a/apps/api/app/middleware/auth.ts b/apps/api/app/middleware/auth.ts index 21e0244a..98246f75 100644 --- a/apps/api/app/middleware/auth.ts +++ b/apps/api/app/middleware/auth.ts @@ -84,6 +84,16 @@ function getClient(env: Record) { }); } +/** + * A token minted for the host installer is good for one call: trading it for + * a single-use install token. The installer signs a person in on a machine + * that may not be theirs, so the session it holds must not be able to do + * anything else with the account — and checking it here, once, means a route + * added later cannot forget to. + */ +const INSTALLER_AUDIENCE = 'installer'; +const INSTALLER_ROUTE = { method: 'POST', path: '/machine/install-token' }; + export const auth: MiddlewareHandler = async (c, next) => { // A registered nessh host proves it is itself, rather than asserting an id // nobody checks. Wrong credentials fall through to public rather than @@ -188,6 +198,17 @@ export const auth: MiddlewareHandler = async (c, next) => { return Actor.with({ type: 'public', properties: {} }, next); } + if ( + verified.aud === INSTALLER_AUDIENCE && + !(c.req.method === INSTALLER_ROUTE.method && c.req.path === INSTALLER_ROUTE.path) + ) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'This sign-in can only add a host' + ); + } + const { subject } = verified; if (subject.type === 'user') { const teamID = c.req.header('x-nestri-team'); diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 290268ba..07c047a6 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -55,7 +55,8 @@ export namespace MachineApi { description: 'The box is registered' }, 401: ErrorResponses[401], - 403: ErrorResponses[403] + 403: ErrorResponses[403], + 429: ErrorResponses[429] } }), validator( @@ -176,7 +177,7 @@ export namespace MachineApi { tags: ['Machine'], summary: 'Issue an install token', description: - 'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.', + 'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history. One person may make five an hour and hold three unspent at once, and a team may own at most ten hosts; fleet hosts are not counted.', responses: { 200: { content: { @@ -194,7 +195,8 @@ export namespace MachineApi { description: 'A token for one host' }, 401: ErrorResponses[401], - 403: ErrorResponses[403] + 403: ErrorResponses[403], + 429: ErrorResponses[429] } }), validator( @@ -291,7 +293,8 @@ export namespace MachineApi { }, description: 'The host is registered' }, - 401: ErrorResponses[401] + 401: ErrorResponses[401], + 429: ErrorResponses[429] } }), validator( diff --git a/apps/api/test/install-token.test.ts b/apps/api/test/install-token.test.ts new file mode 100644 index 00000000..a252b31e --- /dev/null +++ b/apps/api/test/install-token.test.ts @@ -0,0 +1,220 @@ +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 { + 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); + }); +}); diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts index 491e9f63..fb6ffad7 100644 --- a/apps/auth/script/preview.ts +++ b/apps/auth/script/preview.ts @@ -23,6 +23,7 @@ import { MARK_CODE, MARK_GITHUB, MARK_GOOGLE } from '@nestri/auth/ui/mark'; import { HtmlRenderer } from '@nestri/auth/ui/render'; import type { Screen } from '@nestri/auth/ui/screen'; +import { deviceConfirm } from '../src/index.js'; import { THEME_NESTRI } from '../src/theme.js'; const PORT = Number(process.env.PORT ?? 1338); @@ -120,6 +121,17 @@ const STATES: State[] = [ csrf: 'x' }) }, + { + id: 'device-confirm-installer', + group: 'Device', + label: 'Add a host?', + screen: () => + IssuerScreens.deviceConfirm({ + userCode: 'BCDF3467', + csrf: 'x', + wording: deviceConfirm('installer') + }) + }, { id: 'device-approved', group: 'Device', diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 3772db92..837f530a 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -45,7 +45,44 @@ type Env = { * ours: the identifier ends up on the issued token, and without this anything * on the internet could mint a grant naming anything at all. */ -const DEVICE_CLIENTS = new Set(['desktop']); +const DEVICE_CLIENTS = new Set(['desktop', 'installer']); + +export const allowDeviceClient = async (clientID: string) => DEVICE_CLIENTS.has(clientID); + +/** + * What the person approving a device client is asked, where it is not the + * generic "is this you?". + * + * Approving the installer adds a computer to the person's account as a host, + * which is a bigger thing to agree to than signing an app in, so the page says + * that in as many words. Fixed per client id: nothing the request carries is + * shown. + */ +export const deviceConfirm = (clientID: string) => + clientID === 'installer' + ? { + heading: 'Add a computer as a Nestri host?', + body: [ + 'A computer is asking to become a host on your account. The code above should match the one in its terminal.' + ], + footer: + 'If it does not, or you did not run the installer yourself, choose Deny. Approving lets that computer add itself as one host and nothing else.' + } + : undefined; + +/** + * How long a session for each device client lasts, where it is not the + * default. + * + * The installer signs in for one step — trading the session for a single host + * token — and runs as root on a machine that may not be the person's own, so + * it gets a quarter of an hour and no refresh token: nothing it could leave + * behind outlives the install. + * + * Exported, like the list above, so both can be tested against a real grant. + */ +export const clientTTL = (clientID: string) => + clientID === 'installer' ? { access: 15 * 60, refresh: 0 } : undefined; /** * The zone every user-owned host is reached under, and the one path on it that @@ -192,7 +229,9 @@ export default { codeStore: PostgresCodeStore(), refreshStore: PostgresRefreshStore(), deviceStore: PostgresDeviceStore(), - allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID), + allowDeviceClient, + clientTTL, + deviceConfirm, // The default rule allows a redirect back to whatever hostname the // request arrived on, which is right for a site served beside this // one and refuses the proxy in front of user-owned hosts — it diff --git a/apps/auth/test/device.test.ts b/apps/auth/test/device.test.ts new file mode 100644 index 00000000..e3122bb3 --- /dev/null +++ b/apps/auth/test/device.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; + +import { issuer } from '@nestri/auth/index'; +import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { subjects } from '@nestri/core/auth/subjects'; + +import { allowDeviceClient, clientTTL, deviceConfirm } from '../src/index.js'; + +/** + * The issuer the worker builds, with the database taken out and the real rules + * about device clients left in. + * + * The provider is a stub that signs straight in, because what is under test is + * what happens around the sign-in: which programs may start a grant, how long + * the session each is handed lasts, and what the person is asked. + */ +const auth = issuer({ + subjects, + storage: MemoryStorage(), + allow: async () => true, + allowDeviceClient, + clientTTL, + deviceConfirm, + providers: { + stub: { + type: 'stub', + init(route, ctx) { + route.get('/authorize', (c) => ctx.success(c, {})); + } + } + }, + async success(context) { + return context.subject('user', { userID: 'usr_test123', linkedAccountID: '' }); + } +}); + +const ORIGIN = 'https://auth.internal'; + +/** The claims, unverified: the issuer that signed them is right here. */ +function claims(token: string) { + return JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()) as { aud: string }; +} + +function jar() { + const cookies = new Map(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} + +function form(body: Record) { + return { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body) + }; +} + +async function begin(clientID: string) { + return auth.request(`${ORIGIN}/device/authorize`, form({ client_id: clientID })); +} + +/** Start a grant and sign in, stopping at the question the person is asked. */ +async function reachConfirmation(clientID: string) { + const started = (await (await begin(clientID)).json()) as any; + const cookies = jar(); + const entered = await auth.request(`${ORIGIN}/device?user_code=${started.user_code}`); + cookies.absorb(entered); + const asked = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), { + headers: { cookie: cookies.header() } + }); + cookies.absorb(asked); + return { started, cookies, html: await asked.text() }; +} + +/** The whole grant, approved, ending in what the program is handed. */ +async function approved(clientID: string) { + const { started, cookies, html } = await reachConfirmation(clientID); + const csrf = /name="csrf" value="([^"]+)"/.exec(html)![1]!; + await auth.request(`${ORIGIN}/device/confirm`, { + ...form({ csrf, action: 'approve' }), + headers: { + cookie: cookies.header(), + 'content-type': 'application/x-www-form-urlencoded' + } + }); + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + const response = await auth.request( + `${ORIGIN}/token`, + form({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: started.device_code, + client_id: clientID + }) + ); + expect(response.status).toBe(200); + return (await response.json()) as any; +} + +beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z'))); +afterEach(() => setSystemTime()); + +describe('device clients', () => { + test('only the programs this issuer knows may start a grant', async () => { + expect((await begin('desktop')).status).toBe(200); + expect((await begin('installer')).status).toBe(200); + expect((await begin('something-else')).status).toBe(400); + }); + + test('the installer gets a quarter of an hour and no refresh token', async () => { + const tokens = await approved('installer'); + expect(tokens.expires_in).toBe(15 * 60); + expect(claims(tokens.access_token).aud).toBe('installer'); + expect(tokens).not.toHaveProperty('refresh_token'); + }); + + test('approving the installer is asked as adding a host, with the code to compare', async () => { + const { started, html } = await reachConfirmation('installer'); + expect(html).toContain('Add a computer as a Nestri host?'); + expect(html).toContain('the one in its terminal'); + expect(html).toContain(started.user_code.slice(0, 4)); + expect(html).toContain(started.user_code.slice(4)); + expect(html).toContain('Approve'); + expect(html).toContain('Deny'); + }); + + test('the desktop app is asked the generic question', async () => { + const { html } = await reachConfirmation('desktop'); + expect(html).toContain('Is this you?'); + expect(html).not.toContain('Nestri host'); + }); + + test('the desktop app keeps a session it can refresh', async () => { + const tokens = await approved('desktop'); + expect(tokens.expires_in).toBe(60 * 60 * 24 * 30); + expect(claims(tokens.access_token).aud).toBe('desktop'); + expect(tokens.refresh_token).toMatch(/.+/); + }); +}); diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index d0c6d4b6..553031d1 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -231,7 +231,7 @@ import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; import { HtmlRenderer, type Renderer } from './ui/render.js'; -import { IssuerScreens } from './ui/issuer.js'; +import { type DeviceConfirmWording, IssuerScreens } from './ui/issuer.js'; import type { ChooseOption, Screen } from './ui/screen.js'; import type { Theme } from './ui/theme.js'; import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; @@ -394,6 +394,19 @@ export interface IssuerInput< */ deviceInterval?: number; }; + /** + * Token lifetimes for one client, in place of {@link ttl}. + * + * For a client whose tokens should be narrower than everybody else's — one + * that needs a session for a single step and has no business holding one + * afterwards. A `refresh` of `0` means no refresh token is issued at all, + * so the session ends when the access token does. + * + * What this returns wins over a lifetime `success` asks for: how long a + * client may hold a session is a property of the client, and a flow that + * signs somebody in has no reason to know which client is asking. + */ + clientTTL?(clientID: string): { access?: number; refresh?: number } | undefined; /** * Where device authorization grants are kept. * @@ -448,7 +461,17 @@ export interface IssuerInput< /** Length of the window, in seconds. @default 600 */ guessWindow?: number; /** - * Which caller a guess is charged to. + * Device codes one caller may ask for per window. + * + * Asking costs nothing and takes no secret, so without a limit one + * caller can fill the store with grants nobody will ever answer, and + * hold the user code space open for guessing. @default 10 + */ + requestLimit?: number; + /** Length of that window, in seconds. @default 600 */ + requestWindow?: number; + /** + * Which caller a guess, or a request for a code, is charged to. * * Defaults to the usual forwarded-address headers. Returning undefined * puts the request in one shared bucket, which is the right answer for @@ -469,6 +492,15 @@ export interface IssuerInput< * issuer that has not thought about it, and is worth thinking about. */ allowDeviceClient?(clientID: string, req: Request): Promise; + /** + * What the confirmation page says to a person approving `clientID`, where + * the generic wording would undersell what they are agreeing to. + * + * Looked up by the client id and never built from it, so a request cannot + * put words of its own in front of the person deciding on it. Undefined + * keeps the generic page. + */ + deviceConfirm?(clientID: string): DeviceConfirmWording | undefined; /** * Which providers appear on the screen offering a choice of them, and in * what order. @@ -590,6 +622,8 @@ export function issuer< const deviceStore = input.deviceStore ?? MemoryDeviceStore(); const deviceGuessLimit = input.deviceVerification?.guessLimit ?? 10; const deviceGuessWindow = input.deviceVerification?.guessWindow ?? 600; + const deviceRequestLimit = input.deviceVerification?.requestLimit ?? 10; + const deviceRequestWindow = input.deviceVerification?.requestWindow ?? 600; const deviceAddress = input.deviceVerification?.address ?? ((req: Request) => @@ -599,6 +633,15 @@ export function issuer< undefined); const renderer = input.renderer ?? HtmlRenderer({ theme: input.theme }); + /** What a token for `clientID` lives for. See {@link IssuerInput.clientTTL}. */ + function lifetime(clientID: string, requested?: { access?: number; refresh?: number }) { + const rule = input.clientTTL?.(clientID); + return { + access: rule?.access ?? requested?.access ?? ttlAccess, + refresh: rule?.refresh ?? requested?.refresh ?? ttlRefresh + }; + } + /** * The screen offering a choice of providers. * @@ -716,10 +759,7 @@ export function issuer< subject, type: type as string, properties, - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(grant.clientID, subjectOpts?.ttl) } }; await auth.set(ctx, 'device_confirm', ttlDevice, confirmation); @@ -733,14 +773,11 @@ export function issuer< type: type as string, properties, clientID: authorization.client_id, - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(authorization.client_id, subjectOpts?.ttl) }); location.hash = new URLSearchParams({ access_token: tokens.access, - refresh_token: tokens.refresh, + ...(tokens.refresh ? { refresh_token: tokens.refresh } : {}), state: authorization.state || '' }).toString(); await auth.unset(ctx, 'authorization'); @@ -757,10 +794,7 @@ export function issuer< redirectURI: authorization.redirect_uri, clientID: authorization.client_id, pkce: authorization.pkce, - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(authorization.client_id, subjectOpts?.ttl) }, 60 ); @@ -781,10 +815,7 @@ export function issuer< type: type as string, properties, clientID: 'ssh', - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime('ssh', subjectOpts?.ttl) }); return ctx.json({ accessToken: tokens.access, @@ -879,16 +910,25 @@ export function issuer< * makes the limit worth having rather than a way to lock one person out. */ async function chargeGuess(req: Request): Promise { + const next = await charge('oauth:device:guess', req, deviceGuessWindow); + return next.count <= deviceGuessLimit; + } + + /** + * One more event against this caller's counter under `name`, in a window + * of `window` seconds that starts with the first of them. + */ + async function charge(name: string, req: Request, window: number) { const who = deviceAddress(req) ?? 'unknown'; - const key = ['oauth:device:guess', who]; + const key = [name, who]; const now = Date.now(); const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, key); const next = bucket && bucket.resetAt > now ? { count: bucket.count + 1, resetAt: bucket.resetAt } - : { count: 1, resetAt: now + deviceGuessWindow * 1000 }; + : { count: 1, resetAt: now + window * 1000 }; await Storage.set(storage!, key, next, Math.max(1, Math.ceil((next.resetAt - now) / 1000))); - return next.count <= deviceGuessLimit; + return next; } async function guessesLeft(req: Request): Promise { @@ -934,7 +974,10 @@ export function issuer< } function deviceConfirmScreen(confirmation: DeviceConfirmation): Screen { - return IssuerScreens.deviceConfirm(confirmation); + return IssuerScreens.deviceConfirm({ + ...confirmation, + wording: input.deviceConfirm?.(confirmation.clientID) + }); } async function getAuthorization(ctx: Context) { @@ -977,8 +1020,12 @@ export function issuer< generateRefreshToken?: boolean; } ) { + // A client allowed no refresh token gets none, rather than one that + // expires at once: a credential that is never written down cannot be + // replayed, and its absence tells the client what to expect. + const refreshable = value.ttl.refresh > 0; const refreshToken = value.nextToken ?? crypto.randomUUID(); - if (opts?.generateRefreshToken ?? true) { + if (refreshable && (opts?.generateRefreshToken ?? true)) { /** * Generate and store the next refresh token after the one we are currently returning. * Reserving these in advance avoids concurrency issues with multiple refreshes. @@ -1017,7 +1064,7 @@ export function issuer< ) .sign(await signingKey().then((item) => item.private)), expiresIn: Math.floor(accessTimeUsed + value.ttl.access - Date.now() / 1000), - refresh: [value.subject, refreshToken].join(':') + refresh: refreshable ? [value.subject, refreshToken].join(':') : undefined }; } @@ -1398,10 +1445,7 @@ export function issuer< subject: opts?.subject || (await resolveSubject(type, properties)), properties, clientID: clientID.toString(), - ttl: { - access: opts?.ttl?.access ?? ttlAccess, - refresh: opts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(clientID.toString(), opts?.ttl) }); return c.json({ access_token: tokens.access, @@ -1440,6 +1484,25 @@ export function issuer< if (input.allowDeviceClient && !(await input.allowDeviceClient(clientID, c.req.raw))) return c.json({ error: 'invalid_client', error_description: 'Unknown client_id' }, 400); + // Charged per address, like a wrong user code. The answer is + // `slow_down` because it is the one code in this grant's vocabulary + // that means "too often, wait", and a device client already knows + // what to do with it; 429 and `Retry-After` say the same thing to + // anything that only reads HTTP. + const requests = await charge('oauth:device:request', c.req.raw, deviceRequestWindow); + if (requests.count > deviceRequestLimit) { + const wait = Math.max(1, Math.ceil((requests.resetAt - Date.now()) / 1000)); + const minutes = Math.ceil(wait / 60); + c.header('Retry-After', String(wait)); + return c.json( + { + error: 'slow_down', + error_description: `Too many sign-in requests from this network. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.` + }, + 429 + ); + } + // Not `randomUUID`: a device code is the credential the tokens are // handed to, so it gets the same treatment as one — full-width // randomness, and only its hash is written down. diff --git a/packages/auth/src/ui/issuer.ts b/packages/auth/src/ui/issuer.ts index 5cbe7e09..3d384df3 100644 --- a/packages/auth/src/ui/issuer.ts +++ b/packages/auth/src/ui/issuer.ts @@ -14,10 +14,23 @@ import type { Screen } from './screen.js'; +/** + * What the confirmation says, for a client that warrants its own words. + * + * Chosen by the issuer from the client id, never taken from the request: the + * id is a claim, and the words a person decides on must not be the claimant's. + */ +export interface DeviceConfirmWording { + heading: string; + body: string[]; + footer?: string; +} + /** The confirmation a device grant asks for, before it is approved. */ export interface DeviceConfirmCopy { userCode: string; csrf: string; + wording?: DeviceConfirmWording; } export const IssuerScreens = { @@ -93,12 +106,12 @@ export const IssuerScreens = { deviceConfirm(confirmation: DeviceConfirmCopy): Screen { return { kind: 'confirm', - heading: 'Is this you?', + heading: confirmation.wording?.heading ?? 'Is this you?', verify: { code: confirmation.userCode, group: 4 }, // Not the client id. It is chosen by whoever started the grant, so // naming it would let a stranger's request introduce itself as // anything it likes — and to the person reading, it is a device. - body: [ + body: confirmation.wording?.body ?? [ 'A device is asking to sign in to your account. The code above should match the one it is showing you.' ], action: '/device/confirm', @@ -106,6 +119,7 @@ export const IssuerScreens = { approve: { label: 'Approve', name: 'action', value: 'approve' }, deny: { label: 'Deny', name: 'action', value: 'deny' }, footer: + confirmation.wording?.footer ?? 'If it does not, or you did not start this on a device of your own, choose Deny. Nobody can sign in as you unless you approve here.' }; }, diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts index 2fc1f542..3f2598b7 100644 --- a/packages/auth/test/device.test.ts +++ b/packages/auth/test/device.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; +import { decodeJwt } from 'jose'; import { object, string } from 'valibot'; import { hashDeviceCode, MemoryDeviceStore } from '../src/device.js'; @@ -21,7 +22,15 @@ const auth = issuer({ subjects, allow: async () => true, allowDeviceClient: async (clientID) => clientID !== 'banned', - deviceVerification: { guessLimit: 3, guessWindow: 60 }, + clientTTL: (clientID) => (clientID === 'one-step' ? { access: 15 * 60, refresh: 0 } : undefined), + deviceConfirm: (clientID) => + clientID === 'one-step' + ? { heading: 'Let this one step through?', body: ['Words chosen for this client.'] } + : undefined, + // Every test here asks for codes from one address at one frozen instant, + // so the request limit is widened out of the way; it has its own issuer + // below, on the defaults. + deviceVerification: { guessLimit: 3, guessWindow: 60, requestLimit: 10_000 }, providers: { dummy: { type: 'dummy', @@ -285,6 +294,53 @@ describe('approval', () => { }); }); +/** + * A client that is allowed a session for one step, and nothing after it. + * + * Its lifetime is set per client rather than by the sign-in, so the person + * approving it cannot be talked into a longer one and the flow that signs them + * in does not need to know who is asking. + */ +describe('a client with a lifetime of its own', () => { + async function approved(clientID: string) { + const grant = await started(clientID); + await answer(grant.user_code, 'approve'); + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + const response = await poll(grant.device_code, clientID); + expect(response.status).toBe(200); + return response.body; + } + + test('gets the access lifetime it was given and no refresh token', async () => { + const body = await approved('one-step'); + expect(body.expires_in).toBe(15 * 60); + expect(decodeJwt(body.access_token).exp).toBe(Math.floor(Date.now() / 1000) + 15 * 60); + expect(decodeJwt(body.access_token).aud).toBe('one-step'); + expect(body).not.toHaveProperty('refresh_token'); + }); + + test('cannot refresh what it was never given', async () => { + const body = await approved('one-step'); + const subject = decodeJwt(body.access_token).sub!; + const response = await auth.request(`${ORIGIN}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: `${subject}:${crypto.randomUUID()}` + }) + }); + expect(response.status).toBe(400); + expect(((await response.json()) as any).error).toBe('invalid_grant'); + }); + + test('every other client keeps the defaults', async () => { + const body = await approved('desktop'); + expect(body.expires_in).toBe(60 * 60 * 24 * 30); + expect(body.refresh_token).toMatch(/.+/); + }); +}); + /** * The attack this flow exists to stop, and the properties that stop it. * @@ -323,6 +379,16 @@ describe('a code somebody else started', () => { expect(reached.html).toContain('A device is asking'); }); + test('a client the issuer has words for is asked about in those words', async () => { + const grant = await started('one-step'); + const reached = await signInAndReachConfirmation(grant.user_code); + + expect(reached.html).toContain('Let this one step through?'); + expect(reached.html).toContain('Words chosen for this client.'); + expect(reached.html).not.toContain('Is this you?'); + expect(reached.html).toContain(grant.user_code.slice(0, 4)); + }); + test('a confirmation posted without the value from the cookie is refused', async () => { const grant = await started(); const { cookies } = await signInAndReachConfirmation(grant.user_code); @@ -493,3 +559,56 @@ describe('guessing at user codes', () => { expect((await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZD`)).status).toBe(429); }); }); + +/** + * Asking for a code costs the caller nothing, so it is charged per address + * like a wrong guess is. On the defaults, because those are what a deployment + * that never thought about it gets. + */ +describe('asking for codes', () => { + const limited = issuer({ + storage: MemoryStorage(), + subjects, + allow: async () => true, + providers: {}, + success: async (ctx) => ctx.subject('user', { userID: '123' }) + }); + + function ask(address?: string) { + return limited.request(`${ORIGIN}/device/authorize`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(address ? { 'cf-connecting-ip': address } : {}) + }, + body: new URLSearchParams({ client_id: 'desktop' }) + }); + } + + test('a caller gets ten in ten minutes, then a 429 that says when to come back', async () => { + for (let i = 0; i < 10; i++) expect((await ask('198.51.100.20')).status).toBe(200); + + const refused = await ask('198.51.100.20'); + expect(refused.status).toBe(429); + expect(refused.headers.get('retry-after')).toBe('600'); + const body = (await refused.json()) as any; + expect(body.error).toBe('slow_down'); + expect(body.error_description).toBe( + 'Too many sign-in requests from this network. Try again in 10 minutes.' + ); + }); + + test('the window ends, and another caller was never affected', async () => { + for (let i = 0; i < 11; i++) await ask('198.51.100.21'); + expect((await ask('198.51.100.21')).status).toBe(429); + expect((await ask('198.51.100.22')).status).toBe(200); + + setSystemTime(new Date(Date.now() + 601 * 1000)); + expect((await ask('198.51.100.21')).status).toBe(200); + }); + + test('a caller with no address shares one budget', async () => { + for (let i = 0; i < 10; i++) expect((await ask()).status).toBe(200); + expect((await ask()).status).toBe(429); + }); +}); diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 64daf345..5917189d 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -10,6 +10,7 @@ import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { SessionTable } from '../session/session.sql.js'; import { Member } from '../team/member.js'; +import { TeamTable } from '../team/team.sql.js'; import { MachineTable } from './machine.sql.js'; import { Slug } from './slug.js'; @@ -149,10 +150,45 @@ export namespace Machine { return diff === 0; } + /** + * Most machines one team may own. + * + * A host somebody brings is added by a command anyone signed in can run, + * so without a ceiling one account is as many hosts as it can script. + * Hardware an organisation owns outright is not counted: its own people + * add it, deliberately, and it is never reached through a team. + */ + export const TEAM_MACHINE_LIMIT = 10; + + /** + * Refuses when `teamId` already owns {@link TEAM_MACHINE_LIMIT} machines, + * with a message the person can act on. + */ + export async function assertTeamHasRoom(teamId: string) { + const owned = await Database.use((tx) => + tx + .select({ count: sql`count(*)::int` }) + .from(MachineTable) + .where(and(eq(MachineTable.teamId, teamId), isNull(MachineTable.timeDeleted))) + .then((rows) => rows[0]?.count ?? 0) + ); + if (owned >= TEAM_MACHINE_LIMIT) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.RateLimit.QUOTA_EXCEEDED, + `Your team already has ${TEAM_MACHINE_LIMIT} machines. Remove one to add another.` + ); + } + } + /** * Register a box. The secret is returned here and nowhere else — it is * stored only as a digest, so a lost secret means re-registering rather * than looking it up. + * + * A team at {@link TEAM_MACHINE_LIMIT} is refused here, which is what holds + * the limit for every way of registering, an install token minted while + * there was still room included. */ export const register = fn( Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }) @@ -160,50 +196,62 @@ export namespace Machine { .refine((v) => (v.teamId === null) !== ((v.organisationId ?? null) === null), { message: 'A machine belongs to a team or to an organisation, and not to both' }), - async (input) => { - const secret = generateSecret(); - const secretHash = await hashSecret(secret); + async (input) => + Database.transaction(async (tx) => { + if (input.teamId) { + // Held until the machine is written, so two registrations + // at the same instant cannot both see room for one. + await tx + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(eq(TeamTable.id, input.teamId)) + .for('update'); + await assertTeamHasRoom(input.teamId); + } - // The name space holds tens of millions, so a collision is rare - // enough that retrying is cheaper than checking first -- and a - // check-then-insert would be wrong as well as slower, because two - // registrations in the same instant both read "free" and both write. - // The unique index is the thing that actually decides. - for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) { - const slug = Slug.generate(); - try { - await Database.use(async (tx) => { - await tx.insert(MachineTable).values({ - id: input.id, - ownerUserId: input.ownerUserId, - teamId: input.teamId, - organisationId: input.organisationId ?? null, - label: input.label, - slug, - secretHash, - lastSeen: null + const secret = generateSecret(); + const secretHash = await hashSecret(secret); + + // The name space holds tens of millions, so a collision is rare + // enough that retrying is cheaper than checking first -- and a + // check-then-insert would be wrong as well as slower, because two + // registrations in the same instant both read "free" and both write. + // The unique index is the thing that actually decides. + for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) { + const slug = Slug.generate(); + try { + await Database.use(async (tx) => { + await tx.insert(MachineTable).values({ + id: input.id, + ownerUserId: input.ownerUserId, + teamId: input.teamId, + organisationId: input.organisationId ?? null, + label: input.label, + slug, + secretHash, + lastSeen: null + }); }); - }); - return { id: input.id, secret, slug }; - } catch (err) { - if (!isUniqueViolation(err)) { - throw err; - } - // Only a name collision is worth another go. A duplicate id - // or secret means something is wrong that a new name cannot - // fix, and retrying would bury it. - if (!isSlugViolation(err)) { - throw err; + return { id: input.id, secret, slug }; + } catch (err) { + if (!isUniqueViolation(err)) { + throw err; + } + // Only a name collision is worth another go. A duplicate id + // or secret means something is wrong that a new name cannot + // fix, and retrying would bury it. + if (!isSlugViolation(err)) { + throw err; + } } } - } - throw new VisibleError( - 'internal', - ErrorCodes.Server.INTERNAL_ERROR, - 'Could not mint an unused hostname for this machine' - ); - } + throw new VisibleError( + 'internal', + ErrorCodes.Server.INTERNAL_ERROR, + 'Could not mint an unused hostname for this machine' + ); + }) ); /** How many names to try before giving up. See `register`. */ diff --git a/packages/core/src/machine/install-token.test.ts b/packages/core/src/machine/install-token.test.ts index d7effeee..74df5270 100644 --- a/packages/core/src/machine/install-token.test.ts +++ b/packages/core/src/machine/install-token.test.ts @@ -2,6 +2,8 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; +import { VisibleError } from '../error.js'; +import { Machine } from './index.js'; import { InstallToken } from './install-token.js'; const sql = testDb(); @@ -67,4 +69,84 @@ describe('Install tokens', () => { expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull(); expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull(); }); + + test('one person may hold three unspent at once', async () => { + const owner = await newOwner('nit-outstanding'); + const mint = () => InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + for (let i = 0; i < InstallToken.OUTSTANDING_LIMIT; i++) await mint(); + + const refused = await mint().catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.type).toBe('rate_limit'); + expect(refused.code).toBe('too_many_requests'); + expect(refused.message).toBe( + 'You have 3 install tokens that have not been used yet. Use one, or wait for them to expire.' + ); + + // One expiring frees its place. + await sql`update install_token set expires_at = now() - interval '1 minute' + where id = (select id from install_token where created_by_user_id = ${owner.userId} limit 1)`; + expect((await mint()).token).toStartWith('nit_'); + }); + + test('one person may make five an hour, spent or not', async () => { + const owner = await newOwner('nit-hourly'); + const mint = () => InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + for (let i = 0; i < 3; i++) { + const { token } = await mint(); + await InstallToken.redeem({ token, label: `h${i}` }); + } + await mint(); + await mint(); + + const refused = await mint().catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.code).toBe('too_many_requests'); + expect(refused.message).toBe( + 'You have made 5 install tokens in the last hour. Try again later.' + ); + + // The hour is rolling: once the first ones age out, there is room again. + await sql`update install_token set time_created = now() - interval '61 minutes' + where created_by_user_id = ${owner.userId}`; + expect((await mint()).token).toStartWith('nit_'); + }); + + test('the limits are per person', async () => { + const busy = await newOwner('nit-busy'); + const quiet = await newOwner('nit-quiet'); + for (let i = 0; i < InstallToken.OUTSTANDING_LIMIT; i++) { + await InstallToken.create({ teamId: busy.teamId, userId: busy.userId }); + } + const issued = await InstallToken.create({ teamId: quiet.teamId, userId: quiet.userId }); + expect(issued.token).toStartWith('nit_'); + }); + + test('a team with no room for a host is not handed a token for one', async () => { + const owner = await newOwner('nit-cap-mint'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT; i++) await Fixtures.machine(owner, `box-${i}`); + const refused = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }).catch( + (e) => e + ); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.code).toBe('quota_exceeded'); + expect(refused.message).toBe('Your team already has 10 machines. Remove one to add another.'); + }); + + test('a token minted while there was room is refused once there is none, and stays unspent', async () => { + const owner = await newOwner('nit-cap-redeem'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT - 1; i++) { + await Fixtures.machine(owner, `box-${i}`); + } + const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + await Fixtures.machine(owner, 'the-last-place'); + + const refused = await InstallToken.redeem({ token, label: 'late' }).catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.code).toBe('quota_exceeded'); + + const rows = + await sql`select redeemed_at from install_token where created_by_user_id = ${owner.userId}`; + expect(rows[0]!.redeemed_at).toBeNull(); + }); }); diff --git a/packages/core/src/machine/install-token.ts b/packages/core/src/machine/install-token.ts index c2935f3b..6d4000e6 100644 --- a/packages/core/src/machine/install-token.ts +++ b/packages/core/src/machine/install-token.ts @@ -4,8 +4,10 @@ import { and, eq, gt, isNull, sql } from 'drizzle-orm'; import { z } from 'zod'; import { Database } from '../db/index.js'; +import { ErrorCodes, VisibleError } from '../error.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; +import { UserTable } from '../user/user.sql.js'; import { Machine } from './index.js'; import { InstallTokenTable } from './install-token.sql.js'; @@ -20,6 +22,15 @@ export namespace InstallToken { */ export const TTL_MINUTES = 60; + /** + * How many one person may ask for. A token is minted by a signed-in call + * that a script can repeat, and each one is a host waiting to happen, so + * minting is bounded twice: by rate, and by how many can be lying unspent + * at once. Both are far above what installing by hand ever needs. + */ + export const HOURLY_LIMIT = 5; + export const OUTSTANDING_LIMIT = 3; + function generate(): string { return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`; } @@ -34,6 +45,12 @@ export namespace InstallToken { /** * Issue a token for a team, or for an organisation's fleet. The caller has * already checked membership of whichever it names. + * + * Refused once the person has hit {@link HOURLY_LIMIT} or + * {@link OUTSTANDING_LIMIT}, or the team already owns as many machines as + * it may, with a message a terminal can print as it is. The team limit is + * held again when the token is spent; checking here as well means nobody + * is handed a command that cannot work. */ export const create = fn( z @@ -48,8 +65,39 @@ export namespace InstallToken { async (input) => { const token = generate(); const tokenHash = await digest(token); - const expiresAt = await Database.use(async (tx) => - tx + const expiresAt = await Database.transaction(async (tx) => { + // The limits are counts, so they hold only if nothing else can + // mint for this person while they are taken. Their own row is + // the one thing every such call contends on. + await tx + .select({ id: UserTable.id }) + .from(UserTable) + .where(eq(UserTable.id, input.userId)) + .for('update'); + if (input.teamId) await Machine.assertTeamHasRoom(input.teamId); + const [usage] = await tx + .select({ + lastHour: sql`(count(*) filter (where ${InstallTokenTable.timeCreated} > now() - interval '1 hour'))::int`, + outstanding: sql`(count(*) filter (where ${InstallTokenTable.redeemedAt} is null and ${InstallTokenTable.timeDeleted} is null and ${InstallTokenTable.expiresAt} > now()))::int` + }) + .from(InstallTokenTable) + .where(eq(InstallTokenTable.createdByUserId, input.userId)); + if ((usage?.lastHour ?? 0) >= HOURLY_LIMIT) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.RateLimit.TOO_MANY_REQUESTS, + `You have made ${HOURLY_LIMIT} install tokens in the last hour. Try again later.` + ); + } + if ((usage?.outstanding ?? 0) >= OUTSTANDING_LIMIT) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.RateLimit.TOO_MANY_REQUESTS, + `You have ${OUTSTANDING_LIMIT} install tokens that have not been used yet. Use one, or wait for them to expire.` + ); + } + + return tx .insert(InstallTokenTable) .values({ id: Identifier.ascending('installToken'), @@ -60,8 +108,8 @@ export namespace InstallToken { expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'` }) .returning({ expiresAt: InstallTokenTable.expiresAt }) - .then((rows) => rows[0]!.expiresAt) - ); + .then((rows) => rows[0]!.expiresAt); + }); return { token, expiresAt }; } ); diff --git a/packages/core/src/machine/machine.test.ts b/packages/core/src/machine/machine.test.ts index b7a45501..9e061d17 100644 --- a/packages/core/src/machine/machine.test.ts +++ b/packages/core/src/machine/machine.test.ts @@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; +import { VisibleError } from '../error.js'; import { Identifier } from '../id.js'; import { Machine } from './index.js'; @@ -167,3 +168,54 @@ describe('Machine heartbeat', () => { expect(Machine.isOnline(wellPast)).toBe(false); }); }); + +describe('How many machines a team may own', () => { + function register(owner: { userId: string; teamId: string }, label: string) { + return Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: owner.userId, + teamId: owner.teamId, + label + }); + } + + test('ten, and the eleventh is refused with a message to act on', async () => { + const owner = await newOwner('mch-cap'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT; i++) await register(owner, `box-${i}`); + + const refused = await register(owner, 'one-too-many').catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.type).toBe('rate_limit'); + expect(refused.code).toBe('quota_exceeded'); + expect(refused.message).toBe('Your team already has 10 machines. Remove one to add another.'); + + const rows = await sql`select id from machine where team_id = ${owner.teamId}`; + expect(rows).toHaveLength(Machine.TEAM_MACHINE_LIMIT); + }); + + test('a removed machine frees its place', async () => { + const owner = await newOwner('mch-cap-removed'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT; i++) await register(owner, `box-${i}`); + await sql`update machine set time_deleted = now() + where id = (select id from machine where team_id = ${owner.teamId} limit 1)`; + expect((await register(owner, 'replacement')).secret).toStartWith('msk_'); + }); + + test('registrations racing for the last place do not both get it', async () => { + const owner = await newOwner('mch-cap-race'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT - 1; i++) await register(owner, `box-${i}`); + const results = await Promise.allSettled( + Array.from({ length: 4 }, (_, i) => register(owner, `racer-${i}`)) + ); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + const rows = await sql`select id from machine where team_id = ${owner.teamId}`; + expect(rows).toHaveLength(Machine.TEAM_MACHINE_LIMIT); + }); + + test('hardware an organisation owns is not counted', async () => { + const operator = await newOwner('mch-cap-fleet'); + const fleet = await Fixtures.fleet(operator, Machine.TEAM_MACHINE_LIMIT + 1); + expect(fleet.machines).toHaveLength(Machine.TEAM_MACHINE_LIMIT + 1); + expect((await register(operator, 'own-box')).secret).toStartWith('msk_'); + }); +});