mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
Anybody could ask for a device code and be handed a link with the user code already in it. Following that link started a sign-in, and finishing the sign-in approved the grant. So sending somebody the link was enough: they saw an ordinary sign-in prompt, completed it, and whoever kept the device code polled and collected their access and refresh tokens. The victim never saw a question, because there was not one. There is now. Signing in says who the browser belongs to; it does not say the person meant to hand an account to a program somewhere else. Those are two questions and only the second authorizes anything, so the flow ends at a page that names the program, shows the code back so it can be compared with what the device is displaying, and offers Approve and Deny. Approving is a POST carrying a value from the cookie, so another site cannot submit it on somebody's behalf. Denial moved onto the same page: it used to be a GET anyone could fire, which meant a link scanner could cancel a real sign-in and a stranger with a user code could grief one. Three more things that were wrong underneath. The grant was read, modified and written back as a whole record. A poll that read a pending grant and then wrote its bookkeeping erased an approval that landed in between, and the client polled a dead grant until it expired. Grants moved to a table, where approving is one conditional update and redeeming is one delete that returns what it deleted, so neither party can undo the other and two polls cannot both be served. Tokens were minted when the person clicked and left sitting in storage until collected. They are minted at redemption now, so the lifetime the client is told about starts when it receives them, and a grant nobody collects leaves no usable refresh token behind. The client identifier was never checked, at either end. It is validated when the grant is created and has to match when the code is redeemed — without that, a leaked code is redeemable by anyone, and the identifier the token carries is whatever the last caller claimed. The device code is also stored as a hash now, since it is the credential the tokens are handed to. The store is an interface because the issuer cannot reach the database, and because the guarantees are the point: every method is one operation, and no caller reads a grant, decides, and writes it back.
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test';
|
|
|
|
import { object, string } from 'valibot';
|
|
|
|
import { hashDeviceCode, MemoryDeviceStore } from '../src/device.js';
|
|
import { issuer } from '../src/issuer.js';
|
|
import { MemoryStorage } from '../src/storage/memory.js';
|
|
import { createSubjects } from '../src/subject.js';
|
|
|
|
const subjects = createSubjects({
|
|
user: object({
|
|
userID: string()
|
|
})
|
|
});
|
|
|
|
const deviceStore = MemoryDeviceStore();
|
|
|
|
const auth = issuer({
|
|
storage: MemoryStorage(),
|
|
deviceStore,
|
|
subjects,
|
|
allow: async () => true,
|
|
allowDeviceClient: async (clientID) => clientID !== 'banned',
|
|
providers: {
|
|
dummy: {
|
|
type: 'dummy',
|
|
init(route, ctx) {
|
|
route.get('/authorize', async (c) => {
|
|
return ctx.success(c, { email: 'foo@bar.com' });
|
|
});
|
|
}
|
|
}
|
|
},
|
|
success: async (ctx) => ctx.subject('user', { userID: '123' })
|
|
});
|
|
|
|
const ORIGIN = 'https://auth.example.com';
|
|
|
|
/** Two cookies are in play across this flow, and `get` returns only the first. */
|
|
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('; ');
|
|
}
|
|
};
|
|
}
|
|
|
|
async function begin(clientID = 'desktop') {
|
|
const response = await auth.request(`${ORIGIN}/device/authorize`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ client_id: clientID })
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: (await response.json()) as any
|
|
};
|
|
}
|
|
|
|
async function started(clientID = 'desktop') {
|
|
const response = await begin(clientID);
|
|
expect(response.status).toBe(200);
|
|
return response.body as {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri: string;
|
|
verification_uri_complete: string;
|
|
expires_in: number;
|
|
interval: number;
|
|
};
|
|
}
|
|
|
|
async function poll(deviceCode: string, clientID = 'desktop') {
|
|
const response = await auth.request(`${ORIGIN}/token`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
device_code: deviceCode,
|
|
client_id: clientID
|
|
})
|
|
});
|
|
return { status: response.status, body: (await response.json()) as any };
|
|
}
|
|
|
|
/**
|
|
* Walk the browser half as far as the question, and stop there.
|
|
*
|
|
* Returns the confirmation page and the cookies that go with it, so a test can
|
|
* assert what has and has not happened at the moment somebody has signed in
|
|
* but not yet said yes.
|
|
*/
|
|
async function signInAndReachConfirmation(userCode: string) {
|
|
const cookies = jar();
|
|
const entered = await auth.request(`${ORIGIN}/device?user_code=${encodeURIComponent(userCode)}`);
|
|
expect(entered.status).toBe(302);
|
|
cookies.absorb(entered);
|
|
|
|
const asked = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), {
|
|
headers: { cookie: cookies.header() }
|
|
});
|
|
cookies.absorb(asked);
|
|
const html = await asked.text();
|
|
return { status: asked.status, html, cookies };
|
|
}
|
|
|
|
/** The whole browser half, ending in an answer. */
|
|
async function answer(userCode: string, action: 'approve' | 'deny') {
|
|
const { html, cookies, status } = await signInAndReachConfirmation(userCode);
|
|
expect(status).toBe(200);
|
|
const csrf = /name="csrf" value="([^"]+)"/.exec(html)?.[1];
|
|
expect(csrf).toBeTruthy();
|
|
|
|
return auth.request(`${ORIGIN}/device/confirm`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ csrf: csrf!, action })
|
|
});
|
|
}
|
|
|
|
beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z')));
|
|
afterEach(() => setSystemTime());
|
|
|
|
describe('device authorization request', () => {
|
|
test('answers with everything the polling client needs', async () => {
|
|
const grant = await started();
|
|
|
|
expect(grant.device_code).toMatch(/.+/);
|
|
// Eight characters, so the client's four-and-four chunking reads
|
|
// evenly when a person says it out loud.
|
|
expect(grant.user_code).toMatch(/^[A-Z0-9]{8}$/);
|
|
expect(grant.verification_uri).toBe(`${ORIGIN}/device`);
|
|
expect(grant.verification_uri_complete).toContain(grant.user_code);
|
|
expect(grant.interval).toBeGreaterThanOrEqual(1);
|
|
expect(grant.expires_in).toBeGreaterThan(grant.interval);
|
|
});
|
|
|
|
test('two requests do not collide', async () => {
|
|
const a = await started();
|
|
const b = await started();
|
|
expect(a.device_code).not.toBe(b.device_code);
|
|
expect(a.user_code).not.toBe(b.user_code);
|
|
});
|
|
|
|
test('the metadata document advertises the endpoint and the grant', async () => {
|
|
const response = await auth.request(`${ORIGIN}/.well-known/oauth-authorization-server`);
|
|
const body: any = await response.json();
|
|
expect(body.device_authorization_endpoint).toBe(`${ORIGIN}/device/authorize`);
|
|
expect(body.grant_types_supported).toContain('urn:ietf:params:oauth:grant-type:device_code');
|
|
});
|
|
|
|
test('a client the issuer does not know is refused a grant', async () => {
|
|
const refused = await begin('banned');
|
|
expect(refused.status).toBe(400);
|
|
expect(refused.body.error).toBe('invalid_client');
|
|
});
|
|
|
|
// The endpoint hands the code back exactly once, in its answer. What is
|
|
// kept is a hash, so reading the store is not enough to redeem anything.
|
|
test('the code the client is given is not the value that is stored', async () => {
|
|
const grant = await started();
|
|
expect(await deviceStore.byDeviceCode(grant.device_code)).toBeNull();
|
|
expect(await deviceStore.byDeviceCode(await hashDeviceCode(grant.device_code))).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('polling', () => {
|
|
test('an unapproved code is pending', async () => {
|
|
const grant = await started();
|
|
const first = await poll(grant.device_code);
|
|
expect(first.status).toBe(400);
|
|
expect(first.body.error).toBe('authorization_pending');
|
|
});
|
|
|
|
test('polling faster than the interval earns slow_down, and widens it', async () => {
|
|
const grant = await started();
|
|
await poll(grant.device_code);
|
|
|
|
const tooSoon = await poll(grant.device_code);
|
|
expect(tooSoon.body.error).toBe('slow_down');
|
|
|
|
// The interval the client is told to use grows, per RFC 8628 §3.5, so
|
|
// a client that ignores the first warning is not merely told again.
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000));
|
|
const stillTooSoon = await poll(grant.device_code);
|
|
expect(stillTooSoon.body.error).toBe('slow_down');
|
|
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 6) * 1000));
|
|
const patient = await poll(grant.device_code);
|
|
expect(patient.body.error).toBe('authorization_pending');
|
|
});
|
|
|
|
test('an unknown device code is not treated as pending', async () => {
|
|
const response = await poll('not-a-device-code');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('expired_token');
|
|
});
|
|
|
|
test('an expired code says so instead of pending forever', async () => {
|
|
const grant = await started();
|
|
setSystemTime(new Date(Date.now() + (grant.expires_in + 60) * 1000));
|
|
const response = await poll(grant.device_code);
|
|
expect(response.body.error).toBe('expired_token');
|
|
});
|
|
|
|
test('a code belongs to the client that asked for it', async () => {
|
|
const grant = await started();
|
|
const response = await poll(grant.device_code, 'somebody-else');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
test('a poll with no client_id is not a poll', async () => {
|
|
const grant = await started();
|
|
const response = await auth.request(`${ORIGIN}/token`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
device_code: grant.device_code
|
|
})
|
|
});
|
|
expect(response.status).toBe(400);
|
|
expect(((await response.json()) as any).error).toBe('invalid_request');
|
|
});
|
|
});
|
|
|
|
describe('approval', () => {
|
|
test('approving hands the next poll a token', async () => {
|
|
const grant = await started();
|
|
const confirmed = await answer(grant.user_code, 'approve');
|
|
expect(confirmed.status).toBe(200);
|
|
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000));
|
|
const response = await poll(grant.device_code);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.access_token).toMatch(/.+/);
|
|
expect(response.body.refresh_token).toMatch(/.+/);
|
|
});
|
|
|
|
test('a device code is redeemable once', async () => {
|
|
const grant = await started();
|
|
await answer(grant.user_code, 'approve');
|
|
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000));
|
|
expect((await poll(grant.device_code)).status).toBe(200);
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000));
|
|
expect((await poll(grant.device_code)).body.error).toBe('expired_token');
|
|
});
|
|
|
|
test('the user code is accepted in the form a person reads aloud', async () => {
|
|
const grant = await started();
|
|
const chunked = `${grant.user_code.slice(0, 4)}-${grant.user_code.slice(4)}`;
|
|
await answer(chunked.toLowerCase(), 'approve');
|
|
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000));
|
|
expect((await poll(grant.device_code)).status).toBe(200);
|
|
});
|
|
|
|
test('an unknown user code does not start a provider flow', async () => {
|
|
const response = await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZZ`);
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
test('a refusal is final, and says so', async () => {
|
|
const grant = await started();
|
|
const denied = await answer(grant.user_code, 'deny');
|
|
expect(denied.status).toBe(200);
|
|
|
|
const response = await poll(grant.device_code);
|
|
expect(response.body.error).toBe('access_denied');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The attack this flow exists to stop, and the properties that stop it.
|
|
*
|
|
* Anyone can ask for a device code and be handed a link with the user code
|
|
* already in it. Send that link to somebody, keep the device code, and if
|
|
* their signing in were enough you would be holding their tokens. It is not
|
|
* enough, and these say why.
|
|
*/
|
|
describe('a code somebody else started', () => {
|
|
test('following the link and signing in approves nothing', async () => {
|
|
const grant = await started();
|
|
|
|
const reached = await signInAndReachConfirmation(grant.user_code);
|
|
expect(reached.status).toBe(200);
|
|
|
|
// The victim has signed in. The attacker polls. There is still no
|
|
// answer, because being signed in is not the same as having agreed.
|
|
const response = await poll(grant.device_code);
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('authorization_pending');
|
|
});
|
|
|
|
test('the page shows the code, so it can be compared with the device', async () => {
|
|
const grant = await started();
|
|
const reached = await signInAndReachConfirmation(grant.user_code);
|
|
|
|
expect(reached.html).toContain(grant.user_code.slice(0, 4));
|
|
expect(reached.html).toContain(grant.user_code.slice(4));
|
|
expect(reached.html).toContain('desktop');
|
|
});
|
|
|
|
test('a confirmation posted without the value from the cookie is refused', async () => {
|
|
const grant = await started();
|
|
const { cookies } = await signInAndReachConfirmation(grant.user_code);
|
|
|
|
const forged = await auth.request(`${ORIGIN}/device/confirm`, {
|
|
method: 'POST',
|
|
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ csrf: 'guessed', action: 'approve' })
|
|
});
|
|
expect(forged.status).toBe(400);
|
|
expect((await poll(grant.device_code)).body.error).toBe('authorization_pending');
|
|
});
|
|
|
|
test('confirming with no cookie at all authorizes nothing', async () => {
|
|
const grant = await started();
|
|
await signInAndReachConfirmation(grant.user_code);
|
|
|
|
const bare = await auth.request(`${ORIGIN}/device/confirm`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ csrf: 'anything', action: 'approve' })
|
|
});
|
|
expect(bare.status).toBe(400);
|
|
expect((await poll(grant.device_code)).body.error).toBe('authorization_pending');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Two things touching one grant at the same time.
|
|
*
|
|
* The browser and the polling client are always racing; the question is only
|
|
* whether the loser can undo the winner. Held here against the in-memory
|
|
* store, whose methods do not suspend part way through — a store that talks to
|
|
* a database has to give the same guarantees for itself.
|
|
*/
|
|
describe('when both halves move at once', () => {
|
|
test('a poll cannot undo an approval that landed while it was in flight', async () => {
|
|
const grant = await started();
|
|
const hash = await hashDeviceCode(grant.device_code);
|
|
|
|
// A poll reads a pending grant, the browser approves, and then the
|
|
// poll writes its bookkeeping. What it writes must not include the
|
|
// status it read.
|
|
const stale = await deviceStore.byDeviceCode(hash);
|
|
expect(stale!.status).toBe('pending');
|
|
await answer(grant.user_code, 'approve');
|
|
await deviceStore.recordPoll(hash, Date.now(), stale!.interval);
|
|
|
|
expect((await deviceStore.byDeviceCode(hash))!.status).toBe('approved');
|
|
setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000));
|
|
expect((await poll(grant.device_code)).status).toBe(200);
|
|
});
|
|
|
|
test('an approval cannot overwrite a refusal that got there first', async () => {
|
|
const grant = await started();
|
|
const hash = await hashDeviceCode(grant.device_code);
|
|
|
|
// Both halves reach the question; one presses Deny and one presses
|
|
// Approve. Whichever arrives second is answering something that has
|
|
// already been answered.
|
|
const first = await signInAndReachConfirmation(grant.user_code);
|
|
const second = await signInAndReachConfirmation(grant.user_code);
|
|
const csrfOf = (html: string) => /name="csrf" value="([^"]+)"/.exec(html)![1]!;
|
|
|
|
const denied = await auth.request(`${ORIGIN}/device/confirm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
cookie: first.cookies.header(),
|
|
'content-type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams({ csrf: csrfOf(first.html), action: 'deny' })
|
|
});
|
|
expect(denied.status).toBe(200);
|
|
|
|
const late = await auth.request(`${ORIGIN}/device/confirm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
cookie: second.cookies.header(),
|
|
'content-type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams({ csrf: csrfOf(second.html), action: 'approve' })
|
|
});
|
|
expect(late.status).toBe(400);
|
|
|
|
expect((await deviceStore.byDeviceCode(hash))!.status).toBe('denied');
|
|
expect((await poll(grant.device_code)).body.error).toBe('access_denied');
|
|
});
|
|
|
|
test('two polls racing one approved grant serve one of them', async () => {
|
|
const grant = await started();
|
|
await answer(grant.user_code, 'approve');
|
|
const hash = await hashDeviceCode(grant.device_code);
|
|
|
|
const [a, b] = await Promise.all([
|
|
deviceStore.consume(hash, 'desktop'),
|
|
deviceStore.consume(hash, 'desktop')
|
|
]);
|
|
expect([a, b].filter(Boolean)).toHaveLength(1);
|
|
});
|
|
});
|