From ada368bf660ac59ef942798e43a2d9080bc7deac Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 16 Oct 2026 00:52:54 +0300 Subject: [PATCH] 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. --- apps/auth/WIP-installer.md | 2 +- apps/auth/src/index.ts | 21 ++++- apps/auth/test/device.test.ts | 131 ++++++++++++++++++++++++++++++ packages/auth/src/issuer.ts | 57 +++++++------ packages/auth/test/device.test.ts | 49 +++++++++++ 5 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 apps/auth/test/device.test.ts diff --git a/apps/auth/WIP-installer.md b/apps/auth/WIP-installer.md index 5a3fc66c..c8be319c 100644 --- a/apps/auth/WIP-installer.md +++ b/apps/auth/WIP-installer.md @@ -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 diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 3772db92..ee1b722b 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -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 diff --git a/apps/auth/test/device.test.ts b/apps/auth/test/device.test.ts new file mode 100644 index 00000000..51985f0d --- /dev/null +++ b/apps/auth/test/device.test.ts @@ -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(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} + +function form(body: Record) { + return { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body) + }; +} + +async function begin(clientID: string) { + return auth.request(`${ORIGIN}/device/authorize`, form({ client_id: clientID })); +} + +/** Start a grant and sign in, stopping at the question the person is asked. */ +async function reachConfirmation(clientID: string) { + const started = (await (await begin(clientID)).json()) as any; + const cookies = jar(); + const entered = await auth.request(`${ORIGIN}/device?user_code=${started.user_code}`); + cookies.absorb(entered); + const asked = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), { + headers: { cookie: cookies.header() } + }); + cookies.absorb(asked); + return { started, cookies, html: await asked.text() }; +} + +/** The whole grant, approved, ending in what the program is handed. */ +async function approved(clientID: string) { + const { started, cookies, html } = await reachConfirmation(clientID); + const csrf = /name="csrf" value="([^"]+)"/.exec(html)![1]!; + await auth.request(`${ORIGIN}/device/confirm`, { + ...form({ csrf, action: 'approve' }), + headers: { + cookie: cookies.header(), + 'content-type': 'application/x-www-form-urlencoded' + } + }); + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + const response = await auth.request( + `${ORIGIN}/token`, + form({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: started.device_code, + client_id: clientID + }) + ); + expect(response.status).toBe(200); + return (await response.json()) as any; +} + +beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z'))); +afterEach(() => setSystemTime()); + +describe('device clients', () => { + test('only the programs this issuer knows may start a grant', async () => { + expect((await begin('desktop')).status).toBe(200); + expect((await begin('installer')).status).toBe(200); + expect((await begin('something-else')).status).toBe(400); + }); + + test('the installer gets a quarter of an hour and no refresh token', async () => { + const tokens = await approved('installer'); + expect(tokens.expires_in).toBe(15 * 60); + expect(claims(tokens.access_token).aud).toBe('installer'); + expect(tokens).not.toHaveProperty('refresh_token'); + }); + + test('the desktop app keeps a session it can refresh', async () => { + const tokens = await approved('desktop'); + expect(tokens.expires_in).toBe(60 * 60 * 24 * 30); + expect(claims(tokens.access_token).aud).toBe('desktop'); + expect(tokens.refresh_token).toMatch(/.+/); + }); +}); diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index d0c6d4b6..89b7c78a 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -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, diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts index 2fc1f542..40c887be 100644 --- a/packages/auth/test/device.test.ts +++ b/packages/auth/test/device.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; +import { decodeJwt } from 'jose'; import { object, string } from 'valibot'; import { hashDeviceCode, MemoryDeviceStore } from '../src/device.js'; @@ -21,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. *