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