fix(auth): make a device sign-in an answer somebody gave

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.
This commit is contained in:
Wanjohi
2026-09-05 09:40:03 +03:00
parent 15f8d3eb34
commit 36179150a1
11 changed files with 3622 additions and 187 deletions

View File

@@ -4,6 +4,7 @@ import { CodeProvider } from '@nestri/auth/provider/code';
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
import { CodeUI } from '@nestri/auth/ui/code';
import { Actor } from '@nestri/core/actor';
import { PostgresDeviceStore } from '@nestri/core/auth/device-grant';
import { subjects } from '@nestri/core/auth/subjects';
import { Env } from '@nestri/core/env';
import { Team } from '@nestri/core/team/index';
@@ -21,6 +22,17 @@ type Env = {
EMAIL_DEV_LOG?: string;
};
/**
* The programs allowed to start a device authorization grant.
*
* That endpoint takes no secret — a program with no browser has nowhere to keep
* one, which is the whole reason the grant exists — so the identifier is a
* claim and not a proof. What the list buys is that the claim has to be one of
* 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']);
/**
* Enough of an address to be worth trying to deliver to.
*
@@ -52,6 +64,14 @@ export default {
storage: CloudflareStorage({
namespace: env.AuthStorage
}),
// Not the KV store the rest of this uses, and the difference
// matters. A device grant is answered by a browser and collected by
// a program polling at the same time, so approving it and redeeming
// it each have to be one operation that either happens or does not.
// A store that reads and writes whole records lets those two undo
// each other; a conditional update does not.
deviceStore: PostgresDeviceStore(),
allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID),
// One provider, on purpose.
//
// Verifying an email address is the only thing that brings an

165
packages/auth/src/device.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Where a device authorization grant lives while nobody has answered for it.
*
* This is an interface and not an implementation because the guarantees it
* asks for are the whole point. A grant moves between states that must each
* happen once — pending to approved, approved to redeemed — while two parties
* are touching it at the same time: a browser somebody is clicking through,
* and a program on another machine polling every few seconds. Held in a store
* that can only get and put whole records, those two overlap and undo each
* other. Every method below is written so that the store can make it one
* operation, and the issuer never reads a record, decides, and writes it back.
*
* @packageDocumentation
*/
/** How far a grant has got. Terminal in both directions once it leaves pending. */
export type DeviceGrantStatus = 'pending' | 'approved' | 'denied';
/**
* Who the grant turned out to be for, recorded when it is approved.
*
* The tokens themselves are deliberately not here. They are minted when the
* waiting program redeems the code, so their lifetime starts when they are
* handed over rather than whenever the person happened to finish clicking —
* and so a grant nobody collects leaves no usable credential behind.
*/
export interface DeviceGrantSubject {
subject: string;
type: string;
properties: unknown;
ttl: { access: number; refresh: number };
}
export interface DeviceGrant {
/** The hash of the device code, never the code itself. */
deviceCodeHash: string;
userCode: string;
clientID: string;
status: DeviceGrantStatus;
/** Seconds the client is being told to wait between polls. Only grows. */
interval: number;
/** Epoch ms of the last poll that got a real answer; `0` if there has been none. */
lastPolled: number;
/** Epoch ms at which the grant stops being usable. */
expires: number;
subject?: DeviceGrantSubject;
}
export interface DeviceStore {
create(grant: DeviceGrant): Promise<void>;
byDeviceCode(deviceCodeHash: string): Promise<DeviceGrant | null>;
byUserCode(userCode: string): Promise<DeviceGrant | null>;
/**
* Pending to approved, in one operation.
*
* Returns false when the grant was not pending any more, which is how a
* refusal that arrived first survives an approval that arrives second, and
* the other way round. The caller must not decide this by reading first.
*/
approve(deviceCodeHash: string, subject: DeviceGrantSubject): Promise<boolean>;
/** Pending to denied, in one operation. Same rule as {@link approve}. */
deny(deviceCodeHash: string): Promise<boolean>;
/**
* Take an approved grant away and return it, or return null.
*
* This is what makes a device code redeemable once. Two polls arriving
* together must not both be served, so removal and reading have to be the
* same operation — a read, a decision and a delete would serve both.
*/
consume(deviceCodeHash: string, clientID: string): Promise<DeviceGrant | null>;
/**
* Record that a poll happened, and what interval it was told to use.
*
* Touches those two fields and nothing else, on purpose. Writing the whole
* record back here is what lets a poll that read a pending grant undo an
* approval that landed while it was thinking.
*/
recordPoll(deviceCodeHash: string, at: number, interval: number): Promise<void>;
remove(deviceCodeHash: string): Promise<void>;
}
/**
* The hash a device code is stored under.
*
* A device code is a bearer credential: whoever holds it collects the tokens.
* Storing it as written means anything that can read the table can finish
* somebody else's sign-in, so what is kept is enough to recognise the code and
* not enough to present it.
*/
export async function hashDeviceCode(deviceCode: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(deviceCode));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* A store in a single process's memory, for tests and local runs.
*
* Single-threaded JavaScript gives the atomicity the interface asks for for
* free: nothing suspends between the check and the write in any method here,
* so no two callers can interleave inside one. That is a property of this
* implementation and not something a caller may assume about the interface.
*/
export function MemoryDeviceStore(): DeviceStore {
const byHash = new Map<string, DeviceGrant>();
const byCode = new Map<string, string>();
function live(grant: DeviceGrant | undefined): DeviceGrant | null {
if (!grant) return null;
if (grant.expires <= Date.now()) return null;
return grant;
}
return {
async create(grant) {
byHash.set(grant.deviceCodeHash, { ...grant });
byCode.set(grant.userCode, grant.deviceCodeHash);
},
async byDeviceCode(hash) {
const found = byHash.get(hash);
return found ? { ...found } : null;
},
async byUserCode(userCode) {
const hash = byCode.get(userCode);
const found = hash ? byHash.get(hash) : undefined;
return found ? { ...found } : null;
},
async approve(hash, subject) {
const grant = live(byHash.get(hash));
if (!grant || grant.status !== 'pending') return false;
grant.status = 'approved';
grant.subject = subject;
return true;
},
async deny(hash) {
const grant = live(byHash.get(hash));
if (!grant || grant.status !== 'pending') return false;
grant.status = 'denied';
return true;
},
async consume(hash, clientID) {
const grant = live(byHash.get(hash));
if (!grant || grant.status !== 'approved' || grant.clientID !== clientID) return null;
byHash.delete(hash);
byCode.delete(grant.userCode);
return { ...grant };
},
async recordPoll(hash, at, interval) {
const grant = byHash.get(hash);
if (!grant) return;
grant.lastPolled = at;
grant.interval = interval;
},
async remove(hash) {
const grant = byHash.get(hash);
if (!grant) return;
byHash.delete(hash);
byCode.delete(grant.userCode);
}
};
}

