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:
Wanjohi
2026-10-16 18:00:00 +00:00
committed by GitHub
parent fdd78b81bf
commit fd9a0c4508
13 changed files with 950 additions and 81 deletions
+21
View File
@@ -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');
+7 -4
View File
@@ -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(
+220
View File
@@ -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);
});
});
+12
View File
@@ -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
View File
@@ -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
+148
View File
@@ -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
View File
@@ -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.
+16 -2
View File
@@ -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.'
};
},
+120 -1
View File
@@ -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);
});
});
+87 -39
View File
@@ -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();
});
});
+52 -4
View File
@@ -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 };
}
);
+52
View File
@@ -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_');
});
});