diff --git a/.env.example b/.env.example index e810bbe..f86b9da 100644 --- a/.env.example +++ b/.env.example @@ -227,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com # RECFLARE_DISCORD_GUILD_ID=1077000000000000000 # RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002 # -# Also add https:///claim to the app's Redirects in the Discord developer -# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT -# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved. +# Also add https:///claim AND https:///login/discord to the app's +# Redirects in the Discord developer portal, or the exchange is refused. The second is the +# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is +# linked to, or CREATES one — the player then sets a password on the website to sign in to +# the game, including on a Quest with no Meta account. Granting Plus takes effect on the +# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved. # # OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a # bot token, so `platform_account.role` tracks the guild instead of the day they claimed. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 8e220b4..34fdec0 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -37,7 +37,12 @@ import { withNotFound, withOnError, } from '@repo/hono-helpers' -import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' +import { + generateToken, + TOKEN_TTL_SECONDS, + validateAndGetAccountId, + validateIdentityAssertion, +} from '@repo/jwt' // The account-wide ban lives on a `report` row, whose table the api worker owns; its db // module is plain D1 queries with no runtime deps, so it imports cleanly here. @@ -67,6 +72,7 @@ import { import { countAccountsForPlatformIdentity, getLinksForAccount, + getLinksForIdentity, getLinksForPlatformId, getLinksForPlatformIdentity, isPlatformIdentityLinked, @@ -108,6 +114,21 @@ const TOKEN_SCOPE = */ const BLOCKED_DESCRIPTION = 'this device or network is blocked' +/** + * The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it + * alone holds the client secret), then posts an `assertion` — an HS256 token signed with + * the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s + * `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login` + * trusts a Steam ticket, resolves it through the link table, and issues the ordinary + * session. No account linked to the identity means a NEW one is made and linked, so a + * Discord account is a way to join without Steam or Meta; the player then sets a password + * on the website and signs in with it anywhere — a sideloaded Quest included. + */ +const IDENTITY_ASSERTION_GRANT = 'identity_assertion' + +/** What the grant answers when one identity opens several accounts; never true today. */ +const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account' + /** * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded @@ -443,7 +464,13 @@ function oauthError( * already been authenticated — here, because the player approved a Studio code * on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`). */ -async function issueSession(c: Context, accountId: number, grantType: string) { +async function issueSession( + c: Context, + accountId: number, + grantType: string, + /** Extra top-level fields on the token body — `created: true` on an identity signup. */ + extra: Record = {} +) { const clientIp = c.req.header('cf-connecting-ip') ?? '' const ban = await resolveBan(c.env.DB, accountId, { identity: { ip: clientIp }, @@ -516,9 +543,109 @@ async function issueSession(c: Context, accountId: number, grantType: strin refresh_token: refreshToken, scope: TOKEN_SCOPE, key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', + ...extra, }) } +/** + * The `identity_assertion` grant: sign into — or create — the account behind a platform + * identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT. + * + * Handled before the password fallback and outside the version/rollout gates, like the + * Studio device grant: nothing here posts a `ver`, and the credential is the assertion + * alone. The assertion names an IDENTITY, not an account; which account that is (or that + * there is none yet) is decided here, from the same table `cached_login` reads, so the + * website can never choose an account id for itself. + * + * Creating an account when the identity opens none is deliberate — it is the Discord + * sign-up — and takes the same guards `create_account` does for a verified platform + * identity: the evasion arms (a banned player's next move is a new account), the per-IP + * cap (`www` forwards the browser's real address over the service binding) and the + * per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account + * has no identity but its address, and this one has a Discord user Discord itself vouched + * for, one account per user. The new account carries no `platform`/`platformId` on its + * blob — only the link — so its DTO and token read like a password account's and the + * Discord snowflake stays off the wire, exactly as the benefits claim keeps it. + */ +async function identityAssertionGrant(c: Context, body: Record) { + const assertion = typeof body.assertion === 'string' ? body.assertion : '' + const jwtSecret = await c.env.JWT_SECRET.get() + if (jwtSecret === '') { + logger.error('refusing an identity assertion: JWT_SECRET is empty') + return oauthError(c, 'server_error', 'token signing is not configured', 500) + } + const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null + if (identity === null) { + return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion') + } + + const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId) + if (links.length > 1) { + // The benefits claim keeps a Discord identity on one account, so this is a data + // problem rather than a player's choice to make; refused rather than guessed. + logger.error('identity assertion names an identity linked to several accounts', { + platform: identity.platform, + accountIds: links.map((l) => l.accountId), + }) + return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION) + } + if (links.length === 1) { + return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT) + } + + // Nobody yet: the sign-up. Everything below mirrors create_account's platform path. + const clientIp = c.req.header('cf-connecting-ip') ?? '' + const blocked = await resolveBan(c.env.DB, null, { + identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId }, + arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), + }) + if (blocked) { + logger.info('identity signup refused: player banned', { + via: blocked.via, + bannedAccountId: blocked.bannedAccountId, + ip: clientIp, + platform: identity.platform, + }) + return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION) + } + const maxPerPlatformId = intVar( + c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, + DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID + ) + if ( + maxPerPlatformId > 0 && + (await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >= + maxPerPlatformId + ) { + logger.info('identity signup rejected: platform account limit', { platform: identity.platform }) + return oauthError(c, 'invalid_grant', 'account limit reached for this platform account') + } + const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP) + if ( + maxPerIp > 0 && + clientIp !== '' && + (await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp + ) { + logger.info('identity signup rejected: per-IP account limit', { ip: clientIp }) + return oauthError(c, 'invalid_grant', 'too many accounts created from this network') + } + + const account = await createAccount(c.env.DB, { + platforms: 0, + lastLoginTime: new Date().toISOString(), + signupIp: clientIp || undefined, + lastLoginIp: clientIp || undefined, + }) + await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId) + await placeNewPlayerInOrientation(c.env, account.accountId, 0) + logger.info('created an account from an identity assertion', { + accountId: account.accountId, + platform: identity.platform, + ip: clientIp, + }) + return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true }) +} + async function decideDeviceCode(c: Context, decide: typeof approveDeviceGrant) { const id = await authedId(c) if (id === null) return c.body(null, 401) @@ -922,7 +1049,7 @@ const app = new Hono() tags: ['Token'], summary: 'OAuth token endpoint — issues a JWT', description: [ - 'Issues an access token (plus a single-use refresh token) for one of four grants,', + 'Issues an access token (plus a single-use refresh token) for one of six grants,', 'selected by `grant_type`. Every grant returns the same body on success.', '', '**`create_account`** — mints a new account with an auto-assigned random username', @@ -945,6 +1072,17 @@ const app = new Hono() '**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The', 'platform and platform id come from what was stored at issue time, not the body.', '', + '**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the', + 'Discord OAuth2 code exchange (it alone holds the client secret) and posts an', + '`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform', + '(`101`) and platform id it proved, good for a minute. The identity is resolved through', + 'the same link table as `cached_login`: one linked account is signed into; none means a', + 'NEW account is created and linked (the body then carries `created: true`), under the', + 'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP', + 'switch — the Discord user is the identity a password account lacks. The new account', + 'has no password until its owner sets one on the website; with one, it signs in', + 'anywhere the `password` grant does. Several linked accounts is refused.', + '', '**`password`** (the fallback for any unrecognised or absent `grant_type`) —', 'identifies the account by `username` or numeric `account_id` and requires the', 'matching `password`. An account with no stored hash cannot be logged into at all,', @@ -1072,6 +1210,9 @@ const app = new Hono() } return issueSession(c, polled.accountId, DEVICE_CODE_GRANT) } + // The website's Discord sign-in, likewise before the fallback — and before the + // version gates, which it never posts a `ver` for. See identityAssertionGrant. + if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body) // `platform`/`platform_id` come from the body for a fresh login; a refresh // grant overrides them below with what was stored when the token was issued. let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index b1f63fd..86df6bf 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -143,10 +143,12 @@ export const TokenRequest = z.object({ 'refresh_token', 'password', 'urn:ietf:params:oauth:grant-type:device_code', + 'identity_assertion', ]) .describe( 'Anything unrecognised (including absent) is treated as a password grant, except the ' + - 'Studio device-code URN, which polls a code from POST /connect/deviceauthorization' + 'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' + + 'and identity_assertion, the website’s Discord sign-in' ), account_id: z.string().optional().describe('Numeric account id, as a string'), username: z @@ -183,6 +185,13 @@ export const TokenRequest = z.object({ .string() .optional() .describe('Studio device-code grant: the device_code from /connect/deviceauthorization'), + assertion: z + .string() + .optional() + .describe( + 'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' + + 'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved' + ), device_id: z .string() .optional() diff --git a/apps/auth/src/platform-db.ts b/apps/auth/src/platform-db.ts index ba25b60..81c564b 100644 --- a/apps/auth/src/platform-db.ts +++ b/apps/auth/src/platform-db.ts @@ -314,6 +314,87 @@ export async function getLinksForPlatformId( return results.map(toLink) } +/** + * Every account a platform identity is linked to, on ANY platform — including the ones + * {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker. + * + * This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off + * every public route: the filter there exists so nobody can learn which account a Discord + * user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion` + * grant, where the identity being asked about has just been PROVEN by the worker that + * minted the assertion — the website's Discord sign-in — so the answer goes to the person + * it is about and nobody else. Add a caller only under the same condition. + */ +export async function getLinksForIdentity( + db: D1Database, + platform: number, + platformId: string +): Promise { + if (platformId === '') return [] + const { results } = await db + .prepare( + `${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id` + ) + .bind(platform, platformId) + .all() + return results.map(toLink) +} + +/** + * Whether any of the account's links on `platform` has roles RECORDED — a non-null `role` + * column, which only the website's benefits claim (and the sweep behind it) ever writes. + * + * This is what "has this account claimed its Discord benefits" means now that a Discord + * link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion` + * grant) links the identity on a brand-new account with `role` NULL. The claim's one-time + * gift and its page both ask this rather than "is there a link", so a player who made + * their account from Discord is still owed, and still shown, their first claim. + * An EMPTY role list counts as recorded: that is a claim that found no roles. + */ +export async function hasRecordedRoles( + db: D1Database, + accountId: number, + platform: number +): Promise { + const row = await db + .prepare( + `SELECT 1 AS ok FROM platform_account + WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1` + ) + .bind(accountId, platform) + .first<{ ok: number }>() + return row !== null +} + +/** + * Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when + * this call was the one that did it; false when the link is missing or already carries a + * role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh + * is the caller's next step. + * + * The one-shot flip is the point: the benefits claim pays its first-claim gift on the + * INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord + * sign-in is already inserted by the time the claim comes, so this gives the claim the + * same single-winner write on the roles column instead. + */ +export async function recordFirstRoles( + db: D1Database, + accountId: number, + platform: number, + platformId: string, + roles: readonly string[] +): Promise { + if (platformId === '') return false + const res = await db + .prepare( + `UPDATE platform_account SET role = ?4 + WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL` + ) + .bind(accountId, platform, platformId, JSON.stringify([...roles])) + .run() + return res.meta.changes > 0 +} + /** Every platform identity linked to an account (a player's PC and headset, say). */ export async function getLinksForAccount( db: D1Database, diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index f8d862f..246db2b 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -16,7 +16,12 @@ import { SUBROOM_SCHEMA_DDL, updateAccount, } from '@repo/domain' -import { TOKEN_TTL_SECONDS } from '@repo/jwt' +import { + generateIdentityAssertion, + generateToken, + TOKEN_TTL_SECONDS, + validateIdentityAssertion, +} from '@repo/jwt' import { banFromReport, @@ -2579,3 +2584,145 @@ describe('staff login audit', () => { expect(await staffLogins(9605)).toHaveLength(1) }) }) + +// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client +// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant +// resolves it through the link table like a cached_login, or makes the account when there +// is none. Everything here mints the assertion the way www does, with the test key. +describe('identity_assertion grant', () => { + const SIGNING_KEY = 'test-signing-key' + const assertionFor = (platformId: string, platform = PlatformType.Discord) => + generateIdentityAssertion({ platform, platformId }, SIGNING_KEY) + const redeem = async (assertion: string, ip?: string) => + postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip) + + test('an assertion round-trips, and nothing else passes as one', async () => { + const assertion = await assertionFor('700000000000000001') + await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({ + platform: PlatformType.Discord, + platformId: '700000000000000001', + }) + // A different key, and an access token signed with the RIGHT key: both refused. The + // second is the one that matters — the same secret signs sessions, so the audience is + // what keeps a stolen token from being replayed as proof of a platform identity. + await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull() + const session = await generateToken('42', '', 4, SIGNING_KEY) + await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull() + // And the other direction: an assertion carries no `sub`, so it is no session. + const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${assertion}`, + }, + body: 'user_code=ABCDEFGH', + }) + expect(res.status).toBe(401) + }) + + test('signs into the one account the identity is linked to', async () => { + await seedAccount(9400, 'DiscordLinked') + // Linked the way the benefits claim links: with roles recorded. + await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', []) + const res = await redeem(await assertionFor('700000000000009400')) + expect(res.status).toBe(200) + expect(decodePayload(res.json.access_token as string).sub).toBe('9400') + expect(res.json).not.toHaveProperty('created') + expect(typeof res.json.refresh_token).toBe('string') + }) + + test('refuses a missing, forged or replayed-session assertion', async () => { + expect((await postToken('grant_type=identity_assertion')).status).toBe(400) + const forged = await generateIdentityAssertion( + { platform: PlatformType.Discord, platformId: '700000000000009400' }, + 'not-the-key' + ) + const res = await redeem(forged) + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('invalid or expired identity assertion') + // An access token for the linked account, presented as the assertion. + const session = await generateToken('9400', '', 4, SIGNING_KEY) + expect((await redeem(session)).status).toBe(400) + }) + + test('creates and links an account when the identity opens none, once', async () => { + const discordId = '700000000000009401' + const first = await redeem(await assertionFor(discordId)) + expect(first.status).toBe(200) + expect(first.json.created).toBe(true) + const accountId = Number(decodePayload(first.json.access_token as string).sub) + expect(accountId).toBeGreaterThan(0) + + // Linked on the Discord platform, and ONLY there: the blob carries no platform + // identity (the snowflake stays off the DTO and the token), like a password account. + const links = await getLinksForAccount(env.DB, accountId) + expect(links.map((l) => [l.platform, l.platformId])).toEqual([ + [PlatformType.Discord, discordId], + ]) + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1') + .bind(accountId) + .first<{ data: string }>() + const blob = JSON.parse(row!.data) as Record + expect(blob.platformId).toBeUndefined() + expect(blob.passwordHash).toBeUndefined() + // Placed in Orientation like any signup. + const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1') + .bind(accountId) + .first() + expect(presence).not.toBeNull() + + // The same Discord again is a sign-IN to that account, not a second account. + const again = await redeem(await assertionFor(discordId)) + expect(again.status).toBe(200) + expect(again.json).not.toHaveProperty('created') + expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId)) + await expect( + countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId) + ).resolves.toBe(1) + }) + + // The stretch of the feature: a Discord-made account has no password, and a password is + // what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The + // website's password form posts with an EMPTY old password the first time. + test('a Discord-made account can set a password and then sign in with it', async () => { + const created = await redeem(await assertionFor('700000000000009402')) + const accountId = decodePayload(created.json.access_token as string).sub as string + expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400) + + const set = await changePassword( + 'oldPassword=&newPassword=hunter22', + created.json.access_token as string + ) + expect(set.status).toBe(200) + const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`) + expect(login.status).toBe(200) + expect(decodePayload(login.json.access_token as string).sub).toBe(accountId) + }) + + test('refuses an identity that opens several accounts rather than guessing', async () => { + await seedAccount(9403, 'TwoLinksA') + await seedAccount(9404, 'TwoLinksB') + await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', []) + await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', []) + const res = await redeem(await assertionFor('700000000000009403')) + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('platform identity is linked to more than one account') + }) + + // The signup caps reach this path as they reach create_account — www forwards the + // browser's real address over the service binding, so the per-IP cap counts browsers. + test('the per-IP signup cap applies to accounts made this way', async () => { + const ip = '198.51.100.77' + for (let i = 0; i < 3; i++) { + const res = await redeem(await assertionFor(`70000000000000950${i}`), ip) + expect(res.status).toBe(200) + expect(res.json.created).toBe(true) + } + const fourth = await redeem(await assertionFor('700000000000009509'), ip) + expect(fourth.status).toBe(400) + expect(fourth.json.error_description).toBe('too many accounts created from this network') + // Signing IN from that network is untouched: the cap is on creation. + const existing = await redeem(await assertionFor('700000000000009500'), ip) + expect(existing.status).toBe(200) + }) +}) diff --git a/apps/www/README.md b/apps/www/README.md index 6d6a2f9..553f5d7 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -220,11 +220,40 @@ won't pick up newly created secrets until it restarts. which one is missing. `wrangler tail www` shows it. In the [Discord developer portal](https://discord.com/developers/applications), -add `https:///claim` to the app's **Redirects**. It has to match byte -for byte: `www` derives the redirect URI from the incoming request's own origin -(never from the request body, which would turn the client secret into a redemption -oracle for someone else's app), so add `http://localhost:5173/claim` too if you -want the flow to work under `pnpm turbo dev`. +add `https:///claim` **and** `https:///login/discord` to the +app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI +from the incoming request's own origin (never from the request body, which would turn +the client secret into a redemption oracle for someone else's app), so add the +`http://localhost:5173/…` pair too if you want the flows to work under `pnpm turbo dev`. + +### Sign in with Discord + +The same app also serves the login page's **Sign in with Discord** button, on the same +switch (`/api/config` serves `discordLoginUrl` beside `discordAuthorizeUrl`, both null +while the claim is closed). It asks Discord for the `identify` scope only and returns to +`/login/discord`, where the SPA posts the code to `POST /api/login/discord`. `www` +exchanges it, reads `GET /users/@me` for the Discord user id, revokes the token, and +mints an **identity assertion** — a one-minute HS256 token signed with the shared +`JWT_SECRET` naming platform `101` and that id — which it posts to `auth`'s +`identity_assertion` grant over the service binding. `auth` resolves the id through +`platform_account`: + +- **One linked account** (a player who claimed benefits on it) — signed in; the ordinary + token body comes back and the SPA stores it like a password sign-in's. +- **No linked account** — `auth` **creates** one and links the Discord to it, under the + ban-evasion check and the per-IP / per-identity signup caps (but not `PASSWORD_SIGNUP`: + that switch exists because a password account has no identity but its address). The + body carries `created: true`, and the SPA stops to say so: the account has **no + password**, and setting one on the account page is what lets it sign in to the game — + on a sideloaded Quest without a Meta account, which is the case this is for. Its first + benefits claim still pays the welcome gift: the claim keys "first" on roles being + recorded on a Discord link, not on the link existing. +- **Several linked accounts** — refused; the claim never produces this. + +`www` never chooses the account id: the assertion names an identity, and which account +that is (or that there is none) is `auth`'s decision from the same table `cached_login` +reads. The Discord snowflake stays off the new account's blob and token, as it does +after a claim. ### Discord role refresh (cron) diff --git a/apps/www/src/auth-messages.ts b/apps/www/src/auth-messages.ts index ddd755d..eb8b92d 100644 --- a/apps/www/src/auth-messages.ts +++ b/apps/www/src/auth-messages.ts @@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record = { 'That platform cannot be verified — only Steam and Meta are supported.', 'no linked account for this platform identity': 'No account is linked to this platform sign-in yet. Sign in with your password once to link it.', + // The website's Discord sign-in found the Discord user on several accounts — a state the + // benefits claim never produces, so this is for an operator to untangle, not the player. + 'platform identity is linked to more than one account': + 'That Discord account is linked to more than one RecFlare account. Sign in with your username and password instead.', + // www minted the assertion a moment ago, so this is a key mismatch between www and auth + // (or a clock a minute out), never something the player did. + 'invalid or expired identity assertion': + 'Sign-in is unavailable right now. This is a problem on our end — please try again later.', 'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.', // An account that shares a device or network with a BANNED one. Phrased for BOTH the // person evading a ban and the housemate of one — the IP arm cannot tell them apart — diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 1b8f5a8..eafe7bb 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -57,6 +57,12 @@ interface SiteConfig { * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`. */ discordAuthorizeUrl: string | null + /** + * The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own + * return leg (`/login/discord`). On the same switch as the claim, so null whenever + * `benefitsEnabled` is false. See `DiscordLoginPage`. + */ + discordLoginUrl: string | null } /** The private self DTO from `accounts` (`GET /account/me`). */ @@ -593,21 +599,52 @@ const claimBenefits = (code: string): Promise => */ const OAUTH_STATE_KEY = 'rf_discord_state' +/** + * Where to land after a Discord SIGN-IN — the `?next=` the login page was opened with. + * Stashed beside the nonce because the round trip through Discord loses the query string, + * and read back by `DiscordLoginPage` exactly once. + */ +const OAUTH_NEXT_KEY = 'rf_discord_next' + /** * Send the browser to Discord's consent screen. * * A real navigation, not a client-side route — Discord is another origin. The `state` is * minted here and stashed for the return leg; `www` built everything else about the URL - * (see `/api/config`), so this only ever appends the one parameter it owns. + * (see `/api/config`), so this only ever appends the one parameter it owns. `next` is the + * sign-in's post-login destination; the claim passes none. */ -function startDiscordAuth(authorizeUrl: string) { +function startDiscordAuth(authorizeUrl: string, next?: string) { const state = crypto.randomUUID() sessionStorage.setItem(OAUTH_STATE_KEY, state) + if (next) sessionStorage.setItem(OAUTH_NEXT_KEY, next) + else sessionStorage.removeItem(OAUTH_NEXT_KEY) const url = new URL(authorizeUrl) url.searchParams.set('state', state) window.location.assign(url.toString()) } +/** What `/api/login/discord` answers: auth's token body, plus who signed in and whether it made them. */ +interface DiscordSignIn { + access_token?: string + /** True when no account was linked to this Discord and `auth` created one just now. */ + created?: boolean + /** The Discord username, for the confirmation line only. */ + discordUsername?: string +} + +/** + * Redeem the sign-in's authorization code. Goes through `www`, like signup and the claim, + * because the exchange needs the Discord client secret; `www` then has `auth` issue the + * session and relays the token body, which is stored exactly as a password sign-in's is. + */ +async function signInWithDiscord(code: string): Promise { + const data = await call('/api/login/discord', { json: { code } }) + if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login')) + setToken(data.access_token) + return data +} + /** * Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate * — so a session without the role is rejected there (403) even though the UI shows no @@ -2164,6 +2201,7 @@ export function App() { turnstileSiteKey: null, benefitsEnabled: false, discordAuthorizeUrl: null, + discordLoginUrl: null, }) setAccount(null) }) @@ -2181,7 +2219,12 @@ export function App() { return ( <> - {path === '/login' || path === '/signup' ? ( + {path === '/login/discord' ? ( + // Discord's registered redirect for a SIGN-IN (the claim's is `/claim`). Matched + // before `/login`, which it would otherwise never reach; a cold load falls through + // to the SPA shell like every other client-side route. + + ) : path === '/login' || path === '/signup' ? ( // One page, two doors. `/signup` exists so the homepage's create-account link // lands on that tab instead of dropping people on sign-in to find it — and so // the URL is linkable. Unknown paths fall back to index.html (see the assets @@ -2643,6 +2686,7 @@ function LoginPage({ } const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null + const discordLoginUrl = config?.discordLoginUrl ?? null return (
@@ -2689,6 +2733,25 @@ function LoginPage({ your Steam ID — set a password on it and it signs in here too.

+ {/* The other door. Shown on the server's say-so like the signup tab: the URL is + null until the Discord app is configured. `next` rides along through Discord in + sessionStorage, since the round trip drops the query. */} + {discordLoginUrl && ( +
+

or

+ +

+ Signs you into the account your Discord is linked to. No account yet? This makes + one — set a password on it afterwards and it signs in to the game too. +

+
+ )} {/* The tabs above already offer this; the line under the button is where someone who just found out they have no account is actually looking. Gated on the same key, so it can't point at a door that isn't there. */} @@ -2707,6 +2770,109 @@ function LoginPage({ ) } +/** + * `/login/discord` — the page Discord redirects a SIGN-IN back to. Its own route rather than + * `/claim`, because that page wants a session before it does anything and this is how one + * gets made; it is the app's second registered `redirect_uri`, so like `/claim` it has to + * work on a cold load. + * + * The return leg mirrors the claim's: check the nonce this tab minted, strip the code off + * the URL before anything else, redeem it exactly once. What differs is the outcome. A + * sign-in to an existing account just goes where the login page was headed. A sign-in that + * CREATED an account (`created`) stops here first: that account has no password, and + * without one it can't sign in to the game — on a Quest without a Meta account, which is + * the whole point of making it this way — so the one thing to tell a brand-new player is + * where to set one. + */ +function DiscordLoginPage({ + config, + navigate, + onAuthed, +}: { + config: SiteConfig | undefined + navigate: Navigate + onAuthed: (a: SelfAccount) => void +}) { + const [error, setError] = useState('') + const [created, setCreated] = useState(null) + // StrictMode runs effects twice in dev, and a Discord code is single-use — see BenefitsPanel. + const redeemed = useRef(false) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const state = params.get('state') + const expected = sessionStorage.getItem(OAUTH_STATE_KEY) + const next = safeNextPath(sessionStorage.getItem(OAUTH_NEXT_KEY)) ?? '/account' + + if (code === null) { + // Opened directly, or Discord sent the player back with `?error=access_denied` + // because they cancelled. Nothing to redeem; back to the door. + navigate('/login') + return + } + + window.history.replaceState(null, '', '/login/discord') + if (redeemed.current) return + redeemed.current = true + sessionStorage.removeItem(OAUTH_STATE_KEY) + sessionStorage.removeItem(OAUTH_NEXT_KEY) + + if (state === null || expected === null || state !== expected) { + setError('That Discord sign-in did not match this browser. Please start again.') + return + } + + signInWithDiscord(code) + .then(async (result) => { + const me = await fetchMe() + onAuthed(me) + if (result.created) setCreated(me) + else navigate(next) + }) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) + }, [navigate, onAuthed]) + + if (created) { + return ( +
+
+

Welcome to RecFlare

+

+ Your account is ready, linked to your Discord. Your username is{' '} + @{created.username} — you can change it on your account page. +

+

+ It has no password yet. Set one on your account page: your username and that password + are how you sign in to the game — including on a Quest without a Meta account. +

+ + Go to my account + +
+
+ ) + } + + return ( +
+
+

Signing in with Discord

+ {error ? ( + <> +

{error}

+ + Back to sign in + + + ) : ( +

{config === undefined ? 'Loading…' : 'One moment…'}

+ )} +
+
+ ) +} + /** The signed-in account page. Redirects to sign-in when there's no session. */ function AccountPage({ account, diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index e45ac6a..8d20937 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -2074,3 +2074,19 @@ button[type='submit']:disabled { border-radius: 3px; border: 1px solid var(--line); } + +/* The login card's second door, under the password form: an "or" and the Discord button, + separated from the form by a rule so the two aren't read as one. */ +.alt-signin { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +.alt-signin > .muted:first-child { + margin: 0; +} diff --git a/apps/www/src/discord.ts b/apps/www/src/discord.ts index 31c1c76..625c1c9 100644 --- a/apps/www/src/discord.ts +++ b/apps/www/src/discord.ts @@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize' */ export const SCOPES = 'identify guilds.members.read' +/** + * What "Sign in with Discord" asks for: the user's id and nothing else. The sign-in has no + * question to ask about the guild — it only needs to know WHO, so the account linked to that + * Discord user can be signed into (or made). Asking for the member read here would put a + * permission on the consent screen that the sign-in never uses; the benefits claim is where + * roles are read, with its own consent. + */ +export const LOGIN_SCOPES = 'identify' + /** Everything the claim needs configured. Resolved per request; see `discordConfig`. */ export interface DiscordConfig { /** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */ @@ -170,6 +179,15 @@ export async function readSecret(secret: SecretsStoreSecret, name: string): Prom */ export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString() +/** + * The sign-in's return leg — its own registered redirect, so Discord sends a sign-in back to + * a page that knows it is one. `/claim` can't double for it: that page needs a session + * before it does anything, and a sign-in is how the session gets made. Same derivation, + * same byte-for-byte rule, same reason the SPA doesn't get to supply it. See App.tsx. + */ +export const loginRedirectUri = (request: Request): string => + new URL('/login/discord', request.url).toString() + /** * Swap an authorization code for an access token. Returns null on any refusal — a code * that was already spent, expired (they live ~1 minute), issued to another app, or paired @@ -278,6 +296,39 @@ export async function fetchGuildMembership( } } +/** Who a token belongs to — the `identify` scope's one answer. */ +export interface DiscordUser { + /** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */ + id: string + /** Their Discord username, for the confirmation line. Display only, never stored. */ + username: string +} + +/** + * The token owner's own Discord user, or null when Discord won't say or can't be reached. + * The sign-in's one read: it needs the id, and `identify` grants exactly `GET /users/@me`. + */ +export async function fetchUser(accessToken: string): Promise { + try { + const res = await fetch(`${API_BASE}/users/@me`, { + headers: { authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) { + logger.info('discord did not return the token owner', { status: res.status }) + return null + } + const user = (await res.json()) as { id?: unknown; username?: unknown } + if (typeof user.id !== 'string' || user.id === '') { + logger.error('discord returned a user with no id') + return null + } + return { id: user.id, username: typeof user.username === 'string' ? user.username : '' } + } catch (err) { + logger.error('could not reach discord to read the token owner', { error: String(err) }) + return null + } +} + /** * Hand the access token back to Discord once the roles have been read. * diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index eb06060..0028a58 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -44,9 +44,11 @@ import { countAccountsForPlatformIdentity, getLinksForAccount, getLinksForPlatform, + hasRecordedRoles, isPlatformIdentityLinked, linkPlatformIdentity, PLATFORM_SCHEMA_DDL, + recordFirstRoles, } from '../../../../auth/src/platform-db' import { BALANCE_SCHEMA_DDL, @@ -68,7 +70,13 @@ import { INVENTORY_CUSTOM_SCHEMA_DDL, ownedCustomAvatarItemIds, } from '../../../../econ/src/inventory-custom-db' -import { discordConfig, parseRoleIds, qualifies } from '../../discord' +import { + discordConfig, + loginRedirectUri, + parseRoleIds, + qualifies, + redirectUri, +} from '../../discord' import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles' import { DOCUMENTED_SERVICES } from '../../docs' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' @@ -167,6 +175,8 @@ it('advertises signup and where the other workers live', async () => { // even be started. Note the client id is NOT leaked by a closed config. benefitsEnabled: false, discordAuthorizeUrl: null, + // "Sign in with Discord" is on the same switch, and closed with it. + discordLoginUrl: null, hosts: { auth: 'https://auth.rec.example.com', accounts: 'https://accounts.rec.example.com', @@ -671,16 +681,77 @@ it('reports where an account stands on benefits', async () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ hasPlus: false, linked: false }) + // A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a + // claim: no roles were ever recorded, so the page must still offer the claim. await updateAccount(env.DB, 4003, { hasPlus: true }) await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001') res = await SELF.fetch('https://example.com/api/benefits/status', { headers: { authorization: `Bearer ${token}` }, }) - // `linked` says THAT a Discord identity is attached, never which one — the id is of no - // use to the page, and an account's linked identities have no business on the wire. + expect(await res.json()).toEqual({ hasPlus: true, linked: false }) + + // Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed, + // never which one — the id is of no use to the page, and an account's linked identities + // have no business on the wire. + await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', []) + res = await SELF.fetch('https://example.com/api/benefits/status', { + headers: { authorization: `Bearer ${token}` }, + }) expect(await res.json()).toEqual({ hasPlus: true, linked: true }) }) +// How the claim decides "first claim" now that a Discord link can predate it: a link made by +// "Sign in with Discord" has no roles, and writing the first roles onto it is a one-shot +// flip — the single-winner write the gift is gated on, which a fresh link gets from its +// INSERT instead. Exercised at the helpers, since the route runs through discord.com. +it('tells a sign-in’s bare link from a claim, and records the first roles once', async () => { + await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false) + await linkPlatformIdentity(env.DB, 4010, PlatformType.Discord, '99010') + await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false) + + // Nothing to flip on a link that isn't there. + await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99011', ['r1'])).resolves.toBe( + false + ) + // The first write wins; the second finds roles already recorded and writes nothing. + await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r1'])).resolves.toBe( + true + ) + await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r2'])).resolves.toBe( + false + ) + await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(true) + const [link] = await getLinksForAccount(env.DB, 4010) + expect(link.roles).toEqual(['r1']) + + // A claim on a FRESH link records roles in the insert itself — an empty list included. + await linkPlatformIdentity(env.DB, 4011, PlatformType.Discord, '99012', []) + await expect(hasRecordedRoles(env.DB, 4011, PlatformType.Discord)).resolves.toBe(true) +}) + +// ---- Sign in with Discord ------------------------------------------------ + +// Closed on the same switch as the claim, and refused the same way: the half-configured +// app the tests run with has a client id and secret and nothing else. +it('refuses a discord sign-in while the discord app is only half configured', async () => { + const res = await SELF.fetch('https://example.com/api/login/discord', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: 'whatever' }), + }) + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ error: 'Signing in with Discord is not available here.' }) +}) + +// The two return legs are different registered redirects, both derived from the request's +// own origin (never the body). A sign-in can't come back to `/claim`: that page wants a +// session before it does anything, and a sign-in is how one gets made. +it('sends a sign-in back to its own redirect, derived from the request origin', () => { + const request = new Request('https://play.example.com/api/config') + expect(redirectUri(request)).toBe('https://play.example.com/claim') + expect(loginRedirectUri(request)).toBe('https://play.example.com/login/discord') +}) + // The once-only guard the claim is built on. Without it, one Discord member holding the // role could walk it around every RecFlare account they own; with it, the second claim is // refused and the first account keeps the benefit. Exercised at the two lookups the route diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index f7b2c6b..cd2538d 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums' import { countOnlinePlayers } from '@repo/domain/src/presence-db' import { getStatSeries } from '@repo/domain/src/stats-db' import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt' // The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed // Discord identity is stored there as a PlatformType.Discord link — it is an account ↔ @@ -16,9 +16,10 @@ import { validateAndGetAccountId } from '@repo/jwt' // verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS). import { countAccountsForPlatformIdentity, - getLinksForAccount, + hasRecordedRoles, isPlatformIdentityLinked, linkPlatformIdentity, + recordFirstRoles, } from '../../auth/src/platform-db' // The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is // econ's cron paid on demand: the first time an account links its Discord, the claim hands @@ -31,6 +32,9 @@ import { discordConfig, exchangeCode, fetchGuildMembership, + fetchUser, + LOGIN_SCOPES, + loginRedirectUri, qualifies, redirectUri, revokeToken, @@ -132,14 +136,27 @@ const claimant = async (c: Context): Promise => * (see App.tsx). Everything else about the request is fixed by the server. */ function authorizeUrl(request: Request, clientId: string): string { + return consentUrl(clientId, SCOPES, redirectUri(request)) +} + +/** + * The consent URL for "Sign in with Discord": the same app, the narrower `identify` scope, + * and the sign-in's own return leg (`/login/discord`). Served beside the claim's URL so the + * SPA never assembles either. + */ +function loginAuthorizeUrl(request: Request, clientId: string): string { + return consentUrl(clientId, LOGIN_SCOPES, loginRedirectUri(request)) +} + +function consentUrl(clientId: string, scope: string, redirect: string): string { const url = new URL(AUTHORIZE_URL) url.search = new URLSearchParams({ client_id: clientId, response_type: 'code', - scope: SCOPES, - redirect_uri: redirectUri(request), + scope, + redirect_uri: redirect, // Skip Discord's "you've already authorized this app, continue?" interstitial on a - // repeat claim; the player has already pressed a button that says what this does. + // repeat visit; the player has already pressed a button that says what this does. prompt: 'none', }).toString() return url.toString() @@ -195,6 +212,10 @@ const app = new Hono() // ids never leave the worker, since it's the worker that asks Discord the question. benefitsEnabled: discord !== null, discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null, + // "Sign in with Discord", on the SAME switch: the sign-in resolves (or makes) the + // account through the Discord link, and only the claim ever writes one with roles, so + // an operator who hasn't set the claim up has no reason to offer the door either. + discordLoginUrl: discord ? loginAuthorizeUrl(c.req.raw, discord.clientId) : null, hosts: { auth: authBase(c.env), accounts: accountsBase(c.env), @@ -344,6 +365,90 @@ const app = new Hono() return c.json(token) }) + // ---- Sign in with Discord ----------------------------------------------- + + /** + * Redeem a Discord authorization code from the SIGN-IN consent (`/login/discord`), prove + * who the Discord user is, and ask `auth` for a session on the account linked to them — + * or, when there is none, for a brand-new account linked to them. + * + * The browser sends ONLY the code, as it does for the claim, and for the same reason: the + * exchange needs the client secret. What is different is what comes after. The claim acts + * on a session the caller already has; this is how one GETS a session, so there is no + * bearer token and nothing here writes to the account. Instead the proven identity is + * signed into an ASSERTION with the shared JWT_SECRET and posted to `auth`'s + * `identity_assertion` grant, which resolves it through the link table exactly as + * `cached_login` resolves a Steam ticket, and answers the ordinary token body. Resolving + * the account — and minting one — stays `auth`'s: www never picks an account id. + * + * Posted over the service binding with the browser's real IP forwarded, like signup, so + * the per-IP cap on the sign-up path counts the browser and not Cloudflare. The token + * response is relayed verbatim; `created: true` on it is the SPA's cue to say an account + * was just made and that setting a password is what makes it usable in the game. + */ + .post('/api/login/discord', async (c) => { + const config = await discordConfig(c.env) + if (!config) return c.json({ error: 'Signing in with Discord is not available here.' }, 403) + + type LoginBody = { code?: string } + const { code } = await c.req.json().catch(() => ({}) as LoginBody) + if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400) + + const accessToken = await exchangeCode(config, code, loginRedirectUri(c.req.raw)) + if (accessToken === null) { + return c.json( + { error: 'That Discord sign-in could not be completed. Please try again.' }, + 400 + ) + } + const user = await fetchUser(accessToken) + await revokeToken(config, accessToken) + if (user === null) { + return c.json( + { error: 'That Discord sign-in could not be completed. Please try again.' }, + 400 + ) + } + + const jwtSecret = await c.env.JWT_SECRET.get().catch(() => '') + if (jwtSecret === '') { + logger.error('cannot sign an identity assertion: JWT_SECRET is unreadable') + return c.json({ error: authUnreachable('login') }, 502) + } + const assertion = await generateIdentityAssertion( + { platform: PlatformType.Discord, platformId: user.id }, + jwtSecret + ) + const res = await postAuthForm( + c.env, + '/connect/token', + { grant_type: 'identity_assertion', assertion }, + { clientIp: c.req.header('cf-connecting-ip') } + ).catch(() => null) + if (res === null) { + logger.error('could not reach auth to redeem an identity assertion') + return c.json({ error: authUnreachable('login') }, 502) + } + if (!res.ok) { + const failure = await readAuthError(res, 'login') + logger.info('auth refused a discord sign-in', { + status: res.status, + upstream: failure.upstream, + }) + return c.json({ error: failure.message }, failure.status) + } + const token = (await res.json().catch(() => null)) as { + access_token?: string + created?: boolean + } | null + if (!token?.access_token) { + logger.error('auth answered a discord sign-in with no access_token') + return c.json({ error: authUnreachable('login') }, 502) + } + logger.info(token.created ? 'created an account from a discord sign-in' : 'discord sign-in') + return c.json({ ...token, discordUsername: user.username }) + }) + // ---- Discord benefits claim --------------------------------------------- /** @@ -358,16 +463,18 @@ const app = new Hono() .get('/api/benefits/status', async (c) => { const accountId = await claimant(c) if (accountId === null) return c.body(null, 401) - const [account, links] = await Promise.all([ + const [account, claimed] = await Promise.all([ getAccount(c.env.DB, accountId), - getLinksForAccount(c.env.DB, accountId), + hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord), ]) return c.json({ hasPlus: account?.hasPlus ?? false, - // Whether this account is already tied to a Discord identity — not WHICH one. The - // player knows their own Discord; the id is of no use to the page and every reason - // to keep an account's linked identities off the wire. - linked: links.some((link) => link.platform === PlatformType.Discord), + // Whether this account has CLAIMED through a Discord identity — a link with roles + // recorded — not WHICH one. The player knows their own Discord; the id is of no use to + // the page and every reason to keep an account's linked identities off the wire. A + // bare link, which "Sign in with Discord" leaves on a new account, reads as not yet + // claimed: the page then offers the claim, which is what that player still has to do. + linked: claimed, }) }) @@ -456,12 +563,6 @@ const app = new Hono() PlatformType.Discord, membership.userId ) - // Whether this claim is the account's FIRST Discord link — what the one-time gift below - // keys on. Decided before the write, from the table: an account re-claiming with the - // same Discord is not first (`alreadyMine`), and neither is one that already carries a - // DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when - // it could be true, to spare the re-claim a query. - let firstLink = false if (!alreadyMine) { const claimedElsewhere = await countAccountsForPlatformIdentity( c.env.DB, @@ -477,9 +578,26 @@ const app = new Hono() 409 ) } - const links = await getLinksForAccount(c.env.DB, accountId) - firstLink = !links.some((link) => link.platform === PlatformType.Discord) } + // Whether this is the account's FIRST CLAIM — what the one-time gift below keys on. + // Decided before the write, from the table: a claim is recorded as roles on a Discord + // link, so an account with any Discord link carrying roles (this identity's, or a + // DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO + // roles has not: that is what "Sign in with Discord" leaves on an account it just made, + // and that player's first claim is still their first. + const firstClaim = !(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord)) + // On a link that already exists without roles, writing them is the claim's one-shot + // write — the single-winner guard a fresh link gets from its INSERT below. Asked only + // when the link is the caller's: on anyone else's it would be refused above. + const recordedFirst = + alreadyMine && + (await recordFirstRoles( + c.env.DB, + accountId, + PlatformType.Discord, + membership.userId, + membership.roles + )) // The link is written for EVERY guild member, qualifying role or not, and it is // idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the @@ -519,13 +637,14 @@ const app = new Hono() } // The one-time supporter gift: the box econ's cron would hand this member, paid now, - // once. Gated on the insert having happened too (`created`), so two claims racing on + // once. Gated on a single-winner write having happened too — the insert (`created`), or + // the first roles onto a sign-in's bare link (`recordedFirst`) — so two claims racing on // one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is // written: a grant that fails partway is logged with what to check, as econ's cron // logs its own, and the claim still answers — the player got what they came for, and // a retry that also failed partway is how a balance gets credited twice. let tokensAwarded: number | null = null - if (created && firstLink) { + if ((created || recordedFirst) && firstClaim) { try { const role = await grantDiscordRoleGift( c.env, diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index b6f607a..4523ca9 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -9,6 +9,9 @@ export { validateStudioBuildValidationToken, studioBuildValidationId, generatePhotonAuthToken, + generateIdentityAssertion, + validateIdentityAssertion, + IDENTITY_ASSERTION_TTL_SECONDS, TOKEN_TTL_SECONDS, } from './jwt' -export type { PhotonAuthClaims } from './jwt' +export type { IdentityAssertion, PhotonAuthClaims } from './jwt' diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 1d3e75e..423ddf5 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -328,3 +328,82 @@ export async function generateToken( secret ) } + +/** + * The `aud` of an identity assertion — what tells one apart from every other token this + * key signs. An access token's audience is the auth host; a Studio build token's is + * `studio-build-validation`. Checked on validation, so neither can be replayed as an + * assertion, and an assertion — which carries no `sub` — satisfies none of the + * `validateAndGet*` readers either. + */ +const IDENTITY_ASSERTION_AUDIENCE = 'recflare:identity-assertion' + +/** + * How long an assertion is good for. It is minted and redeemed within one request on the + * server side (www proves the identity, then posts the assertion to auth), so a minute + * covers clock skew between two Workers and nothing else. + */ +export const IDENTITY_ASSERTION_TTL_SECONDS = 60 + +/** A platform identity one worker has verified and is vouching for to `auth`. */ +export interface IdentityAssertion { + /** PlatformType int — Discord (101) today. */ + platform: number + /** The platform's own id for the user — a Discord snowflake, as a string. */ + platformId: string +} + +/** + * Mint a short-lived, signed statement that the caller has VERIFIED this platform + * identity — the credential behind `auth`'s `identity_assertion` grant. + * + * This exists so a worker other than `auth` can prove an identity `auth` cannot: www + * completes a Discord OAuth2 code exchange (which needs the Discord client secret, bound + * only to www) and hands `auth` the result, rather than `auth` learning Discord. Signing it + * with the shared `JWT_SECRET` adds no trust that didn't exist — a holder of that key can + * already mint access tokens outright — and it means the grant works the same over the + * service binding and over the public hostname. The assertion names an identity, never an + * account: resolving it to one (or minting one) stays `auth`'s decision. + */ +export async function generateIdentityAssertion( + identity: IdentityAssertion, + secret: string +): Promise { + const now = Math.floor(Date.now() / 1000) + return sign( + { + aud: IDENTITY_ASSERTION_AUDIENCE, + platform: identity.platform, + platform_id: identity.platformId, + iat: now, + nbf: now, + exp: now + IDENTITY_ASSERTION_TTL_SECONDS, + jti: crypto.randomUUID(), + }, + secret, + 'HS256' + ) +} + +/** + * Validate an identity assertion and return the identity it vouches for, or null when + * the token is malformed, mis-signed, expired, or not an assertion at all. The audience + * check is what refuses an access token presented here, and a missing `sub` is required + * for the same reason in the other direction: an assertion must never pass as a session. + */ +export async function validateIdentityAssertion( + token: string, + secret: string +): Promise { + try { + const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature + if (payload.aud !== IDENTITY_ASSERTION_AUDIENCE || payload.sub !== undefined) return null + const platform = payload.platform + const platformId = payload.platform_id + if (typeof platform !== 'number' || !Number.isInteger(platform) || platform < 0) return null + if (typeof platformId !== 'string' || platformId === '') return null + return { platform, platformId } + } catch { + return null + } +}