mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
feat: add a host by approving it in a browser, without pasting a token (#417)
Releases #416 without install.sh, which waits for a host agent that can sign in.
This commit is contained in:
@@ -84,6 +84,16 @@ function getClient(env: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
+41
-2
@@ -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
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<string, string>) {
|
||||
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(/.+/);
|
||||
});
|
||||
});
|
||||
+92
-29
@@ -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<boolean>;
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<boolean> {
|
||||
@@ -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.
|
||||
|
||||
@@ -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.'
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number>`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`. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number>`(count(*) filter (where ${InstallTokenTable.timeCreated} > now() - interval '1 hour'))::int`,
|
||||
outstanding: sql<number>`(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 };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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_');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user