diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index b53e382f..6a001a7c 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -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 diff --git a/packages/auth/src/device.ts b/packages/auth/src/device.ts new file mode 100644 index 00000000..65c3b0c3 --- /dev/null +++ b/packages/auth/src/device.ts @@ -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; + byDeviceCode(deviceCodeHash: string): Promise; + byUserCode(userCode: string): Promise; + + /** + * 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; + + /** Pending to denied, in one operation. Same rule as {@link approve}. */ + deny(deviceCodeHash: string): Promise; + + /** + * 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; + + /** + * 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; + + remove(deviceCodeHash: string): Promise; +} + +/** + * 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 { + 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(); + const byCode = new Map(); + + 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); + } + }; +} diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index e512bbdb..05e5bee3 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -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; /** * 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( - 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } - 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 ( + `` + + `Confirm sign-in` + + `

Is this you?

` + + `

${client} is asking to sign in to your account.

` + + `

The code it is showing you should be:

` + + `

${code.slice(0, 4)}-${code.slice(4)}

` + + `

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.

` + + `
` + + `` + + ` ` + + `` + + `
` + ); } - 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(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(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( `` + + `Sign in to a device` + `
` + `` + `` + @@ -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'); diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts index ccc26614..4be67308 100644 --- a/packages/auth/test/device.test.ts +++ b/packages/auth/test/device.test.ts @@ -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(); + 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); }); }); diff --git a/packages/core/migrations/0010_device_authorization_grant.sql b/packages/core/migrations/0010_device_authorization_grant.sql new file mode 100644 index 00000000..94d4020f --- /dev/null +++ b/packages/core/migrations/0010_device_authorization_grant.sql @@ -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"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0010_snapshot.json b/packages/core/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..3699756f --- /dev/null +++ b/packages/core/migrations/meta/0010_snapshot.json @@ -0,0 +1,2451 @@ +{ + "id": "8915fb5b-8f0d-4f6f-a808-27b19bb4604a", + "prevId": "b26664d8-eebf-4563-9c3c-7e00e41b646b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 2a19ba6a..57035969 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/core/src/auth/device-grant.sql.ts b/packages/core/src/auth/device-grant.sql.ts new file mode 100644 index 00000000..e534ed22 --- /dev/null +++ b/packages/core/src/auth/device-grant.sql.ts @@ -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) + ] +); diff --git a/packages/core/src/auth/device-grant.test.ts b/packages/core/src/auth/device-grant.test.ts new file mode 100644 index 00000000..e057585f --- /dev/null +++ b/packages/core/src/auth/device-grant.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/src/auth/device-grant.ts b/packages/core/src/auth/device-grant.ts new file mode 100644 index 00000000..81153881 --- /dev/null +++ b/packages/core/src/auth/device-grant.ts @@ -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)); + }); + } + }; +} diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index d52b5771..35fd625c 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -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) {