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

Anybody could ask for a device code and be handed a link with the user
code already in it. Following that link started a sign-in, and finishing
the sign-in approved the grant. So sending somebody the link was enough:
they saw an ordinary sign-in prompt, completed it, and whoever kept the
device code polled and collected their access and refresh tokens. The
victim never saw a question, because there was not one.

There is now. Signing in says who the browser belongs to; it does not say
the person meant to hand an account to a program somewhere else. Those
are two questions and only the second authorizes anything, so the flow
ends at a page that names the program, shows the code back so it can be
compared with what the device is displaying, and offers Approve and Deny.
Approving is a POST carrying a value from the cookie, so another site
cannot submit it on somebody's behalf. Denial moved onto the same page:
it used to be a GET anyone could fire, which meant a link scanner could
cancel a real sign-in and a stranger with a user code could grief one.

Three more things that were wrong underneath.

The grant was read, modified and written back as a whole record. A poll
that read a pending grant and then wrote its bookkeeping erased an
approval that landed in between, and the client polled a dead grant until
it expired. Grants moved to a table, where approving is one conditional
update and redeeming is one delete that returns what it deleted, so
neither party can undo the other and two polls cannot both be served.

Tokens were minted when the person clicked and left sitting in storage
until collected. They are minted at redemption now, so the lifetime the
client is told about starts when it receives them, and a grant nobody
collects leaves no usable refresh token behind.

The client identifier was never checked, at either end. It is validated
when the grant is created and has to match when the code is redeemed —
without that, a leaked code is redeemable by anyone, and the identifier
the token carries is whatever the last caller claimed. The device code
is also stored as a hash now, since it is the credential the tokens are
handed to.

The store is an interface because the issuer cannot reach the database,
and because the guarantees are the point: every method is one operation,
and no caller reads a grant, decides, and writes it back.
This commit is contained in:
Wanjohi
2026-09-05 09:40:03 +03:00
parent 15f8d3eb34
commit 36179150a1
11 changed files with 3622 additions and 187 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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