mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
add friendly message
This commit is contained in:
@@ -30,10 +30,22 @@ interface SiteConfig {
|
|||||||
turnstileSiteKey: string | null
|
turnstileSiteKey: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a
|
||||||
|
* lower_snake_case word with no spaces. Bodies relayed from a worker that speaks OAuth
|
||||||
|
* put one of these in `error`, where the readable reason is in `error_description`.
|
||||||
|
*/
|
||||||
|
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
|
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
|
||||||
* the upstream error message (auth uses `error`/`error_description`, the account
|
* the upstream error message (auth uses `error`/`error_description`, the account
|
||||||
* mutations use `error`) so callers can surface it.
|
* mutations use `error`) so callers can surface it.
|
||||||
|
*
|
||||||
|
* `error` wins, since that's where www puts the message it wrote for the player — but
|
||||||
|
* NOT when it's a bare OAuth code: showing "invalid_grant" tells nobody anything, so a
|
||||||
|
* relayed OAuth body falls through to its description. www translates the signup/login
|
||||||
|
* grants itself (see `readAuthError`); this covers the endpoints that still relay.
|
||||||
*/
|
*/
|
||||||
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
|
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
@@ -43,9 +55,12 @@ async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
|
|||||||
})
|
})
|
||||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
const error = typeof data.error === 'string' ? data.error : ''
|
||||||
|
const description = typeof data.error_description === 'string' ? data.error_description : ''
|
||||||
const message =
|
const message =
|
||||||
(typeof data.error === 'string' && data.error) ||
|
(error && !(isErrorCode(error) && description) && error) ||
|
||||||
(typeof data.error_description === 'string' && data.error_description) ||
|
description ||
|
||||||
|
error ||
|
||||||
`Request failed (${res.status})`
|
`Request failed (${res.status})`
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { beforeAll, expect, it } from 'vitest'
|
|||||||
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'
|
||||||
import { turnstileKeys } from '../../turnstile'
|
import { turnstileKeys } from '../../turnstile'
|
||||||
|
import { readAuthError } from '../../upstream'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -110,6 +111,58 @@ it('refuses a signup with no password', async () => {
|
|||||||
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A refused grant reaches the form as a sentence, never as the OAuth code. auth answers
|
||||||
|
// `{ error: 'invalid_grant', error_description: <the actual reason> }`, and www used to
|
||||||
|
// relay that untouched — so every failed signup, including one the player could act on
|
||||||
|
// (the per-network cap), read simply "invalid_grant". Checked directly because the pass
|
||||||
|
// path can't be reached from here (it would call the real auth worker).
|
||||||
|
it('explains a refused signup instead of relaying invalid_grant', async () => {
|
||||||
|
const refused = (description: string, status = 400) =>
|
||||||
|
new Response(JSON.stringify({ error: 'invalid_grant', error_description: description }), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const capped = await readAuthError(
|
||||||
|
refused('too many accounts created from this network'),
|
||||||
|
'signup'
|
||||||
|
)
|
||||||
|
expect(capped.status).toBe(400)
|
||||||
|
expect(capped.message).toContain('Too many accounts have already been created from your network')
|
||||||
|
// The raw pair still reaches the operator's log line.
|
||||||
|
expect(capped.upstream).toBe('invalid_grant: too many accounts created from this network')
|
||||||
|
|
||||||
|
const badPassword = await readAuthError(refused('invalid account_id or password'), 'login')
|
||||||
|
expect(badPassword.message).toBe('That username or password is incorrect.')
|
||||||
|
|
||||||
|
// A description auth grew since this table was written must not leak through as-is:
|
||||||
|
// it's written for an operator, so an unmapped one falls back to the generic sentence.
|
||||||
|
const unmapped = await readAuthError(refused('some new internal reason'), 'signup')
|
||||||
|
expect(unmapped.message).not.toContain('some new internal reason')
|
||||||
|
expect(unmapped.message).toContain('could not be created')
|
||||||
|
|
||||||
|
// Nothing about the form was wrong — auth couldn't proceed (an unset JWT_SECRET). Don't
|
||||||
|
// send them back to re-check their details, and don't answer 400 for our own fault.
|
||||||
|
const broken = await readAuthError(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'server_error',
|
||||||
|
error_description: 'token signing is not configured',
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'content-type': 'application/json' } }
|
||||||
|
),
|
||||||
|
'signup'
|
||||||
|
)
|
||||||
|
expect(broken.status).toBe(502)
|
||||||
|
expect(broken.message).toContain('problem on our end')
|
||||||
|
|
||||||
|
// A body from something in front of auth (an edge error page) is not JSON at all.
|
||||||
|
const html = await readAuthError(new Response('<html>502</html>', { status: 502 }), 'signup')
|
||||||
|
expect(html.status).toBe(502)
|
||||||
|
expect(html.message).toContain('problem on our end')
|
||||||
|
expect(html.upstream).toBe('HTTP 502')
|
||||||
|
})
|
||||||
|
|
||||||
it('requires credentials to log in', async () => {
|
it('requires credentials to log in', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/api/login', {
|
const res = await SELF.fetch('https://example.com/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -34,3 +34,90 @@ export async function postForm(
|
|||||||
body: new URLSearchParams(fields).toString(),
|
body: new URLSearchParams(fields).toString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which grant www was making, so a shared refusal reads right on either form. */
|
||||||
|
export type AuthAction = 'signup' | 'login'
|
||||||
|
|
||||||
|
/** A rejected `/connect/token` grant, translated for the browser. */
|
||||||
|
export interface AuthFailure {
|
||||||
|
/** The sentence to put in front of the player. */
|
||||||
|
message: string
|
||||||
|
/** 400 when the grant was refused, 502 when `auth` itself couldn't proceed. */
|
||||||
|
status: 400 | 502
|
||||||
|
/** The raw `error`/`error_description` pair, for the operator's log line only. */
|
||||||
|
upstream: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each of auth's `error_description`s means to somebody filling in a form.
|
||||||
|
*
|
||||||
|
* Keyed on the exact string auth sends (see its `/connect/token` handler). Only a few
|
||||||
|
* are reachable from the web — signup posts `create_account` with no platform, login
|
||||||
|
* posts `password` with no `platform_auth` — but the platform arms are mapped anyway so
|
||||||
|
* a future web flow that does assert one can't regress to a bare code.
|
||||||
|
*/
|
||||||
|
const AUTH_MESSAGES: Record<string, string> = {
|
||||||
|
'too many accounts created from this network':
|
||||||
|
'Too many accounts have already been created from your network. Try again later, or from a different connection.',
|
||||||
|
'account limit reached for this platform account':
|
||||||
|
'This platform account has already created as many accounts as it is allowed.',
|
||||||
|
'invalid account_id or password': 'That username or password is incorrect.',
|
||||||
|
'account_id or username is required': 'Username and password are required.',
|
||||||
|
'invalid or missing platform_auth': 'Your platform sign-in could not be verified.',
|
||||||
|
'unsupported platform; only Steam and Meta can be verified':
|
||||||
|
'That platform cannot be verified — only Steam and Meta are supported.',
|
||||||
|
'no linked account for this platform identity':
|
||||||
|
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||||
|
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
|
||||||
|
const GENERIC_MESSAGES: Record<AuthAction, { rejected: string; broken: string }> = {
|
||||||
|
signup: {
|
||||||
|
rejected: 'Your account could not be created. Please check your details and try again.',
|
||||||
|
broken:
|
||||||
|
'Accounts cannot be created right now. This is a problem on our end — please try again later.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
rejected: 'You could not be signed in. Please check your details and try again.',
|
||||||
|
broken:
|
||||||
|
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message for an `auth` that couldn't be reached at all (the fetch itself threw). */
|
||||||
|
export const authUnreachable = (action: AuthAction): string => GENERIC_MESSAGES[action].broken
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a failed `auth` `/connect/token` response into something worth showing.
|
||||||
|
*
|
||||||
|
* auth answers the OAuth shape — `{ error: 'invalid_grant', error_description: … }` —
|
||||||
|
* where `error` is one of three machine codes and the DESCRIPTION carries the actual
|
||||||
|
* reason. Relaying that body verbatim put "invalid_grant" on screen for every failure,
|
||||||
|
* including the ones a player can act on (the per-network signup cap), so the
|
||||||
|
* description is matched to a sentence here instead. An unrecognised body — or a
|
||||||
|
* non-JSON one from something in front of auth — falls back to the generic line for
|
||||||
|
* the action rather than leaking whatever it did say.
|
||||||
|
*/
|
||||||
|
export async function readAuthError(res: Response, action: AuthAction): Promise<AuthFailure> {
|
||||||
|
const parsed = (await res.json().catch(() => null)) as {
|
||||||
|
error?: unknown
|
||||||
|
error_description?: unknown
|
||||||
|
} | null
|
||||||
|
const body = parsed ?? {}
|
||||||
|
const code = typeof body.error === 'string' ? body.error : ''
|
||||||
|
const description = typeof body.error_description === 'string' ? body.error_description : ''
|
||||||
|
|
||||||
|
// A 5xx (or a `server_error`) is an operator misconfiguration — an unset JWT_SECRET,
|
||||||
|
// an unset META_APP_SECRET — not something the player got wrong. Don't send them back
|
||||||
|
// to re-check a form that was fine; the real reason is in auth's log, not theirs.
|
||||||
|
const broken = res.status >= 500 || code === 'server_error'
|
||||||
|
const generic = GENERIC_MESSAGES[action]
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
(!broken && AUTH_MESSAGES[description]) || (broken ? generic.broken : generic.rejected),
|
||||||
|
status: broken ? 502 : 400,
|
||||||
|
upstream: description ? `${code || 'unknown'}: ${description}` : code || `HTTP ${res.status}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+64
-9
@@ -8,11 +8,21 @@ import { NotificationType } from '../../notify/src/notification-types'
|
|||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||||
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
import {
|
||||||
|
accountsBase,
|
||||||
|
apiBase,
|
||||||
|
authBase,
|
||||||
|
authUnreachable,
|
||||||
|
imgBase,
|
||||||
|
notifyBase,
|
||||||
|
postForm,
|
||||||
|
readAuthError,
|
||||||
|
} from './upstream'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { CookieOptions } from 'hono/utils/cookie'
|
import type { CookieOptions } from 'hono/utils/cookie'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
import type { AuthAction } from './upstream'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* www — the first frontend worker. It serves the React SPA (create account, set
|
* www — the first frontend worker. It serves the React SPA (create account, set
|
||||||
@@ -92,13 +102,32 @@ async function relay(c: Context<App>, res: Response) {
|
|||||||
* `email`, when given, is saved onto the new account before that fetch, so the account
|
* `email`, when given, is saved onto the new account before that fetch, so the account
|
||||||
* comes back already carrying it. `create_account` takes no email — the accounts worker
|
* comes back already carrying it. `create_account` takes no email — the accounts worker
|
||||||
* owns that field — which is why this is a second call rather than another grant field.
|
* owns that field — which is why this is a second call rather than another grant field.
|
||||||
|
*
|
||||||
|
* A refused grant is translated (see `readAuthError`) rather than relayed: auth answers
|
||||||
|
* the OAuth shape, whose `error` is always a code like `invalid_grant`, and that code is
|
||||||
|
* what the form used to show for every failure — including the per-network signup cap,
|
||||||
|
* which the player could otherwise understand. The raw pair is logged for the operator.
|
||||||
*/
|
*/
|
||||||
async function establishSession(c: Context<App>, tokenResponse: Response, email?: string) {
|
async function establishSession(
|
||||||
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
c: Context<App>,
|
||||||
|
action: AuthAction,
|
||||||
|
tokenResponse: Response,
|
||||||
|
email?: string
|
||||||
|
) {
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const failure = await readAuthError(tokenResponse, action)
|
||||||
|
logger.info('auth refused a token grant', {
|
||||||
|
action,
|
||||||
|
status: tokenResponse.status,
|
||||||
|
upstream: failure.upstream,
|
||||||
|
})
|
||||||
|
return c.json({ error: failure.message }, failure.status)
|
||||||
|
}
|
||||||
|
|
||||||
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
||||||
if (!token.access_token) {
|
if (!token.access_token) {
|
||||||
return c.json({ error: 'auth did not return an access token' }, 502)
|
logger.error('auth answered a token grant with no access_token', { action })
|
||||||
|
return c.json({ error: authUnreachable(action) }, 502)
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie(
|
setCookie(
|
||||||
@@ -130,7 +159,21 @@ async function establishSession(c: Context<App>, tokenResponse: Response, email?
|
|||||||
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
||||||
headers: { authorization: `Bearer ${token.access_token}` },
|
headers: { authorization: `Bearer ${token.access_token}` },
|
||||||
})
|
})
|
||||||
if (!me.ok) return c.json({ error: 'failed to load account after auth' }, 502)
|
// The session cookie is already set, so this is the one failure where telling them to
|
||||||
|
// retry would be wrong: on signup the account exists (and a second attempt spends
|
||||||
|
// another slot against auth's per-IP cap), and either way a reload finds them signed in.
|
||||||
|
if (!me.ok) {
|
||||||
|
logger.error('failed to load the account after a token grant', { action, status: me.status })
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
action === 'signup'
|
||||||
|
? 'Your account was created, but loading it failed. Reload the page — you are already signed in.'
|
||||||
|
: 'You are signed in, but loading your account failed. Please reload the page.',
|
||||||
|
},
|
||||||
|
502
|
||||||
|
)
|
||||||
|
}
|
||||||
const account = (await me.json()) as Record<string, unknown>
|
const account = (await me.json()) as Record<string, unknown>
|
||||||
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
|
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
|
||||||
}
|
}
|
||||||
@@ -201,11 +244,19 @@ const app = new Hono<App>()
|
|||||||
// A token is single-use, so the client resets its widget before letting them retry.
|
// A token is single-use, so the client resets its widget before letting them retry.
|
||||||
if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403)
|
if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403)
|
||||||
|
|
||||||
|
// A throw here is auth being unreachable, not a rejected signup — answered as such
|
||||||
|
// rather than falling through to the generic 500 handler, whose "internal server
|
||||||
|
// error" tells the player nothing about whether they now have an account (they don't:
|
||||||
|
// nothing was created).
|
||||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
||||||
grant_type: 'create_account',
|
grant_type: 'create_account',
|
||||||
password,
|
password,
|
||||||
})
|
}).catch(() => null)
|
||||||
return establishSession(c, res, signupEmail || undefined)
|
if (res === null) {
|
||||||
|
logger.error('could not reach auth to create an account')
|
||||||
|
return c.json({ error: authUnreachable('signup') }, 502)
|
||||||
|
}
|
||||||
|
return establishSession(c, 'signup', res, signupEmail || undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log in with a username + password, then start a session. The auth password grant
|
// Log in with a username + password, then start a session. The auth password grant
|
||||||
@@ -224,8 +275,12 @@ const app = new Hono<App>()
|
|||||||
username,
|
username,
|
||||||
platform: WEB_PLATFORM,
|
platform: WEB_PLATFORM,
|
||||||
password,
|
password,
|
||||||
})
|
}).catch(() => null)
|
||||||
return establishSession(c, res)
|
if (res === null) {
|
||||||
|
logger.error('could not reach auth to sign in')
|
||||||
|
return c.json({ error: authUnreachable('login') }, 502)
|
||||||
|
}
|
||||||
|
return establishSession(c, 'login', res)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear the session cookie.
|
// Clear the session cookie.
|
||||||
|
|||||||
Reference in New Issue
Block a user