refactor(auth): serve one provider, and make it the email one

Signing in with a gaming account or with an SSH key could both bring a
user into existence. That makes an account only as recoverable as the
thing that created it, and gives one person as many accounts as they
have gaming logins — neither of which is what an account is supposed to
be now that verifying an address is what creates one.

Both are unwired rather than deleted. The provider implementations stay
where they are, because connecting a gaming account is still something
this product does; it just does it from the API, against a user who
already exists, which is a connection hanging off an identity rather
than an identity of its own.

The worker test followed: it exercised the two flows that are gone, and
now covers the one that is left plus an assertion that the other two are
not routed, so they cannot come back quietly.
This commit is contained in:
Wanjohi
2026-09-05 09:27:18 +03:00
parent bd163392ca
commit affe1e3c73
3 changed files with 137 additions and 264 deletions

View File

@@ -5,7 +5,6 @@ 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';
@@ -41,11 +40,12 @@ export const Auth = Effect.gen(function* () {
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
HYPERDRIVE: Database
},
...(isPermanent ? { observability: { enabled: true } } : {})
});

View File

@@ -1,17 +1,13 @@
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
import { issuer } from '@nestri/auth/index';
import { CodeProvider } from '@nestri/auth/provider/code';
import { SshProvider } from '@nestri/auth/provider/ssh';
import { SteamProvider } from '@nestri/auth/provider/steam';
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
import { CodeUI } from '@nestri/auth/ui/code';
import { Actor } from '@nestri/core/actor';
import { subjects } from '@nestri/core/auth/subjects';
import { Env } from '@nestri/core/env';
import { Steam } from '@nestri/core/steam/index';
import { Team } from '@nestri/core/team/index';
import { Identity } from '@nestri/core/user/identity';
import { User } from '@nestri/core/user/index';
import { LinkedAccount } from '@nestri/core/user/linked-account';
import { sendVerificationCode } from './email.js';
@@ -19,12 +15,10 @@ 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;
NODE_ENV?: string;
EMAIL_DEV_LOG?: string;
};
/**
@@ -58,10 +52,21 @@ export default {
storage: CloudflareStorage({
namespace: env.AuthStorage
}),
// 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: {
// Verifying an email address is what creates an account. It is
// listed first because it is the only branch below that is
// allowed to bring a person into existence. ref(d-0048)
code: CodeProvider({
// The UI, with delivery replaced. `CodeUI`'s own hook cannot
// report a bad address back to the screen — it returns
@@ -78,9 +83,7 @@ export default {
}
await sendVerificationCode(env, email, code);
}
}),
steam: SteamProvider(),
ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY })
})
},
async success(context, response) {
if (response.provider === 'code') {
@@ -99,65 +102,6 @@ export default {
return context.subject('user', { userID, linkedAccountID });
}
if (response.provider === 'steam') {
const { steamid } = response;
// Signing in with Steam resolves an account; it never
// creates one. A Steam account is something a person
// attaches to an account they already have, so losing it
// costs them a link and not everything they own. Accounts
// that predate the rule already have the link this finds,
// so they keep working unchanged. ref(d-0048)
const { userID, linkedAccountID } = await Identity.resolveSteamLogin({
steamId: steamid
});
// The persona is refreshed on the way through, because this
// is the only moment the current one is in hand.
const player = await steamProfile(env.STEAM_API_KEY, steamid);
if (player) {
await LinkedAccount.updateProfile({ id: linkedAccountID, profile: player });
}
const user = await User.fromID(userID);
await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () =>
Team.ensurePersonal({
displayName: user?.name || (player?.personaname as string) || 'Player'
})
);
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 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
});
}
throw new Error('Unknown provider');
}
});
@@ -165,24 +109,3 @@ export default {
return inner.fetch(request, env, ctx);
}
};
/** The current persona for a Steam account, or null if Steam did not answer. */
async function steamProfile(
apiKey: string,
steamid: string
): Promise<Record<string, unknown> | null> {
try {
const profileUrl = new URL('https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/');
profileUrl.searchParams.set('key', apiKey);
profileUrl.searchParams.set('steamids', steamid);
const res = await fetch(profileUrl.toString());
const data = (await res.json()) as {
response?: { players?: Array<Record<string, unknown>> };
};
return data?.response?.players?.[0] ?? null;
} catch {
// A stale display name is not a reason to refuse a sign-in.
return null;
}
}

View File

@@ -1,125 +1,148 @@
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. */
async function signIn() {
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: 'ada@example.com' })
});
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();
expect(tokens.access).toBeString();
expect(tokens.refresh).toBeString();
@@ -130,88 +153,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();
const infoRes = await auth.request('https://auth.internal/userinfo', {
headers: { Authorization: `Bearer ${tokens.access}` }
@@ -221,7 +171,7 @@ describe('User info', () => {
const userinfo = await infoRes.json();
expect(userinfo).toMatchObject({
userID: 'usr_test123',
linkedAccountID: 'lac_test456'
linkedAccountID: ''
});
});
});