From da65cca4f2f098c7114407c0102b5e1fa0a3a244 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:01:15 +0300 Subject: [PATCH 01/13] feat(core): an account is an email address, and Steam is a connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in with Steam used to create the account. That made a second Steam account a second person, and it made losing a Steam account lose everything attached to it — the boxes, the team, the billing history. Invert it. A user comes into existence by verifying an email address and nothing else; a Steam account hangs off a user that already exists, capped at four. Signing in with Steam resolves an account and refuses when there is none, so the accounts made before this keep working — they already have the connection this looks for — while nothing new is created behind a persona. The cap lives here rather than in the schema because a unique index cannot count the rows sharing a foreign key. The email column gains a partial unique index instead, which is the constraint that can be expressed, and the address is trimmed and lower-cased at the edge so two spellings are not two accounts. --- packages/core/src/user/identity.test.ts | 200 ++++++++++++++++++++++ packages/core/src/user/identity.ts | 215 ++++++++++++++++++++++++ packages/core/src/user/user.sql.ts | 42 ++++- 3 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/user/identity.test.ts create mode 100644 packages/core/src/user/identity.ts diff --git a/packages/core/src/user/identity.test.ts b/packages/core/src/user/identity.test.ts new file mode 100644 index 00000000..d42305f7 --- /dev/null +++ b/packages/core/src/user/identity.test.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; + +import { testDb } from '../db/test.js'; +import { Identifier } from '../id.js'; +import { Identity } from './identity.js'; +import { User } from './index.js'; +import { LinkedAccount } from './linked-account.js'; + +const sql = testDb(); + +const createdUserIDs: string[] = []; + +function steamID(n: number): string { + return String(76561197960299000n + BigInt(n)); +} + +function email(n: number): string { + return `identity-fixture-${n}@example.test`; +} + +function track(userID: string) { + createdUserIDs.push(userID); + return userID; +} + +async function cleanup() { + createdUserIDs.length = 0; + // By fixture shape rather than by tracked id: a test that throws before it + // records the row it made would otherwise leave one behind, and the next + // run would read it as an account that already existed. + await sql`delete from "user" where email like 'identity-fixture-%@example.test'`; + await sql` + delete from "user" u + where exists ( + select 1 from linked_account l + where l.user_id = u.id + and l.provider = 'steam' + and l.provider_account_id like '765611979602990%' + ) + `; +} + +async function countUsers(): Promise { + const rows = await sql`select count(*)::int as n from "user"`; + return rows[0]!.n as number; +} + +/** A user as the database holds them today: made by Steam, with no email. */ +async function legacySteamUser(n: number) { + const userID = track(Identifier.ascending('user')); + await User.create({ + id: userID, + name: `legacy-${n}`, + email: undefined, + emailVerified: false, + image: null + }); + const linkID = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ + id: linkID, + userId: userID, + provider: 'steam', + providerAccountId: steamID(n), + profile: null + }); + return { userID, linkID, steamId: steamID(n) }; +} + +afterAll(async () => { + await cleanup(); + await sql.end(); +}); + +describe('Identity.resolveSteamLogin', () => { + beforeEach(cleanup); + + test('a Steam account made before email existed still signs in to the same user', async () => { + const legacy = await legacySteamUser(1); + + const resolved = await Identity.resolveSteamLogin({ steamId: legacy.steamId }); + + expect(resolved.userID).toBe(legacy.userID); + expect(resolved.linkedAccountID).toBe(legacy.linkID); + }); + + test('an unknown Steam account is refused and creates no user', async () => { + const before = await countUsers(); + + let thrown: any = null; + try { + await Identity.resolveSteamLogin({ steamId: steamID(99) }); + } catch (err) { + thrown = err; + } + + expect(thrown).not.toBeNull(); + expect(thrown.type).toBe('not_found'); + expect(await countUsers()).toBe(before); + }); +}); + +describe('Identity.fromVerifiedEmail', () => { + beforeEach(cleanup); + + test('a verified email creates the user, and nothing else is needed', async () => { + const result = await Identity.fromVerifiedEmail({ email: email(1), name: 'Player One' }); + track(result.userID); + + expect(result.created).toBe(true); + const user = await User.fromID(result.userID); + expect(user?.email).toBe(email(1)); + expect(user?.emailVerified).toBe(true); + }); + + test('the same address resolves to the same user rather than a second one', async () => { + const first = await Identity.fromVerifiedEmail({ email: email(2) }); + track(first.userID); + const before = await countUsers(); + + const second = await Identity.fromVerifiedEmail({ email: ` ${email(2).toUpperCase()} ` }); + + expect(second.userID).toBe(first.userID); + expect(second.created).toBe(false); + expect(await countUsers()).toBe(before); + }); +}); + +describe('input the flow refuses before it reaches the database', () => { + // `fn()` parses before it calls, so a rejected input throws where it is + // written rather than resolving to a rejected promise later on. + test('an address that is not one never becomes an account', () => { + expect(() => Identity.fromVerifiedEmail({ email: 'not-an-address' })).toThrow(); + }); + + test('a Steam id of the wrong shape is not looked up', () => { + expect(() => Identity.resolveSteamLogin({ steamId: '123' })).toThrow(); + }); +}); + +describe('Identity.linkSteam', () => { + beforeEach(cleanup); + + test('a fifth Steam account is refused and the fourth still stands', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(3) }); + track(userID); + + for (let n = 10; n < 14; n++) { + await Identity.linkSteam({ userId: userID, steamId: steamID(n) }); + } + + let thrown: any = null; + try { + await Identity.linkSteam({ userId: userID, steamId: steamID(14) }); + } catch (err) { + thrown = err; + } + + expect(thrown).not.toBeNull(); + expect(thrown.code).toBe('invalid_state'); + const links = await LinkedAccount.listByUser(userID); + expect(links.filter((l) => l.provider === 'steam')).toHaveLength(Identity.MAX_STEAM_ACCOUNTS); + }); + + test('relinking the same Steam account is not a fifth account', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(4) }); + track(userID); + + const first = await Identity.linkSteam({ userId: userID, steamId: steamID(20) }); + const again = await Identity.linkSteam({ userId: userID, steamId: steamID(20) }); + + expect(again).toBe(first); + }); + + test('a Steam account already held by somebody else is a conflict', async () => { + const legacy = await legacySteamUser(30); + const { userID } = await Identity.fromVerifiedEmail({ email: email(5) }); + track(userID); + + let thrown: any = null; + try { + await Identity.linkSteam({ userId: userID, steamId: legacy.steamId }); + } catch (err) { + thrown = err; + } + + expect(thrown).not.toBeNull(); + expect(thrown.type).toBe('already_exists'); + }); + + test('a legacy user is claimed by attaching an email, and keeps its Steam link', async () => { + const legacy = await legacySteamUser(40); + + const claimed = await Identity.claimWithEmail({ userId: legacy.userID, email: email(6) }); + + expect(claimed.email).toBe(email(6)); + expect(claimed.emailVerified).toBe(true); + const resolved = await Identity.resolveSteamLogin({ steamId: legacy.steamId }); + expect(resolved.userID).toBe(legacy.userID); + }); +}); diff --git a/packages/core/src/user/identity.ts b/packages/core/src/user/identity.ts new file mode 100644 index 00000000..a07c07be --- /dev/null +++ b/packages/core/src/user/identity.ts @@ -0,0 +1,215 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import z from 'zod'; + +import { Database } from '../db/index.js'; +import { ErrorCodes, VisibleError } from '../error.js'; +import { fn } from '../fn.js'; +import { Identifier } from '../id.js'; +import { User } from './index.js'; +import { LinkedAccount } from './linked-account.js'; +import { LinkedAccountTable } from './linked-account.sql.js'; + +const STEAM_ID_RE = /^\d{17}$/; + +/** + * Email is the root of an account, so two spellings of one address must not be + * two accounts. Case and surrounding whitespace are the two ways the same + * address arrives looking different; both are removed at the edge, before + * anything is stored or compared, and the unique index in the database + * assumes it has been. + */ +const Email = z.string().trim().toLowerCase().pipe(z.email()); + +export namespace Identity { + /** + * How many Steam accounts one person may hang off their account. + * + * This is not — and cannot be — a database constraint. A unique index makes a + * value unique; it cannot count the rows that share a foreign key, so there is + * no index shape that says "at most four of these". The number lives here and + * a direct write to the table can still exceed it. Anyone reading the schema + * and looking for the rule will not find one, which is why it is written down + * in the migration as well as here. ref(d-0048) + * + * Four comes from the size of a household that shares a game library and from + * the account switcher needing to fit on one row. It is a product constraint + * and not a measured one. + */ + export const MAX_STEAM_ACCOUNTS = 4; + + /** + * The account behind a verified email address, created if it is new. + * + * This is the only way a user comes into existence. Everything else — a + * Steam account, an SSH key — attaches to a user that already exists, + * which is what makes losing one of them survivable. ref(d-0048) + * + * Idempotent on the address: verifying the same mailbox twice is one + * person signing in twice, not two accounts. + */ + export const fromVerifiedEmail = fn( + z.object({ email: Email, name: z.string().optional() }), + async (input) => { + const email = input.email; + return Database.transaction(async () => { + const existing = await User.fromEmail(email); + if (existing) { + // An address that was attached but never confirmed is + // confirmed now: getting here means a code was redeemed. + if (!existing.emailVerified) { + await User.setEmail({ id: existing.id, email, emailVerified: true }); + } + return { userID: existing.id, created: false }; + } + + const userID = Identifier.ascending('user'); + await User.create({ + id: userID, + name: input.name?.trim() || email.split('@')[0]!, + email, + emailVerified: true, + image: null + }); + return { userID, created: true }; + }); + } + ); + + /** + * Attach a verified email to an account that never had one. + * + * Accounts predate the rule that email is the root, so a live database + * holds users with no address at all. Each one is claimed exactly once, + * here, and afterwards it is an ordinary account. + */ + export const claimWithEmail = fn( + z.object({ userId: z.string(), email: Email }), + async (input) => { + const email = input.email; + return Database.transaction(async () => { + const holder = await User.fromEmail(email); + if (holder && holder.id !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That email address already belongs to another account' + ); + } + const updated = await User.setEmail({ + id: input.userId, + email, + emailVerified: true + }); + if (!updated) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such account' + ); + } + return updated; + }); + } + ); + + /** + * The account a Steam sign-in belongs to, or an error. + * + * Signing in with Steam never creates anything. A Steam account is a link + * on a user, so one that names no user is a person who has not signed up — + * an answer the interface has to render, not a reason to mint a row. The + * accounts that predate this are exactly the ones a link already exists + * for, so they keep working without a special case. ref(d-0048) + */ + export const resolveSteamLogin = fn( + z.object({ steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID') }), + async (input) => { + const link = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: input.steamId + }); + if (!link) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'This Steam account is not connected to an account. Sign in with your email address first, then connect Steam from your settings.' + ); + } + return { userID: link.userId, linkedAccountID: link.id }; + } + ); + + /** + * Hang a Steam account off a user, up to {@link MAX_STEAM_ACCOUNTS}. + * + * The count and the insert are one transaction because they are one + * decision: read the four, then write the fifth, and two concurrent calls + * each see four. + */ + export const linkSteam = fn( + z.object({ + userId: z.string(), + steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID'), + profile: z.record(z.string(), z.unknown()).nullable().optional() + }), + async (input) => { + return Database.transaction(async (tx) => { + const existing = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: input.steamId + }); + if (existing) { + if (existing.userId !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That Steam account is already connected to a different account' + ); + } + if (input.profile) { + await LinkedAccount.updateProfile({ id: existing.id, profile: input.profile }); + } + return existing.id; + } + + // `for update` on the rows already there, so a second caller + // holding at the cap waits rather than counting alongside. + const held = await tx + .select({ id: LinkedAccountTable.id }) + .from(LinkedAccountTable) + .where( + and( + eq(LinkedAccountTable.userId, input.userId), + eq(LinkedAccountTable.provider, 'steam'), + isNull(LinkedAccountTable.timeDeleted) + ) + ) + .for('update'); + + if (held.length >= MAX_STEAM_ACCOUNTS) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_STATE, + `An account can have at most ${MAX_STEAM_ACCOUNTS} Steam accounts connected. Disconnect one before adding another.` + ); + } + + const id = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ + id, + userId: input.userId, + provider: 'steam', + providerAccountId: input.steamId, + profile: input.profile ?? null + }); + return id; + }); + } + ); + + /** Every Steam account connected to this user, oldest first. */ + export const listSteam = fn(z.string(), async (userId) => { + const links = await LinkedAccount.listByUser(userId); + return links.filter((link) => link.provider === 'steam'); + }); +} diff --git a/packages/core/src/user/user.sql.ts b/packages/core/src/user/user.sql.ts index b21ba1eb..40499edb 100644 --- a/packages/core/src/user/user.sql.ts +++ b/packages/core/src/user/user.sql.ts @@ -1,12 +1,36 @@ -import { boolean, pgTable, text } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { boolean, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { id, timestamps } from '../db/types.js'; -export const UserTable = pgTable('user', { - ...id, - ...timestamps, - name: text('name').notNull(), - email: text('email'), - emailVerified: boolean('email_verified').notNull().default(false), - image: text('image') -}); +export const UserTable = pgTable( + 'user', + { + ...id, + ...timestamps, + name: text('name').notNull(), + /** + * The address the account is rooted in. + * + * Nullable only because accounts exist that predate the rule — every + * one of those was made by signing in with a gaming account and was + * never asked for an address. A new account cannot be created without + * one. ref(d-0048) + */ + email: text('email'), + emailVerified: boolean('email_verified').notNull().default(false), + image: text('image') + }, + (t) => [ + // One address, one account, which is what makes it a root identity + // rather than a contact detail. Partial because the accounts made + // before this rule have no address at all, and "no address" is not a + // value two of them can collide on. + // + // The column holds a trimmed, lower-cased address; nothing here + // enforces that, so anything writing it has to normalize first. + uniqueIndex('user_email_unique') + .on(t.email) + .where(sql`email is not null and time_deleted is null`) + ] +); From 1e81a8f92d81c7a0f9c33379255d22f67b154823 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:02:00 +0300 Subject: [PATCH 02/13] feat(core): make one address one account, on rows that never had one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs against a database where every user was created by a gaming sign-in, so most rows have no email at all and nothing has ever stopped two rows from sharing one. The address is normalized first, duplicates are separated before the unique index exists — the older row keeps the address, the newer one is asked for a new one and loses nothing else — and the index is partial so that accounts with no address do not collide with each other. Verified against a database built to contain the awkward rows rather than against an empty schema, by the script alongside it: an account with no address, one with both, one with two connections, a duplicated address in two different cases, an account already over the connection cap, and a deleted row holding an address a live row also holds. Removing the de-duplication makes the index creation fail, which is how we know the fixtures are load-bearing. Also adds a nullable column recording which attempt holds a session run. It is not part of the change above and carries no reason of its own; the endpoint that reads and writes it arrives separately, and it is here because a schema change has one owner at a time. --- .../0009_email_is_the_root_identity.sql | 66 + .../core/migrations/meta/0009_snapshot.json | 2316 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/script/verify-migration-0009.sh | 177 ++ 4 files changed, 2566 insertions(+) create mode 100644 packages/core/migrations/0009_email_is_the_root_identity.sql create mode 100644 packages/core/migrations/meta/0009_snapshot.json create mode 100755 packages/core/script/verify-migration-0009.sh diff --git a/packages/core/migrations/0009_email_is_the_root_identity.sql b/packages/core/migrations/0009_email_is_the_root_identity.sql new file mode 100644 index 00000000..dbffc35c --- /dev/null +++ b/packages/core/migrations/0009_email_is_the_root_identity.sql @@ -0,0 +1,66 @@ +-- One address, one account. ref(d-0048) +-- +-- Runs against a database in which every user was created by signing in with a +-- gaming account, which means most rows have no email at all and nothing has +-- ever stopped two rows from sharing one. Three consequences, in order: +-- +-- 1. The column is normalized first. The address is about to become an +-- identity, so `Ada@Example.com ` and `ada@example.com` have to stop +-- being two of them. Trimming and lower-casing happens here once; the +-- code that writes the column does the same thing on the way in. +-- 2. Duplicates are separated before the index exists, because +-- `CREATE UNIQUE INDEX` fails outright on the first pair it meets, and a +-- migration that dies half way through is worse than one that decides. +-- 3. The index is partial. A null email is not a value, so the accounts that +-- have none do not collide with each other — which is the only reason a +-- unique index can land on these rows at all. + +UPDATE "user" +SET "email" = lower(btrim("email")) +WHERE "email" IS NOT NULL + AND "email" <> lower(btrim("email"));--> statement-breakpoint + +-- Where two accounts claim one address, the older keeps it. +-- +-- Nothing is deleted: both accounts survive, with their games, their hardware +-- and their team. What the newer one loses is the address, and `email_verified` +-- goes back to false to say so — the next sign-in asks for an address and the +-- person supplies one, which is a prompt rather than a loss. +-- +-- The older row wins because its address has been in use longest, so it is the +-- one a receipt or a reset was most likely sent to. `id` breaks a tie on +-- `time_created`, so the choice is total and re-running this changes nothing. +UPDATE "user" u +SET "email" = NULL, "email_verified" = false +WHERE u."email" IS NOT NULL + AND u."time_deleted" IS NULL + AND EXISTS ( + SELECT 1 FROM "user" older + WHERE older."email" = u."email" + AND older."time_deleted" IS NULL + AND (older."time_created", older."id") < (u."time_created", u."id") + );--> statement-breakpoint + +CREATE UNIQUE INDEX "user_email_unique" ON "user" USING btree ("email") WHERE email is not null and time_deleted is null;--> statement-breakpoint + +-- There is no constraint here for the cap on how many gaming accounts one +-- person may connect, and there cannot be one. +-- +-- A unique index makes a value unique; it cannot count the rows that share a +-- foreign key, so no index shape says "at most four of these". The cap is +-- enforced in application code, and a direct write to `linked_account` can +-- exceed it. This is written where the schema is read so that nobody looks for +-- the rule here, fails to find it, and concludes there is not one. Rows +-- already over the cap are left alone: the limit governs connecting another, +-- not keeping what is already connected. + +-- Below is not part of the above, and carries no reason of its own. +-- +-- It records which attempt holds a run: the agent generates an opaque value +-- per claim, the row remembers the first one to arrive, and every later write +-- has to present it. Nullable and unbackfilled, because a run nobody has +-- claimed genuinely has no holder, and never cleared, because a finished run +-- still has to say which attempt ran it. The endpoint that reads and writes it +-- arrives separately; it is here because a schema change has one owner at a +-- time. +ALTER TABLE "session" ADD COLUMN "claim_token" text; diff --git a/packages/core/migrations/meta/0009_snapshot.json b/packages/core/migrations/meta/0009_snapshot.json new file mode 100644 index 00000000..d1b56e7c --- /dev/null +++ b/packages/core/migrations/meta/0009_snapshot.json @@ -0,0 +1,2316 @@ +{ + "id": "b26664d8-eebf-4563-9c3c-7e00e41b646b", + "prevId": "2c16a831-745e-4e1d-ad95-11aaa30e31f0", + "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.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 + } + }, + "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.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 ffa36f48..2a19ba6a 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1788547836146, "tag": "0008_session_one_active_run_per_box", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1788555252186, + "tag": "0009_email_is_the_root_identity", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/script/verify-migration-0009.sh b/packages/core/script/verify-migration-0009.sh new file mode 100755 index 00000000..50f72669 --- /dev/null +++ b/packages/core/script/verify-migration-0009.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# +# Prove this migration against a database built to look like the live one, +# rather than against an empty schema. +# +# A migration that only ever runs on a database with no rows in it has +# demonstrated nothing: every statement here that could go wrong goes wrong +# because of what is already in the table. So this builds the awkward rows by +# hand — an account with no address, two accounts sharing one address in +# different cases, an account already over the connection cap, a soft-deleted +# row holding an address a live row also holds — applies every migration +# before this one, then applies this one and checks each of them individually. +# +# Usage: PGHOST=localhost PGPORT=5434 ./verify-migration-0009.sh +set -euo pipefail + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5434}" +PGUSER="${PGUSER:-postgres}" +export PGPASSWORD="${PGPASSWORD:-postgres}" +DB="${DB:-nestri_mig_0009}" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATIONS="$HERE/../migrations" + +psql_admin() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -qtA "$@"; } +psql_db() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$DB" -qtA -v ON_ERROR_STOP=1 "$@"; } + +failures=0 +check() { # check + local got + got="$(psql_db -c "$3" | tr -d '[:space:]')" + if [ "$got" = "$2" ]; then + printf 'ok %s\n' "$1" + else + printf 'FAIL %s — expected %s, got %s\n' "$1" "$2" "$got" + failures=$((failures + 1)) + fi +} + +echo "== rebuilding $DB ==" +psql_admin -c "drop database if exists $DB" >/dev/null +psql_admin -c "create database $DB" >/dev/null + +echo "== applying everything before it ==" +for f in "$MIGRATIONS"/000[0-8]_*.sql; do + psql_db -f "$f" >/dev/null + printf ' %s\n' "$(basename "$f")" +done + +echo "== seeding rows the way the live database actually looks ==" +psql_db >/dev/null <<'SQL' +-- ids are char(30): a four-character prefix and 26 more. +-- A: made by a gaming sign-in, no address at all. The ordinary case today. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'no-email', null, false, now() - interval '10 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'steam', '76561100000000001'); + +-- B: has both, and the address is stored with the case and spacing a person typed. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'both', ' Ada@Example.COM ', true, now() - interval '9 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'steam', '76561100000000002'); + +-- C: one person, two gaming accounts. Nothing may touch either. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_cccccccccccccccccccccccccc', 'two-links', null, false, now() - interval '8 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_cc1ccccccccccccccccccccccc', 'usr_cccccccccccccccccccccccccc', 'steam', '76561100000000003'), + ('lac_cc2ccccccccccccccccccccccc', 'usr_cccccccccccccccccccccccccc', 'steam', '76561100000000004'); + +-- D and E: two accounts on one address, spelled differently. Nothing ever +-- stopped this, so a live database is entitled to contain it. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_dddddddddddddddddddddddddd', 'older-dup', 'grace@example.com', true, now() - interval '7 days'), + ('usr_eeeeeeeeeeeeeeeeeeeeeeeeee', 'newer-dup', 'GRACE@example.com', true, now() - interval '6 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_eeeeeeeeeeeeeeeeeeeeeeeeee', 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee', 'steam', '76561100000000005'); + +-- F: already over the cap the application is about to start enforcing. +insert into "user" (id, name, email, email_verified, time_created) values + ('usr_ffffffffffffffffffffffffff', 'over-cap', null, false, now() - interval '5 days'); +insert into linked_account (id, user_id, provider, provider_account_id) values + ('lac_ff1fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000006'), + ('lac_ff2fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000007'), + ('lac_ff3fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000008'), + ('lac_ff4fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000009'), + ('lac_ff5fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000010'); + +-- G: a deleted account still holding an address a live account also holds. The +-- index has to tolerate this or the migration fails on a row nobody can see. +insert into "user" (id, name, email, email_verified, time_created, time_deleted) values + ('usr_gggggggggggggggggggggggggg', 'deleted-dup', 'grace@example.com', true, now() - interval '4 days', now()); + +-- A run in flight, so the new column lands on a row that already exists. +insert into team (id, name, slug, owner_id) values + ('tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'T', 't', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'); +insert into team_member (id, team_id, user_id, role) values + ('mem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'owner'); +insert into machine (id, owner_user_id, team_id, label, secret_hash) values + ('mch_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'host', 'hash'); +insert into game (id, steam_app_id, name, slug) values + ('gam_aaaaaaaaaaaaaaaaaaaaaaaaaa', 730, 'G', 'g'); +insert into box (id, user_id, machine_id, label) values + ('box_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'mch_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'b'); +insert into "session" (id, box_id, game_id, linked_account_id, state) values + ('ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'box_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'gam_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'lac_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'live'); +SQL + +before_users="$(psql_db -c 'select count(*) from "user"')" +before_links="$(psql_db -c 'select count(*) from linked_account')" +echo " $before_users users, $before_links connected accounts" + +echo "== applying the migration under test ==" +psql_db -f "$MIGRATIONS/0009_email_is_the_root_identity.sql" >/dev/null +echo " 0009_email_is_the_root_identity.sql" + +echo "== checking ==" +check "no account was deleted" "$before_users" 'select count(*) from "user"' +check "no connection was deleted" "$before_links" 'select count(*) from linked_account' + +check "A: an account with no address is untouched" "t" \ + "select email is null and email_verified = false from \"user\" where id = 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'" +check "A: its connected account survives" "1" \ + "select count(*) from linked_account where user_id = 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'" + +check "B: the address is normalized in place" "ada@example.com" \ + "select email from \"user\" where id = 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb'" +check "B: it stays verified" "t" \ + "select email_verified from \"user\" where id = 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb'" + +check "C: two connected accounts are still two" "2" \ + "select count(*) from linked_account where user_id = 'usr_cccccccccccccccccccccccccc'" + +check "D: the older of the pair keeps the address" "grace@example.com" \ + "select email from \"user\" where id = 'usr_dddddddddddddddddddddddddd'" +check "D: and stays verified" "t" \ + "select email_verified from \"user\" where id = 'usr_dddddddddddddddddddddddddd'" +check "E: the newer one loses it and is asked again" "t" \ + "select email is null and email_verified = false from \"user\" where id = 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee'" +check "E: but keeps its account and its connection" "1" \ + "select count(*) from linked_account where user_id = 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee'" + +check "F: an account already over the cap is left alone" "5" \ + "select count(*) from linked_account where user_id = 'usr_ffffffffffffffffffffffffff'" + +check "G: a deleted row may keep a live row's address" "grace@example.com" \ + "select email from \"user\" where id = 'usr_gggggggggggggggggggggggggg'" + +check "the index exists" "1" \ + "select count(*) from pg_indexes where indexname = 'user_email_unique'" +# If the insert is allowed, the raise below is not a unique_violation, so it is +# not caught, and psql stops on it — which reads as a failure rather than a pass. +check "and a second live account cannot take a taken address" "refused" \ + "do \$\$ begin + insert into \"user\" (id, name, email) values ('usr_zzzzzzzzzzzzzzzzzzzzzzzzzz', 'z', 'grace@example.com'); + raise exception 'the index allowed a duplicate address'; + exception when unique_violation then null; + end \$\$; select 'refused'" + +check "the claim column is there" "1" \ + "select count(*) from information_schema.columns where table_name = 'session' and column_name = 'claim_token'" +check "the claim column is nullable" "YES" \ + "select is_nullable from information_schema.columns where table_name = 'session' and column_name = 'claim_token'" +check "the claim column has no default" "1" \ + "select count(*) from information_schema.columns where table_name = 'session' and column_name = 'claim_token' and column_default is null" +check "and nothing was backfilled into it" "1" \ + "select count(*) from \"session\" where claim_token is null" + +echo +if [ "$failures" -eq 0 ]; then + echo "all checks passed" +else + echo "$failures check(s) failed" + exit 1 +fi From 2a1b7abe9ad604ac863d1ef733f10e389bb10375 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:02:15 +0300 Subject: [PATCH 03/13] feat(auth): serve the device authorization grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program with no browser — the desktop app — had a client for RFC 8628 and nothing to point it at. This serves the other half: a device authorization request that hands back a code, a page a person enters that code on, and a token endpoint that answers the poll. Both of the paths the client already implements are now reachable. Polling faster than the advertised interval gets slow_down, and each warning widens the interval so ignoring one costs more than the last; refusing gets access_denied, so a request nobody started stops instead of being polled until it ages out. The interval is capped, because it only ever grows and a code has to stay pollable for the whole of its life. The codes live in the same storage as the other short-lived grants rather than in a table, since that is what they are. User codes are drawn from an alphabet with no vowels and no look-alike pairs, and are accepted back in whatever case and spacing a person retyped them in. --- packages/auth/package.json | 4 + packages/auth/src/issuer.ts | 352 +++++++++++++++++++++++++++++- packages/auth/src/random.ts | 22 ++ packages/auth/test/device.test.ts | 195 +++++++++++++++++ 4 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 packages/auth/test/device.test.ts diff --git a/packages/auth/package.json b/packages/auth/package.json index a7627a76..70729b14 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,6 +7,10 @@ "type": "module", "sideEffects": false, "exports": { + "./ui/code": { + "types": "./src/ui/code.tsx", + "import": "./src/ui/code.tsx" + }, "./*": { "types": "./src/*.ts", "import": "./src/*.ts" diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index ebc421ea..e512bbdb 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -175,6 +175,13 @@ export interface AuthorizationState { challenge: string; method: 'S256'; }; + /** + * 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. + */ + device_code?: string; } /** @@ -196,6 +203,7 @@ import { } from './error.js'; import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js'; import { validatePKCE } from './pkce.js'; +import { generateUnbiasedString } from './random.js'; import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; @@ -206,6 +214,12 @@ import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; /** @internal */ export const aws = awsHandle; +/** RFC 8628's grant type, spelled out because it is a URN and not a word. */ +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +/** The longest a device is ever told to wait between polls, in seconds. */ +const DEVICE_MAX_INTERVAL = 60; + export interface IssuerInput< Providers extends Record>, Subjects extends SubjectSchema, @@ -348,6 +362,18 @@ export interface IssuerInput< * @default 0s */ retention?: number; + /** + * Interval in seconds a device code stays usable before the user has to + * start again. + * @default 600s + */ + device?: number; + /** + * Slowest a device may poll the token endpoint without being told to + * slow down, in seconds. + * @default 5s + */ + deviceInterval?: number; }; /** * Optionally, configure the UI that's displayed when the user visits the root URL of the @@ -466,6 +492,8 @@ export function issuer< const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365; const ttlRefreshReuse = input.ttl?.reuse ?? 60; const ttlRefreshRetention = input.ttl?.retention ?? 0; + const ttlDevice = input.ttl?.device ?? 60 * 10; + const deviceInterval = input.ttl?.deviceInterval ?? 5; if (input.theme) { setTheme(input.theme); } @@ -525,6 +553,44 @@ export function issuer< ? subjectOpts.subject : 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) + ); + await auth.unset(ctx, 'authorization'); + 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, + clientID: grant.clientID, + 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.'); + } if (authorization) { if (authorization.response_type === 'token') { const location = new URL(authorization.redirect_uri); @@ -635,6 +701,86 @@ 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. + * + * Someone reads this off one screen and types it into another, so every + * pair that looks or sounds alike is a support ticket: no vowels, so no + * accidental words; no `0`/`O`, `1`/`I`, `5`/`S`, `2`/`Z`. What is left is + * unambiguous read aloud over a phone. RFC 8628 §6.1 asks for exactly this + * trade and the entropy lost is bought back by the length. + */ + const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRTVWXY346789'; + const USER_CODE_LENGTH = 8; + + /** + * The code as stored, from the code as a person typed it. + * + * People retype what they see, which includes the separator that made it + * readable and whatever case their keyboard was in. Neither carries + * meaning, so neither is allowed to make a valid code fail. + */ + function canonicalUserCode(raw: string) { + return raw.replace(/[^0-9a-zA-Z]/g, '').toUpperCase(); + } + + function deviceKey(deviceCode: string) { + return ['oauth:device', deviceCode]; + } + + function userCodeKey(userCode: string) { + return ['oauth:device:user', userCode]; + } + + 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) { const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization'); if (!match) throw new UnknownStateError(); @@ -786,8 +932,15 @@ export function issuer< issuer: iss, authorization_endpoint: `${iss}/authorize`, token_endpoint: `${iss}/token`, + device_authorization_endpoint: `${iss}/device/authorize`, jwks_uri: `${iss}/.well-known/jwks.json`, - response_types_supported: ['code', 'token'] + response_types_supported: ['code', 'token'], + grant_types_supported: [ + 'authorization_code', + 'refresh_token', + 'client_credentials', + DEVICE_GRANT + ] }); } ); @@ -948,6 +1101,79 @@ export function issuer< }); } + if (grantType === DEVICE_GRANT) { + const deviceCode = form.get('device_code')?.toString(); + if (!deviceCode) + return c.json( + { error: 'invalid_request', error_description: 'Missing device_code' }, + 400 + ); + const grant = await Storage.get(storage, deviceKey(deviceCode)); + + // 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); + return c.json( + { error: 'expired_token', error_description: 'The device code has expired' }, + 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); + return c.json( + { error: 'access_denied', error_description: 'The request was denied' }, + 400 + ); + } + + const now = Date.now(); + if (now - grant.lastPolled < grant.interval * 1000) { + // RFC 8628 §3.5: every warning widens the interval for this + // and every later poll, so a client that ignores the answer + // is not simply told the same thing again. `lastPolled` is + // deliberately not moved — the window is measured from the + // last poll that got a real answer, so a burst of impatient + // polls costs one wait rather than compounding into one the + // client can never satisfy. + // Capped, because the interval only ever grows and a code + // 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) + }); + 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); + return c.json({ + access_token: grant.tokens.access, + refresh_token: grant.tokens.refresh, + expires_in: grant.tokens.expiresIn + }); + } + + await putDevice(deviceCode, { ...grant, lastPolled: now }); + return c.json( + { + error: 'authorization_pending', + error_description: 'The user has not finished signing in' + }, + 400 + ); + } + if (grantType === 'client_credentials') { const provider = form.get('provider'); if (!provider) return c.json({ error: 'missing `provider` form value' }, 400); @@ -995,6 +1221,123 @@ export function issuer< } ); + // The machine half of RFC 8628. A program with no browser asks for a code + // here, shows it to whoever is sitting in front of it, and polls `/token` + // until somebody has answered for it on a device that does have one. + app.post( + '/device/authorize', + cors({ + origin: '*', + allowHeaders: ['*'], + allowMethods: ['POST'], + credentials: false + }), + async (c) => { + const form = await c.req.formData().catch(() => null); + const clientID = form?.get('client_id')?.toString(); + if (!clientID) + return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400); + + 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 + // somebody else's machine. + 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)))) { + userCode = candidate; + break; + } + } + if (!userCode) + return c.json( + { error: 'server_error', error_description: 'Could not allocate a user code' }, + 500 + ); + + const now = Date.now(); + const grant: DeviceGrant = { + userCode, + clientID, + status: 'pending', + interval: deviceInterval, + lastPolled: 0, + expires: now + ttlDevice * 1000 + }; + await putDevice(deviceCode, grant); + await Storage.set(storage, userCodeKey(userCode), { deviceCode }, ttlDevice); + + const iss = issuer(c); + return c.json({ + device_code: deviceCode, + user_code: userCode, + verification_uri: `${iss}/device`, + verification_uri_complete: `${iss}/device?user_code=${userCode}`, + expires_in: ttlDevice, + interval: deviceInterval + }); + } + ); + + // 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. + app.get('/device', async (c) => { + const raw = c.req.query('user_code'); + if (!raw) { + return c.html( + `` + + `
` + + `` + + `` + + `` + + `
` + ); + } + + const found = await findDeviceByUserCode(raw); + if (!found || found.grant.status !== 'pending' || found.grant.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 + } as AuthorizationState; + await auth.set(c, 'authorization', ttlDevice, authorization); + + const provider = c.req.query('provider'); + if (provider) return c.redirect(`/${provider}/authorize`); + const providers = Object.keys(input.providers); + if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); + return auth.forward( + c, + await select()( + Object.fromEntries( + Object.entries(input.providers).map(([key, value]) => [key, value.type]) + ), + c.req.raw + ) + ); + }); + + // 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); + } + await putDevice(found.deviceCode, { ...found.grant, status: 'denied' }); + return c.text('That sign-in request was refused.'); + }); + app.get('/authorize', async (c) => { const provider = c.req.query('provider'); const response_type = c.req.query('response_type'); @@ -1125,6 +1468,13 @@ export function issuer< return auth.forward(c, await error(err, c.req.raw)); } const authorization = await getAuthorization(c); + // A device grant has no redirect to carry the error back on, so it is + // said here instead. Without this the reporting path throws on a URL + // built from `undefined` and the real failure is never printed. + if (!authorization.redirect_uri) { + const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); + return c.text(oauth.description || oauth.error, 400); + } const url = new URL(authorization.redirect_uri); const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); url.searchParams.set('error', oauth.error); diff --git a/packages/auth/src/random.ts b/packages/auth/src/random.ts index dc6dec2d..7666afac 100644 --- a/packages/auth/src/random.ts +++ b/packages/auth/src/random.ts @@ -22,3 +22,25 @@ export function timingSafeCompare(a: string, b: string): boolean { } return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } + +/** + * A random string over an explicit alphabet, without modulo bias. + * + * Bytes that fall outside the largest whole multiple of the alphabet size are + * thrown away rather than folded in, because folding them makes the first few + * symbols more likely than the rest — which for a short code that gates an + * account is a real narrowing of the search space and not a rounding error. + */ +export function generateUnbiasedString(alphabet: string, length: number): string { + const limit = 256 - (256 % alphabet.length); + let result = ''; + while (result.length < length) { + const buffer = crypto.getRandomValues(new Uint8Array(length * 2)); + for (const byte of buffer) { + if (byte < limit && result.length < length) { + result += alphabet[byte % alphabet.length]; + } + } + } + return result; +} diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts new file mode 100644 index 00000000..ccc26614 --- /dev/null +++ b/packages/auth/test/device.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; + +import { object, string } from 'valibot'; + +import { issuer } from '../src/issuer.js'; +import { MemoryStorage } from '../src/storage/memory.js'; +import { createSubjects } from '../src/subject.js'; + +const subjects = createSubjects({ + user: object({ + userID: string() + }) +}); + +const auth = issuer({ + storage: MemoryStorage(), + subjects, + allow: async () => true, + providers: { + dummy: { + type: 'dummy', + init(route, ctx) { + route.get('/authorize', async (c) => { + return ctx.success(c, { email: 'foo@bar.com' }); + }); + } + } + }, + success: async (ctx) => ctx.subject('user', { userID: '123' }) +}); + +const ORIGIN = 'https://auth.example.com'; + +async function begin() { + const response = await auth.request(`${ORIGIN}/device/authorize`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: 'desktop' }) + }); + expect(response.status).toBe(200); + return response.json() as Promise<{ + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }>; +} + +async function poll(deviceCode: string) { + 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' + }) + }); + 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) { + 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 } + }); + expect(done.status).toBe(200); + return done; +} + +beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z'))); +afterEach(() => setSystemTime()); + +describe('device authorization request', () => { + test('answers with everything the polling client needs', async () => { + const started = await begin(); + + expect(started.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); + }); + + test('two requests do not collide', async () => { + const a = await begin(); + const b = await begin(); + expect(a.device_code).not.toBe(b.device_code); + expect(a.user_code).not.toBe(b.user_code); + }); + + test('the metadata document advertises the endpoint and the grant', async () => { + const response = await auth.request(`${ORIGIN}/.well-known/oauth-authorization-server`); + const body: any = await response.json(); + expect(body.device_authorization_endpoint).toBe(`${ORIGIN}/device/authorize`); + expect(body.grant_types_supported).toContain('urn:ietf:params:oauth:grant-type:device_code'); + }); +}); + +describe('polling', () => { + test('an unapproved code is pending', async () => { + const started = await begin(); + const first = await poll(started.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 tooSoon = await poll(started.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); + expect(stillTooSoon.body.error).toBe('slow_down'); + + setSystemTime(new Date(Date.now() + (started.interval + 6) * 1000)); + const patient = await poll(started.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'); + }); + + 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'); + }); +}); + +describe('approval', () => { + test('approving hands the next poll a token', async () => { + const started = await begin(); + await approve(started.user_code); + + 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(/.+/); + }); + + test('a device code is redeemable once', async () => { + const started = await begin(); + await approve(started.user_code); + + 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'); + }); + + 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()); + + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + expect((await poll(started.device_code)).status).toBe(200); + }); + + test('an unknown user code does not start a provider flow', async () => { + const response = await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZZ`); + expect(response.status).toBe(400); + }); + + test('a refusal is final, and says so', async () => { + const started = await begin(); + const denied = await auth.request( + `${ORIGIN}/device/deny?user_code=${encodeURIComponent(started.user_code)}` + ); + expect(denied.status).toBe(200); + + const answer = await poll(started.device_code); + expect(answer.body.error).toBe('access_denied'); + }); +}); From 96b0cf811177dca83ea6d6e98088fa77e8676a11 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:02:16 +0300 Subject: [PATCH 04/13] feat(auth): sign in with an email address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the pin-code provider, which existed and was never reachable, and makes it the only branch that can create an account. Steam now resolves an existing connection instead of minting a user from a persona, and refuses when there is no account behind it — which is an answer the interface renders rather than an implicit signup. Delivery is a small provider-neutral POST rather than a vendor SDK: configure an endpoint, a key and a from address. With none of them set it logs the code outside production so a local sign-in works, and throws in production, because a screen that says "check your email" when nothing was sent leaves someone waiting instead of telling anybody. A person who has only ever signed in by email has no connected account, and the token says so with an empty value — the same one a server-to-server caller has always carried. --- apps/auth/src/email.ts | 61 +++++++++++++ apps/auth/src/index.ts | 171 +++++++++++++++++++++++------------ apps/auth/test/email.test.ts | 81 +++++++++++++++++ 3 files changed, 253 insertions(+), 60 deletions(-) create mode 100644 apps/auth/src/email.ts create mode 100644 apps/auth/test/email.test.ts diff --git a/apps/auth/src/email.ts b/apps/auth/src/email.ts new file mode 100644 index 00000000..e5cfa64b --- /dev/null +++ b/apps/auth/src/email.ts @@ -0,0 +1,61 @@ +/** + * Getting a pin code to a mailbox. + * + * Deliberately not tied to one mail vendor: it posts a small JSON body to + * whatever endpoint is configured, so swapping providers is configuration and + * not a code change. Three settings, all optional except in production — + * `EMAIL_SEND_URL`, `EMAIL_API_KEY`, `EMAIL_FROM`. + */ +export interface MailerConfig { + EMAIL_SEND_URL?: string; + EMAIL_API_KEY?: string; + EMAIL_FROM?: string; + NODE_ENV?: string; +} + +/** + * Send the code, or fail loudly. + * + * With no mailer configured this logs the code and carries on, which is what + * makes a local sign-in possible without a mail account. In production the + * same situation throws instead: a signup screen that says "check your email" + * when nothing was sent is worse than one that says it is broken, because the + * person waits instead of telling anybody. + */ +export async function sendVerificationCode( + config: MailerConfig, + email: string, + code: string +): Promise { + const configured = config.EMAIL_SEND_URL && config.EMAIL_API_KEY && config.EMAIL_FROM; + + if (!configured) { + if (config.NODE_ENV === 'production') { + throw new Error('Email delivery is not configured, so no sign-in code can be sent'); + } + console.log(`[auth] sign-in code for ${email}: ${code}`); + return; + } + + const response = await fetch(config.EMAIL_SEND_URL!, { + method: 'POST', + headers: { + authorization: `Bearer ${config.EMAIL_API_KEY}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + from: config.EMAIL_FROM, + to: [email], + subject: `${code} is your Nestri sign-in code`, + text: + `Your Nestri sign-in code is ${code}.\n\n` + + `It expires shortly. If you did not ask to sign in, you can ignore this.` + }) + }); + + if (!response.ok) { + // The body is included because the useful part of a delivery failure is + // always the provider's own message, and it is otherwise lost. + throw new Error(`Sending the sign-in code failed: ${response.status} ${await response.text()}`); + } +} diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 11336900..58831153 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,25 +1,55 @@ import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; +import { CodeProvider } from '@nestri/auth/provider/code'; import { SshProvider } from '@nestri/auth/provider/ssh'; import { SteamProvider } from '@nestri/auth/provider/steam'; import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; -import { subjects } from '@nestri/core/auth/subjects'; -import { Database } from '@nestri/core/db/index'; -import { Env } from '@nestri/core/env'; +import { CodeUI } from '@nestri/auth/ui/code'; import { Actor } from '@nestri/core/actor'; -import { Identifier } from '@nestri/core/id'; +import { subjects } from '@nestri/core/auth/subjects'; +import { Env } from '@nestri/core/env'; import { Steam } from '@nestri/core/steam/index'; import { Team } from '@nestri/core/team/index'; +import { Identity } from '@nestri/core/user/identity'; import { User } from '@nestri/core/user/index'; import { LinkedAccount } from '@nestri/core/user/linked-account'; +import { sendVerificationCode } from './email.js'; + type Env = { AuthStorage: KVNamespace; HYPERDRIVE: Hyperdrive; STEAM_API_KEY: string; SSH_AUTH_KEY: string; + EMAIL_SEND_URL?: string; + EMAIL_API_KEY?: string; + EMAIL_FROM?: string; + NODE_ENV?: string; }; +/** + * Enough of an address to be worth trying to deliver to. + * + * Deliberately loose: the only test that settles whether an address is real is + * whether the code arrives, and this flow already runs that test. What this + * catches is the empty box and the missing `@` — the cases where nothing could + * possibly be sent — so the screen can say so instead of pretending. + */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Which linked account a token names, for a person who may have none. + * + * An account rooted in an email address starts with nothing attached, so there + * is genuinely no linked account to name and the empty string says so. The + * middleware that reads this already treats an empty value as "no linked + * account", because a server-to-server caller has never had one either. + */ +async function firstSteamLink(userID: string): Promise { + const link = await LinkedAccount.findSteamByUser(userID); + return link?.id ?? ''; +} + export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { Env.init(env as unknown as Record); @@ -29,71 +59,71 @@ export default { namespace: env.AuthStorage }), providers: { + // Verifying an email address is what creates an account. It is + // listed first because it is the only branch below that is + // allowed to bring a person into existence. ref(d-0048) + code: CodeProvider({ + // The UI, with delivery replaced. `CodeUI`'s own hook cannot + // report a bad address back to the screen — it returns + // nothing — and a mistyped address that silently succeeds + // leaves someone waiting for mail that went nowhere. + ...CodeUI({ + copy: { code_info: "We'll email you a code to sign in." }, + sendCode: async () => {} + }), + sendCode: async (claims, code) => { + const email = claims.email?.trim().toLowerCase(); + if (!email || !EMAIL_RE.test(email)) { + return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' }; + } + await sendVerificationCode(env, email, code); + } + }), steam: SteamProvider(), ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY }) }, async success(context, response) { + if (response.provider === 'code') { + const email = (response.claims as Record).email!.trim().toLowerCase(); + const { userID } = await Identity.fromVerifiedEmail({ email }); + + // Every user needs a personal team, because `machine.teamId` + // is notNull and registering a host has nowhere to put it + // otherwise. Idempotent, so running it on every sign-in is + // also what backfills the accounts made before it existed. + const linkedAccountID = await firstSteamLink(userID); + await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => + Team.ensurePersonal({ displayName: email.split('@')[0]! }) + ); + + return context.subject('user', { userID, linkedAccountID }); + } + if (response.provider === 'steam') { const { steamid } = response; - const profileUrl = new URL( - 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/' - ); - profileUrl.searchParams.set('key', env.STEAM_API_KEY); - profileUrl.searchParams.set('steamids', steamid); - const profileRes = await fetch(profileUrl.toString()); - const profileData = (await profileRes.json()) as { - response?: { players?: Array> }; - }; - - const player = profileData?.response?.players?.[0] as any; - const personaname: string = player?.personaname ?? 'Player'; - const avatarfull: string = player?.avatarfull; - - const { userID, linkedAccountID } = await Database.transaction(async () => { - const existing = await LinkedAccount.findByProvider({ - provider: 'steam', - providerAccountId: steamid - }); - - if (existing) { - const user = await User.fromID(existing.userId); - if (!user) throw new Error('User not found for linked account'); - return { userID: user.id, linkedAccountID: existing.id }; - } - - const newUserID = Identifier.ascending('user'); - await User.create({ - id: newUserID, - name: personaname, - email: undefined, - emailVerified: false, - image: avatarfull ?? null - }); - - const newLinkedAccountID = Identifier.ascending('linkedAccount'); - await LinkedAccount.create({ - id: newLinkedAccountID, - userId: newUserID, - provider: 'steam', - providerAccountId: steamid, - profile: player ?? {} - }); - - return { userID: newUserID, linkedAccountID: newLinkedAccountID }; + // Signing in with Steam resolves an account; it never + // creates one. A Steam account is something a person + // attaches to an account they already have, so losing it + // costs them a link and not everything they own. Accounts + // that predate the rule already have the link this finds, + // so they keep working unchanged. ref(d-0048) + const { userID, linkedAccountID } = await Identity.resolveSteamLogin({ + steamId: steamid }); - // Every user needs a personal team, because `machine.teamId` is - // notNull and registering a host has nowhere to put it - // otherwise. `packages/core/CLAUDE.md` documented this call as - // part of the login flow and it was never actually made, so no - // user in the database has one. ref(d-0048) - // - // Run on every login rather than only on creation: that is what - // backfills the accounts made before this existed, and - // `ensurePersonal` is idempotent precisely so it can be. + // The persona is refreshed on the way through, because this + // is the only moment the current one is in hand. + const player = await steamProfile(env.STEAM_API_KEY, steamid); + if (player) { + await LinkedAccount.updateProfile({ id: linkedAccountID, profile: player }); + } + + const user = await User.fromID(userID); await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - Team.ensurePersonal({ displayName: personaname }) + Team.ensurePersonal({ + displayName: user?.name || (player?.personaname as string) || 'Player' + }) ); return context.subject('user', { @@ -111,7 +141,7 @@ export default { profile }); - // Same reason as the Steam branch above. The SSH path creates + // Same reason as the branch above. The SSH path creates // users too, so leaving it out would give a host registered // from `nessh` nowhere to live. await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => @@ -135,3 +165,24 @@ export default { return inner.fetch(request, env, ctx); } }; + +/** The current persona for a Steam account, or null if Steam did not answer. */ +async function steamProfile( + apiKey: string, + steamid: string +): Promise | null> { + try { + const profileUrl = new URL('https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/'); + profileUrl.searchParams.set('key', apiKey); + profileUrl.searchParams.set('steamids', steamid); + + const res = await fetch(profileUrl.toString()); + const data = (await res.json()) as { + response?: { players?: Array> }; + }; + return data?.response?.players?.[0] ?? null; + } catch { + // A stale display name is not a reason to refuse a sign-in. + return null; + } +} diff --git a/apps/auth/test/email.test.ts b/apps/auth/test/email.test.ts new file mode 100644 index 00000000..1961caed --- /dev/null +++ b/apps/auth/test/email.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; + +import { sendVerificationCode } from '../src/email.js'; + +describe('sending a sign-in code', () => { + test('with nothing configured outside production, it does not block a sign-in', async () => { + await sendVerificationCode({ NODE_ENV: 'development' }, 'ada@example.com', '123456'); + }); + + test('with nothing configured in production, it says so instead of pretending', async () => { + await expect( + sendVerificationCode({ NODE_ENV: 'production' }, 'ada@example.com', '123456') + ).rejects.toThrow(/not configured/); + }); + + test('a configured mailer is called with the address and the code', async () => { + let seen: { url: string; body: any; auth: string | null } | null = null; + const original = globalThis.fetch; + globalThis.fetch = (async (url: any, init: any) => { + seen = { + url: String(url), + body: JSON.parse(init.body), + auth: new Headers(init.headers).get('authorization') + }; + return new Response('{}', { status: 200 }); + }) as unknown as typeof fetch; + + try { + await sendVerificationCode( + { + NODE_ENV: 'production', + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io' + }, + 'ada@example.com', + '123456' + ); + } finally { + globalThis.fetch = original; + } + + expect(seen!.url).toBe('https://mail.example.com/send'); + expect(seen!.auth).toBe('Bearer key'); + expect(seen!.body.to).toEqual(['ada@example.com']); + expect(seen!.body.from).toBe('hello@nestri.io'); + expect(seen!.body.text).toContain('123456'); + }); + + test('a refusal from the mailer is not swallowed', async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response('over quota', { status: 429 })) as unknown as typeof fetch; + try { + await expect( + sendVerificationCode( + { + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io' + }, + 'ada@example.com', + '123456' + ) + ).rejects.toThrow(/over quota/); + } finally { + globalThis.fetch = original; + } + }); +}); + +describe('the worker itself', () => { + // Cheap, and it catches the thing a type check cannot: the sign-in screen + // lives in a `.tsx` file, and whether that file can be imported across a + // package boundary at run time is decided by the package's export map + // rather than by the compiler. + test('loads, with every provider it wires resolvable', async () => { + const worker = await import('../src/index.js'); + expect(typeof worker.default.fetch).toBe('function'); + }); +}); From 1b61d2251e909d68e9e2f352858b405ab70471b5 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:03:53 +0300 Subject: [PATCH 05/13] fix(core): hold the connection cap on the path a settings screen uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting a Steam account wrote the row itself, so the limit on how many one person may connect was enforced on the sign-in path and nowhere else — and this is the path the settings screen calls, which makes it the one that would have gone over. It now resolves who is asking and hands over to the single place the rule lives. Two things fall out of that. A Steam account already connected to somebody else is a conflict rather than a silent success returning the other person's row id, and a Steam id of the wrong shape is refused before a lookup. --- packages/core/src/steam/index.ts | 53 ++++++++++++------------- packages/core/src/user/identity.test.ts | 23 +++++++++++ 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/packages/core/src/steam/index.ts b/packages/core/src/steam/index.ts index fc580407..811cdf47 100644 --- a/packages/core/src/steam/index.ts +++ b/packages/core/src/steam/index.ts @@ -6,6 +6,7 @@ import { ErrorCodes, VisibleError } from '../error.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; import { Fingerprint } from '../user/fingerprint.js'; +import { Identity } from '../user/identity.js'; import { User } from '../user/index.js'; import { LinkedAccount } from '../user/linked-account.js'; @@ -163,6 +164,15 @@ async function resolveSshIdentityOnce( } export namespace Steam { + /** + * Connect a Steam account to whoever is asking. + * + * Works out who that is and then hands over to the one place the rules + * live. It used to write the row itself, which meant the cap on how many + * accounts one person may connect held on the sign-in path and not on + * this one — and this is the path a settings screen uses, so it is the + * one that would have been over the limit. + */ export const link = fn( z.object({ steamId: z.string(), @@ -170,34 +180,21 @@ export namespace Steam { userId: z.string().optional() }), async (input) => { - return Database.transaction(async () => { - const existing = await LinkedAccount.findByProvider({ - provider: 'steam', - providerAccountId: input.steamId - }); - if (existing) { - return existing.id; - } - const actor = Actor.use(); - const uid = - input.userId ?? - (actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined); - if (!uid) { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, - 'Cannot link Steam account without a user ID' - ); - } - const id = Identifier.ascending('linkedAccount'); - await LinkedAccount.create({ - id, - userId: uid, - provider: 'steam', - providerAccountId: input.steamId, - profile: input.profile ?? null - }); - return id; + const actor = Actor.use(); + const uid = + input.userId ?? + (actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined); + if (!uid) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Cannot link Steam account without a user ID' + ); + } + return Identity.linkSteam({ + userId: uid, + steamId: input.steamId, + profile: input.profile }); } ); diff --git a/packages/core/src/user/identity.test.ts b/packages/core/src/user/identity.test.ts index d42305f7..6c70472a 100644 --- a/packages/core/src/user/identity.test.ts +++ b/packages/core/src/user/identity.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; +import { Actor } from '../actor.js'; import { testDb } from '../db/test.js'; import { Identifier } from '../id.js'; +import { Steam } from '../steam/index.js'; import { Identity } from './identity.js'; import { User } from './index.js'; import { LinkedAccount } from './linked-account.js'; @@ -187,6 +189,27 @@ describe('Identity.linkSteam', () => { expect(thrown.type).toBe('already_exists'); }); + test('the cap holds on the path the settings screen uses', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(7) }); + track(userID); + for (let n = 50; n < 54; n++) { + await Identity.linkSteam({ userId: userID, steamId: steamID(n) }); + } + + let thrown: any = null; + await Actor.with({ type: 'user', properties: { userID, linkedAccountID: '' } }, async () => { + try { + await Steam.link({ steamId: steamID(54) }); + } catch (err) { + thrown = err; + } + }); + + expect(thrown).not.toBeNull(); + expect(thrown.code).toBe('invalid_state'); + expect(await Identity.listSteam(userID)).toHaveLength(Identity.MAX_STEAM_ACCOUNTS); + }); + test('a legacy user is claimed by attaching an email, and keeps its Steam link', async () => { const legacy = await legacySteamUser(40); From bd163392ca3a576ab78224aed2ba0f2e214b647d Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:10:57 +0300 Subject: [PATCH 06/13] fix(core): declare the claim column the migration adds The migration adds session.claim_token, but neither the schema nor the snapshot knew about it. Nothing breaks today because the two agree with each other; it breaks the moment someone declares the field, because generate then diffs against a snapshot without it and emits ALTER TABLE "session" ADD COLUMN "claim_token" text; which fails on every database the migration has already run against. Declared with no writer yet, so the schema, the snapshot and the database say the same thing. --- packages/core/migrations/meta/0009_snapshot.json | 8 +++++++- packages/core/src/session/session.sql.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/core/migrations/meta/0009_snapshot.json b/packages/core/migrations/meta/0009_snapshot.json index d1b56e7c..9c9a5fac 100644 --- a/packages/core/migrations/meta/0009_snapshot.json +++ b/packages/core/migrations/meta/0009_snapshot.json @@ -1219,6 +1219,12 @@ "type": "text", "primaryKey": false, "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false } }, "indexes": { @@ -2313,4 +2319,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/core/src/session/session.sql.ts b/packages/core/src/session/session.sql.ts index 9c4c31fc..8017f80f 100644 --- a/packages/core/src/session/session.sql.ts +++ b/packages/core/src/session/session.sql.ts @@ -62,7 +62,17 @@ export const SessionTable = pgTable( timeStarted: utc('time_started'), timeStopped: utc('time_stopped'), /** Why it ended badly, when it did. */ - errorMessage: text('error_message') + errorMessage: text('error_message'), + /** + * Which attempt holds this run. Null until an agent claims it, and never + * cleared afterwards — a terminal row still records who ran it, and a + * token that goes back to null lets a dead claim be replayed. + * + * Nothing writes it yet. It is declared here so the schema, the snapshot + * and the database agree; without it the next generated migration adds a + * column that already exists and fails wherever it has run once. + */ + claimToken: text('claim_token') }, (t) => [ index('session_box_idx').on(t.boxId), From affe1e3c738dbafda6f9c8b6b410bbacc9f9f16e Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 09:27:18 +0300 Subject: [PATCH 07/13] refactor(auth): serve one provider, and make it the email one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in with a gaming account or with an SSH key could both bring a user into existence. That makes an account only as recoverable as the thing that created it, and gives one person as many accounts as they have gaming logins — neither of which is what an account is supposed to be now that verifying an address is what creates one. Both are unwired rather than deleted. The provider implementations stay where they are, because connecting a gaming account is still something this product does; it just does it from the API, against a user who already exists, which is a connection hanging off an identity rather than an identity of its own. The worker test followed: it exercised the two flows that are gone, and now covers the one that is left plus an assertion that the other two are not routed, so they cannot come back quietly. --- alchemy.run.ts | 8 +- apps/auth/src/index.ts | 109 ++----------- apps/auth/test/worker.test.ts | 284 ++++++++++++++-------------------- 3 files changed, 137 insertions(+), 264 deletions(-) diff --git a/alchemy.run.ts b/alchemy.run.ts index c58c7170..c943941e 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -5,7 +5,6 @@ import { Redacted } from 'effect'; import * as Effect from 'effect/Effect'; const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!); -const sshAuthKey = process.env.SSH_AUTH_KEY || 'dev-ssh-auth-key-change-in-prod'; const adminSharedSecret = process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod'; @@ -41,11 +40,12 @@ export const Auth = Effect.gen(function* () { return yield* Cloudflare.Worker('auth', { main: 'apps/auth/src/index.ts', compatibility: { flags: ['nodejs_compat'] }, + // No Steam or SSH settings: the issuer serves one provider, and it is + // the email one. Linking a Steam account is `apps/api`'s job and its + // key is bound there. env: { AuthStorage, - HYPERDRIVE: Database, - STEAM_API_KEY: steamApiKey, - SSH_AUTH_KEY: sshAuthKey + HYPERDRIVE: Database }, ...(isPermanent ? { observability: { enabled: true } } : {}) }); diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 58831153..b53e382f 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,17 +1,13 @@ import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; import { CodeProvider } from '@nestri/auth/provider/code'; -import { SshProvider } from '@nestri/auth/provider/ssh'; -import { SteamProvider } from '@nestri/auth/provider/steam'; import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; import { CodeUI } from '@nestri/auth/ui/code'; import { Actor } from '@nestri/core/actor'; import { subjects } from '@nestri/core/auth/subjects'; import { Env } from '@nestri/core/env'; -import { Steam } from '@nestri/core/steam/index'; import { Team } from '@nestri/core/team/index'; import { Identity } from '@nestri/core/user/identity'; -import { User } from '@nestri/core/user/index'; import { LinkedAccount } from '@nestri/core/user/linked-account'; import { sendVerificationCode } from './email.js'; @@ -19,12 +15,10 @@ import { sendVerificationCode } from './email.js'; type Env = { AuthStorage: KVNamespace; HYPERDRIVE: Hyperdrive; - STEAM_API_KEY: string; - SSH_AUTH_KEY: string; EMAIL_SEND_URL?: string; EMAIL_API_KEY?: string; EMAIL_FROM?: string; - NODE_ENV?: string; + EMAIL_DEV_LOG?: string; }; /** @@ -58,10 +52,21 @@ export default { storage: CloudflareStorage({ namespace: env.AuthStorage }), + // One provider, on purpose. + // + // Verifying an email address is the only thing that brings an + // account into existence. Steam and SSH were sign-ins here as well, + // and both could mint a user from a persona or a key — which makes + // the account only as recoverable as the thing that made it, and + // gives one person as many accounts as they have gaming logins. + // + // They are unwired rather than deleted: the providers still exist + // under `packages/auth/src/provider/`, because connecting a Steam + // account is something this product still does. It does it from + // `apps/api`'s `POST /steam/link`, against a user who already + // exists — which is a connection hanging off an identity, and not + // an identity of its own. ref(d-0048) providers: { - // Verifying an email address is what creates an account. It is - // listed first because it is the only branch below that is - // allowed to bring a person into existence. ref(d-0048) code: CodeProvider({ // The UI, with delivery replaced. `CodeUI`'s own hook cannot // report a bad address back to the screen — it returns @@ -78,9 +83,7 @@ export default { } await sendVerificationCode(env, email, code); } - }), - steam: SteamProvider(), - ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY }) + }) }, async success(context, response) { if (response.provider === 'code') { @@ -99,65 +102,6 @@ export default { return context.subject('user', { userID, linkedAccountID }); } - if (response.provider === 'steam') { - const { steamid } = response; - - // Signing in with Steam resolves an account; it never - // creates one. A Steam account is something a person - // attaches to an account they already have, so losing it - // costs them a link and not everything they own. Accounts - // that predate the rule already have the link this finds, - // so they keep working unchanged. ref(d-0048) - const { userID, linkedAccountID } = await Identity.resolveSteamLogin({ - steamId: steamid - }); - - // The persona is refreshed on the way through, because this - // is the only moment the current one is in hand. - const player = await steamProfile(env.STEAM_API_KEY, steamid); - if (player) { - await LinkedAccount.updateProfile({ id: linkedAccountID, profile: player }); - } - - const user = await User.fromID(userID); - await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - Team.ensurePersonal({ - displayName: user?.name || (player?.personaname as string) || 'Player' - }) - ); - - return context.subject('user', { - userID, - linkedAccountID - }); - } - - if (response.provider === 'ssh') { - const { fingerprint, steamId, username, profile } = response; - const { userID, linkedAccountID } = await Steam.resolveSshIdentity({ - fingerprint, - steamId, - username, - profile - }); - - // Same reason as the branch above. The SSH path creates - // users too, so leaving it out would give a host registered - // from `nessh` nowhere to live. - await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - // `username` is optional on the SSH path — a key can arrive - // before a persona does. The slug only has to be derivable, - // not pretty, and a rename is a later problem. - Team.ensurePersonal({ displayName: username ?? 'Player' }) - ); - - return context.subject('user', { - userID, - linkedAccountID, - fingerprint - }); - } - throw new Error('Unknown provider'); } }); @@ -165,24 +109,3 @@ export default { return inner.fetch(request, env, ctx); } }; - -/** The current persona for a Steam account, or null if Steam did not answer. */ -async function steamProfile( - apiKey: string, - steamid: string -): Promise | null> { - try { - const profileUrl = new URL('https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/'); - profileUrl.searchParams.set('key', apiKey); - profileUrl.searchParams.set('steamids', steamid); - - const res = await fetch(profileUrl.toString()); - const data = (await res.json()) as { - response?: { players?: Array> }; - }; - return data?.response?.players?.[0] ?? null; - } catch { - // A stale display name is not a reason to refuse a sign-in. - return null; - } -} diff --git a/apps/auth/test/worker.test.ts b/apps/auth/test/worker.test.ts index 9e0de1e1..bcc47647 100644 --- a/apps/auth/test/worker.test.ts +++ b/apps/auth/test/worker.test.ts @@ -1,125 +1,148 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { createClient } from '@nestri/auth/client'; import { issuer } from '@nestri/auth/index'; -import { SshProvider } from '@nestri/auth/provider/ssh'; -import { SteamProvider } from '@nestri/auth/provider/steam'; +import { CodeProvider } from '@nestri/auth/provider/code'; import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { CodeUI } from '@nestri/auth/ui/code'; import { subjects } from '@nestri/core/auth/subjects'; +/** + * The issuer the worker builds, with the database taken out. + * + * The provider list is the load-bearing part and is the same one + * `apps/auth/src/index.ts` passes: one entry, `code`. `success` is a stub + * because what the real one does — resolve an address to a user and give it a + * team — is core's behaviour and is held by core's own tests. What this file + * holds is the shape of the issuer around it. + */ +let lastCode = ''; const storage = MemoryStorage(); - const auth = issuer({ subjects, storage, allow: async () => true, providers: { - steam: SteamProvider(), - ssh: SshProvider({ sshAuthKey: 'test-ssh-key' }) + code: CodeProvider({ + ...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }), + sendCode: async (_claims, code) => { + lastCode = code; + } + }) }, async success(context, response) { - if (response.provider === 'steam') { + if (response.provider === 'code') { return context.subject('user', { userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' }); } - if (response.provider === 'ssh') { - return context.subject('user', { - userID: 'usr_test123', - linkedAccountID: 'lac_test456', - fingerprint: response.fingerprint - }); - } - throw new Error('unknown provider'); + throw new Error('Unknown provider'); } }); -beforeEach(() => { - globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - - if (url.includes('steamcommunity.com/openid/login')) { - return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 }); - } - - if (url.includes('api.steampowered.com')) { - return new Response( - JSON.stringify({ - response: { - players: [ - { - personaname: 'TestPlayer', - avatarfull: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg', - steamid: '76561197960287956' - } - ] - } - }), - { status: 200 } - ); - } - - return new Response('not found', { status: 404 }); - }) as unknown as typeof fetch; -}); - -afterEach(() => { - globalThis.fetch = fetch; -}); - -describe('Steam auth flow', () => { - test('authorize redirects to Steam OpenID', async () => { +/** + * Signing in with a gaming account or a key is gone, and this is the assertion + * that keeps it gone. + * + * Both used to be providers here and both could bring a user into existence + * from something that is not an address, which is the shape the account model + * no longer has. The provider implementations still exist and can be wired + * back; what must not happen quietly is them becoming reachable again. + */ +describe('what the issuer serves', () => { + test('there is no sign-in with a gaming account', async () => { const response = await auth.request('https://auth.internal/steam/authorize'); - expect(response.status).toBe(302); - expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/); + expect(response.status).toBe(404); }); - test('full code flow and token verification', async () => { - const client = createClient({ - issuer: 'https://auth.internal', - clientID: 'api', - fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + test('there is no sign-in with a key', async () => { + const response = await auth.request('https://auth.internal/ssh/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fingerprint: 'SHA256:abc123', steamId: '76561198012345678' }) }); + expect(response.status).toBe(404); + }); - const { challenge, url } = await client.authorize( - 'https://client.example.com/callback', - 'code', - { pkce: true, provider: 'steam' } - ); + test('asking for a code is where a sign-in starts', async () => { + const response = await auth.request('https://auth.internal/code/authorize'); + expect(response.status).toBe(200); + }); +}); - // Step 1: hit the authorize URL → redirects to Steam OpenID - const authResponse = await auth.request(url); - expect(authResponse.status).toBe(302); - const cookie = authResponse.headers.get('set-cookie')!; - expect(cookie).toBeDefined(); +/** + * A cookie jar, because this flow needs two cookies at once. + * + * `/authorize` sets the one holding the authorization, the code provider sets + * the one holding its own state, and both have to be presented at the verify + * step. `Headers.get('set-cookie')` returns only the first of several, which + * silently drops one of them. + */ +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('; '); + } + }; +} - // Step 2: simulate Steam redirecting back to our callback with valid OpenID params - const callbackUrl = - 'https://auth.internal/steam/callback?' + - 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + - 'openid.mode=id_res&' + - 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + - 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + - 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; +/** Ask for a code, redeem it, and come back holding tokens. */ +async function signIn() { + const client = createClient({ + issuer: 'https://auth.internal', + clientID: 'api', + fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + }); - const callbackResponse = await auth.request(callbackUrl, { - headers: { cookie } - }); - expect(callbackResponse.status).toBe(302); + const { challenge, url } = await client.authorize('https://client.example.com/callback', 'code', { + pkce: true, + provider: 'code' + }); - const location = new URL(callbackResponse.headers.get('location')!); - const code = location.searchParams.get('code'); - expect(code).not.toBeNull(); + const cookies = jar(); + cookies.absorb(await auth.request(url)); + expect(cookies.header()).not.toBe(''); - const exchanged = await client.exchange( - code!, - 'https://client.example.com/callback', - challenge.verifier - ); - if (exchanged.err) throw exchanged.err; - const tokens = exchanged.tokens!; + const requested = await auth.request('https://auth.internal/code/authorize', { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'request', email: 'ada@example.com' }) + }); + cookies.absorb(requested); + expect(lastCode).not.toBe(''); + + const verified = await auth.request('https://auth.internal/code/authorize', { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: lastCode }) + }); + expect(verified.status).toBe(302); + + const location = new URL(verified.headers.get('location')!); + const code = location.searchParams.get('code'); + expect(code).not.toBeNull(); + + const exchanged = await client.exchange( + code!, + 'https://client.example.com/callback', + challenge.verifier + ); + if (exchanged.err) throw exchanged.err; + return { client, tokens: exchanged.tokens! }; +} + +describe('signing in with an email address', () => { + test('a redeemed code becomes tokens that verify', async () => { + const { client, tokens } = await signIn(); expect(tokens.access).toBeString(); expect(tokens.refresh).toBeString(); @@ -130,88 +153,15 @@ describe('Steam auth flow', () => { type: 'user', properties: { userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' } }); }); }); -describe('SSH login', () => { - test('valid login returns tokens', async () => { - const loginResponse = await auth.request('https://auth.internal/ssh/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer test-ssh-key' - }, - body: JSON.stringify({ - fingerprint: 'SHA256:abc123', - steamId: '76561198012345678' - }) - }); - - expect(loginResponse.status).toBe(200); - const body: any = await loginResponse.json(); - expect(body.accessToken).toBeString(); - expect(body.refreshToken).toBeString(); - }); - - test('invalid auth key returns 401', async () => { - const response = await auth.request('https://auth.internal/ssh/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer wrong-key' - }, - body: JSON.stringify({ - fingerprint: 'SHA256:abc123', - steamId: '76561198012345678' - }) - }); - - expect(response.status).toBe(401); - }); -}); - describe('User info', () => { - async function getTokens() { - const client = createClient({ - issuer: 'https://auth.internal', - clientID: 'api', - fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) - }); - - const { challenge, url } = await client.authorize( - 'https://client.example.com/callback', - 'code', - { pkce: true, provider: 'steam' } - ); - - const authResponse = await auth.request(url); - const cookie = authResponse.headers.get('set-cookie')!; - - const callbackUrl = - 'https://auth.internal/steam/callback?' + - 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + - 'openid.mode=id_res&' + - 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + - 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + - 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; - - const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } }); - const location = new URL(callbackResponse.headers.get('location')!); - const code = location.searchParams.get('code'); - const exchanged = await client.exchange( - code!, - 'https://client.example.com/callback', - challenge.verifier - ); - if (exchanged.err) throw exchanged.err; - return { client, tokens: exchanged.tokens! }; - } - test('returns subject properties for valid access token', async () => { - const { tokens } = await getTokens(); + const { tokens } = await signIn(); const infoRes = await auth.request('https://auth.internal/userinfo', { headers: { Authorization: `Bearer ${tokens.access}` } @@ -221,7 +171,7 @@ describe('User info', () => { const userinfo = await infoRes.json(); expect(userinfo).toMatchObject({ userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' }); }); }); From 2c4e9d9b0bbe094ac2da20758042d4cf8e7b6637 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 09:27:49 +0300 Subject: [PATCH 08/13] fix(auth): refuse to send a sign-in code rather than log one The rule was "throw when the environment says production, otherwise log the code and carry on". The deployment sets no such marker, so the branch that ran was the developer one: every recipient and every usable sign-in code printed to a retained log, the screen reporting success, and nobody receiving anything. That is what a fail-open default costs. The deployment that forgets its mail settings is exactly the deployment with no marker saying it is a real one, so it takes the lenient branch precisely when it should not. Turned around: printing a live code is asked for by name and anything else is an error, so absence of configuration is a refusal instead of an assumption. Two settings out of three is also an error now, because it means somebody is halfway through wiring a provider up and a quiet fallback would hide the missing half. Stages anyone else can reach are checked at deploy time, so a missing setting stops the deploy with the name of the variable it wanted rather than surfacing later as a person waiting for mail that never comes. --- alchemy.run.ts | 58 ++++++++++++++++++++++++++++++++++-- apps/auth/src/email.ts | 52 ++++++++++++++++++++++---------- apps/auth/test/email.test.ts | 55 ++++++++++++++++++++++++++++++---- 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/alchemy.run.ts b/alchemy.run.ts index c943941e..91bf22e6 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -8,6 +8,57 @@ const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!); const adminSharedSecret = process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod'; +/** + * Stages where a missing setting is a deploy failure rather than a default. + * + * A stage somebody else can reach has to be configured; a throwaway one a + * developer made this morning does not. The list is the same one that decides + * observability and DNS below, named once so the two cannot drift apart. + */ +const PERMANENT_STAGES = ['production', 'sandbox', 'dev']; + +/** + * Mail settings, refused rather than defaulted when a stage needs them. + * + * Verifying an address is the only way to sign in, so a worker that cannot + * send mail cannot sign anybody in — and the failure to catch is the one where + * that is discovered by a person staring at a screen that says "check your + * email". Checking here turns it into a deploy that stops with the name of the + * variable it wanted. + */ +function mailEnv(stage: string) { + const url = process.env.EMAIL_SEND_URL; + const key = process.env.EMAIL_API_KEY; + const from = process.env.EMAIL_FROM; + + if (PERMANENT_STAGES.includes(stage)) { + const missing = [ + ['EMAIL_SEND_URL', url], + ['EMAIL_API_KEY', key], + ['EMAIL_FROM', from] + ] + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length > 0) { + throw new Error( + `Stage "${stage}" serves sign-in, so it needs mail delivery configured. ` + + `Missing: ${missing.join(', ')}.` + ); + } + } + + return { + ...(url ? { EMAIL_SEND_URL: url } : {}), + ...(key ? { EMAIL_API_KEY: Redacted.make(key) } : {}), + ...(from ? { EMAIL_FROM: from } : {}), + // Printing a live sign-in code to the log is a thing you ask for by + // name. It is never set on a stage anyone else can reach, and the + // worker refuses to send without either this or real settings, so an + // unconfigured deploy fails loudly instead of quietly logging codes. + ...(PERMANENT_STAGES.includes(stage) ? {} : { EMAIL_DEV_LOG: 'true' }) + }; +} + const AuthStorage = Cloudflare.KV.Namespace('auth-storage'); const Database = Effect.gen(function* () { @@ -36,7 +87,7 @@ const Database = Effect.gen(function* () { export const Auth = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; - const isPermanent = ['production', 'sandbox', 'dev'].includes(stage); + const isPermanent = PERMANENT_STAGES.includes(stage); return yield* Cloudflare.Worker('auth', { main: 'apps/auth/src/index.ts', compatibility: { flags: ['nodejs_compat'] }, @@ -45,7 +96,8 @@ export const Auth = Effect.gen(function* () { // key is bound there. env: { AuthStorage, - HYPERDRIVE: Database + HYPERDRIVE: Database, + ...mailEnv(stage) }, ...(isPermanent ? { observability: { enabled: true } } : {}) }); @@ -53,7 +105,7 @@ export const Auth = Effect.gen(function* () { export const Api = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; - const isPermanent = ['production', 'sandbox', 'dev'].includes(stage); + const isPermanent = PERMANENT_STAGES.includes(stage); const prefix = stage === 'production' ? '' : `${stage}.`; const authDomain = ['production', 'sandbox'].includes(stage) ? `${prefix}auth.nestri.io` diff --git a/apps/auth/src/email.ts b/apps/auth/src/email.ts index e5cfa64b..40a7e640 100644 --- a/apps/auth/src/email.ts +++ b/apps/auth/src/email.ts @@ -3,38 +3,60 @@ * * Deliberately not tied to one mail vendor: it posts a small JSON body to * whatever endpoint is configured, so swapping providers is configuration and - * not a code change. Three settings, all optional except in production — - * `EMAIL_SEND_URL`, `EMAIL_API_KEY`, `EMAIL_FROM`. + * not a code change. Three settings — `EMAIL_SEND_URL`, `EMAIL_API_KEY`, + * `EMAIL_FROM` — and a fourth, `EMAIL_DEV_LOG`, that asks for the code to be + * printed instead of sent. */ export interface MailerConfig { EMAIL_SEND_URL?: string; EMAIL_API_KEY?: string; EMAIL_FROM?: string; - NODE_ENV?: string; + /** + * Print the code to the log rather than sending it. `'true'` and nothing + * else, so a variable left holding `'false'` or `'0'` cannot switch it on. + */ + EMAIL_DEV_LOG?: string; } /** - * Send the code, or fail loudly. + * Send the code, or refuse. * - * With no mailer configured this logs the code and carries on, which is what - * makes a local sign-in possible without a mail account. In production the - * same situation throws instead: a signup screen that says "check your email" - * when nothing was sent is worse than one that says it is broken, because the - * person waits instead of telling anybody. + * The rule is that printing a live sign-in code to a log is something you ask + * for by name, and that anything else is an error. It reads that way round + * because the alternative — treat an unconfigured mailer as "must be a + * developer" — fails *open*: the deployment that forgets its mail settings is + * exactly the deployment with no marker saying it is a real one, so it takes + * the developer branch, logs every recipient and every usable code to a + * retained log, and reports success while nobody receives anything. + * + * Configuration is also all-or-nothing. Two settings out of three is somebody + * halfway through wiring a provider up, and quietly falling back would hide + * the half that is missing. */ export async function sendVerificationCode( config: MailerConfig, email: string, code: string ): Promise { - const configured = config.EMAIL_SEND_URL && config.EMAIL_API_KEY && config.EMAIL_FROM; + const present = [config.EMAIL_SEND_URL, config.EMAIL_API_KEY, config.EMAIL_FROM].filter(Boolean); - if (!configured) { - if (config.NODE_ENV === 'production') { - throw new Error('Email delivery is not configured, so no sign-in code can be sent'); + if (present.length === 0) { + if (config.EMAIL_DEV_LOG === 'true') { + console.log(`[auth] sign-in code for ${email}: ${code}`); + return; } - console.log(`[auth] sign-in code for ${email}: ${code}`); - return; + throw new Error( + 'Email delivery is not configured, so no sign-in code can be sent. ' + + 'Set EMAIL_SEND_URL, EMAIL_API_KEY and EMAIL_FROM, or set EMAIL_DEV_LOG=true ' + + 'to print codes to the log instead.' + ); + } + + if (present.length < 3) { + throw new Error( + 'Email delivery is half configured: EMAIL_SEND_URL, EMAIL_API_KEY and EMAIL_FROM ' + + 'are needed together.' + ); } const response = await fetch(config.EMAIL_SEND_URL!, { diff --git a/apps/auth/test/email.test.ts b/apps/auth/test/email.test.ts index 1961caed..2df7df16 100644 --- a/apps/auth/test/email.test.ts +++ b/apps/auth/test/email.test.ts @@ -3,16 +3,36 @@ import { describe, expect, test } from 'bun:test'; import { sendVerificationCode } from '../src/email.js'; describe('sending a sign-in code', () => { - test('with nothing configured outside production, it does not block a sign-in', async () => { - await sendVerificationCode({ NODE_ENV: 'development' }, 'ada@example.com', '123456'); + test('printing the code to the log has to be asked for by name', async () => { + await sendVerificationCode({ EMAIL_DEV_LOG: 'true' }, 'ada@example.com', '123456'); }); - test('with nothing configured in production, it says so instead of pretending', async () => { + // The regression this holds: the previous rule was "throw only when the + // environment says production", which meant a deployment that set no + // marker at all — which is what the real one did — took the developer + // branch and logged live codes. Absence is now a refusal. + test('nothing configured and nothing asked for is a refusal, not a log', async () => { + await expect(sendVerificationCode({}, 'ada@example.com', '123456')).rejects.toThrow( + /not configured/ + ); + }); + + test('a variable left holding something other than true does not switch logging on', async () => { await expect( - sendVerificationCode({ NODE_ENV: 'production' }, 'ada@example.com', '123456') + sendVerificationCode({ EMAIL_DEV_LOG: 'false' }, 'ada@example.com', '123456') ).rejects.toThrow(/not configured/); }); + test('half a mailer is an error rather than a fallback', async () => { + await expect( + sendVerificationCode( + { EMAIL_SEND_URL: 'https://mail.example.com/send', EMAIL_DEV_LOG: 'true' }, + 'ada@example.com', + '123456' + ) + ).rejects.toThrow(/half configured/); + }); + test('a configured mailer is called with the address and the code', async () => { let seen: { url: string; body: any; auth: string | null } | null = null; const original = globalThis.fetch; @@ -28,7 +48,6 @@ describe('sending a sign-in code', () => { try { await sendVerificationCode( { - NODE_ENV: 'production', EMAIL_SEND_URL: 'https://mail.example.com/send', EMAIL_API_KEY: 'key', EMAIL_FROM: 'hello@nestri.io' @@ -47,6 +66,32 @@ describe('sending a sign-in code', () => { expect(seen!.body.text).toContain('123456'); }); + test('a configured mailer sends even when dev logging is on', async () => { + let called = false; + const original = globalThis.fetch; + globalThis.fetch = (async () => { + called = true; + return new Response('{}', { status: 200 }); + }) as unknown as typeof fetch; + + try { + await sendVerificationCode( + { + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io', + EMAIL_DEV_LOG: 'true' + }, + 'ada@example.com', + '123456' + ); + } finally { + globalThis.fetch = original; + } + + expect(called).toBe(true); + }); + test('a refusal from the mailer is not swallowed', async () => { const original = globalThis.fetch; globalThis.fetch = (async () => From 15f8d3eb3496f8d0f368bd8d09f9b5f61bab0887 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 09:32:16 +0300 Subject: [PATCH 09/13] fix(core): hold the account rules when two requests arrive together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rules here are enforced across a lookup and then a write, and each was only as good as whatever stopped the two from interleaving. Nothing did. The connection cap counted with `select ... for update` over the connections a user already had. That locks the rows it finds, and when it finds none it locks nothing — there are no gap locks under read committed — so several first-time links all counted zero and all inserted. Six concurrent links against a cap of four produced six. The count now happens under a lock on the account's own row, which is the one thing every caller for that account is guaranteed to contend on. Creating an account from a verified address looked the address up and then inserted. Two tabs finishing the same sign-in both found nothing, and the loser got the driver's constraint violation instead of the account the winner had just made. The unique index is the thing that actually arbitrates, so the loser now reads back what the winner wrote. Claiming an address on an older account had the same shape and now gives the same sentence a screen would have shown a moment earlier. The tests run each call several times at once against a real database, because run one at a time all three pass whether or not any of this exists. --- packages/core/src/user/identity.test.ts | 70 +++++++++ packages/core/src/user/identity.ts | 179 +++++++++++++++++------- 2 files changed, 202 insertions(+), 47 deletions(-) diff --git a/packages/core/src/user/identity.test.ts b/packages/core/src/user/identity.test.ts index 6c70472a..3aa6ab73 100644 --- a/packages/core/src/user/identity.test.ts +++ b/packages/core/src/user/identity.test.ts @@ -221,3 +221,73 @@ describe('Identity.linkSteam', () => { expect(resolved.userID).toBe(legacy.userID); }); }); + +/** + * The same call, several times at once, against a real database. + * + * Every one of these holds a rule that is enforced across two statements — a + * lookup and then a write — which means the rule is only as good as whatever + * stops the two from interleaving. Run one at a time they all pass whether or + * not that protection exists, which is exactly why they are written this way. + */ +describe('the same thing happening twice at once', () => { + beforeEach(cleanup); + + test('the cap holds when the links arrive together', async () => { + const { userID } = await Identity.fromVerifiedEmail({ email: email(8) }); + track(userID); + + const wanted = Identity.MAX_STEAM_ACCOUNTS + 2; + const results = await Promise.allSettled( + Array.from({ length: wanted }, (_, i) => + Identity.linkSteam({ userId: userID, steamId: steamID(60 + i) }) + ) + ); + + expect(await Identity.listSteam(userID)).toHaveLength(Identity.MAX_STEAM_ACCOUNTS); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength( + Identity.MAX_STEAM_ACCOUNTS + ); + for (const rejected of results.filter((r) => r.status === 'rejected')) { + expect((rejected as PromiseRejectedResult).reason.code).toBe('invalid_state'); + } + }); + + test('several sign-ins for one new address make one account', async () => { + const address = email(9); + + const results = await Promise.all([ + Identity.fromVerifiedEmail({ email: address }), + Identity.fromVerifiedEmail({ email: address }), + Identity.fromVerifiedEmail({ email: address }) + ]); + results.forEach((r) => track(r.userID)); + + expect(new Set(results.map((r) => r.userID)).size).toBe(1); + expect(results.filter((r) => r.created)).toHaveLength(1); + + const rows = await sql` + select count(*)::int as n from "user" + where email = ${address} and time_deleted is null + `; + expect(rows[0]!.n).toBe(1); + }); + + test('two accounts claiming one address get an answer rather than a driver error', async () => { + const first = await legacySteamUser(70); + const second = await legacySteamUser(71); + const address = email(10); + + const results = await Promise.allSettled([ + Identity.claimWithEmail({ userId: first.userID, email: address }), + Identity.claimWithEmail({ userId: second.userID, email: address }) + ]); + + const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; + expect(rejected).toHaveLength(1); + // The point of the assertion: a sentence a screen can render, and not + // whatever text the driver puts on a constraint violation. + expect(rejected[0]!.reason.type).toBe('already_exists'); + expect(rejected[0]!.reason.message).toMatch(/another account/); + }); +}); diff --git a/packages/core/src/user/identity.ts b/packages/core/src/user/identity.ts index a07c07be..15b94be2 100644 --- a/packages/core/src/user/identity.ts +++ b/packages/core/src/user/identity.ts @@ -6,11 +6,38 @@ import { ErrorCodes, VisibleError } from '../error.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; import { User } from './index.js'; +import { UserTable } from './user.sql.js'; import { LinkedAccount } from './linked-account.js'; import { LinkedAccountTable } from './linked-account.sql.js'; const STEAM_ID_RE = /^\d{17}$/; +/** The partial unique index on a live account's address, named by the migration. */ +const EMAIL_UNIQUE = 'user_email_unique'; + +/** + * Whether a failure is the database refusing a duplicate. + * + * Every read-then-write below has a window between the read and the write, and + * the index is what actually closes it. Recognising the refusal is how the + * loser of a race turns a raw driver error into the answer it was asking for — + * so the constraint is the mechanism and this is how the code hears from it. + */ +function isUniqueViolation(err: unknown, constraint: string): boolean { + // Walked rather than read off the top, because the query builder wraps what + // the driver threw: the outer error carries the SQL and the parameters, and + // the code and the constraint name are on the cause underneath it. + for (let e: unknown = err, depth = 0; e && depth < 8; depth++) { + if (typeof e !== 'object') break; + const candidate = e as { code?: unknown; constraint_name?: unknown; cause?: unknown }; + if (String(candidate.code) === '23505' && candidate.constraint_name === constraint) { + return true; + } + e = candidate.cause; + } + return false; +} + /** * Email is the root of an account, so two spellings of one address must not be * two accounts. Case and surrounding whitespace are the two ways the same @@ -51,27 +78,48 @@ export namespace Identity { z.object({ email: Email, name: z.string().optional() }), async (input) => { const email = input.email; - return Database.transaction(async () => { - const existing = await User.fromEmail(email); - if (existing) { - // An address that was attached but never confirmed is - // confirmed now: getting here means a code was redeemed. - if (!existing.emailVerified) { - await User.setEmail({ id: existing.id, email, emailVerified: true }); - } - return { userID: existing.id, created: false }; - } - const userID = Identifier.ascending('user'); - await User.create({ - id: userID, - name: input.name?.trim() || email.split('@')[0]!, - email, - emailVerified: true, - image: null + async function attempt() { + return Database.transaction(async () => { + const existing = await User.fromEmail(email); + if (existing) { + // An address that was attached but never confirmed is + // confirmed now: getting here means a code was redeemed. + if (!existing.emailVerified) { + await User.setEmail({ id: existing.id, email, emailVerified: true }); + } + return { userID: existing.id, created: false }; + } + + const userID = Identifier.ascending('user'); + await User.create({ + id: userID, + name: input.name?.trim() || email.split('@')[0]!, + email, + emailVerified: true, + image: null + }); + return { userID, created: true }; }); - return { userID, created: true }; - }); + } + + try { + return await attempt(); + } catch (err) { + if (!isUniqueViolation(err, EMAIL_UNIQUE)) throw err; + + // Somebody else finished the same sign-in first. + // + // Two people redeeming a code for one address is one person + // with two tabs, and the answer they both want is the account + // that now exists. The lookup and the insert cannot be made one + // statement here — the row is built from an id this process + // generates — so the index arbitrates and the loser reads back + // what the winner wrote. Retried once and not in a loop: a + // second refusal means the row is gone again, which is a + // deletion racing a sign-in and not something to spin on. + return await attempt(); + } } ); @@ -86,29 +134,43 @@ export namespace Identity { z.object({ userId: z.string(), email: Email }), async (input) => { const email = input.email; - return Database.transaction(async () => { - const holder = await User.fromEmail(email); - if (holder && holder.id !== input.userId) { - throw new VisibleError( - 'already_exists', - ErrorCodes.Validation.ALREADY_EXISTS, - 'That email address already belongs to another account' - ); - } - const updated = await User.setEmail({ - id: input.userId, - email, - emailVerified: true + try { + return await Database.transaction(async () => { + const holder = await User.fromEmail(email); + if (holder && holder.id !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That email address already belongs to another account' + ); + } + const updated = await User.setEmail({ + id: input.userId, + email, + emailVerified: true + }); + if (!updated) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such account' + ); + } + return updated; }); - if (!updated) { - throw new VisibleError( - 'not_found', - ErrorCodes.NotFound.RESOURCE_NOT_FOUND, - 'No such account' - ); - } - return updated; - }); + } catch (err) { + // The check above and the update below are two statements, so + // two accounts claiming one address can both find it free. The + // index refuses the second, and the person deserves the same + // sentence they would have got a moment earlier rather than a + // driver's error text. + if (!isUniqueViolation(err, EMAIL_UNIQUE)) throw err; + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That email address already belongs to another account' + ); + } } ); @@ -143,8 +205,8 @@ export namespace Identity { * Hang a Steam account off a user, up to {@link MAX_STEAM_ACCOUNTS}. * * The count and the insert are one transaction because they are one - * decision: read the four, then write the fifth, and two concurrent calls - * each see four. + * decision, and the transaction takes the account's own row first so that + * two callers cannot each count four and each write a fifth. */ export const linkSteam = fn( z.object({ @@ -154,6 +216,30 @@ export namespace Identity { }), async (input) => { return Database.transaction(async (tx) => { + // Take the account's own row first, and hold it. + // + // The cap is a count, and a count only means something if it + // is taken while nothing can change it. Locking the + // connections instead locks nothing at all when there are + // none: there are no gap locks under read committed, so + // `for update` over an empty result set is an empty set of + // locks, and several simultaneous first-time links all read + // zero and all insert. The account's own row is the one thing + // every caller for it is guaranteed to contend on, so it is + // what serializes them. + const [owner] = await tx + .select({ id: UserTable.id }) + .from(UserTable) + .where(and(eq(UserTable.id, input.userId), isNull(UserTable.timeDeleted))) + .for('update'); + if (!owner) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such account' + ); + } + const existing = await LinkedAccount.findByProvider({ provider: 'steam', providerAccountId: input.steamId @@ -172,8 +258,8 @@ export namespace Identity { return existing.id; } - // `for update` on the rows already there, so a second caller - // holding at the cap waits rather than counting alongside. + // Counted under the lock taken above, so what is counted is + // what is still there at the insert. const held = await tx .select({ id: LinkedAccountTable.id }) .from(LinkedAccountTable) @@ -183,8 +269,7 @@ export namespace Identity { eq(LinkedAccountTable.provider, 'steam'), isNull(LinkedAccountTable.timeDeleted) ) - ) - .for('update'); + ); if (held.length >= MAX_STEAM_ACCOUNTS) { throw new VisibleError( From 36179150a1c58e3b2bb73cdf81a3b7aa09966943 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 09:40:03 +0300 Subject: [PATCH 10/13] fix(auth): make a device sign-in an answer somebody gave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anybody could ask for a device code and be handed a link with the user code already in it. Following that link started a sign-in, and finishing the sign-in approved the grant. So sending somebody the link was enough: they saw an ordinary sign-in prompt, completed it, and whoever kept the device code polled and collected their access and refresh tokens. The victim never saw a question, because there was not one. There is now. Signing in says who the browser belongs to; it does not say the person meant to hand an account to a program somewhere else. Those are two questions and only the second authorizes anything, so the flow ends at a page that names the program, shows the code back so it can be compared with what the device is displaying, and offers Approve and Deny. Approving is a POST carrying a value from the cookie, so another site cannot submit it on somebody's behalf. Denial moved onto the same page: it used to be a GET anyone could fire, which meant a link scanner could cancel a real sign-in and a stranger with a user code could grief one. Three more things that were wrong underneath. The grant was read, modified and written back as a whole record. A poll that read a pending grant and then wrote its bookkeeping erased an approval that landed in between, and the client polled a dead grant until it expired. Grants moved to a table, where approving is one conditional update and redeeming is one delete that returns what it deleted, so neither party can undo the other and two polls cannot both be served. Tokens were minted when the person clicked and left sitting in storage until collected. They are minted at redemption now, so the lifetime the client is told about starts when it receives them, and a grant nobody collects leaves no usable refresh token behind. The client identifier was never checked, at either end. It is validated when the grant is created and has to match when the code is redeemed — without that, a leaked code is redeemable by anyone, and the identifier the token carries is whatever the last caller claimed. The device code is also stored as a hash now, since it is the credential the tokens are handed to. The store is an interface because the issuer cannot reach the database, and because the guarantees are the point: every method is one operation, and no caller reads a grant, decides, and writes it back. --- apps/auth/src/index.ts | 20 + packages/auth/src/device.ts | 165 ++ packages/auth/src/issuer.ts | 364 ++- packages/auth/test/device.test.ts | 344 ++- .../0010_device_authorization_grant.sql | 39 + .../core/migrations/meta/0010_snapshot.json | 2451 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/auth/device-grant.sql.ts | 62 + packages/core/src/auth/device-grant.test.ts | 201 ++ packages/core/src/auth/device-grant.ts | 153 + packages/core/src/id.ts | 3 +- 11 files changed, 3622 insertions(+), 187 deletions(-) create mode 100644 packages/auth/src/device.ts create mode 100644 packages/core/migrations/0010_device_authorization_grant.sql create mode 100644 packages/core/migrations/meta/0010_snapshot.json create mode 100644 packages/core/src/auth/device-grant.sql.ts create mode 100644 packages/core/src/auth/device-grant.test.ts create mode 100644 packages/core/src/auth/device-grant.ts 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) { From 355d1492d9a1d0b8ffd28ec50e1c3a2fe36a56fa Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 09:44:32 +0300 Subject: [PATCH 11/13] fix(auth): give a sign-in code a budget of guesses and a short life A six-digit code has a million values, and nothing was counting how many of them a caller tried. The code travelled in an encrypted cookie the caller held, verification compared against that cookie, and a wrong answer simply re-rendered the form. Nobody has to be the person the code was mailed to: type somebody else's address into the first screen and the code goes to their mailbox while the cookie stays with you. At that point the only thing between a stranger and an account is a million requests, and the constant-time comparison protecting the code was guarding a door you could just keep knocking on. Guesses are now counted on the server, under a name that changes with every code. That placement is the point: a counter kept beside the code, in the cookie, is a counter the guesser can wind back by replaying an older copy. Starting over is still allowed and still costs a fresh code sent to the mailbox being aimed at, which is where somebody notices. A correct code spends its record too, so its remaining guesses do not carry into the next one. The cookie also lived for twenty-four hours, which made the pin a password with a million possible values and a day to try them. Ten minutes now, and the code stops being accepted when the clock says so rather than when the cookie happens to go away. Resend had no limit either, so the button was a way to mail a stranger as fast as requests go out. Codes to one address are spaced, and one attempt at signing in can only ask for so many. Both refusals say the same thing on purpose. Which of the two it was is a fact about somebody else's mailbox. --- apps/auth/test/worker.test.ts | 16 ++- packages/auth/src/provider/code.ts | 167 +++++++++++++++++++++- packages/auth/src/ui/code.tsx | 10 +- packages/auth/test/code.test.ts | 215 +++++++++++++++++++++++++++++ 4 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 packages/auth/test/code.test.ts diff --git a/apps/auth/test/worker.test.ts b/apps/auth/test/worker.test.ts index bcc47647..2707745f 100644 --- a/apps/auth/test/worker.test.ts +++ b/apps/auth/test/worker.test.ts @@ -95,8 +95,14 @@ function jar() { }; } -/** Ask for a code, redeem it, and come back holding tokens. */ -async function signIn() { +/** + * Ask for a code, redeem it, and come back holding tokens. + * + * The address is a parameter because codes to one mailbox are rate limited, and + * two sign-ins in the same second are exactly what that limit is for. Each + * caller uses its own. + */ +async function signIn(email: string) { const client = createClient({ issuer: 'https://auth.internal', clientID: 'api', @@ -115,7 +121,7 @@ async function signIn() { const requested = await auth.request('https://auth.internal/code/authorize', { method: 'POST', headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ action: 'request', email: 'ada@example.com' }) + body: new URLSearchParams({ action: 'request', email }) }); cookies.absorb(requested); expect(lastCode).not.toBe(''); @@ -142,7 +148,7 @@ async function signIn() { describe('signing in with an email address', () => { test('a redeemed code becomes tokens that verify', async () => { - const { client, tokens } = await signIn(); + const { client, tokens } = await signIn('ada@example.com'); expect(tokens.access).toBeString(); expect(tokens.refresh).toBeString(); @@ -161,7 +167,7 @@ describe('signing in with an email address', () => { describe('User info', () => { test('returns subject properties for valid access token', async () => { - const { tokens } = await signIn(); + const { tokens } = await signIn('grace@example.com'); const infoRes = await auth.request('https://auth.internal/userinfo', { headers: { Authorization: `Bearer ${tokens.access}` } diff --git a/packages/auth/src/provider/code.ts b/packages/auth/src/provider/code.ts index 9464dc3f..e643fb06 100644 --- a/packages/auth/src/provider/code.ts +++ b/packages/auth/src/provider/code.ts @@ -54,7 +54,8 @@ */ import { Context } from 'hono'; -import { generateUnbiasedDigits, timingSafeCompare } from '../random.js'; +import { generateUnbiasedDigits, generateUnbiasedString, timingSafeCompare } from '../random.js'; +import { Storage } from '../storage/storage.js'; import { Provider } from './provider.js'; export interface CodeProviderConfig< @@ -66,6 +67,46 @@ export interface CodeProviderConfig< * @default 6 */ length?: number; + /** + * How long a code stays usable, in seconds. + * + * A pin is six digits, which is a small space, and the only thing keeping + * it small enough to type is that it does not have to last. A code that is + * still good tomorrow is a password with a million possible values. + * + * @default 600 + */ + ttl?: number; + /** + * How many wrong guesses a code survives. + * + * Counted where the person asking cannot reach it, which is the whole + * point: the code itself travels in an encrypted cookie the caller holds, + * so a counter kept alongside it would be a counter they could reset by + * replaying an older copy. Starting over is allowed and costs them a fresh + * code — sent to the mailbox they are trying to break into, where somebody + * notices. + * + * @default 5 + */ + maxAttempts?: number; + /** + * How many codes one attempt at signing in may ask for. + * + * @default 3 + */ + maxSends?: number; + /** + * Seconds between one code and the next for the same claim. + * + * Without this, `resend` is an open relay pointed at anybody's mailbox: the + * address is not the caller's own and nothing asks them to prove otherwise, + * so the send button is a way to mail a stranger as fast as requests go + * out. + * + * @default 30 + */ + resendInterval?: number; /** * The request handler to generate the UI for the code flow. * @@ -116,6 +157,14 @@ export type CodeProviderState = resend?: boolean; code: string; claims: Record; + /** + * Names the server-side record holding this code's remaining + * guesses. Regenerated with every code, so a caller who rolls back + * to an older cookie rolls back to a code that is no longer live. + */ + flow: string; + /** When the code stops being accepted, in ms. */ + expires: number; }; /** @@ -134,16 +183,50 @@ export type CodeProviderError = type: 'invalid_claim'; key: string; value: string; + } + /** Too many guesses, or codes asked for too quickly. */ + | { + type: 'rate_limit'; }; +/** Nothing a person reads, so the whole alphabet is available. */ +const FLOW_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + export function CodeProvider = Record>( config: CodeProviderConfig ): Provider<{ claims: Claims }> { const length = config.length || 6; + const ttl = config.ttl ?? 60 * 10; + const maxAttempts = config.maxAttempts ?? 5; + const maxSends = config.maxSends ?? 3; + const resendInterval = config.resendInterval ?? 30; + function generate() { return generateUnbiasedDigits(length); } + /** Where a flow's remaining guesses live, on the server. */ + function attemptKey(flow: string) { + return ['oauth:code:flow', flow]; + } + + /** + * Where the last send to one claim is remembered. + * + * Keyed by the claim and not by the caller, because the mailbox is what is + * being protected and the caller is whoever is pointing at it. Two people + * asking for a code for one address in the same minute is the case this is + * for, and it is the same case whether they are the same person or not. + */ + function claimKey(claims: Record) { + const flattened = Object.entries(claims) + .filter(([key]) => key !== 'action') + .map(([key, value]) => `${key}=${String(value).trim().toLowerCase()}`) + .sort() + .join('&'); + return ['oauth:code:claim', flattened]; + } + return { type: 'code', init(routes, ctx) { @@ -153,10 +236,14 @@ export function CodeProvider = Record(c, 'provider', 60 * 60 * 24, next); + // The cookie lives exactly as long as the code inside it. + // Twenty-four hours, which is what this was, made a six-digit + // pin usable for a day. + await ctx.set(c, 'provider', ttl, next); const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err)); return resp; } + routes.get('/authorize', async (c) => { const resp = await transition(c, { type: 'start' @@ -165,7 +252,6 @@ export function CodeProvider = Record { - const code = generate(); const fd = await c.req.formData(); const state = await ctx.get(c, 'provider'); const action = fd.get('action')?.toString(); @@ -173,22 +259,83 @@ export function CodeProvider = Record(ctx.storage, claimKey(claims)); + if (sentAt && Date.now() - sentAt.at < resendInterval * 1000) { + return transition(c, state ?? { type: 'start' }, fd, { type: 'rate_limit' }); + } + if (action === 'resend' && state?.type === 'code') { + const record = await Storage.get<{ attempts: number; sends: number }>( + ctx.storage, + attemptKey(state.flow) + ); + if ((record?.sends ?? 1) >= maxSends) { + return transition(c, state, fd, { type: 'rate_limit' }); + } + } + + const code = generate(); const err = await config.sendCode(claims, code); if (err) return transition(c, { type: 'start' }, fd, err); + + // A new code means a new flow, which means a fresh budget + // of guesses — and, more to the point, that the budget + // attached to the previous code is now unreachable rather + // than reset. + const flow = generateUnbiasedString(FLOW_ALPHABET, 32); + const sends = + action === 'resend' && state?.type === 'code' + ? (( + await Storage.get<{ sends: number }>(ctx.storage, attemptKey(state.flow)) + )?.sends ?? 1) + 1 + : 1; + await Storage.set(ctx.storage, attemptKey(flow), { attempts: 0, sends }, ttl); + await Storage.set(ctx.storage, claimKey(claims), { at: Date.now() }, resendInterval); + return transition( c, { type: 'code', resend: action === 'resend', claims, - code + code, + flow, + expires: Date.now() + ttl * 1000 }, fd ); } - if (fd.get('action')?.toString() === 'verify' && state.type === 'code') { - const fd = await c.req.formData(); + if (action === 'verify' && state?.type === 'code') { + if (state.expires <= Date.now()) { + await ctx.unset(c, 'provider'); + return transition(c, { type: 'start' }, fd, { type: 'invalid_code' }); + } + + // Counted before the comparison, so a guess costs whether or + // not it is right. Counted on the server, so the caller + // holding the cookie cannot wind it back. + const record = await Storage.get<{ attempts: number; sends: number }>( + ctx.storage, + attemptKey(state.flow) + ); + if (!record || record.attempts >= maxAttempts) { + await ctx.unset(c, 'provider'); + await Storage.remove(ctx.storage, attemptKey(state.flow)); + return transition(c, { type: 'start' }, fd, { type: 'rate_limit' }); + } + await Storage.set( + ctx.storage, + attemptKey(state.flow), + { ...record, attempts: record.attempts + 1 }, + Math.max(1, Math.ceil((state.expires - Date.now()) / 1000)) + ); + const compare = fd.get('code')?.toString(); if (!state.code || !compare || !timingSafeCompare(state.code, compare)) { return transition( @@ -201,12 +348,18 @@ export function CodeProvider = Record {error?.type === 'invalid_claim' && } + {error?.type === 'rate_limit' && } {error?.type === 'invalid_code' && } + {error?.type === 'rate_limit' && } {state.type === 'code' && ( true, + providers: { + code: CodeProvider({ + maxAttempts: 3, + maxSends: 2, + resendInterval: 0, + request: async (_req, _state, _form, error) => + new Response(JSON.stringify({ error: error?.type ?? null }), { + status: 200, + headers: { 'content-type': 'application/json' } + }), + sendCode: async (claims, code) => { + if (!claims.email?.includes('@')) { + return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' }; + } + sent.push(code); + } + }) + }, + success: async (ctx, value) => ctx.subject('user', { email: (value as any).claims.email }) +}); + +const ORIGIN = 'https://auth.example.com'; + +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 post(cookies: ReturnType, body: Record) { + const response = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body) + }); + cookies.absorb(response); + return response; +} + +/** + * Begin an authorization the way a client does, so success has somewhere to go. + * + * Without this there is no authorization state and a correct code produces + * tokens rather than the redirect a browser flow ends in — which would make + * "did this sign in?" a different question in the test than in the product. + */ +async function begin() { + const cookies = jar(); + const url = new URL(`${ORIGIN}/authorize`); + url.searchParams.set('client_id', 'test'); + url.searchParams.set('redirect_uri', 'https://client.example.com/callback'); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('provider', 'code'); + cookies.absorb(await auth.request(url.toString())); + return cookies; +} + +/** Start a sign-in and ask for a code, coming back with the cookies and the code. */ +async function ask(email = 'ada@example.com') { + const cookies = await begin(); + await post(cookies, { action: 'request', email }); + return { cookies, code: sent.at(-1)! }; +} + +/** What the stub UI reported, so a test can name the error rather than a status. */ +async function errorOf(response: Response) { + return ((await response.clone().json()) as { error: string | null }).error; +} + +beforeEach(() => { + sent = []; +}); + +describe('signing in with a code', () => { + test('the right code signs you in', async () => { + const { cookies, code } = await ask(); + const response = await post(cookies, { action: 'verify', code }); + expect(response.status).toBe(302); + }); + + test('a wrong code is refused and says so', async () => { + const { cookies, code } = await ask(); + const response = await post(cookies, { action: 'verify', code: code === '000000' ? '111111' : '000000' }); + expect(response.status).toBe(200); + expect(await errorOf(response)).toBe('invalid_code'); + }); +}); + +/** + * The attack a six-digit pin invites, and what stops it. + * + * The code travels in an encrypted cookie the caller holds, and the caller is + * not necessarily the person the code was mailed to — anybody can type somebody + * else's address into the first screen. So the only thing between an attacker + * and an account is how many times they may guess, and that number has to be + * kept somewhere they cannot reach. + */ +describe('guessing the code', () => { + test('runs out of guesses long before it runs out of codes', async () => { + const { cookies, code } = await ask(); + const wrong = code === '000000' ? '111111' : '000000'; + + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'invalid_code' + ); + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'invalid_code' + ); + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'invalid_code' + ); + + // Out of budget. The next guess is refused whether or not it is right. + expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( + 'rate_limit' + ); + }); + + test('the real code stops working once the guesses are spent', async () => { + const { cookies, code } = await ask(); + const wrong = code === '000000' ? '111111' : '000000'; + for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong }); + + const response = await post(cookies, { action: 'verify', code }); + expect(response.status).toBe(200); + expect(await errorOf(response)).toBe('rate_limit'); + }); + + // The counter would be worthless if it lived where the guesser does. This + // replays the cookie from before any guess was made, which is the cheapest + // way to wind back anything held in one. + test('replaying an earlier cookie does not hand back the spent guesses', async () => { + const { cookies, code } = await ask(); + const untouched = cookies.header(); + const wrong = code === '000000' ? '111111' : '000000'; + for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong }); + + const replayed = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: wrong }) + }); + expect(await errorOf(replayed)).toBe('rate_limit'); + }); + + test('a code is spent when it is used, so its guesses do not carry over', async () => { + const { cookies, code } = await ask(); + expect((await post(cookies, { action: 'verify', code })).status).toBe(302); + + const again = await post(cookies, { action: 'verify', code }); + expect(again.status).toBe(200); + }); +}); + +describe('asking for codes', () => { + test('a fresh code comes with a fresh budget of guesses', async () => { + const first = await ask(); + const wrong = '000000' === first.code ? '111111' : '000000'; + for (let i = 0; i < 3; i++) await post(first.cookies, { action: 'verify', code: wrong }); + expect(await errorOf(await post(first.cookies, { action: 'verify', code: wrong }))).toBe( + 'rate_limit' + ); + + // Starting over is allowed. It costs a code sent to the mailbox being + // aimed at, which is where somebody would notice. + const second = await ask(); + expect(second.code).not.toBe(first.code); + expect((await post(second.cookies, { action: 'verify', code: second.code })).status).toBe(302); + }); + + test('one sign-in cannot ask for codes forever', async () => { + const { cookies } = await ask(); + expect(await errorOf(await post(cookies, { action: 'resend', email: 'ada@example.com' }))).toBe( + null + ); + expect(await errorOf(await post(cookies, { action: 'resend', email: 'ada@example.com' }))).toBe( + 'rate_limit' + ); + expect(sent).toHaveLength(2); + }); + + test('a bad address still gets told it is a bad address', async () => { + const cookies = await begin(); + const response = await post(cookies, { action: 'request', email: 'not-an-address' }); + expect(await errorOf(response)).toBe('invalid_claim'); + expect(sent).toHaveLength(0); + }); +}); From fc825f5219db6144ea8b026f3fa52965bb2ded3b Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 09:54:59 +0300 Subject: [PATCH 12/13] fix(auth): stop a caller working through the user code space A user code is eight characters from a twenty-five character alphabet, which is a large space but a fixed one, and the endpoint that checked them had no opinion about how often you asked. That is the guessing attack RFC 8628 section 5.2 asks implementations to limit, and nothing here did. Wrong codes are now counted per caller address over a rolling window, and the endpoint stops answering once the budget is gone. Getting a code right is not charged for, so somebody who mistypes once and then succeeds is not walking towards a lockout. A caller whose address cannot be established shares one bucket with every other such caller, which makes stripping the headers that say where you are buy a smaller budget rather than an unlimited one. The counter lives in the general-purpose store and is approximate. The number that decides this is whether somebody is working through the code space, and a handful either way does not change that answer. --- packages/auth/src/issuer.ts | 80 +++++++++++++++++++++++++++++++ packages/auth/test/device.test.ts | 77 ++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index 05e5bee3..48def0e5 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -397,6 +397,31 @@ export interface IssuerInput< * store can make each transition a single operation. */ deviceStore?: DeviceStore; + /** + * How hard a caller may guess at user codes before `/device` stops + * answering them. + * + * A user code is short so that a person can read it off one screen and type + * it into another, and short means guessable given enough tries. RFC 8628 + * §5.2 asks for a limit on the verification endpoint for exactly this + * reason. Counted per caller address over a rolling window; a caller who + * gets one right is not charged for it. + */ + deviceVerification?: { + /** Wrong codes allowed per window. @default 10 */ + guessLimit?: number; + /** Length of the window, in seconds. @default 600 */ + guessWindow?: number; + /** + * Which caller a guess is charged to. + * + * Defaults to the usual forwarded-address headers. Returning undefined + * puts the request in one shared bucket, which is the right answer for + * a caller whose address cannot be established: it means stripping the + * headers buys a smaller budget rather than an unlimited one. + */ + address?(req: Request): string | undefined; + }; /** * Whether a client may start a device authorization grant. * @@ -529,6 +554,15 @@ export function issuer< const ttlDevice = input.ttl?.device ?? 60 * 10; const deviceInterval = input.ttl?.deviceInterval ?? 5; const deviceStore = input.deviceStore ?? MemoryDeviceStore(); + const deviceGuessLimit = input.deviceVerification?.guessLimit ?? 10; + const deviceGuessWindow = input.deviceVerification?.guessWindow ?? 600; + const deviceAddress = + input.deviceVerification?.address ?? + ((req: Request) => + req.headers.get('cf-connecting-ip') ?? + req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-real-ip') ?? + undefined); if (input.theme) { setTheme(input.theme); } @@ -769,6 +803,44 @@ export function issuer< subject: DeviceGrantSubject; } + /** + * How many user codes this caller has got wrong lately. + * + * Kept in the general-purpose store rather than with the grants, because it + * is a counter and not a grant, and because being approximate is fine here: + * the number that matters is whether somebody is working through the code + * space, and a handful either way does not change the answer. A caller + * spread across several addresses gets a budget per address, which is what + * makes the limit worth having rather than a way to lock one person out. + */ + async function chargeGuess(req: Request): Promise { + const who = deviceAddress(req) ?? 'unknown'; + const key = ['oauth:device:guess', who]; + const now = Date.now(); + const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, key); + const next = + bucket && bucket.resetAt > now + ? { count: bucket.count + 1, resetAt: bucket.resetAt } + : { count: 1, resetAt: now + deviceGuessWindow * 1000 }; + await Storage.set( + storage!, + key, + next, + Math.max(1, Math.ceil((next.resetAt - now) / 1000)) + ); + return next.count <= deviceGuessLimit; + } + + async function guessesLeft(req: Request): Promise { + const who = deviceAddress(req) ?? 'unknown'; + const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, [ + 'oauth:device:guess', + who + ]); + if (!bucket || bucket.resetAt <= Date.now()) return true; + return bucket.count < deviceGuessLimit; + } + /** * The code as stored, from the code as a person typed it. * @@ -1390,8 +1462,16 @@ export function issuer< ); } + if (!(await guessesLeft(c.req.raw))) { + return c.text('Too many codes tried. Wait a while and start again from the app.', 429); + } + const found = await deviceStore.byUserCode(canonicalUserCode(raw)); if (!found || found.status !== 'pending' || found.expires <= Date.now()) { + // Charged only when the code was wrong. Getting one right costs + // nothing, so a person mistyping once and then succeeding is not + // walking towards a lockout. + await chargeGuess(c.req.raw); return c.text('That code is not valid any more. Ask the app for a new one.', 400); } diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts index 4be67308..b559672f 100644 --- a/packages/auth/test/device.test.ts +++ b/packages/auth/test/device.test.ts @@ -21,6 +21,7 @@ const auth = issuer({ subjects, allow: async () => true, allowDeviceClient: async (clientID) => clientID !== 'banned', + deviceVerification: { guessLimit: 3, guessWindow: 60 }, providers: { dummy: { type: 'dummy', @@ -266,7 +267,11 @@ describe('approval', () => { }); test('an unknown user code does not start a provider flow', async () => { - const response = await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZZ`); + // Its own address, so the budget it spends is its own — the shared + // bucket for callers with no address is asserted on further down. + const response = await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZZ`, { + headers: { 'cf-connecting-ip': '198.51.100.9' } + }); expect(response.status).toBe(400); }); @@ -411,3 +416,73 @@ describe('when both halves move at once', () => { expect([a, b].filter(Boolean)).toHaveLength(1); }); }); + +/** + * Working through the code space, and what stops it. + * + * A user code is eight characters from an alphabet of twenty-five, so guessing + * one is not cheap — but it is a fixed cost, and the endpoint that checks them + * had no opinion about how often you asked. RFC 8628 §5.2 asks for one. + */ +describe('guessing at user codes', () => { + /** A caller with an address of its own, so budgets do not run together. */ + function from(address: string) { + return (userCode: string) => + auth.request(`${ORIGIN}/device?user_code=${encodeURIComponent(userCode)}`, { + headers: { 'cf-connecting-ip': address } + }); + } + + test('a caller runs out of tries', async () => { + const tries = from('198.51.100.1'); + + expect((await tries('ZZZZZZZZ')).status).toBe(400); + expect((await tries('ZZZZZZZY')).status).toBe(400); + expect((await tries('ZZZZZZZX')).status).toBe(400); + expect((await tries('ZZZZZZZW')).status).toBe(429); + }); + + test('one caller running out does not lock out another', async () => { + const noisy = from('198.51.100.2'); + for (let i = 0; i < 4; i++) await noisy(`ZZZZZZZ${'ABCD'[i]}`); + expect((await noisy('ZZZZZZZZ')).status).toBe(429); + + const grant = await started(); + const quiet = await auth.request( + `${ORIGIN}/device?user_code=${encodeURIComponent(grant.user_code)}`, + { headers: { 'cf-connecting-ip': '198.51.100.3' } } + ); + expect(quiet.status).toBe(302); + }); + + test('getting one right is not charged for', async () => { + const address = '198.51.100.4'; + const tries = from(address); + expect((await tries('ZZZZZZZZ')).status).toBe(400); + expect((await tries('ZZZZZZZY')).status).toBe(400); + + // Two wrong out of a budget of three. A correct code in between must + // not be what tips the next wrong one over. + const grant = await started(); + const right = await auth.request( + `${ORIGIN}/device?user_code=${encodeURIComponent(grant.user_code)}`, + { headers: { 'cf-connecting-ip': address } } + ); + expect(right.status).toBe(302); + + expect((await tries('ZZZZZZZX')).status).toBe(400); + expect((await tries('ZZZZZZZW')).status).toBe(429); + }); + + // A caller who strips the headers that say where they are lands in one + // shared bucket. That is deliberate: it makes hiding cost a smaller budget + // rather than buying an unlimited one. + test('a caller with no address still has a budget', async () => { + for (let i = 0; i < 3; i++) { + expect((await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZ${'ABC'[i]}`)).status).toBe( + 400 + ); + } + expect((await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZD`)).status).toBe(429); + }); +}); From 304bb1f2ef181821a5e7ee387ab937ed92a4a833 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 10:05:11 +0300 Subject: [PATCH 13/13] fix(auth): count sign-in codes against the mailbox, not the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap on how many codes a sign-in could ask for was held per attempt, keyed by a value in the caller's own cookie. That bounds nothing. The caller decides how many attempts to start, and starting a fresh one costs them a discarded cookie — so either replaying an older cookie or simply beginning again walked straight around it, and the only thing left spacing the mail out was the interval between sends. The count now sits against the claim, over a window. That is the thing being protected: the mailbox belongs to somebody who did not ask to hear from us, and whoever is pointing at it is not the party to trust with the tally. A resend also left the previous code live, with a budget of guesses of its own. Several resends therefore meant several working codes and several times the chances at them, which made asking for a new code the cheapest way to buy more tries at the old one. A new code now retires the one before it. Reported against the replay path. The replay was real and the same hole was wider than that: starting a new attempt needed no replay at all. --- packages/auth/src/provider/code.ts | 70 ++++++++++++++++--------- packages/auth/test/code.test.ts | 83 ++++++++++++++++++++++++------ 2 files changed, 113 insertions(+), 40 deletions(-) diff --git a/packages/auth/src/provider/code.ts b/packages/auth/src/provider/code.ts index e643fb06..7b3a2dd5 100644 --- a/packages/auth/src/provider/code.ts +++ b/packages/auth/src/provider/code.ts @@ -91,11 +91,23 @@ export interface CodeProviderConfig< */ maxAttempts?: number; /** - * How many codes one attempt at signing in may ask for. + * How many codes may be sent to one claim inside {@link sendWindow}. * - * @default 3 + * Counted against the mailbox and not against the browser asking. A budget + * held per sign-in attempt bounds nothing: the caller chooses how many + * attempts to start, and starting a new one costs them a discarded cookie. + * The thing being protected is the address, so the address is what carries + * the count. + * + * @default 5 */ maxSends?: number; + /** + * The window {@link maxSends} is counted over, in seconds. + * + * @default 3600 + */ + sendWindow?: number; /** * Seconds between one code and the next for the same claim. * @@ -198,7 +210,8 @@ export function CodeProvider = Record = Record(ctx.storage, claimKey(claims)); - if (sentAt && Date.now() - sentAt.at < resendInterval * 1000) { + const now = Date.now(); + const sent = await Storage.get<{ at: number; count: number; since: number }>( + ctx.storage, + claimKey(claims) + ); + const open = sent && now - sent.since < sendWindow * 1000; + if (sent && now - sent.at < resendInterval * 1000) { return transition(c, state ?? { type: 'start' }, fd, { type: 'rate_limit' }); } - if (action === 'resend' && state?.type === 'code') { - const record = await Storage.get<{ attempts: number; sends: number }>( - ctx.storage, - attemptKey(state.flow) - ); - if ((record?.sends ?? 1) >= maxSends) { - return transition(c, state, fd, { type: 'rate_limit' }); - } + if (open && sent.count >= maxSends) { + return transition(c, state ?? { type: 'start' }, fd, { type: 'rate_limit' }); } const code = generate(); const err = await config.sendCode(claims, code); if (err) return transition(c, { type: 'start' }, fd, err); + // The code that was live until a moment ago stops being + // live now. Leaving it usable would mean each resend added + // a working code and another budget of guesses to spend on + // it, so asking for a new code would be how you bought more + // chances at the old one. + if (state?.type === 'code') { + await Storage.remove(ctx.storage, attemptKey(state.flow)); + } + // A new code means a new flow, which means a fresh budget // of guesses — and, more to the point, that the budget // attached to the previous code is now unreachable rather // than reset. const flow = generateUnbiasedString(FLOW_ALPHABET, 32); - const sends = - action === 'resend' && state?.type === 'code' - ? (( - await Storage.get<{ sends: number }>(ctx.storage, attemptKey(state.flow)) - )?.sends ?? 1) + 1 - : 1; - await Storage.set(ctx.storage, attemptKey(flow), { attempts: 0, sends }, ttl); - await Storage.set(ctx.storage, claimKey(claims), { at: Date.now() }, resendInterval); + await Storage.set(ctx.storage, attemptKey(flow), { attempts: 0 }, ttl); + await Storage.set( + ctx.storage, + claimKey(claims), + { + at: now, + count: open ? sent.count + 1 : 1, + since: open ? sent.since : now + }, + sendWindow + ); return transition( c, @@ -320,7 +344,7 @@ export function CodeProvider = Record( + const record = await Storage.get<{ attempts: number }>( ctx.storage, attemptKey(state.flow) ); diff --git a/packages/auth/test/code.test.ts b/packages/auth/test/code.test.ts index 397c8695..516ade08 100644 --- a/packages/auth/test/code.test.ts +++ b/packages/auth/test/code.test.ts @@ -19,6 +19,7 @@ const auth = issuer({ code: CodeProvider({ maxAttempts: 3, maxSends: 2, + sendWindow: 3600, resendInterval: 0, request: async (_req, _state, _form, error) => new Response(JSON.stringify({ error: error?.type ?? null }), { @@ -83,7 +84,7 @@ async function begin() { } /** Start a sign-in and ask for a code, coming back with the cookies and the code. */ -async function ask(email = 'ada@example.com') { +async function ask(email: string) { const cookies = await begin(); await post(cookies, { action: 'request', email }); return { cookies, code: sent.at(-1)! }; @@ -100,13 +101,13 @@ beforeEach(() => { describe('signing in with a code', () => { test('the right code signs you in', async () => { - const { cookies, code } = await ask(); + const { cookies, code } = await ask('right@example.com'); const response = await post(cookies, { action: 'verify', code }); expect(response.status).toBe(302); }); test('a wrong code is refused and says so', async () => { - const { cookies, code } = await ask(); + const { cookies, code } = await ask('wrong@example.com'); const response = await post(cookies, { action: 'verify', code: code === '000000' ? '111111' : '000000' }); expect(response.status).toBe(200); expect(await errorOf(response)).toBe('invalid_code'); @@ -124,7 +125,7 @@ describe('signing in with a code', () => { */ describe('guessing the code', () => { test('runs out of guesses long before it runs out of codes', async () => { - const { cookies, code } = await ask(); + const { cookies, code } = await ask('budget@example.com'); const wrong = code === '000000' ? '111111' : '000000'; expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe( @@ -144,7 +145,7 @@ describe('guessing the code', () => { }); test('the real code stops working once the guesses are spent', async () => { - const { cookies, code } = await ask(); + const { cookies, code } = await ask('spent@example.com'); const wrong = code === '000000' ? '111111' : '000000'; for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong }); @@ -157,7 +158,7 @@ describe('guessing the code', () => { // replays the cookie from before any guess was made, which is the cheapest // way to wind back anything held in one. test('replaying an earlier cookie does not hand back the spent guesses', async () => { - const { cookies, code } = await ask(); + const { cookies, code } = await ask('replay@example.com'); const untouched = cookies.header(); const wrong = code === '000000' ? '111111' : '000000'; for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong }); @@ -171,7 +172,7 @@ describe('guessing the code', () => { }); test('a code is spent when it is used, so its guesses do not carry over', async () => { - const { cookies, code } = await ask(); + const { cookies, code } = await ask('once@example.com'); expect((await post(cookies, { action: 'verify', code })).status).toBe(302); const again = await post(cookies, { action: 'verify', code }); @@ -181,7 +182,7 @@ describe('guessing the code', () => { describe('asking for codes', () => { test('a fresh code comes with a fresh budget of guesses', async () => { - const first = await ask(); + const first = await ask('fresh-a@example.com'); const wrong = '000000' === first.code ? '111111' : '000000'; for (let i = 0; i < 3; i++) await post(first.cookies, { action: 'verify', code: wrong }); expect(await errorOf(await post(first.cookies, { action: 'verify', code: wrong }))).toBe( @@ -190,22 +191,70 @@ describe('asking for codes', () => { // Starting over is allowed. It costs a code sent to the mailbox being // aimed at, which is where somebody would notice. - const second = await ask(); + const second = await ask('fresh-b@example.com'); expect(second.code).not.toBe(first.code); expect((await post(second.cookies, { action: 'verify', code: second.code })).status).toBe(302); }); - test('one sign-in cannot ask for codes forever', async () => { - const { cookies } = await ask(); - expect(await errorOf(await post(cookies, { action: 'resend', email: 'ada@example.com' }))).toBe( - null - ); - expect(await errorOf(await post(cookies, { action: 'resend', email: 'ada@example.com' }))).toBe( - 'rate_limit' - ); + test('a mailbox cannot be sent codes forever', async () => { + const email = 'flood@example.com'; + const { cookies } = await ask(email); + expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe(null); + expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe('rate_limit'); expect(sent).toHaveLength(2); }); + // The budget was once held per sign-in attempt, which bounded nothing: the + // caller decides how many attempts to start, and starting one costs a + // discarded cookie. Both of these walk around a per-attempt budget and land + // on the mailbox anyway, which is why the count lives there. + test('replaying an earlier cookie does not buy more codes', async () => { + const email = 'replay-send@example.com'; + const { cookies } = await ask(email); + const untouched = cookies.header(); + await post(cookies, { action: 'resend', email }); + expect(sent).toHaveLength(2); + + const replayed = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'resend', email }) + }); + expect(await errorOf(replayed)).toBe('rate_limit'); + expect(sent).toHaveLength(2); + }); + + test('starting over does not buy more codes either', async () => { + const email = 'restart-send@example.com'; + await ask(email); + await ask(email); + expect(sent).toHaveLength(2); + + const third = await begin(); + expect(await errorOf(await post(third, { action: 'request', email }))).toBe('rate_limit'); + expect(sent).toHaveLength(2); + }); + + // Each resend used to leave the code before it live, with a budget of + // guesses of its own. Five resends meant five working codes and five times + // the chances, so asking for a new code was how you bought more tries at + // the old one. + test('a resend retires the code before it', async () => { + const email = 'retire@example.com'; + const { cookies, code: first } = await ask(email); + const untouched = cookies.header(); + await post(cookies, { action: 'resend', email }); + expect(sent.at(-1)).not.toBe(first); + + const withOldCode = await auth.request(`${ORIGIN}/code/authorize`, { + method: 'POST', + headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: first }) + }); + expect(withOldCode.status).toBe(200); + expect(await errorOf(withOldCode)).toBe('rate_limit'); + }); + test('a bad address still gets told it is a bad address', async () => { const cookies = await begin(); const response = await post(cookies, { action: 'request', email: 'not-an-address' });