mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35: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:
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,125 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { createClient } from '@nestri/auth/client';
|
||||
import { issuer } from '@nestri/auth/index';
|
||||
import { SshProvider } from '@nestri/auth/provider/ssh';
|
||||
import { SteamProvider } from '@nestri/auth/provider/steam';
|
||||
import { CodeProvider } from '@nestri/auth/provider/code';
|
||||
import { MemoryStorage } from '@nestri/auth/storage/memory';
|
||||
import { CodeUI } from '@nestri/auth/ui/code';
|
||||
import { subjects } from '@nestri/core/auth/subjects';
|
||||
|
||||
/**
|
||||
* The issuer the worker builds, with the database taken out.
|
||||
*
|
||||
* The provider list is the load-bearing part and is the same one
|
||||
* `apps/auth/src/index.ts` passes: one entry, `code`. `success` is a stub
|
||||
* because what the real one does — resolve an address to a user and give it a
|
||||
* team — is core's behaviour and is held by core's own tests. What this file
|
||||
* holds is the shape of the issuer around it.
|
||||
*/
|
||||
let lastCode = '';
|
||||
const storage = MemoryStorage();
|
||||
|
||||
const auth = issuer({
|
||||
subjects,
|
||||
storage,
|
||||
allow: async () => true,
|
||||
providers: {
|
||||
steam: SteamProvider(),
|
||||
ssh: SshProvider({ sshAuthKey: 'test-ssh-key' })
|
||||
code: CodeProvider({
|
||||
...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }),
|
||||
sendCode: async (_claims, code) => {
|
||||
lastCode = code;
|
||||
}
|
||||
})
|
||||
},
|
||||
async success(context, response) {
|
||||
if (response.provider === 'steam') {
|
||||
if (response.provider === 'code') {
|
||||
return context.subject('user', {
|
||||
userID: 'usr_test123',
|
||||
linkedAccountID: 'lac_test456'
|
||||
linkedAccountID: ''
|
||||
});
|
||||
}
|
||||
if (response.provider === 'ssh') {
|
||||
return context.subject('user', {
|
||||
userID: 'usr_test123',
|
||||
linkedAccountID: 'lac_test456',
|
||||
fingerprint: response.fingerprint
|
||||
});
|
||||
}
|
||||
throw new Error('unknown provider');
|
||||
throw new Error('Unknown provider');
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url.includes('steamcommunity.com/openid/login')) {
|
||||
return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 });
|
||||
}
|
||||
|
||||
if (url.includes('api.steampowered.com')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
response: {
|
||||
players: [
|
||||
{
|
||||
personaname: 'TestPlayer',
|
||||
avatarfull:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg',
|
||||
steamid: '76561197960287956'
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = fetch;
|
||||
});
|
||||
|
||||
describe('Steam auth flow', () => {
|
||||
test('authorize redirects to Steam OpenID', async () => {
|
||||
/**
|
||||
* Signing in with a gaming account or a key is gone, and this is the assertion
|
||||
* that keeps it gone.
|
||||
*
|
||||
* Both used to be providers here and both could bring a user into existence
|
||||
* from something that is not an address, which is the shape the account model
|
||||
* no longer has. The provider implementations still exist and can be wired
|
||||
* back; what must not happen quietly is them becoming reachable again.
|
||||
*/
|
||||
describe('what the issuer serves', () => {
|
||||
test('there is no sign-in with a gaming account', async () => {
|
||||
const response = await auth.request('https://auth.internal/steam/authorize');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test('full code flow and token verification', async () => {
|
||||
const client = createClient({
|
||||
issuer: 'https://auth.internal',
|
||||
clientID: 'api',
|
||||
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
|
||||
test('there is no sign-in with a key', async () => {
|
||||
const response = await auth.request('https://auth.internal/ssh/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fingerprint: 'SHA256:abc123', steamId: '76561198012345678' })
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
const { challenge, url } = await client.authorize(
|
||||
'https://client.example.com/callback',
|
||||
'code',
|
||||
{ pkce: true, provider: 'steam' }
|
||||
);
|
||||
test('asking for a code is where a sign-in starts', async () => {
|
||||
const response = await auth.request('https://auth.internal/code/authorize');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// Step 1: hit the authorize URL → redirects to Steam OpenID
|
||||
const authResponse = await auth.request(url);
|
||||
expect(authResponse.status).toBe(302);
|
||||
const cookie = authResponse.headers.get('set-cookie')!;
|
||||
expect(cookie).toBeDefined();
|
||||
/**
|
||||
* A cookie jar, because this flow needs two cookies at once.
|
||||
*
|
||||
* `/authorize` sets the one holding the authorization, the code provider sets
|
||||
* the one holding its own state, and both have to be presented at the verify
|
||||
* step. `Headers.get('set-cookie')` returns only the first of several, which
|
||||
* silently drops one of them.
|
||||
*/
|
||||
function jar() {
|
||||
const cookies = new Map<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('; ');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: simulate Steam redirecting back to our callback with valid OpenID params
|
||||
const callbackUrl =
|
||||
'https://auth.internal/steam/callback?' +
|
||||
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
|
||||
'openid.mode=id_res&' +
|
||||
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
|
||||
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
|
||||
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
|
||||
/**
|
||||
* Ask for a code, redeem it, and come back holding tokens.
|
||||
*
|
||||
* 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 callbackResponse = await auth.request(callbackUrl, {
|
||||
headers: { cookie }
|
||||
});
|
||||
expect(callbackResponse.status).toBe(302);
|
||||
const { challenge, url } = await client.authorize('https://client.example.com/callback', 'code', {
|
||||
pkce: true,
|
||||
provider: 'code'
|
||||
});
|
||||
|
||||
const location = new URL(callbackResponse.headers.get('location')!);
|
||||
const code = location.searchParams.get('code');
|
||||
expect(code).not.toBeNull();
|
||||
const cookies = jar();
|
||||
cookies.absorb(await auth.request(url));
|
||||
expect(cookies.header()).not.toBe('');
|
||||
|
||||
const exchanged = await client.exchange(
|
||||
code!,
|
||||
'https://client.example.com/callback',
|
||||
challenge.verifier
|
||||
);
|
||||
if (exchanged.err) throw exchanged.err;
|
||||
const tokens = exchanged.tokens!;
|
||||
const requested = await auth.request('https://auth.internal/code/authorize', {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ action: 'request', email })
|
||||
});
|
||||
cookies.absorb(requested);
|
||||
expect(lastCode).not.toBe('');
|
||||
|
||||
const verified = await auth.request('https://auth.internal/code/authorize', {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ action: 'verify', code: lastCode })
|
||||
});
|
||||
expect(verified.status).toBe(302);
|
||||
|
||||
const location = new URL(verified.headers.get('location')!);
|
||||
const code = location.searchParams.get('code');
|
||||
expect(code).not.toBeNull();
|
||||
|
||||
const exchanged = await client.exchange(
|
||||
code!,
|
||||
'https://client.example.com/callback',
|
||||
challenge.verifier
|
||||
);
|
||||
if (exchanged.err) throw exchanged.err;
|
||||
return { client, tokens: exchanged.tokens! };
|
||||
}
|
||||
|
||||
describe('signing in with an email address', () => {
|
||||
test('a redeemed code becomes tokens that verify', async () => {
|
||||
const { client, tokens } = await signIn('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: ''
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user