feat(auth): an installer device client whose session lasts one step

A host installer needs a signed-in session for exactly one call: trading
it for a single-use host token. It runs as root, on a machine that may
not belong to the person signing in, so a 30-day session with a year of
refresh behind it is far more than it should ever hold.

The issuer gains `clientTTL`, a per-client override of token lifetimes
that wins over what the sign-in asks for, and a refresh lifetime of 0
now means no refresh token is issued at all rather than one that
expires immediately. The `installer` client is allowed to start a
device grant and gets 15 minutes and no refresh token. The desktop
client keeps its defaults.
This commit is contained in:
Wanjohi
2026-10-16 00:52:54 +03:00
parent 49cb4170e0
commit ada368bf66
5 changed files with 234 additions and 26 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
Branch: feat/installer-sign-in
## Done
- nothing yet
- 1. installer device client, 15 min, no refresh (engine `clientTTL`, apps/auth `clientTTL`)
## Left
1. issuer: `installer` device client, 15 min access, no refresh token
+19 -2
View File
@@ -45,7 +45,23 @@ 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);
/**
* 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 +208,8 @@ export default {
codeStore: PostgresCodeStore(),
refreshStore: PostgresRefreshStore(),
deviceStore: PostgresDeviceStore(),
allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID),
allowDeviceClient,
clientTTL,
// 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
+131
View File
@@ -0,0 +1,131 @@
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 } 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,
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('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(/.+/);
});
});
+34 -23
View File
@@ -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.
*
@@ -599,6 +612,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 +738,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 +752,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 +773,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 +794,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,
@@ -977,8 +987,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 +1031,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 +1412,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,
+49
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,6 +22,7 @@ const auth = issuer({
subjects,
allow: async () => true,
allowDeviceClient: async (clientID) => clientID !== 'banned',
clientTTL: (clientID) => (clientID === 'one-step' ? { access: 15 * 60, refresh: 0 } : undefined),
deviceVerification: { guessLimit: 3, guessWindow: 60 },
providers: {
dummy: {
@@ -285,6 +287,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.
*