mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(auth): email is the root of an account, and Steam is a connection (#318)
## What landed
**Email is the root of an account.** A `user` is created by verifying an
email
address and nothing else. Every Steam account is now a connection
hanging off a
user that already exists, capped at four.
**And it is the only thing that creates one.** Signing in with a gaming
account
or with an SSH key are both unwired from the issuer. Each could mint a
user,
which makes an account only as recoverable as the thing that made it and
gives
one person as many accounts as they have gaming logins. The providers
still
exist under `packages/auth/src/provider/` and can be wired back;
connecting a
Steam account is unaffected, because that runs through `POST
/steam/link` in
`apps/api` against a user who already exists. A test asserts the two
routes are
not served, so they cannot come back quietly.
**The pin-code provider is wired**, and email delivery refuses rather
than
guesses. **The device authorization grant is served**, and it now ends
at a
question somebody has to answer.
## The review found five real defects. All five are fixed, and so are
six more
The first pass of this branch shipped a device flow that handed out
tokens
without asking anybody, an email path whose pin could be guessed
outright, and
three read-then-write races. Each fix below has a test that fails
without it —
verified by reverting the fix and watching the test go red, not by
assertion.
### Signing in was mistaken for saying yes
`GET /device?user_code=…` started a provider flow and provider success
approved
the grant. So the whole attack was: ask for a device code, mail somebody
the
pre-filled link, keep the device code, poll. They see an ordinary
sign-in
prompt, complete it correctly, and you hold their access **and refresh**
tokens.
They were never asked a question, because there wasn't one.
There is now. Signing in establishes who the browser belongs to; it does
not
establish that the person meant to hand an account to a program running
somewhere else. The flow ends at a page that names the client, shows the
user
code back so it can be compared against what the device is displaying,
and
offers Approve and Deny. Approving is a POST carrying a value placed in
the
cookie alongside it, so another site cannot submit it on their behalf.
Denial moved onto that page too. It was a `GET` anybody could fire with
no
authentication: a link scanner or a chat unfurler would cancel real
sign-ins,
and anyone who learned a user code could grief one.
### A six-digit pin with unlimited guesses and a day to use them
The code travelled in an encrypted cookie held by the caller,
verification
compared against that cookie, and a wrong answer re-rendered the form.
Nobody
has to be the person the code was mailed to — type someone 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 was
a
million requests. The constant-time comparison was guarding a door you
could
keep knocking on.
Guesses are counted on the server now, under a name that rotates with
every
code. The placement is the point: a counter kept beside the code, in the
cookie,
is one the guesser winds back by replaying an older copy. Starting over
is still
allowed and still costs a fresh code sent to the mailbox being aimed at,
where
somebody notices. The cookie's twenty-four hour life is ten minutes.
Resend is
spaced and bounded, because it was otherwise a way to mail a stranger as
fast as
requests go out. Both refusals say the same thing, since which one it
was is a
fact about someone else's mailbox.
The user codes on the other side of the flow are rate limited too —
eight
characters over a twenty-five character alphabet is a large space but a
fixed
one, and the endpoint had no opinion about how often you asked.
### Three read-then-write races
- **A poll could erase an approval.** The grant was read, modified and
written
back whole, so a poll that read a pending record and then wrote its
bookkeeping undid an approval that landed in between, leaving the client
polling a dead grant until it aged out. The same window let an approval
overwrite a denial.
- **The connection cap counted nothing.** `select … for update` over the
connections a user already had locks the rows it finds, and finding none
locks
nothing — there are no gap locks under read committed. Six concurrent
links
against a cap of four produced six; the test asserts that.
- **Concurrent email sign-ins returned a driver error.** Two tabs
finishing the
same sign-in both found no user, and the loser got a raw constraint
violation
instead of the account the winner had just made.
The cap now counts under a lock on the account's own row, which is the
one thing
every caller for that account contends on. The email paths let the
unique index
arbitrate and read back what the winner wrote. Device grants moved out
of the
key-value store into a table, where approving is one conditional update
and
redeeming is one delete that returns what it deleted.
### Three more the review did not raise
- **`client_id` was never checked at either end.** Anyone could mint a
grant
naming any client, and any holder of a leaked device code could redeem
it. It
is validated at issue and has to match at redemption.
- **Tokens were minted at approval** and left in storage until
collected, so the
lifetime reported to the client overstated what was left, and a grant
nobody
collected still left a usable refresh token lying around. They are
minted at
redemption.
- **The device code was stored as written.** It is the credential the
tokens are
handed to, so what is kept is now its hash: enough to recognise it, not
enough
to present it.
### And the environment check that decided none of this mattered
Mail delivery threw only when the environment said `production`, and
logged the
recipient and the live code otherwise. The deployment sets no such
marker — see
`alchemy.run.ts` before this change — so production took the developer
branch,
printed every code to a retained log, and reported success while nobody
received
anything.
That is the cost of a fail-open default: the deployment that forgets its
mail
settings is exactly the one with no marker saying it is real, so it gets
the
lenient branch precisely when it should not. Turned around. Printing a
code is
asked for by name; absence of configuration is a refusal; two settings
out of
three is an error rather than a fallback. 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.
## Where device grants live, and why it is a table
Short-lived state that would sit happily in a cache, in Postgres anyway.
The
reason is not durability. Every transition has to happen exactly once
while two
parties touch the same record — 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. The key-value store behind the rest
of the
issuer has no compare-and-swap, so on it the poll/approval race and
single
redemption can be narrowed and never closed.
The issuer cannot reach the database, so the store is an interface
(`packages/auth/src/device.ts`) with two implementations: one in memory
for
tests, one in `packages/core/src/auth/device-grant.ts`. Every method is
a single
operation and no caller reads a grant, decides, and writes it back.
Migration
`0010` adds the table. Rows are swept when a grant is created rather
than on a
schedule, since a grant lives ten minutes and that is the only statement
that
adds one.
## `session.claim_token` is here on another lane's behalf
Migration `0009` adds `session.claim_token`, nullable `text`, no default
and no
backfill. **It is not identity work and carries no identity reason** —
do not go
looking for one. It records which attempt holds a session run; the
endpoint that
reads and writes it arrives separately. It is in this migration only
because a
schema change has one owner at a time. The column is declared in
`session.sql.ts` and the snapshot, so the next `drizzle-kit generate`
will not
try to drop it — `0010` was generated clean, which is the proof.
## The tests
```
$ bun test
268 pass
0 fail
738 expect() calls
Ran 268 tests across 21 files.
```
Baseline on `dev` before any of this, measured on the same database:
**198 pass,
0 fail, 531 expect() calls.**
The tests that matter are the ones that would have caught the defects,
so each
was checked by putting the defect back:
| Reverted | What goes red |
|---|---|
| the confirmation step | signing in through the link leaves the grant
pending |
| the guess counter | four tests, including one that replays an older
cookie |
| the lock on the account row | six connections against a cap of four |
| the unique-violation handling | a driver error where a sentence should
be |
| the field-level poll write | an approval erased by a poll behind it |
| the verification rate limit | four tests |
Concurrency is exercised by running the same call several times at once
against
a real database, because run one at a time all of it passes whether or
not any
of the protection exists.
## The migration, against a database built to be awkward
`packages/core/script/verify-migration-0009.sh` builds a database
containing the
rows that make the statements do work — an account with no address, two
accounts
holding one address in different cases, an account already over the cap,
a
soft-deleted row holding a live row's address — applies everything
before `0009`,
applies it, and checks each case. All nineteen checks still pass. The
negative
control still dies where it should: with the de-duplication statement
neutered,
`create unique index` fails on the first duplicate pair.
## What this still does not verify
**1. No real email has ever been sent.** The mailer is tested against a
stubbed
`fetch`: the URL, the bearer header, the body it builds, and that it
refuses
when unconfigured. The body shape follows the common `{from, to,
subject, text}`
convention and may need a field the first real provider wants. This now
fails
closed and the deploy refuses without settings, so the failure mode is
loud
rather than silent — but it is still untested against a provider.
**2. The migration is verified against a database I built, not the one
that
matters.** I do not know whether production has duplicate addresses, how
many
rows carry case or whitespace, or whether any account is already over
the cap.
The fixtures cover those because they are *possible*. Worth three
`select`s
against production before this is applied.
**3. The verification rate limit is approximate.** The counter is in the
key-value store, so a caller spread across a distributed edge can exceed
the
budget somewhat. The number that decides the question is whether
somebody is
working through the code space, and a handful either way does not change
it.
**4. The single-redemption and conditional-transition properties are
held
against Postgres, and the in-memory store is trusted rather than
proved.** The
memory implementation gets its atomicity from nothing suspending inside
a
method, which is true of it and is not a promise the interface makes. It
is for
tests and local runs.
**5. A person who signed up by email carries an empty connected-account
id in
their token.** The subject schema requires the field and an account with
no
connection has nothing to put there, so it gets `''` — the same value a
server-to-server caller has always carried, and the one consumer already
falls
back to `''`. The honest shape is an optional field, and making it
optional
touches `subjects.ts`, the actor model and the API middleware. Flagging
rather
than doing.
**6. The desktop client cannot actually complete this flow yet.** Its
`DeviceCode` struct has no field for the device code, so it has nothing
to poll
with. That is a different lane's file and nothing here touches it, but
the grant
is not reachable end to end until it does.
**7. The four-account cap and the user-code alphabet are asserted, not
measured.** Four comes from a household's size and a screen's width. The
alphabet excludes look-alikes on the same reasoning. No measurement
decides
either, and no test here pretends one does.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR makes verified email the root account identity, turns Steam into
an attached account connection, adds provider-neutral email-code
delivery, and implements an RFC 8628 device authorization flow backed by
atomic PostgreSQL grant transitions. It also aligns the related
identity, session, and device-grant migrations and adds concurrency and
flow tests.
- Removes Steam and SSH as direct auth-worker sign-in providers.
- Adds email-code account creation and deployment-time mail
configuration checks.
- Adds explicit device approval, denial, polling throttling, client
binding, and one-time redemption.
- Serializes Steam-link cap enforcement and handles concurrent email
uniqueness conflicts.
- Adds and aligns migrations, snapshots, durable device-grant storage,
and `session.claim_token`.
- One non-blocking resend-limit replay issue remains.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge, with one non-blocking email-delivery abuse
limitation that should be hardened.
The prior device-token theft, polling race, Steam-link concurrency,
email uniqueness, and session-schema findings are fixed in the current
code; the five corresponding threads were manually resolved without
explanatory replies. The remaining new issue permits bypassing the
intended email send cap through replay of an older provider cookie, but
the resend interval still bounds its rate and it does not compromise
account authentication.
**Files Needing Attention:** packages/auth/src/provider/code.ts
<details open><summary><h3>Security Review</h3></summary>
The device flow now requires an explicit, CSRF-protected confirmation
and uses atomic, client-bound redemption. One lower-impact abuse issue
remains: replaying an older code-provider cookie can bypass the intended
email send cap.
</details>
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| packages/auth/src/issuer.ts | Adds an explicitly confirmed RFC 8628
device flow with client-bound, one-time token redemption. |
| packages/auth/src/provider/code.ts | Adds server-side attempt and send
accounting, but replacement flows leave earlier cookie-referenced
counters replayable. |
| packages/core/src/auth/device-grant.ts | Implements durable device
grants using atomic conditional approval, denial, polling updates, and
consumption. |
| packages/core/src/user/identity.ts | Makes email identity creation
conflict-aware and serializes Steam-link cap enforcement on the user
row. |
| apps/auth/src/index.ts | Reconfigures the deployed issuer around email
sign-in and PostgreSQL-backed desktop device authorization. |
| alchemy.run.ts | Requires complete mail configuration for permanent
stages and explicitly enables code logging only for ephemeral
development stages. |
| packages/core/migrations/0010_device_authorization_grant.sql | Adds
the device-grant enum, table, and unique indexes in alignment with the
Drizzle model and snapshot. |
<h3>Sequence Diagram</h3>
```mermaid
sequenceDiagram
participant D as Desktop client
participant A as Auth issuer
participant B as Browser
participant E as Email provider
participant DB as PostgreSQL
D->>A: POST /device/authorize
A->>DB: Create pending grant
A-->>D: device_code, user_code, interval
B->>A: Enter user_code
A->>E: Send email verification code
B->>A: Verify email code
A-->>B: Display client and user-code confirmation
B->>A: Approve or deny
A->>DB: Atomic terminal transition
loop Until terminal
D->>A: Poll /token
end
A->>DB: Delete-and-return approved grant
A-->>D: Access and refresh tokens
```
<details><summary>Prompt To Fix All With AI</summary>
`````markdown
### Issue 1
packages/auth/src/provider/code.ts:291-297
**Cookie replay bypasses send cap**
Replaying an earlier encrypted provider cookie bypasses `maxSends`. A resend creates a new flow with an incremented counter but leaves the old flow and its lower counter valid, so the old cookie can call `sendCode` again after each resend interval. This permits repeated unsolicited sign-in emails to an attacker-selected address, although the resend interval still limits their rate.
**How this was verified:** The resend check reads only the flow named by the presented cookie, while creating its replacement neither updates nor removes that old flow.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
`````
</details>
<sub>Reviews (3): Last reviewed commit: ["fix(auth): stop a caller
working
through..."](fc825f5219)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60581000)</sub>
> Greptile also left **1 inline comment** on this PR.
<details><summary><h4>Context used (4)</h4></summary>
- Knowledge Base — [Authentication
platform](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/auth-platform.md)
- Knowledge Base — [Auth providers, sessions, and
storage](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/auth-providers-and-storage.md)
- Knowledge Base — [Core domain and
persistence](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-domain-data.md)
- Knowledge Base — [Users, identity, and game
libraries](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-identity-and-library.md)
</details>
<!-- /greptile_comment -->
This commit is contained in:
@@ -5,10 +5,60 @@ 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';
|
||||
|
||||
/**
|
||||
* 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* () {
|
||||
@@ -37,15 +87,17 @@ 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'] },
|
||||
// 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
|
||||
...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`
|
||||
|
||||
83
apps/auth/src/email.ts
Normal file
83
apps/auth/src/email.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 — `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;
|
||||
/**
|
||||
* 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 refuse.
|
||||
*
|
||||
* 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<void> {
|
||||
const present = [config.EMAIL_SEND_URL, config.EMAIL_API_KEY, config.EMAIL_FROM].filter(Boolean);
|
||||
|
||||
if (present.length === 0) {
|
||||
if (config.EMAIL_DEV_LOG === 'true') {
|
||||
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!, {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,61 @@
|
||||
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
|
||||
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 { 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 { Steam } from '@nestri/core/steam/index';
|
||||
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';
|
||||
import { User } from '@nestri/core/user/index';
|
||||
import { Identity } from '@nestri/core/user/identity';
|
||||
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;
|
||||
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.
|
||||
*
|
||||
* 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<string> {
|
||||
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<string, unknown>);
|
||||
@@ -28,104 +64,62 @@ 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
|
||||
// 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: {
|
||||
steam: SteamProvider(),
|
||||
ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY })
|
||||
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);
|
||||
}
|
||||
})
|
||||
},
|
||||
async success(context, response) {
|
||||
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);
|
||||
if (response.provider === 'code') {
|
||||
const email = (response.claims as Record<string, string>).email!.trim().toLowerCase();
|
||||
const { userID } = await Identity.fromVerifiedEmail({ email });
|
||||
|
||||
const profileRes = await fetch(profileUrl.toString());
|
||||
const profileData = (await profileRes.json()) as {
|
||||
response?: { players?: Array<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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: personaname })
|
||||
Team.ensurePersonal({ displayName: email.split('@')[0]! })
|
||||
);
|
||||
|
||||
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 Steam 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
|
||||
});
|
||||
return context.subject('user', { userID, linkedAccountID });
|
||||
}
|
||||
|
||||
throw new Error('Unknown provider');
|
||||
|
||||
126
apps/auth/test/email.test.ts
Normal file
126
apps/auth/test/email.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { sendVerificationCode } from '../src/email.js';
|
||||
|
||||
describe('sending a sign-in code', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
// 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({ 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;
|
||||
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(
|
||||
{
|
||||
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 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 () =>
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,115 +1,139 @@
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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<string, string>();
|
||||
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('; ');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
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' }
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
// 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';
|
||||
|
||||
const callbackResponse = await auth.request(callbackUrl, {
|
||||
headers: { cookie }
|
||||
const { challenge, url } = await client.authorize('https://client.example.com/callback', 'code', {
|
||||
pkce: true,
|
||||
provider: 'code'
|
||||
});
|
||||
expect(callbackResponse.status).toBe(302);
|
||||
|
||||
const location = new URL(callbackResponse.headers.get('location')!);
|
||||
const cookies = jar();
|
||||
cookies.absorb(await auth.request(url));
|
||||
expect(cookies.header()).not.toBe('');
|
||||
|
||||
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 })
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -119,7 +143,12 @@ describe('Steam auth flow', () => {
|
||||
challenge.verifier
|
||||
);
|
||||
if (exchanged.err) throw exchanged.err;
|
||||
const tokens = exchanged.tokens!;
|
||||
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('ada@example.com');
|
||||
|
||||
expect(tokens.access).toBeString();
|
||||
expect(tokens.refresh).toBeString();
|
||||
@@ -130,88 +159,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('grace@example.com');
|
||||
|
||||
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.access}` }
|
||||
@@ -221,7 +177,7 @@ describe('User info', () => {
|
||||
const userinfo = await infoRes.json();
|
||||
expect(userinfo).toMatchObject({
|
||||
userID: 'usr_test123',
|
||||
linkedAccountID: 'lac_test456'
|
||||
linkedAccountID: ''
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
165
packages/auth/src/device.ts
Normal file
165
packages/auth/src/device.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -175,6 +175,17 @@ 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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +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, timingSafeCompare } from './random.js';
|
||||
import { DynamoStorage } from './storage/dynamo.js';
|
||||
import { MemoryStorage } from './storage/memory.js';
|
||||
import { Storage, StorageAdapter } from './storage/storage.js';
|
||||
@@ -206,6 +225,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<string, Provider<any>>,
|
||||
Subjects extends SubjectSchema,
|
||||
@@ -348,7 +373,67 @@ 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;
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `/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<boolean>;
|
||||
/**
|
||||
* Optionally, configure the UI that's displayed when the user visits the root URL of the
|
||||
* of the OpenAuth server.
|
||||
@@ -466,6 +551,18 @@ 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;
|
||||
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);
|
||||
}
|
||||
@@ -525,6 +622,45 @@ export function issuer<
|
||||
? subjectOpts.subject
|
||||
: await resolveSubject(type, properties);
|
||||
await successOpts?.invalidate?.(await resolveSubject(type, properties));
|
||||
if (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
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
csrf: generateUnbiasedString(CSRF_ALPHABET, 32),
|
||||
subject: {
|
||||
subject,
|
||||
type: type as string,
|
||||
properties,
|
||||
ttl: {
|
||||
access: subjectOpts?.ttl?.access ?? ttlAccess,
|
||||
refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh
|
||||
}
|
||||
}
|
||||
};
|
||||
await auth.set(ctx, 'device_confirm', ttlDevice, confirmation);
|
||||
return ctx.html(deviceConfirmPage(confirmation));
|
||||
}
|
||||
if (authorization) {
|
||||
if (authorization.response_type === 'token') {
|
||||
const location = new URL(authorization.redirect_uri);
|
||||
@@ -635,6 +771,126 @@ export function issuer<
|
||||
storage
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<boolean> {
|
||||
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.
|
||||
*
|
||||
* 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();
|
||||
}
|
||||
|
||||
/** Enough escaping to put an attacker-chosen client name on a page safely. */
|
||||
function escapeHtml(raw: string) {
|
||||
return raw
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
||||
`<title>Confirm sign-in</title>` +
|
||||
`<h1>Is this you?</h1>` +
|
||||
`<p><strong>${client}</strong> is asking to sign in to your account.</p>` +
|
||||
`<p>The code it is showing you should be:</p>` +
|
||||
`<p><code style="font-size:2em;letter-spacing:.2em">${code.slice(0, 4)}-${code.slice(4)}</code></p>` +
|
||||
`<p>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.</p>` +
|
||||
`<form method="post" action="/device/confirm">` +
|
||||
`<input type="hidden" name="csrf" value="${escapeHtml(confirmation.csrf)}">` +
|
||||
`<button type="submit" name="action" value="approve">Approve</button> ` +
|
||||
`<button type="submit" name="action" value="deny">Deny</button>` +
|
||||
`</form>`
|
||||
);
|
||||
}
|
||||
|
||||
async function getAuthorization(ctx: Context) {
|
||||
const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization');
|
||||
if (!match) throw new UnknownStateError();
|
||||
@@ -786,8 +1042,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 +1211,122 @@ 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
|
||||
);
|
||||
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 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 deviceStore.remove(hash);
|
||||
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 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') {
|
||||
// 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: tokens.access,
|
||||
refresh_token: tokens.refresh,
|
||||
expires_in: tokens.expiresIn
|
||||
});
|
||||
}
|
||||
|
||||
await deviceStore.recordPoll(hash, now, grant.interval);
|
||||
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 +1374,168 @@ 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);
|
||||
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);
|
||||
|
||||
// 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 deviceStore.byUserCode(candidate))) {
|
||||
userCode = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!userCode)
|
||||
return c.json(
|
||||
{ error: 'server_error', error_description: 'Could not allocate a user code' },
|
||||
500
|
||||
);
|
||||
|
||||
await deviceStore.create({
|
||||
deviceCodeHash,
|
||||
userCode,
|
||||
clientID,
|
||||
status: 'pending',
|
||||
interval: deviceInterval,
|
||||
lastPolled: 0,
|
||||
expires: Date.now() + ttlDevice * 1000
|
||||
});
|
||||
|
||||
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.
|
||||
//
|
||||
// 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(
|
||||
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
||||
`<title>Sign in to a device</title>` +
|
||||
`<form method="get" action="/device">` +
|
||||
`<label for="user_code">Enter the code shown in the app</label>` +
|
||||
`<input id="user_code" name="user_code" autocomplete="off" autofocus>` +
|
||||
`<button type="submit">Continue</button>` +
|
||||
`</form>`
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const authorization: AuthorizationState = {
|
||||
response_type: 'device_code',
|
||||
client_id: found.clientID,
|
||||
device_code: found.deviceCodeHash
|
||||
} 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
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// 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 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) => {
|
||||
const provider = c.req.query('provider');
|
||||
const response_type = c.req.query('response_type');
|
||||
@@ -1125,6 +1666,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);
|
||||
|
||||
@@ -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,58 @@ 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 may be sent to one claim inside {@link sendWindow}.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 +169,14 @@ export type CodeProviderState =
|
||||
resend?: boolean;
|
||||
code: string;
|
||||
claims: Record<string, string>;
|
||||
/**
|
||||
* 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 +195,51 @@ 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<Claims extends Record<string, string> = Record<string, string>>(
|
||||
config: CodeProviderConfig<Claims>
|
||||
): Provider<{ claims: Claims }> {
|
||||
const length = config.length || 6;
|
||||
const ttl = config.ttl ?? 60 * 10;
|
||||
const maxAttempts = config.maxAttempts ?? 5;
|
||||
const maxSends = config.maxSends ?? 5;
|
||||
const sendWindow = config.sendWindow ?? 60 * 60;
|
||||
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<string, string>) {
|
||||
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 +249,14 @@ export function CodeProvider<Claims extends Record<string, string> = Record<stri
|
||||
fd?: FormData,
|
||||
err?: CodeProviderError
|
||||
) {
|
||||
await ctx.set<CodeProviderState>(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<CodeProviderState>(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 +265,6 @@ export function CodeProvider<Claims extends Record<string, string> = Record<stri
|
||||
});
|
||||
|
||||
routes.post('/authorize', async (c) => {
|
||||
const code = generate();
|
||||
const fd = await c.req.formData();
|
||||
const state = await ctx.get<CodeProviderState>(c, 'provider');
|
||||
const action = fd.get('action')?.toString();
|
||||
@@ -173,22 +272,94 @@ export function CodeProvider<Claims extends Record<string, string> = Record<stri
|
||||
if (action === 'request' || action === 'resend') {
|
||||
const claims = Object.fromEntries(fd) as Claims;
|
||||
delete claims.action;
|
||||
|
||||
// Asked for too soon, or too many times for this mailbox.
|
||||
// Both answers are the same on purpose: saying which would
|
||||
// tell a caller whether the address they typed has had a
|
||||
// code sent to it lately, which is a fact about somebody
|
||||
// else's mailbox.
|
||||
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 (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);
|
||||
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,
|
||||
{
|
||||
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 }>(
|
||||
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,9 +372,15 @@ export function CodeProvider<Claims extends Record<string, string> = Record<stri
|
||||
{ type: 'invalid_code' }
|
||||
);
|
||||
}
|
||||
|
||||
// Spent. Without this the same code answers again, and the
|
||||
// budget of guesses is per code rather than per sign-in.
|
||||
await Storage.remove(ctx.storage, attemptKey(state.flow));
|
||||
await ctx.unset(c, 'provider');
|
||||
return ctx.forward(c, await ctx.success(c, { claims: state.claims as Claims }));
|
||||
}
|
||||
|
||||
return transition(c, { type: 'start' }, fd);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,13 @@ const DEFAULT_COPY = {
|
||||
/**
|
||||
* Copy for the resend button.
|
||||
*/
|
||||
code_resend: 'Resend'
|
||||
code_resend: 'Resend',
|
||||
/**
|
||||
* Error message when too many codes have been asked for, or too many
|
||||
* guesses made. Deliberately one message for both: which of the two it was
|
||||
* is a fact about somebody else's mailbox.
|
||||
*/
|
||||
rate_limited: 'Too many attempts. Wait a moment and start again.'
|
||||
};
|
||||
|
||||
export type CodeUICopy = typeof DEFAULT_COPY;
|
||||
@@ -124,6 +130,7 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
|
||||
<Layout>
|
||||
<form data-component="form" method="post">
|
||||
{error?.type === 'invalid_claim' && <FormAlert message={copy.email_invalid} />}
|
||||
{error?.type === 'rate_limit' && <FormAlert message={copy.rate_limited} />}
|
||||
<input type="hidden" name="action" value="request" />
|
||||
<input
|
||||
data-component="input"
|
||||
@@ -151,6 +158,7 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
|
||||
<Layout>
|
||||
<form data-component="form" class="form" method="post">
|
||||
{error?.type === 'invalid_code' && <FormAlert message={copy.code_invalid} />}
|
||||
{error?.type === 'rate_limit' && <FormAlert message={copy.rate_limited} />}
|
||||
{state.type === 'code' && (
|
||||
<FormAlert
|
||||
message={(state.resend ? copy.code_resent : copy.code_sent) + state.claims.email}
|
||||
|
||||
264
packages/auth/test/code.test.ts
Normal file
264
packages/auth/test/code.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { object, string } from 'valibot';
|
||||
|
||||
import { CodeProvider } from '../src/provider/code.js';
|
||||
import { issuer } from '../src/issuer.js';
|
||||
import { MemoryStorage } from '../src/storage/memory.js';
|
||||
import { createSubjects } from '../src/subject.js';
|
||||
|
||||
const subjects = createSubjects({ user: object({ email: string() }) });
|
||||
|
||||
let sent: string[] = [];
|
||||
|
||||
const auth = issuer({
|
||||
storage: MemoryStorage(),
|
||||
subjects,
|
||||
allow: async () => true,
|
||||
providers: {
|
||||
code: CodeProvider({
|
||||
maxAttempts: 3,
|
||||
maxSends: 2,
|
||||
sendWindow: 3600,
|
||||
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<string, string>();
|
||||
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<typeof jar>, body: Record<string, string>) {
|
||||
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: string) {
|
||||
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('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('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');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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('budget@example.com');
|
||||
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('spent@example.com');
|
||||
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('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 });
|
||||
|
||||
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('once@example.com');
|
||||
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('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(
|
||||
'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('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('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' });
|
||||
expect(await errorOf(response)).toBe('invalid_claim');
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
488
packages/auth/test/device.test.ts
Normal file
488
packages/auth/test/device.test.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test';
|
||||
|
||||
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';
|
||||
|
||||
const subjects = createSubjects({
|
||||
user: object({
|
||||
userID: string()
|
||||
})
|
||||
});
|
||||
|
||||
const deviceStore = MemoryDeviceStore();
|
||||
|
||||
const auth = issuer({
|
||||
storage: MemoryStorage(),
|
||||
deviceStore,
|
||||
subjects,
|
||||
allow: async () => true,
|
||||
allowDeviceClient: async (clientID) => clientID !== 'banned',
|
||||
deviceVerification: { guessLimit: 3, guessWindow: 60 },
|
||||
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';
|
||||
|
||||
/** Two cookies are in play across this flow, and `get` returns only the first. */
|
||||
function jar() {
|
||||
const cookies = new Map<string, string>();
|
||||
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: 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.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, 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: clientID
|
||||
})
|
||||
});
|
||||
return { status: response.status, body: (await response.json()) as any };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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 })
|
||||
});
|
||||
}
|
||||
|
||||
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 grant = await started();
|
||||
|
||||
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(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 started();
|
||||
const b = await started();
|
||||
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');
|
||||
});
|
||||
|
||||
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 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 grant = await started();
|
||||
await poll(grant.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() + (grant.interval + 1) * 1000));
|
||||
const stillTooSoon = await poll(grant.device_code);
|
||||
expect(stillTooSoon.body.error).toBe('slow_down');
|
||||
|
||||
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 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 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 grant = await started();
|
||||
const confirmed = await answer(grant.user_code, 'approve');
|
||||
expect(confirmed.status).toBe(200);
|
||||
|
||||
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 grant = await started();
|
||||
await answer(grant.user_code, 'approve');
|
||||
|
||||
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 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() + (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 () => {
|
||||
// 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);
|
||||
});
|
||||
|
||||
test('a refusal is final, and says so', async () => {
|
||||
const grant = await started();
|
||||
const denied = await answer(grant.user_code, 'deny');
|
||||
expect(denied.status).toBe(200);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
66
packages/core/migrations/0009_email_is_the_root_identity.sql
Normal file
66
packages/core/migrations/0009_email_is_the_root_identity.sql
Normal file
@@ -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;
|
||||
39
packages/core/migrations/0010_device_authorization_grant.sql
Normal file
39
packages/core/migrations/0010_device_authorization_grant.sql
Normal 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");
|
||||
2322
packages/core/migrations/meta/0009_snapshot.json
Normal file
2322
packages/core/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2451
packages/core/migrations/meta/0010_snapshot.json
Normal file
2451
packages/core/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,20 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1788590292860,
|
||||
"tag": "0010_device_authorization_grant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
177
packages/core/script/verify-migration-0009.sh
Executable file
177
packages/core/script/verify-migration-0009.sh
Executable file
@@ -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 <name> <expected> <sql>
|
||||
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
|
||||
62
packages/core/src/auth/device-grant.sql.ts
Normal file
62
packages/core/src/auth/device-grant.sql.ts
Normal file
@@ -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)
|
||||
]
|
||||
);
|
||||
201
packages/core/src/auth/device-grant.test.ts
Normal file
201
packages/core/src/auth/device-grant.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
153
packages/core/src/auth/device-grant.ts
Normal file
153
packages/core/src/auth/device-grant.ts
Normal file
@@ -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));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,14 +180,6 @@ 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 ??
|
||||
@@ -189,15 +191,10 @@ export namespace Steam {
|
||||
'Cannot link Steam account without a user ID'
|
||||
);
|
||||
}
|
||||
const id = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id,
|
||||
return Identity.linkSteam({
|
||||
userId: uid,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
return id;
|
||||
steamId: input.steamId,
|
||||
profile: input.profile
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
293
packages/core/src/user/identity.test.ts
Normal file
293
packages/core/src/user/identity.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
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';
|
||||
|
||||
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<number> {
|
||||
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('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);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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/);
|
||||
});
|
||||
});
|
||||
300
packages/core/src/user/identity.ts
Normal file
300
packages/core/src/user/identity.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
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 { 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
|
||||
* 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;
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
});
|
||||
} 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 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, 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({
|
||||
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) => {
|
||||
// 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
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
.where(
|
||||
and(
|
||||
eq(LinkedAccountTable.userId, input.userId),
|
||||
eq(LinkedAccountTable.provider, 'steam'),
|
||||
isNull(LinkedAccountTable.timeDeleted)
|
||||
)
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -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', {
|
||||
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`)
|
||||
]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user