mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user