[auth] discord login

This commit is contained in:
Devin Zuczek
2026-10-24 00:28:10 -04:00
parent 09e40af6b7
commit d1e69bfd14
14 changed files with 964 additions and 41 deletions
+6 -3
View File
@@ -227,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000 # RECFLARE_DISCORD_GUILD_ID=1077000000000000000
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002 # RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
# #
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer # Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT # Redirects in the Discord developer portal, or the exchange is refused. The second is the
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved. # 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 # 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. # bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
+144 -3
View File
@@ -37,7 +37,12 @@ import {
withNotFound, withNotFound,
withOnError, withOnError,
} from '@repo/hono-helpers' } 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 // 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. // module is plain D1 queries with no runtime deps, so it imports cleanly here.
@@ -67,6 +72,7 @@ import {
import { import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount, getLinksForAccount,
getLinksForIdentity,
getLinksForPlatformId, getLinksForPlatformId,
getLinksForPlatformIdentity, getLinksForPlatformIdentity,
isPlatformIdentityLinked, isPlatformIdentityLinked,
@@ -108,6 +114,21 @@ const TOKEN_SCOPE =
*/ */
const BLOCKED_DESCRIPTION = 'this device or network is blocked' 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 * 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 * 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 * already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`). * on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
*/ */
async function issueSession(c: Context<App>, accountId: number, grantType: string) { async function issueSession(
c: Context<App>,
accountId: number,
grantType: string,
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
extra: Record<string, unknown> = {}
) {
const clientIp = c.req.header('cf-connecting-ip') ?? '' const clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, { const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp }, identity: { ip: clientIp },
@@ -516,9 +543,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
refresh_token: refreshToken, refresh_token: refreshToken,
scope: TOKEN_SCOPE, scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', 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<App>, body: Record<string, unknown>) {
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<App>, decide: typeof approveDeviceGrant) { async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -922,7 +1049,7 @@ const app = new Hono<App>()
tags: ['Token'], tags: ['Token'],
summary: 'OAuth token endpoint — issues a JWT', summary: 'OAuth token endpoint — issues a JWT',
description: [ 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.', 'selected by `grant_type`. Every grant returns the same body on success.',
'', '',
'**`create_account`** — mints a new account with an auto-assigned random username', '**`create_account`** — mints a new account with an auto-assigned random username',
@@ -945,6 +1072,17 @@ const app = new Hono<App>()
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The', '**`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.', '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`) —', '**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the', '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,', 'matching `password`. An account with no stored hash cannot be logged into at all,',
@@ -1072,6 +1210,9 @@ const app = new Hono<App>()
} }
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT) 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 // `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. // grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
+10 -1
View File
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
'refresh_token', 'refresh_token',
'password', 'password',
'urn:ietf:params:oauth:grant-type:device_code', 'urn:ietf:params:oauth:grant-type:device_code',
'identity_assertion',
]) ])
.describe( .describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' + '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'), account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z username: z
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
.string() .string()
.optional() .optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'), .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 device_id: z
.string() .string()
.optional() .optional()
+81
View File
@@ -314,6 +314,87 @@ export async function getLinksForPlatformId(
return results.map(toLink) 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<PlatformLink[]> {
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<LinkRow>()
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<boolean> {
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<boolean> {
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). */ /** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount( export async function getLinksForAccount(
db: D1Database, db: D1Database,
+148 -1
View File
@@ -16,7 +16,12 @@ import {
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
updateAccount, updateAccount,
} from '@repo/domain' } from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt' import {
generateIdentityAssertion,
generateToken,
TOKEN_TTL_SECONDS,
validateIdentityAssertion,
} from '@repo/jwt'
import { import {
banFromReport, banFromReport,
@@ -2579,3 +2584,145 @@ describe('staff login audit', () => {
expect(await staffLogins(9605)).toHaveLength(1) 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<string, unknown>
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)
})
})
+34 -5
View File
@@ -220,11 +220,40 @@ won't pick up newly created secrets until it restarts.
which one is missing. `wrangler tail www` shows it. which one is missing. `wrangler tail www` shows it.
In the [Discord developer portal](https://discord.com/developers/applications), In the [Discord developer portal](https://discord.com/developers/applications),
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
for byte: `www` derives the redirect URI from the incoming request's own origin app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
(never from the request body, which would turn the client secret into a redemption from the incoming request's own origin (never from the request body, which would turn
oracle for someone else's app), so add `http://localhost:5173/claim` too if you the client secret into a redemption oracle for someone else's app), so add the
want the flow to work under `pnpm turbo dev`. `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) ### Discord role refresh (cron)
+8
View File
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
'That platform cannot be verified — only Steam and Meta are supported.', 'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity': '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.', '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.', '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 // 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 — // person evading a ban and the housemate of one — the IP arm cannot tell them apart —
+169 -3
View File
@@ -57,6 +57,12 @@ interface SiteConfig {
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`. * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
*/ */
discordAuthorizeUrl: string | null 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`). */ /** The private self DTO from `accounts` (`GET /account/me`). */
@@ -593,21 +599,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
*/ */
const OAUTH_STATE_KEY = 'rf_discord_state' 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. * Send the browser to Discord's consent screen.
* *
* A real navigation, not a client-side route — Discord is another origin. The `state` is * 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 * 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() const state = crypto.randomUUID()
sessionStorage.setItem(OAUTH_STATE_KEY, state) 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) const url = new URL(authorizeUrl)
url.searchParams.set('state', state) url.searchParams.set('state', state)
window.location.assign(url.toString()) 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<DiscordSignIn> {
const data = await call<DiscordSignIn>('/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 * 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 * — 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, turnstileSiteKey: null,
benefitsEnabled: false, benefitsEnabled: false,
discordAuthorizeUrl: null, discordAuthorizeUrl: null,
discordLoginUrl: null,
}) })
setAccount(null) setAccount(null)
}) })
@@ -2181,7 +2219,12 @@ export function App() {
return ( return (
<> <>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} /> <NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{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.
<DiscordLoginPage config={config} navigate={navigate} onAuthed={setAccount} />
) : path === '/login' || path === '/signup' ? (
// One page, two doors. `/signup` exists so the homepage's create-account link // 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 // 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 // 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 siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
const discordLoginUrl = config?.discordLoginUrl ?? null
return ( return (
<main className="shell"> <main className="shell">
@@ -2689,6 +2733,25 @@ function LoginPage({
your Steam ID — set a password on it and it signs in here too. your Steam ID — set a password on it and it signs in here too.
</p> </p>
<LoginForm onAuthed={authed} /> <LoginForm onAuthed={authed} />
{/* 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 && (
<div className="alt-signin">
<p className="muted">or</p>
<button
type="button"
className="cta discord"
onClick={() => startDiscordAuth(discordLoginUrl, next)}
>
Sign in with Discord
</button>
<p className="muted">
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.
</p>
</div>
)}
{/* The tabs above already offer this; the line under the button is where {/* 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. 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. */} 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<SelfAccount | null>(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 (
<main className="shell">
<section className="card">
<h2>Welcome to RecFlare</h2>
<p>
Your account is ready, linked to your Discord. Your username is{' '}
<strong>@{created.username}</strong> — you can change it on your account page.
</p>
<p className="muted">
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.
</p>
<Link to="/account" navigate={navigate} className="cta">
Go to my account
</Link>
</section>
</main>
)
}
return (
<main className="shell">
<section className="card">
<h2>Signing in with Discord</h2>
{error ? (
<>
<p className="error">{error}</p>
<Link to="/login" navigate={navigate} className="cta ghost">
Back to sign in
</Link>
</>
) : (
<p className="muted">{config === undefined ? 'Loading…' : 'One moment…'}</p>
)}
</section>
</main>
)
}
/** The signed-in account page. Redirects to sign-in when there's no session. */ /** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({ function AccountPage({
account, account,
+16
View File
@@ -2074,3 +2074,19 @@ button[type='submit']:disabled {
border-radius: 3px; border-radius: 3px;
border: 1px solid var(--line); 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;
}
+51
View File
@@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
*/ */
export const SCOPES = 'identify guilds.members.read' 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`. */ /** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
export interface DiscordConfig { export interface DiscordConfig {
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */ /** 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() 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 * 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 * 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<DiscordUser | null> {
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. * Hand the access token back to Discord once the roles have been read.
* *
+74 -3
View File
@@ -44,9 +44,11 @@ import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount, getLinksForAccount,
getLinksForPlatform, getLinksForPlatform,
hasRecordedRoles,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
PLATFORM_SCHEMA_DDL, PLATFORM_SCHEMA_DDL,
recordFirstRoles,
} from '../../../../auth/src/platform-db' } from '../../../../auth/src/platform-db'
import { import {
BALANCE_SCHEMA_DDL, BALANCE_SCHEMA_DDL,
@@ -68,7 +70,13 @@ import {
INVENTORY_CUSTOM_SCHEMA_DDL, INVENTORY_CUSTOM_SCHEMA_DDL,
ownedCustomAvatarItemIds, ownedCustomAvatarItemIds,
} from '../../../../econ/src/inventory-custom-db' } 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 { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
import { DOCUMENTED_SERVICES } from '../../docs' import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' 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. // even be started. Note the client id is NOT leaked by a closed config.
benefitsEnabled: false, benefitsEnabled: false,
discordAuthorizeUrl: null, discordAuthorizeUrl: null,
// "Sign in with Discord" is on the same switch, and closed with it.
discordLoginUrl: null,
hosts: { hosts: {
auth: 'https://auth.rec.example.com', auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.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(res.status).toBe(200)
expect(await res.json()).toEqual({ hasPlus: false, linked: false }) 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 updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001') await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', { res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
}) })
// `linked` says THAT a Discord identity is attached, never which one — the id is of no expect(await res.json()).toEqual({ hasPlus: true, linked: false })
// use to the page, and an account's linked identities have no business on the wire.
// 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 }) 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 // 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 // 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 // refused and the first account keeps the benefit. Exercised at the two lookups the route
+140 -21
View File
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db' import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { getStatSeries } from '@repo/domain/src/stats-db' import { getStatSeries } from '@repo/domain/src/stats-db'
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers' 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 // 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 ↔ // 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). // verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
import { import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount, hasRecordedRoles,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
recordFirstRoles,
} from '../../auth/src/platform-db' } from '../../auth/src/platform-db'
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is // 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 // econ's cron paid on demand: the first time an account links its Discord, the claim hands
@@ -31,6 +32,9 @@ import {
discordConfig, discordConfig,
exchangeCode, exchangeCode,
fetchGuildMembership, fetchGuildMembership,
fetchUser,
LOGIN_SCOPES,
loginRedirectUri,
qualifies, qualifies,
redirectUri, redirectUri,
revokeToken, revokeToken,
@@ -132,14 +136,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
* (see App.tsx). Everything else about the request is fixed by the server. * (see App.tsx). Everything else about the request is fixed by the server.
*/ */
function authorizeUrl(request: Request, clientId: string): string { 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) const url = new URL(AUTHORIZE_URL)
url.search = new URLSearchParams({ url.search = new URLSearchParams({
client_id: clientId, client_id: clientId,
response_type: 'code', response_type: 'code',
scope: SCOPES, scope,
redirect_uri: redirectUri(request), redirect_uri: redirect,
// Skip Discord's "you've already authorized this app, continue?" interstitial on a // 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', prompt: 'none',
}).toString() }).toString()
return url.toString() return url.toString()
@@ -195,6 +212,10 @@ const app = new Hono<App>()
// ids never leave the worker, since it's the worker that asks Discord the question. // ids never leave the worker, since it's the worker that asks Discord the question.
benefitsEnabled: discord !== null, benefitsEnabled: discord !== null,
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : 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: { hosts: {
auth: authBase(c.env), auth: authBase(c.env),
accounts: accountsBase(c.env), accounts: accountsBase(c.env),
@@ -344,6 +365,90 @@ const app = new Hono<App>()
return c.json(token) 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<LoginBody>().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 --------------------------------------------- // ---- Discord benefits claim ---------------------------------------------
/** /**
@@ -358,16 +463,18 @@ const app = new Hono<App>()
.get('/api/benefits/status', async (c) => { .get('/api/benefits/status', async (c) => {
const accountId = await claimant(c) const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401) 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), getAccount(c.env.DB, accountId),
getLinksForAccount(c.env.DB, accountId), hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
]) ])
return c.json({ return c.json({
hasPlus: account?.hasPlus ?? false, hasPlus: account?.hasPlus ?? false,
// Whether this account is already tied to a Discord identity — not WHICH one. The // Whether this account has CLAIMED through a Discord identity — a link with roles
// player knows their own Discord; the id is of no use to the page and every reason // recorded — not WHICH one. The player knows their own Discord; the id is of no use to
// to keep an account's linked identities off the wire. // the page and every reason to keep an account's linked identities off the wire. A
linked: links.some((link) => link.platform === PlatformType.Discord), // 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<App>()
PlatformType.Discord, PlatformType.Discord,
membership.userId 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) { if (!alreadyMine) {
const claimedElsewhere = await countAccountsForPlatformIdentity( const claimedElsewhere = await countAccountsForPlatformIdentity(
c.env.DB, c.env.DB,
@@ -477,9 +578,26 @@ const app = new Hono<App>()
409 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 // 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 // idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
@@ -519,13 +637,14 @@ const app = new Hono<App>()
} }
// The one-time supporter gift: the box econ's cron would hand this member, paid now, // 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 // 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 // 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 // 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. // a retry that also failed partway is how a balance gets credited twice.
let tokensAwarded: number | null = null let tokensAwarded: number | null = null
if (created && firstLink) { if ((created || recordedFirst) && firstClaim) {
try { try {
const role = await grantDiscordRoleGift( const role = await grantDiscordRoleGift(
c.env, c.env,
+4 -1
View File
@@ -9,6 +9,9 @@ export {
validateStudioBuildValidationToken, validateStudioBuildValidationToken,
studioBuildValidationId, studioBuildValidationId,
generatePhotonAuthToken, generatePhotonAuthToken,
generateIdentityAssertion,
validateIdentityAssertion,
IDENTITY_ASSERTION_TTL_SECONDS,
TOKEN_TTL_SECONDS, TOKEN_TTL_SECONDS,
} from './jwt' } from './jwt'
export type { PhotonAuthClaims } from './jwt' export type { IdentityAssertion, PhotonAuthClaims } from './jwt'
+79
View File
@@ -328,3 +328,82 @@ export async function generateToken(
secret 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<string> {
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<IdentityAssertion | null> {
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
}
}