mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user