mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 15:38:52 -07:00
[auth] discord login
This commit is contained in:
+144
-3
@@ -37,7 +37,12 @@ import {
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
import {
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateAndGetAccountId,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||
@@ -67,6 +72,7 @@ import {
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
getLinksForIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
isPlatformIdentityLinked,
|
||||
@@ -108,6 +114,21 @@ const TOKEN_SCOPE =
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
/**
|
||||
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
|
||||
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
|
||||
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
|
||||
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
|
||||
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
|
||||
* session. No account linked to the identity means a NEW one is made and linked, so a
|
||||
* Discord account is a way to join without Steam or Meta; the player then sets a password
|
||||
* on the website and signs in with it anywhere — a sideloaded Quest included.
|
||||
*/
|
||||
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
|
||||
|
||||
/** What the grant answers when one identity opens several accounts; never true today. */
|
||||
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
|
||||
|
||||
/**
|
||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||
@@ -443,7 +464,13 @@ function oauthError(
|
||||
* already been authenticated — here, because the player approved a Studio code
|
||||
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
|
||||
*/
|
||||
async function issueSession(c: Context<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 ban = await resolveBan(c.env.DB, accountId, {
|
||||
identity: { ip: clientIp },
|
||||
@@ -516,9 +543,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `identity_assertion` grant: sign into — or create — the account behind a platform
|
||||
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
|
||||
*
|
||||
* Handled before the password fallback and outside the version/rollout gates, like the
|
||||
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
|
||||
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
|
||||
* there is none yet) is decided here, from the same table `cached_login` reads, so the
|
||||
* website can never choose an account id for itself.
|
||||
*
|
||||
* Creating an account when the identity opens none is deliberate — it is the Discord
|
||||
* sign-up — and takes the same guards `create_account` does for a verified platform
|
||||
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
|
||||
* cap (`www` forwards the browser's real address over the service binding) and the
|
||||
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
|
||||
* has no identity but its address, and this one has a Discord user Discord itself vouched
|
||||
* for, one account per user. The new account carries no `platform`/`platformId` on its
|
||||
* blob — only the link — so its DTO and token read like a password account's and the
|
||||
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
|
||||
*/
|
||||
async function identityAssertionGrant(c: Context<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) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
@@ -922,7 +1049,7 @@ const app = new Hono<App>()
|
||||
tags: ['Token'],
|
||||
summary: 'OAuth token endpoint — issues a JWT',
|
||||
description: [
|
||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
||||
'Issues an access token (plus a single-use refresh token) for one of six grants,',
|
||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||
'',
|
||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||
@@ -945,6 +1072,17 @@ const app = new Hono<App>()
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
'',
|
||||
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
|
||||
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
|
||||
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
|
||||
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
|
||||
'the same link table as `cached_login`: one linked account is signed into; none means a',
|
||||
'NEW account is created and linked (the body then carries `created: true`), under the',
|
||||
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
|
||||
'switch — the Discord user is the identity a password account lacks. The new account',
|
||||
'has no password until its owner sets one on the website; with one, it signs in',
|
||||
'anywhere the `password` grant does. Several linked accounts is refused.',
|
||||
'',
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
@@ -1072,6 +1210,9 @@ const app = new Hono<App>()
|
||||
}
|
||||
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
|
||||
}
|
||||
// The website's Discord sign-in, likewise before the fallback — and before the
|
||||
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
|
||||
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
|
||||
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
|
||||
'refresh_token',
|
||||
'password',
|
||||
'urn:ietf:params:oauth:grant-type:device_code',
|
||||
'identity_assertion',
|
||||
])
|
||||
.describe(
|
||||
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
|
||||
'and identity_assertion, the website’s Discord sign-in'
|
||||
),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
|
||||
assertion: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
|
||||
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
|
||||
),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -314,6 +314,87 @@ export async function getLinksForPlatformId(
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account a platform identity is linked to, on ANY platform — including the ones
|
||||
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
|
||||
*
|
||||
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
|
||||
* every public route: the filter there exists so nobody can learn which account a Discord
|
||||
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
|
||||
* grant, where the identity being asked about has just been PROVEN by the worker that
|
||||
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
|
||||
* it is about and nobody else. Add a caller only under the same condition.
|
||||
*/
|
||||
export async function getLinksForIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<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). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||
import {
|
||||
generateIdentityAssertion,
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
import {
|
||||
banFromReport,
|
||||
@@ -2579,3 +2584,145 @@ describe('staff login audit', () => {
|
||||
expect(await staffLogins(9605)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
|
||||
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
|
||||
// resolves it through the link table like a cached_login, or makes the account when there
|
||||
// is none. Everything here mints the assertion the way www does, with the test key.
|
||||
describe('identity_assertion grant', () => {
|
||||
const SIGNING_KEY = 'test-signing-key'
|
||||
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
|
||||
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
|
||||
const redeem = async (assertion: string, ip?: string) =>
|
||||
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
|
||||
|
||||
test('an assertion round-trips, and nothing else passes as one', async () => {
|
||||
const assertion = await assertionFor('700000000000000001')
|
||||
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
|
||||
platform: PlatformType.Discord,
|
||||
platformId: '700000000000000001',
|
||||
})
|
||||
// A different key, and an access token signed with the RIGHT key: both refused. The
|
||||
// second is the one that matters — the same secret signs sessions, so the audience is
|
||||
// what keeps a stolen token from being replayed as proof of a platform identity.
|
||||
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
|
||||
const session = await generateToken('42', '', 4, SIGNING_KEY)
|
||||
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
|
||||
// And the other direction: an assertion carries no `sub`, so it is no session.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${assertion}`,
|
||||
},
|
||||
body: 'user_code=ABCDEFGH',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('signs into the one account the identity is linked to', async () => {
|
||||
await seedAccount(9400, 'DiscordLinked')
|
||||
// Linked the way the benefits claim links: with roles recorded.
|
||||
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
|
||||
const res = await redeem(await assertionFor('700000000000009400'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
|
||||
expect(res.json).not.toHaveProperty('created')
|
||||
expect(typeof res.json.refresh_token).toBe('string')
|
||||
})
|
||||
|
||||
test('refuses a missing, forged or replayed-session assertion', async () => {
|
||||
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
|
||||
const forged = await generateIdentityAssertion(
|
||||
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
|
||||
'not-the-key'
|
||||
)
|
||||
const res = await redeem(forged)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('invalid or expired identity assertion')
|
||||
// An access token for the linked account, presented as the assertion.
|
||||
const session = await generateToken('9400', '', 4, SIGNING_KEY)
|
||||
expect((await redeem(session)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('creates and links an account when the identity opens none, once', async () => {
|
||||
const discordId = '700000000000009401'
|
||||
const first = await redeem(await assertionFor(discordId))
|
||||
expect(first.status).toBe(200)
|
||||
expect(first.json.created).toBe(true)
|
||||
const accountId = Number(decodePayload(first.json.access_token as string).sub)
|
||||
expect(accountId).toBeGreaterThan(0)
|
||||
|
||||
// Linked on the Discord platform, and ONLY there: the blob carries no platform
|
||||
// identity (the snowflake stays off the DTO and the token), like a password account.
|
||||
const links = await getLinksForAccount(env.DB, accountId)
|
||||
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[PlatformType.Discord, discordId],
|
||||
])
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>()
|
||||
const blob = JSON.parse(row!.data) as Record<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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user