mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user