mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
A six-digit code has a million values, and nothing was counting how many of them a caller tried. The code travelled in an encrypted cookie the caller held, verification compared against that cookie, and a wrong answer simply re-rendered the form. Nobody has to be the person the code was mailed to: type somebody 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 is a million requests, and the constant-time comparison protecting the code was guarding a door you could just keep knocking on. Guesses are now counted on the server, under a name that changes with every code. That placement is the point: a counter kept beside the code, in the cookie, is a counter the guesser can wind back by replaying an older copy. Starting over is still allowed and still costs a fresh code sent to the mailbox being aimed at, which is where somebody notices. A correct code spends its record too, so its remaining guesses do not carry into the next one. The cookie also lived for twenty-four hours, which made the pin a password with a million possible values and a day to try them. Ten minutes now, and the code stops being accepted when the clock says so rather than when the cookie happens to go away. Resend had no limit either, so the button was a way to mail a stranger as fast as requests go out. Codes to one address are spaced, and one attempt at signing in can only ask for so many. Both refusals say the same thing on purpose. Which of the two it was is a fact about somebody else's mailbox.
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
|
|
import { createClient } from '@nestri/auth/client';
|
|
import { issuer } from '@nestri/auth/index';
|
|
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: {
|
|
code: CodeProvider({
|
|
...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }),
|
|
sendCode: async (_claims, code) => {
|
|
lastCode = code;
|
|
}
|
|
})
|
|
},
|
|
async success(context, response) {
|
|
if (response.provider === 'code') {
|
|
return context.subject('user', {
|
|
userID: 'usr_test123',
|
|
linkedAccountID: ''
|
|
});
|
|
}
|
|
throw new Error('Unknown provider');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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(404);
|
|
});
|
|
|
|
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: 'code'
|
|
});
|
|
|
|
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();
|
|
|
|
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();
|
|
|
|
const verified = await client.verify(subjects, tokens.access);
|
|
if (verified.err) throw verified.err;
|
|
expect(verified.subject).toEqual({
|
|
type: 'user',
|
|
properties: {
|
|
userID: 'usr_test123',
|
|
linkedAccountID: ''
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('User info', () => {
|
|
test('returns subject properties for valid access token', async () => {
|
|
const { tokens } = await signIn('grace@example.com');
|
|
|
|
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.access}` }
|
|
});
|
|
|
|
expect(infoRes.status).toBe(200);
|
|
const userinfo = await infoRes.json();
|
|
expect(userinfo).toMatchObject({
|
|
userID: 'usr_test123',
|
|
linkedAccountID: ''
|
|
});
|
|
});
|
|
});
|