View File

@@ -179,7 +179,11 @@ export interface AuthorizationState {
* Set when the browser half of a device authorization grant is running.
* There is no `redirect_uri` in that case: the thing waiting for the answer
* is a program on another machine polling the token endpoint, so the
* result is written to storage instead of into a redirect.
* result is recorded against the grant instead of into a redirect.
*
* This is the *hash* of the device code. The browser half never sees the
* code itself — it arrives holding a user code, and the code that redeems
* tokens stays with the program that asked for it.
*/
device_code?: string;
}
@@ -202,8 +206,15 @@ import {
UnknownStateError
} from './error.js';
import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js';
import {
type DeviceGrant,
type DeviceGrantSubject,
type DeviceStore,
hashDeviceCode,
MemoryDeviceStore
} from './device.js';
import { validatePKCE } from './pkce.js';
import { generateUnbiasedString } from './random.js';
import { generateUnbiasedString, timingSafeCompare } from './random.js';
import { DynamoStorage } from './storage/dynamo.js';
import { MemoryStorage } from './storage/memory.js';
import { Storage, StorageAdapter } from './storage/storage.js';
@@ -375,6 +386,29 @@ export interface IssuerInput<
*/
deviceInterval?: number;
};
/**
* Where device authorization grants are kept.
*
* Defaults to one held in this process's memory, which is right for tests
* and for a single local process and wrong for anything else — a grant
* created by one instance has to be findable by whichever instance the
* browser and the polling client happen to reach. A real deployment passes
* a store backed by something shared, and the interface is written so that
* store can make each transition a single operation.
*/
deviceStore?: DeviceStore;
/**
* Whether a client may start a device authorization grant.
*
* `/device/authorize` takes no secret — that is what the grant is for — so
* without this any caller can mint a grant naming any client identifier,
* and that identifier is what the issued token ends up carrying. Returning
* false refuses the request.
*
* Defaults to allowing everything, which preserves the behaviour of an
* issuer that has not thought about it, and is worth thinking about.
*/
allowDeviceClient?(clientID: string, req: Request): Promise<boolean>;
/**
* Optionally, configure the UI that's displayed when the user visits the root URL of the
* of the OpenAuth server.
@@ -494,6 +528,7 @@ export function issuer<
const ttlRefreshRetention = input.ttl?.retention ?? 0;
const ttlDevice = input.ttl?.device ?? 60 * 10;
const deviceInterval = input.ttl?.deviceInterval ?? 5;
const deviceStore = input.deviceStore ?? MemoryDeviceStore();
if (input.theme) {
setTheme(input.theme);
}
@@ -554,42 +589,43 @@ export function issuer<
: await resolveSubject(type, properties);
await successOpts?.invalidate?.(await resolveSubject(type, properties));
if (authorization?.device_code) {
// The device grant has nowhere to redirect to. The
// program that started this is on another machine
// polling `/token`, so the tokens are left where
// that poll will find them and the person gets a
// page telling them they are done.
const grant = await Storage.get<DeviceGrant>(
storage,
deviceKey(authorization.device_code)
);
// A device grant has nowhere to redirect to, and it is
// also not finished. Signing in says who this browser
// is; it does not say that the person meant to hand an
// account to whatever program is holding the other half
// of this code. Those are two different questions and
// only the second one authorizes anything, so what
// happens here is a page that asks it.
await auth.unset(ctx, 'authorization');
const grant = await deviceStore.byDeviceCode(authorization.device_code);
if (!grant || grant.status !== 'pending' || grant.expires <= Date.now()) {
return ctx.text(
'That sign-in request has expired. Start it again from the app.',
400
);
}
const tokens = await generateTokens(ctx, {
subject,
type: type as string,
properties,
// Carried in an encrypted cookie rather than written to
// the grant, so that a request nobody has confirmed
// leaves nothing on the record a later poll could
// mistake for an answer.
const confirmation: DeviceConfirmation = {
deviceCode: authorization.device_code,
userCode: grant.userCode,
clientID: grant.clientID,
ttl: {
access: subjectOpts?.ttl?.access ?? ttlAccess,
refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh
csrf: generateUnbiasedString(CSRF_ALPHABET, 32),
subject: {
subject,
type: type as string,
properties,
ttl: {
access: subjectOpts?.ttl?.access ?? ttlAccess,
refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh
}
}
});
await putDevice(authorization.device_code, {
...grant,
status: 'approved',
tokens: {
access: tokens.access,
refresh: tokens.refresh,
expiresIn: tokens.expiresIn
}
});
return ctx.text('You are signed in. You can close this page and go back to the app.');
};
await auth.set(ctx, 'device_confirm', ttlDevice, confirmation);
return ctx.html(deviceConfirmPage(confirmation));
}
if (authorization) {
if (authorization.response_type === 'token') {
@@ -701,36 +737,6 @@ export function issuer<
storage
};
/**
* What a device code is while nobody has answered for it yet.
*
* It lives in the same storage as the other short-lived grants rather than
* in a table of its own: it is one of these, an authorization in flight,
* and a code that outlives its own expiry is a bug in whatever swept the
* table rather than something the storage forgets on its own.
*/
interface DeviceGrant {
userCode: string;
clientID: string;
status: 'pending' | 'approved' | 'denied';
/** Seconds the client is being told to wait between polls. Grows. */
interval: number;
/**
* When the last poll that got a real answer arrived, in ms; `0` while
* there has not been one. The first poll is never too early — the
* client has no way to know how long the request itself took, and
* charging it for that would make the first answer arbitrary.
*/
lastPolled: number;
/** When the code stops being usable, in ms. */
expires: number;
tokens?: {
access: string;
refresh: string;
expiresIn: number;
};
}
/**
* The alphabet a user code is drawn from, which is not the whole one.
*
@@ -743,6 +749,26 @@ export function issuer<
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRTVWXY346789';
const USER_CODE_LENGTH = 8;
/** Nothing a person reads, so the whole alphabet is available. */
const CSRF_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
/**
* What is known after signing in and before confirming.
*
* This is the half of the flow that has no answer yet: a browser that has
* proved who it belongs to, holding a code it has not said yes to. It is
* kept in an encrypted cookie rather than on the grant so that a person who
* closes the tab at this point has authorized nothing.
*/
interface DeviceConfirmation {
/** The hash, which is all this side of the flow ever sees. */
deviceCode: string;
userCode: string;
clientID: string;
csrf: string;
subject: DeviceGrantSubject;
}
/**
* The code as stored, from the code as a person typed it.
*
@@ -754,34 +780,46 @@ export function issuer<
return raw.replace(/[^0-9a-zA-Z]/g, '').toUpperCase();
}
function deviceKey(deviceCode: string) {
return ['oauth:device', deviceCode];
/** Enough escaping to put an attacker-chosen client name on a page safely. */
function escapeHtml(raw: string) {
return raw
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function userCodeKey(userCode: string) {
return ['oauth:device:user', userCode];
/**
* The page that asks the only question that authorizes anything.
*
* It shows the code back, because that is the check a person can actually
* perform: the code here and the code on the device in front of them either
* match or they do not, and if they do not then somebody else sent this
* link. Approving is a POST carrying a value that was put in the cookie
* alongside it, so a page on another site cannot submit it on their behalf.
*/
function deviceConfirmPage(confirmation: DeviceConfirmation) {
const code = escapeHtml(confirmation.userCode);
const client = escapeHtml(confirmation.clientID);
return (
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<title>Confirm sign-in</title>` +
`<h1>Is this you?</h1>` +
`<p><strong>${client}</strong> is asking to sign in to your account.</p>` +
`<p>The code it is showing you should be:</p>` +
`<p><code style="font-size:2em;letter-spacing:.2em">${code.slice(0, 4)}-${code.slice(4)}</code></p>` +
`<p>If those do not match, or you did not start this on a device of your own, ` +
`choose Deny. Nobody can sign in as you unless you approve here.</p>` +
`<form method="post" action="/device/confirm">` +
`<input type="hidden" name="csrf" value="${escapeHtml(confirmation.csrf)}">` +
`<button type="submit" name="action" value="approve">Approve</button> ` +
`<button type="submit" name="action" value="deny">Deny</button>` +
`</form>`
);
}
async function findDeviceByUserCode(raw: string) {
const userCode = canonicalUserCode(raw);
const pointer = await Storage.get<{ deviceCode: string }>(storage!, userCodeKey(userCode));
if (!pointer) return null;
const grant = await Storage.get<DeviceGrant>(storage!, deviceKey(pointer.deviceCode));
if (!grant) return null;
return { deviceCode: pointer.deviceCode, grant };
}
async function putDevice(deviceCode: string, grant: DeviceGrant) {
const ttl = Math.max(1, Math.ceil((grant.expires - Date.now()) / 1000));
await Storage.set(storage!, deviceKey(deviceCode), grant, ttl);
}
async function forgetDevice(deviceCode: string, grant: DeviceGrant) {
await Storage.remove(storage!, deviceKey(deviceCode));
await Storage.remove(storage!, userCodeKey(grant.userCode));
}
async function getAuthorization(ctx: Context) {
async function getAuthorization(ctx: Context) {
const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization');
if (!match) throw new UnknownStateError();
return match as AuthorizationState;
@@ -1103,29 +1141,48 @@ export function issuer<
if (grantType === DEVICE_GRANT) {
const deviceCode = form.get('device_code')?.toString();
const clientID = form.get('client_id')?.toString();
if (!deviceCode)
return c.json(
{ error: 'invalid_request', error_description: 'Missing device_code' },
400
);
const grant = await Storage.get<DeviceGrant>(storage, deviceKey(deviceCode));
if (!clientID)
return c.json(
{ error: 'invalid_request', error_description: 'Missing client_id' },
400
);
const hash = await hashDeviceCode(deviceCode);
const grant = await deviceStore.byDeviceCode(hash);
// A code nobody issued and a code that has aged out are the
// same answer on purpose: telling the two apart would let a
// caller learn which random strings were once real.
if (!grant || grant.expires <= Date.now()) {
if (grant) await forgetDevice(deviceCode, grant);
if (grant) await deviceStore.remove(hash);
return c.json(
{ error: 'expired_token', error_description: 'The device code has expired' },
400
);
}
// The code belongs to the program that asked for it. Without
// this, a code leaked to anybody at all is redeemable by
// anybody at all, and the client identifier the token ends up
// carrying is whatever the last caller claimed.
if (grant.clientID !== clientID) {
return c.json(
{ error: 'invalid_grant', error_description: 'That device code belongs to another client' },
400
);
}
// Terminal answers come before the rate limit. Slowing down a
// client that has already been refused just means it takes
// longer to find out, and it has no reason to poll again.
if (grant.status === 'denied') {
await forgetDevice(deviceCode, grant);
await deviceStore.remove(hash);
return c.json(
{ error: 'access_denied', error_description: 'The request was denied' },
400
@@ -1145,26 +1202,50 @@ export function issuer<
// that lives ten minutes must stay pollable for all of it.
// Uncapped, enough impatience early on makes the code
// unusable for the rest of its life.
await putDevice(deviceCode, {
...grant,
interval: Math.min(grant.interval + 5, DEVICE_MAX_INTERVAL)
});
await deviceStore.recordPoll(
hash,
grant.lastPolled,
Math.min(grant.interval + 5, DEVICE_MAX_INTERVAL)
);
return c.json({ error: 'slow_down', error_description: 'Polling too frequently' }, 400);
}
if (grant.status === 'approved' && grant.tokens) {
// One redemption. A device code that keeps working after it
// has produced tokens is a bearer token with none of a
// bearer token's expiry.
await forgetDevice(deviceCode, grant);
if (grant.status === 'approved') {
// One redemption, and the store is what enforces it: taking
// the grant away and reading it are the same operation, so
// two polls arriving together cannot both be served. A
// device code that keeps working after it has produced
// tokens is a bearer token with none of a bearer token's
// expiry.
const claimed = await deviceStore.consume(hash, clientID);
if (!claimed?.subject) {
return c.json(
{ error: 'expired_token', error_description: 'The device code has expired' },
400
);
}
// Minted now rather than at approval, so the lifetime the
// client is told about starts when it receives them. Tokens
// made when the person clicked would already have been
// ageing for however long the next poll took, and a grant
// nobody ever collects would have left a usable refresh
// token lying in the store.
const tokens = await generateTokens(c, {
subject: claimed.subject.subject,
type: claimed.subject.type,
properties: claimed.subject.properties,
clientID: claimed.clientID,
ttl: claimed.subject.ttl
});
return c.json({
access_token: grant.tokens.access,
refresh_token: grant.tokens.refresh,
expires_in: grant.tokens.expiresIn
access_token: tokens.access,
refresh_token: tokens.refresh,
expires_in: tokens.expiresIn
});
}
await putDevice(deviceCode, { ...grant, lastPolled: now });
await deviceStore.recordPoll(hash, now, grant.interval);
return c.json(
{
error: 'authorization_pending',
@@ -1237,8 +1318,18 @@ export function issuer<
const clientID = form?.get('client_id')?.toString();
if (!clientID)
return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400);
if (input.allowDeviceClient && !(await input.allowDeviceClient(clientID, c.req.raw)))
return c.json(
{ error: 'invalid_client', error_description: 'Unknown client_id' },
400
);
// 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.
const deviceCode = generateUnbiasedString(CSRF_ALPHABET, 43);
const deviceCodeHash = await hashDeviceCode(deviceCode);
const deviceCode = crypto.randomUUID();
// Retried rather than trusted to be unique: the alphabet is small
// on purpose, so a collision is likelier than it would be for the
// device code, and a collision here hands one person's sign-in to
@@ -1246,7 +1337,7 @@ export function issuer<
let userCode = '';
for (let attempt = 0; attempt < 5; attempt++) {
const candidate = generateUnbiasedString(USER_CODE_ALPHABET, USER_CODE_LENGTH);
if (!(await Storage.get(storage, userCodeKey(candidate)))) {
if (!(await deviceStore.byUserCode(candidate))) {
userCode = candidate;
break;
}
@@ -1257,17 +1348,15 @@ export function issuer<
500
);
const now = Date.now();
const grant: DeviceGrant = {
await deviceStore.create({
deviceCodeHash,
userCode,
clientID,
status: 'pending',
interval: deviceInterval,
lastPolled: 0,
expires: now + ttlDevice * 1000
};
await putDevice(deviceCode, grant);
await Storage.set(storage, userCodeKey(userCode), { deviceCode }, ttlDevice);
expires: Date.now() + ttlDevice * 1000
});
const iss = issuer(c);
return c.json({
@@ -1284,11 +1373,15 @@ export function issuer<
// The browser half. Entering the code puts the flow into the same
// authorization state a redirect-based client would have set, so the
// providers below are reached by exactly one path either way.
//
// Reaching this page authorizes nothing. It starts a sign-in, and the
// sign-in ends at a confirmation page — see `/device/confirm`.
app.get('/device', async (c) => {
const raw = c.req.query('user_code');
if (!raw) {
return c.html(
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<title>Sign in to a device</title>` +
`<form method="get" action="/device">` +
`<label for="user_code">Enter the code shown in the app</label>` +
`<input id="user_code" name="user_code" autocomplete="off" autofocus>` +
@@ -1297,15 +1390,15 @@ export function issuer<
);
}
const found = await findDeviceByUserCode(raw);
if (!found || found.grant.status !== 'pending' || found.grant.expires <= Date.now()) {
const found = await deviceStore.byUserCode(canonicalUserCode(raw));
if (!found || found.status !== 'pending' || found.expires <= Date.now()) {
return c.text('That code is not valid any more. Ask the app for a new one.', 400);
}
const authorization: AuthorizationState = {
response_type: 'device_code',
client_id: found.grant.clientID,
device_code: found.deviceCode
client_id: found.clientID,
device_code: found.deviceCodeHash
} as AuthorizationState;
await auth.set(c, 'authorization', ttlDevice, authorization);
@@ -1324,21 +1417,46 @@ export function issuer<
);
});
// Refusing is an answer, and the client has a screen for it. Without this
// a person who did not start the sign-in can only walk away, and the
// program on the other machine keeps polling until the code expires.
app.get('/device/deny', async (c) => {
const raw = c.req.query('user_code');
if (!raw) return c.text('Missing user_code', 400);
const found = await findDeviceByUserCode(raw);
if (!found || found.grant.expires <= Date.now()) {
return c.text('That code is not valid any more.', 400);
// The step that actually authorizes, and the reason there is one.
//
// Anybody at all can ask for a device code and be handed a link with the
// user code already filled in. If following that link and signing in were
// enough, then sending it to somebody would be enough: they would sign in
// to what looks like an ordinary prompt, and whoever kept the device code
// would poll and collect their tokens. What stops that is not the sign-in,
// which the victim performs perfectly well — it is being shown the code and
// the program asking, and having to say yes to *that*.
//
// A POST, because it changes something. Carrying a value from the cookie,
// so another site cannot post it on the person's behalf.
app.post('/device/confirm', async (c) => {
const confirmation = (await auth.get(c, 'device_confirm')) as DeviceConfirmation | undefined;
if (!confirmation) {
return c.text('That sign-in request has expired. Start it again from the app.', 400);
}
await putDevice(found.deviceCode, { ...found.grant, status: 'denied' });
return c.text('That sign-in request was refused.');
await auth.unset(c, 'device_confirm');
const form = await c.req.formData().catch(() => null);
const csrf = form?.get('csrf')?.toString() ?? '';
if (!timingSafeCompare(confirmation.csrf, csrf)) {
return c.text('That form was not the one we sent. Start again from the app.', 400);
}
if (form?.get('action')?.toString() === 'deny') {
await deviceStore.deny(confirmation.deviceCode);
return c.text('That sign-in request was refused. You can close this page.');
}
// The store decides, not this code. If a refusal got here first the
// answer is already given and an approval must not overwrite it.
const approved = await deviceStore.approve(confirmation.deviceCode, confirmation.subject);
if (!approved) {
return c.text('That sign-in request has already been answered.', 400);
}
return c.text('You are signed in. You can close this page and go back to the app.');
});
app.get('/authorize', async (c) => {
app.get('/authorize', async (c) => {
const provider = c.req.query('provider');
const response_type = c.req.query('response_type');
const redirect_uri = c.req.query('redirect_uri');

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bu
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';
@@ -12,10 +13,14 @@ const subjects = createSubjects({
})
});
const deviceStore = MemoryDeviceStore();
const auth = issuer({
storage: MemoryStorage(),
deviceStore,
subjects,
allow: async () => true,
allowDeviceClient: async (clientID) => clientID !== 'banned',
providers: {
dummy: {
type: 'dummy',
@@ -31,47 +36,94 @@ const auth = issuer({
const ORIGIN = 'https://auth.example.com';
async function begin() {
/** 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: 'desktop' })
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.json() as Promise<{
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) {
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: 'desktop'
client_id: clientID
})
});
return { status: response.status, body: (await response.json()) as any };
}
/** Walk the browser half: enter the code, then finish the provider flow. */
async function approve(userCode: string) {
/**
* 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);
const cookie = entered.headers.get('set-cookie')!;
expect(cookie).toBeTruthy();
const done = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), {
headers: { cookie }
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 })
});
expect(done.status).toBe(200);
return done;
}
beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z')));
@@ -79,21 +131,21 @@ afterEach(() => setSystemTime());
describe('device authorization request', () => {
test('answers with everything the polling client needs', async () => {
const started = await begin();
const grant = await started();
expect(started.device_code).toMatch(/.+/);
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(started.user_code).toMatch(/^[A-Z0-9]{8}$/);
expect(started.verification_uri).toBe(`${ORIGIN}/device`);
expect(started.verification_uri_complete).toContain(started.user_code);
expect(started.interval).toBeGreaterThanOrEqual(1);
expect(started.expires_in).toBeGreaterThan(started.interval);
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 begin();
const b = await begin();
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);
});
@@ -104,77 +156,113 @@ describe('device authorization request', () => {
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 started = await begin();
const first = await poll(started.device_code);
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 started = await begin();
await poll(started.device_code);
const grant = await started();
await poll(grant.device_code);
const tooSoon = await poll(started.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() + (started.interval + 1) * 1000));
const stillTooSoon = await poll(started.device_code);
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() + (started.interval + 6) * 1000));
const patient = await poll(started.device_code);
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 answer = await poll('not-a-device-code');
expect(answer.status).toBe(400);
expect(answer.body.error).toBe('expired_token');
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 started = await begin();
setSystemTime(new Date(Date.now() + (started.expires_in + 60) * 1000));
const answer = await poll(started.device_code);
expect(answer.body.error).toBe('expired_token');
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 started = await begin();
await approve(started.user_code);
const grant = await started();
const confirmed = await answer(grant.user_code, 'approve');
expect(confirmed.status).toBe(200);
setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000));
const answer = await poll(started.device_code);
expect(answer.status).toBe(200);
expect(answer.body.access_token).toMatch(/.+/);
expect(answer.body.refresh_token).toMatch(/.+/);
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 started = await begin();
await approve(started.user_code);
const grant = await started();
await answer(grant.user_code, 'approve');
setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000));
expect((await poll(started.device_code)).status).toBe(200);
setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000));
expect((await poll(started.device_code)).body.error).toBe('expired_token');
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 started = await begin();
const chunked = `${started.user_code.slice(0, 4)}-${started.user_code.slice(4)}`;
await approve(chunked.toLowerCase());
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() + (started.interval + 1) * 1000));
expect((await poll(started.device_code)).status).toBe(200);
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 () => {
@@ -183,13 +271,143 @@ describe('approval', () => {
});
test('a refusal is final, and says so', async () => {
const started = await begin();
const denied = await auth.request(
`${ORIGIN}/device/deny?user_code=${encodeURIComponent(started.user_code)}`
);
const grant = await started();
const denied = await answer(grant.user_code, 'deny');
expect(denied.status).toBe(200);
const answer = await poll(started.device_code);
expect(answer.body.error).toBe('access_denied');
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);
});
});

View File

@@ -0,0 +1,39 @@
-- A device authorization grant, while it is still in flight.
--
-- Short-lived state that would sit happily in a cache, in a table anyway. The
-- reason is not durability. Each transition here has to happen exactly once
-- while two parties are touching the same row — a browser somebody is clicking
-- through, and a program on another machine polling every few seconds — and a
-- store that can only read and write whole records cannot promise that: the
-- poll reads, the browser approves, the poll writes back what it read, and the
-- approval is gone. Here, approving is one conditional update and redeeming is
-- one delete that returns what it deleted, so neither can undo the other.
--
-- `device_code_hash` and not the code. The device code is the credential the
-- tokens are handed to, so what is kept is enough to recognise it and not
-- enough to present it. `user_code` is stored as written, because it is read
-- off one screen and typed into another by the person looking at both, and it
-- lives for minutes.
--
-- Rows are swept when a new grant is created rather than on a schedule. A grant
-- lives ten minutes and that is the only statement that adds one, so the table
-- stays bounded by how many sign-ins are in flight.
CREATE TYPE "public"."device_grant_status" AS ENUM('pending', 'approved', 'denied');--> statement-breakpoint
CREATE TABLE "device_grant" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"device_code_hash" text NOT NULL,
"user_code" text NOT NULL,
"client_id" text NOT NULL,
"status" "device_grant_status" DEFAULT 'pending' NOT NULL,
"poll_interval" integer NOT NULL,
"last_polled_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"subject" jsonb
);
--> statement-breakpoint
CREATE UNIQUE INDEX "device_grant_device_code_unique" ON "device_grant" USING btree ("device_code_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "device_grant_user_code_unique" ON "device_grant" USING btree ("user_code");

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1788555252186,
"tag": "0009_email_is_the_root_identity",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788590292860,
"tag": "0010_device_authorization_grant",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,62 @@
import { integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
export const DeviceGrantStatusEnum = pgEnum('device_grant_status', [
'pending',
'approved',
'denied'
]);
/**
* A device authorization grant, while it is still in flight.
*
* This is short-lived state that would sit happily in a cache, and it is in a
* table anyway. The reason is that every transition here has to happen exactly
* once while two parties are touching the row — a browser somebody is clicking
* through, and a program polling every few seconds — and a store that can only
* read and write whole records cannot promise that. Here, approving is one
* conditional update and redeeming is one delete that returns what it deleted,
* so the two cannot interleave into each other.
*
* `device_code_hash` and not the code: the code is the credential the tokens
* are handed to, so what is kept is enough to recognise it and not enough to
* present it. `user_code` is stored as written, because it is read off a screen
* by the person who is looking at it and lives for minutes.
*/
export const DeviceGrantTable = pgTable(
'device_grant',
{
...id,
...timestamps,
deviceCodeHash: text('device_code_hash').notNull(),
userCode: text('user_code').notNull(),
clientId: text('client_id').notNull(),
status: DeviceGrantStatusEnum('status').notNull().default('pending'),
/** Seconds the client is currently being told to wait between polls. */
pollInterval: integer('poll_interval').notNull(),
/** Null until a poll has been given a real answer. */
lastPolledAt: utc('last_polled_at'),
expiresAt: utc('expires_at').notNull(),
/**
* Who the grant turned out to be for, written when it is approved.
*
* Not the tokens. Those are minted when the waiting program redeems the
* code, so their lifetime starts when they are handed over and a grant
* nobody collects leaves no usable credential behind.
*/
subject: jsonb('subject').$type<{
subject: string;
type: string;
properties: unknown;
ttl: { access: number; refresh: number };
}>()
},
(t) => [
uniqueIndex('device_grant_device_code_unique').on(t.deviceCodeHash),
uniqueIndex('device_grant_user_code_unique').on(t.userCode)
]
);

View File

@@ -0,0 +1,201 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import type { DeviceGrant, DeviceGrantSubject } from '@nestri/auth/device';
import { testDb } from '../db/test.js';
import { PostgresDeviceStore } from './device-grant.js';
const sql = testDb();
const store = PostgresDeviceStore();
const SUBJECT: DeviceGrantSubject = {
subject: 'user:usr_fixture',
type: 'user',
properties: { userID: 'usr_fixture' },
ttl: { access: 60, refresh: 600 }
};
let counter = 0;
function hash(): string {
counter += 1;
return `device-grant-fixture-${counter}`.padEnd(64, '0');
}
function pending(overrides: Partial<DeviceGrant> = {}): DeviceGrant {
const deviceCodeHash = overrides.deviceCodeHash ?? hash();
return {
deviceCodeHash,
userCode: `UC${deviceCodeHash.slice(-6)}`,
clientID: 'desktop',
status: 'pending',
interval: 5,
lastPolled: 0,
expires: Date.now() + 600_000,
...overrides
};
}
async function cleanup() {
await sql`delete from device_grant where device_code_hash like 'device-grant-fixture-%'`;
}
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await sql.end();
});
describe('what the store remembers', () => {
test('a grant is findable by either code, and comes back as it went in', async () => {
const grant = pending();
await store.create(grant);
const byDevice = await store.byDeviceCode(grant.deviceCodeHash);
expect(byDevice).toMatchObject({
deviceCodeHash: grant.deviceCodeHash,
userCode: grant.userCode,
clientID: 'desktop',
status: 'pending',
interval: 5,
lastPolled: 0
});
expect((await store.byUserCode(grant.userCode))?.deviceCodeHash).toBe(grant.deviceCodeHash);
});
test('creating a grant clears out the ones that aged out', async () => {
const stale = pending({ expires: Date.now() - 1000 });
await store.create(stale);
await store.create(pending());
const rows = await sql`
select count(*)::int as n from device_grant where device_code_hash = ${stale.deviceCodeHash}
`;
expect(rows[0]!.n).toBe(0);
});
});
/**
* The properties the flow is built on, asserted against a real database.
*
* Each of these is a claim that a transition happens once even though two
* parties are racing for it, and each is enforced by a `where` clause rather
* than by application code. That is exactly the sort of claim that reads as
* obviously true and is obviously false the moment the condition is dropped, so
* it is worth a test that would notice.
*/
describe('transitions that must happen once', () => {
test('a grant is approved once', async () => {
const grant = pending();
await store.create(grant);
expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(true);
expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(false);
});
test('an approval cannot overwrite a refusal', async () => {
const grant = pending();
await store.create(grant);
expect(await store.deny(grant.deviceCodeHash)).toBe(true);
expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(false);
expect((await store.byDeviceCode(grant.deviceCodeHash))?.status).toBe('denied');
});
test('a refusal cannot overwrite an approval', async () => {
const grant = pending();
await store.create(grant);
expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(true);
expect(await store.deny(grant.deviceCodeHash)).toBe(false);
expect((await store.byDeviceCode(grant.deviceCodeHash))?.status).toBe('approved');
});
test('several approvals arriving together settle on one', async () => {
const grant = pending();
await store.create(grant);
const results = await Promise.all(
Array.from({ length: 5 }, () => store.approve(grant.deviceCodeHash, SUBJECT))
);
expect(results.filter(Boolean)).toHaveLength(1);
});
test('a grant that has aged out can no longer be answered', async () => {
const grant = pending({ expires: Date.now() - 1000 });
// Inserted directly, because creating one sweeps it.
await sql`
insert into device_grant (id, device_code_hash, user_code, client_id, status, poll_interval, expires_at)
values ('dvg_expired_fixture0000000000', ${grant.deviceCodeHash}, ${grant.userCode},
'desktop', 'pending', 5, now() - interval '1 second')
`;
expect(await store.approve(grant.deviceCodeHash, SUBJECT)).toBe(false);
expect(await store.deny(grant.deviceCodeHash)).toBe(false);
});
});
describe('redeeming', () => {
test('an approved grant is redeemed once, and carries who it was for', async () => {
const grant = pending();
await store.create(grant);
await store.approve(grant.deviceCodeHash, SUBJECT);
const claimed = await store.consume(grant.deviceCodeHash, 'desktop');
expect(claimed?.subject).toEqual(SUBJECT);
expect(await store.consume(grant.deviceCodeHash, 'desktop')).toBeNull();
});
test('several polls arriving together are served once', async () => {
const grant = pending();
await store.create(grant);
await store.approve(grant.deviceCodeHash, SUBJECT);
const results = await Promise.all(
Array.from({ length: 5 }, () => store.consume(grant.deviceCodeHash, 'desktop'))
);
expect(results.filter(Boolean)).toHaveLength(1);
});
test('another client cannot redeem the code', async () => {
const grant = pending();
await store.create(grant);
await store.approve(grant.deviceCodeHash, SUBJECT);
expect(await store.consume(grant.deviceCodeHash, 'somebody-else')).toBeNull();
// And the real client is not robbed of it in the attempt.
expect(await store.consume(grant.deviceCodeHash, 'desktop')).not.toBeNull();
});
test('a grant nobody approved is not redeemable', async () => {
const grant = pending();
await store.create(grant);
expect(await store.consume(grant.deviceCodeHash, 'desktop')).toBeNull();
});
});
/**
* The bug this store exists to make impossible.
*
* A poll reads a pending grant, the browser approves while the poll is in
* flight, and then the poll writes down that it happened. If writing that down
* means writing the whole record back, the approval is gone and the client
* polls a dead grant until it expires.
*/
describe('recording a poll', () => {
test('touches the bookkeeping and nothing else', async () => {
const grant = pending();
await store.create(grant);
const stale = await store.byDeviceCode(grant.deviceCodeHash);
expect(stale!.status).toBe('pending');
await store.approve(grant.deviceCodeHash, SUBJECT);
await store.recordPoll(grant.deviceCodeHash, Date.now(), stale!.interval + 5);
const after = await store.byDeviceCode(grant.deviceCodeHash);
expect(after!.status).toBe('approved');
expect(after!.subject).toEqual(SUBJECT);
expect(after!.interval).toBe(10);
expect(after!.lastPolled).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,153 @@
import type { DeviceGrant, DeviceGrantSubject, DeviceStore } from '@nestri/auth/device';
import { and, eq, lt, sql } from 'drizzle-orm';
import { Database } from '../db/index.js';
import { Identifier } from '../id.js';
import { DeviceGrantTable } from './device-grant.sql.js';
type Row = typeof DeviceGrantTable.$inferSelect;
function toGrant(row: Row): DeviceGrant {
return {
deviceCodeHash: row.deviceCodeHash,
userCode: row.userCode,
clientID: row.clientId,
status: row.status,
interval: row.pollInterval,
lastPolled: row.lastPolledAt?.getTime() ?? 0,
expires: row.expiresAt.getTime(),
subject: row.subject ?? undefined
};
}
/**
* Device authorization grants, kept where a conditional write is possible.
*
* Each method below is one statement on purpose. The interface asks for
* transitions that happen exactly once while a browser and a polling client are
* both touching the same grant, and the only way to promise that is to let the
* database decide: `update ... where status = 'pending'` either changes a row
* or does not, and `delete ... returning` hands the row to exactly one caller.
* Read it, decide in application code, and write it back, and the two callers
* undo each other — which is the bug this shape exists to make impossible.
*/
export function PostgresDeviceStore(): DeviceStore {
return {
async create(grant) {
await Database.use(async (tx) => {
// Swept here rather than on a schedule. A grant lives ten
// minutes and this is the only statement that adds one, so the
// table is bounded by how many sign-ins are in flight without
// anything else having to run.
await tx.delete(DeviceGrantTable).where(lt(DeviceGrantTable.expiresAt, new Date()));
await tx.insert(DeviceGrantTable).values({
id: Identifier.ascending('deviceGrant'),
deviceCodeHash: grant.deviceCodeHash,
userCode: grant.userCode,
clientId: grant.clientID,
status: grant.status,
pollInterval: grant.interval,
lastPolledAt: grant.lastPolled ? new Date(grant.lastPolled) : null,
expiresAt: new Date(grant.expires),
subject: grant.subject ?? null
});
});
},
async byDeviceCode(deviceCodeHash) {
return Database.use(async (tx) =>
tx
.select()
.from(DeviceGrantTable)
.where(eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash))
.then((rows) => (rows[0] ? toGrant(rows[0]) : null))
);
},
async byUserCode(userCode) {
return Database.use(async (tx) =>
tx
.select()
.from(DeviceGrantTable)
.where(eq(DeviceGrantTable.userCode, userCode))
.then((rows) => (rows[0] ? toGrant(rows[0]) : null))
);
},
async approve(deviceCodeHash, subject: DeviceGrantSubject) {
return Database.use(async (tx) =>
tx
.update(DeviceGrantTable)
.set({ status: 'approved', subject })
.where(
and(
eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash),
eq(DeviceGrantTable.status, 'pending'),
sql`${DeviceGrantTable.expiresAt} > now()`
)
)
.returning({ id: DeviceGrantTable.id })
.then((rows) => rows.length > 0)
);
},
async deny(deviceCodeHash) {
return Database.use(async (tx) =>
tx
.update(DeviceGrantTable)
.set({ status: 'denied' })
.where(
and(
eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash),
eq(DeviceGrantTable.status, 'pending'),
sql`${DeviceGrantTable.expiresAt} > now()`
)
)
.returning({ id: DeviceGrantTable.id })
.then((rows) => rows.length > 0)
);
},
async consume(deviceCodeHash, clientID) {
// Deleting and reading are the same statement, so two polls
// arriving together cannot both be served: one deletes the row and
// gets it, the other deletes nothing and gets nothing.
return Database.use(async (tx) =>
tx
.delete(DeviceGrantTable)
.where(
and(
eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash),
eq(DeviceGrantTable.clientId, clientID),
eq(DeviceGrantTable.status, 'approved'),
sql`${DeviceGrantTable.expiresAt} > now()`
)
)
.returning()
.then((rows) => (rows[0] ? toGrant(rows[0]) : null))
);
},
async recordPoll(deviceCodeHash, at, interval) {
// Two columns, and deliberately not the rest of the row. Writing
// the whole grant back here is what would let a poll that read a
// pending record undo an approval that landed while it was in
// flight.
await Database.use(async (tx) => {
await tx
.update(DeviceGrantTable)
.set({ lastPolledAt: new Date(at), pollInterval: interval })
.where(eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash));
});
},
async remove(deviceCodeHash) {
await Database.use(async (tx) => {
await tx
.delete(DeviceGrantTable)
.where(eq(DeviceGrantTable.deviceCodeHash, deviceCodeHash));
});
}
};
}

View File

@@ -19,7 +19,8 @@ export namespace Identifier {
userLibrary: 'ulb',
gameDepot: 'gdp',
gameDownload: 'gdl',
waitlistEntry: 'wle'
waitlistEntry: 'wle',
deviceGrant: 'dvg'
} as const;
export function schema(prefix: keyof typeof prefixes) {