mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[auth] discord login
This commit is contained in:
+6
-3
@@ -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
@@ -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 : ''
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user