feat(bots-discord): land hCaptcha challenge handling for QR-login with sentinel-prefixed bridge protocol and Dawn-themed widget UI
This commit is contained in:
parent
a0537a56a2
commit
d1bf95f541
8 changed files with 680 additions and 34 deletions
|
|
@ -144,6 +144,10 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
|
|||
return t('auth-error.login-failed', { reason: err.reason ?? '' });
|
||||
case 'captcha_required':
|
||||
return t('auth-error.captcha-required');
|
||||
case 'captcha_send_failed':
|
||||
return t('auth-error.captcha-send-failed');
|
||||
case 'captcha_expired':
|
||||
return t('auth-error.captcha-expired');
|
||||
case 'login_websocket_failed':
|
||||
return t('auth-error.websocket-failed', { reason: err.reason ?? '' });
|
||||
case 'connect_after_login_failed':
|
||||
|
|
@ -166,6 +170,7 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
|
|||
// failure of the login attempt and gets red treatment.
|
||||
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
|
||||
if (err.kind === 'captcha_required') return 'warn';
|
||||
if (err.kind === 'captcha_expired') return 'warn';
|
||||
if (err.kind === 'already_logged_in') return 'warn';
|
||||
return 'error';
|
||||
};
|
||||
|
|
@ -319,6 +324,294 @@ const QrPanel = ({ state, t, onCancel }: QrPanelProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Captcha panel — Vojo-patched bridge surfaces an hCaptcha challenge via
|
||||
// the `VOJO-CAPTCHA-CHALLENGE-V1` notice. The panel lazy-loads hCaptcha's
|
||||
// own JS, renders the challenge widget into our container with the supplied
|
||||
// sitekey + rqdata (the latter is REQUIRED for hCaptcha-enterprise — Discord
|
||||
// uses the enterprise tier), and on solve hands the token back to the App
|
||||
// which sends `login-captcha <token>` to the bridge.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Canonical hCaptcha bootstrapping for explicit render: combine
|
||||
// `?render=explicit` (don't auto-render any data-sitekey div) with
|
||||
// `?onload=fnname` (call our resolver when the SDK is fully initialized).
|
||||
// docs.hcaptcha.com/configuration recommends this over polling
|
||||
// `window.hcaptcha` because (a) the global appears before its methods are
|
||||
// safe to call in some SDK versions, (b) onload fires once and is cheaper
|
||||
// than a 50ms tick. We still keep a wall-clock timeout as a backstop —
|
||||
// covers ad-block / CSP / third-party-cookie blocks where the script
|
||||
// itself fails to load.
|
||||
const HCAPTCHA_ONLOAD_FN = '__vojoHCaptchaOnload';
|
||||
const HCAPTCHA_API_URL = `https://js.hcaptcha.com/1/api.js?render=explicit&onload=${HCAPTCHA_ONLOAD_FN}`;
|
||||
const HCAPTCHA_LOAD_TIMEOUT_MS = 15_000;
|
||||
|
||||
// hCaptcha render() does NOT accept rqdata as a config field — for
|
||||
// hCaptcha-enterprise (which Discord uses) rqdata must be applied via
|
||||
// `setData(widgetId, { rqdata })` AFTER `render()`. Passing rqdata to
|
||||
// render is silently ignored, the resulting token isn't bound to the
|
||||
// challenge, and Discord rejects with `invalid-response` /
|
||||
// `sitekey-secret-mismatch`. Sources:
|
||||
// https://github.com/hCaptcha/vue-hcaptcha (search `setData(`)
|
||||
// https://github.com/hCaptcha/react-hcaptcha/issues/190
|
||||
type HCaptchaConfig = {
|
||||
sitekey: string;
|
||||
callback?: (token: string) => void;
|
||||
'expired-callback'?: () => void;
|
||||
'error-callback'?: () => void;
|
||||
size?: 'normal' | 'compact' | 'invisible';
|
||||
theme?: 'light' | 'dark';
|
||||
};
|
||||
type HCaptchaApi = {
|
||||
render: (container: HTMLElement, config: HCaptchaConfig) => string;
|
||||
setData: (widgetId: string, data: { rqdata: string }) => void;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
declare global {
|
||||
interface Window {
|
||||
hcaptcha?: HCaptchaApi;
|
||||
[HCAPTCHA_ONLOAD_FN]?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
let hcaptchaScriptPromise: Promise<HCaptchaApi> | null = null;
|
||||
|
||||
const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
||||
if (typeof window === 'undefined') return Promise.reject(new Error('no window'));
|
||||
// Already initialised (e.g. earlier mount of the captcha panel) — skip
|
||||
// the script dance entirely.
|
||||
if (window.hcaptcha) return Promise.resolve(window.hcaptcha);
|
||||
if (hcaptchaScriptPromise) return hcaptchaScriptPromise;
|
||||
hcaptchaScriptPromise = new Promise<HCaptchaApi>((resolve, reject) => {
|
||||
const existing = document.querySelector(
|
||||
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
||||
) as HTMLScriptElement | null;
|
||||
|
||||
let timeoutHandle: number | undefined;
|
||||
let settled = false;
|
||||
const settle = (action: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutHandle !== undefined) window.clearTimeout(timeoutHandle);
|
||||
// Drop the global — leaves `window` clean if no other consumer
|
||||
// is hooked. Future `loadHCaptcha()` calls hit the early-return
|
||||
// `if (window.hcaptcha)` and skip this whole path.
|
||||
delete window[HCAPTCHA_ONLOAD_FN];
|
||||
action();
|
||||
};
|
||||
|
||||
// `?onload=` calls our global once the SDK finishes initialising.
|
||||
// We register BEFORE appending the script so the listener is in
|
||||
// place if the script is already cached and runs synchronously.
|
||||
window[HCAPTCHA_ONLOAD_FN] = () => {
|
||||
if (window.hcaptcha) {
|
||||
settle(() => resolve(window.hcaptcha as HCaptchaApi));
|
||||
} else {
|
||||
// Defence-in-depth: onload fired but global missing. Treat as
|
||||
// a load failure and let the panel show the error UI.
|
||||
settle(() => {
|
||||
hcaptchaScriptPromise = null;
|
||||
reject(new Error('hcaptcha onload fired without window.hcaptcha'));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
const script = document.createElement('script');
|
||||
script.src = HCAPTCHA_API_URL;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener('error', () =>
|
||||
settle(() => {
|
||||
hcaptchaScriptPromise = null;
|
||||
reject(new Error('hcaptcha script failed to load'));
|
||||
})
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
} else if (window.hcaptcha) {
|
||||
// A previous mount already loaded the SDK fully; resolve immediately.
|
||||
settle(() => resolve(window.hcaptcha as HCaptchaApi));
|
||||
return;
|
||||
}
|
||||
// Otherwise: existing script is mid-load — our onload hook will fire
|
||||
// when it finishes.
|
||||
|
||||
timeoutHandle = window.setTimeout(() => {
|
||||
settle(() => {
|
||||
hcaptchaScriptPromise = null;
|
||||
reject(new Error('hcaptcha script load timed out'));
|
||||
});
|
||||
}, HCAPTCHA_LOAD_TIMEOUT_MS);
|
||||
});
|
||||
return hcaptchaScriptPromise;
|
||||
};
|
||||
|
||||
type CaptchaPanelProps = {
|
||||
state: {
|
||||
kind: 'awaiting_captcha_solve';
|
||||
service: string;
|
||||
sitekey: string;
|
||||
rqdata: string;
|
||||
rqtoken: string;
|
||||
};
|
||||
t: T;
|
||||
onSolved: (token: string) => void;
|
||||
onCancel: () => void;
|
||||
// Fired when the hCaptcha challenge expires (either Discord's
|
||||
// server-side rqtoken TTL or our local 90s timer beats the user). The
|
||||
// App rolls back to disconnected with a localized warn so the user
|
||||
// retries login-qr instead of trying to solve a dead challenge.
|
||||
onExpired: () => void;
|
||||
};
|
||||
|
||||
// Match the bridge's CAPTCHA_HYDRATE_FRESHNESS_MS — Discord invalidates
|
||||
// rqtoken around the 2-minute mark; we expire the UI a little earlier so
|
||||
// the user retries fresh instead of getting a server-side rejection.
|
||||
const CAPTCHA_EXPIRY_MS = 90 * 1000;
|
||||
|
||||
const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<boolean>(false);
|
||||
// Set true once we've finished render + setData — gate the solve
|
||||
// callback on this so a hCaptcha auto-pass (low-friction users) or a
|
||||
// fast click before setData lands can't ship a token unbound to rqdata.
|
||||
// Discord rejects unbound tokens with `invalid-response`, burning the
|
||||
// rqtoken and forcing a fresh challenge.
|
||||
const dataReadyRef = useRef<boolean>(false);
|
||||
// Idempotency latch on the solve callback — guards against hCaptcha
|
||||
// firing the callback twice (observed when users double-click "I am
|
||||
// human" or when low-risk users get the silent auto-pass that some
|
||||
// SDK versions deliver via two events). Without this, two
|
||||
// login-captcha commands ship to the bridge with the same single-use
|
||||
// token and the second one tears down the session via
|
||||
// `response-already-used`.
|
||||
const solvedRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
dataReadyRef.current = false;
|
||||
solvedRef.current = false;
|
||||
setLoadError(false);
|
||||
|
||||
// 90-second client-side expiry timer. Drives the localized
|
||||
// "captcha_expired" error rather than letting the user solve a
|
||||
// dead rqtoken and watch the bridge return a generic Discord
|
||||
// failure.
|
||||
const expiryTimer = window.setTimeout(() => {
|
||||
if (!cancelled && !solvedRef.current) onExpired();
|
||||
}, CAPTCHA_EXPIRY_MS);
|
||||
|
||||
// Pass the host theme into hCaptcha so the chrome inside the iframe
|
||||
// matches Vojo's surface — the host writes `data-theme` on
|
||||
// <html> at boot from the `?theme=` URL param. Without this the
|
||||
// hCaptcha widget paints its default light variant inside our
|
||||
// Dawn-dark card and reads as a foreign white box.
|
||||
const hcaptchaTheme: 'light' | 'dark' =
|
||||
document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||
|
||||
loadHCaptcha()
|
||||
.then((api) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const id = api.render(containerRef.current, {
|
||||
sitekey: state.sitekey,
|
||||
theme: hcaptchaTheme,
|
||||
size: 'normal',
|
||||
callback: (token) => {
|
||||
// Gate on dataReadyRef AND solvedRef. dataReadyRef rejects a
|
||||
// token before setData bound rqdata; solvedRef rejects
|
||||
// duplicate callbacks for the same challenge.
|
||||
if (cancelled || !dataReadyRef.current || solvedRef.current) return;
|
||||
solvedRef.current = true;
|
||||
onSolved(token);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
if (cancelled || solvedRef.current) return;
|
||||
onExpired();
|
||||
},
|
||||
'error-callback': () => {
|
||||
if (!cancelled) setLoadError(true);
|
||||
},
|
||||
});
|
||||
// Race guard: cancel may have flipped between the await and
|
||||
// render. If so, the iframe is mounted into a detached node — clean
|
||||
// up immediately rather than leaking the widget id.
|
||||
if (cancelled) {
|
||||
try {
|
||||
api.remove(id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
widgetIdRef.current = id;
|
||||
// Bind the solve to Discord's challenge nonce. hCaptcha-enterprise
|
||||
// (which Discord uses) requires setData AFTER render — passing
|
||||
// rqdata into render() is silently ignored.
|
||||
try {
|
||||
api.setData(id, { rqdata: state.rqdata });
|
||||
dataReadyRef.current = true;
|
||||
} catch (err) {
|
||||
// setData failed (unknown widget id, SDK bug, etc.) — tear the
|
||||
// widget down so a stray callback can't ship an unbound token.
|
||||
// Surface the load-error UI; the user can retry login-qr.
|
||||
try {
|
||||
api.remove(id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
widgetIdRef.current = null;
|
||||
if (!cancelled) setLoadError(true);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[captcha] hcaptcha.setData failed', err);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(expiryTimer);
|
||||
// Best-effort cleanup. hCaptcha's own remove() is idempotent and
|
||||
// tolerates missing widget ids; if the iframe was never rendered
|
||||
// (load failure) the catch is harmless.
|
||||
try {
|
||||
if (window.hcaptcha && widgetIdRef.current) {
|
||||
window.hcaptcha.remove(widgetIdRef.current);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
widgetIdRef.current = null;
|
||||
};
|
||||
// rqtoken is the per-challenge nonce — chained captchas reuse the same
|
||||
// sitekey/rqdata-shape but bring a new rqtoken; we want a remount on
|
||||
// any of these for safety. The whole CaptchaSolveState normally
|
||||
// replaces wholesale, but listing all three guards against future
|
||||
// partial mutations.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.sitekey, state.rqdata, state.rqtoken]);
|
||||
|
||||
return (
|
||||
<div class="auth-card auth-card-captcha">
|
||||
<div class="auth-card-title">{t('auth-card.captcha.title')}</div>
|
||||
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
|
||||
<div class="auth-card-captcha-frame">
|
||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||
{loadError ? (
|
||||
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div class="auth-card-row">
|
||||
<button type="button" class="btn-text" onClick={onCancel}>
|
||||
{t('auth-card.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// About card + modal
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -628,6 +921,12 @@ export function App({ bootstrap, api }: Props) {
|
|||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
appendedAnyHistory = true;
|
||||
}
|
||||
} else if (parsed.kind === 'captcha_challenge') {
|
||||
// Captcha challenge body carries hCaptcha rqdata/rqtoken
|
||||
// — short-lived but credential-bearing. Diag-only on
|
||||
// hydrate too; the live path treats it identically.
|
||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||
appendedAnyHistory = true;
|
||||
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||
// m.text / m.notice — body is safe to replay verbatim,
|
||||
// BUT we still scrub any login-URL-shaped substring as
|
||||
|
|
@ -696,6 +995,12 @@ export function App({ bootstrap, api }: Props) {
|
|||
) {
|
||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
}
|
||||
} else if (event.kind === 'captcha_challenge') {
|
||||
// The raw body carries Discord's hCaptcha sitekey + rqdata + rqtoken
|
||||
// — short-lived but credential-bearing. Don't echo into the
|
||||
// transcript DOM (where screenshots / accessibility tools could
|
||||
// leak them). Diag-only display.
|
||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||
const body = ev.content.body ?? '';
|
||||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
||||
|
|
@ -759,6 +1064,27 @@ export function App({ bootstrap, api }: Props) {
|
|||
[append, api, t]
|
||||
);
|
||||
|
||||
// Specialised send for hCaptcha tokens. The token is a single-use
|
||||
// credential — we send it to the bridge but redact the body in the
|
||||
// user-visible transcript so it doesn't sit in DOM (and screenshots /
|
||||
// accessibility tools can't see it). The error path keeps the same
|
||||
// shape as `sendBare` so the caller can `try { await … } catch { roll back }`.
|
||||
const sendCaptchaToken = useCallback(
|
||||
async (token: string): Promise<void> => {
|
||||
append({ kind: 'from-user', text: '→ login-captcha [redacted]' });
|
||||
try {
|
||||
await api.sendCommand(`login-captcha ${token}`);
|
||||
} catch (err) {
|
||||
append({
|
||||
kind: 'error',
|
||||
text: t('diag.send-failed', { message: (err as Error).message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[append, api, t]
|
||||
);
|
||||
|
||||
// In-flight guard against double-tap. The button is on the disconnected
|
||||
// screen which unmounts as soon as state advances, BUT a rapid second
|
||||
// click can fire in the microtask window between dispatch and the next
|
||||
|
|
@ -788,6 +1114,35 @@ export function App({ bootstrap, api }: Props) {
|
|||
dispatch({ kind: 'cancel_pending' });
|
||||
}, []);
|
||||
|
||||
// Captcha solve callback — fires from hCaptcha's own iframe. We dispatch
|
||||
// `submit_captcha_token` first (transition to qr_verifying immediately so
|
||||
// the user sees a spinner), then send `login-captcha <token>` to the
|
||||
// bridge. The bridge re-fires Discord's RemoteAuthLogin with the
|
||||
// X-Captcha-Key header attached and replies via the live event stream.
|
||||
const onCaptchaSolved = useCallback(
|
||||
async (token: string) => {
|
||||
dispatch({ kind: 'submit_captcha_token' });
|
||||
try {
|
||||
await sendCaptchaToken(token);
|
||||
} catch {
|
||||
// Couldn't deliver to the bridge — roll the optimistic
|
||||
// qr_verifying back to disconnected with a localized error.
|
||||
// Don't auto-fall-through to ping: the failure is local
|
||||
// (transport), not a bridge state mismatch, and a stale ping
|
||||
// would mask it.
|
||||
dispatch({ kind: 'captcha_send_failed' });
|
||||
}
|
||||
},
|
||||
[sendCaptchaToken]
|
||||
);
|
||||
|
||||
// hCaptcha challenge expired (server-side rqtoken TTL or 90s local
|
||||
// timer). Tell the user with a localized warn rather than letting them
|
||||
// solve a dead challenge.
|
||||
const onCaptchaExpired = useCallback(() => {
|
||||
dispatch({ kind: 'captcha_expired' });
|
||||
}, []);
|
||||
|
||||
const onClickRefresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
|
|
@ -945,6 +1300,18 @@ export function App({ bootstrap, api }: Props) {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'awaiting_captcha_solve' ? (
|
||||
<section class="section">
|
||||
<CaptchaPanel
|
||||
state={state}
|
||||
t={t}
|
||||
onSolved={onCaptchaSolved}
|
||||
onCancel={onClickCancel}
|
||||
onExpired={onCaptchaExpired}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'qr_verifying' ? (
|
||||
<section class="section">
|
||||
<StatusRow
|
||||
|
|
|
|||
|
|
@ -45,11 +45,21 @@ const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection
|
|||
// fires `ping` after to pick up the snowflake.
|
||||
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
||||
|
||||
// login-qr CAPTCHA path — commands.go:fnLoginQR (l.207-209). The bridge
|
||||
// appends «CAPTCHAs are currently not supported - use token login instead»
|
||||
// to the standard `Error logging in: %v` reply. Detect the suffix
|
||||
// independently of the leading error verb so we surface a useful hint
|
||||
// rather than a raw stack-trace tail.
|
||||
// login-qr CAPTCHA path — Vojo-patched bridge sends a single-line
|
||||
// `VOJO-CAPTCHA-CHALLENGE-V1 {json}` m.notice carrying the hCaptcha
|
||||
// sitekey, session_id, rqdata, rqtoken. The sentinel is markdown-inert by
|
||||
// design (no `_`, `*`, `` ` ``, `[`, `<` characters): even if a future
|
||||
// patch routes the bridge reply through goldmark again, the prefix
|
||||
// survives intact. The bridge currently sends the notice via
|
||||
// SendMessageEvent directly to bypass the framework's markdown round-trip.
|
||||
// See bridge `commands_captcha.go` for the producer side.
|
||||
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||
|
||||
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||
// instead». Kept so a deployment running unpatched bridge still produces a
|
||||
// useful hint rather than a generic Go-error tail.
|
||||
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;
|
||||
|
||||
// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
|
||||
|
|
@ -127,6 +137,27 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
|
|||
}
|
||||
|
||||
// Login lifecycle.
|
||||
// Vojo-patched bridge: structured hCaptcha challenge wins over every
|
||||
// legacy regex — checked first so the JSON payload survives.
|
||||
if (body.startsWith(CAPTCHA_CHALLENGE_PREFIX)) {
|
||||
const match = CAPTCHA_CHALLENGE_RE.exec(body);
|
||||
if (match) {
|
||||
try {
|
||||
const payload = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
const service = typeof payload.service === 'string' ? payload.service : '';
|
||||
const sitekey = typeof payload.sitekey === 'string' ? payload.sitekey : '';
|
||||
const sessionId = typeof payload.session_id === 'string' ? payload.session_id : '';
|
||||
const rqdata = typeof payload.rqdata === 'string' ? payload.rqdata : '';
|
||||
const rqtoken = typeof payload.rqtoken === 'string' ? payload.rqtoken : '';
|
||||
if (sitekey && rqtoken) {
|
||||
return { kind: 'captcha_challenge', service, sitekey, sessionId, rqdata, rqtoken };
|
||||
}
|
||||
} catch {
|
||||
// fall through — malformed payload is treated as unknown
|
||||
}
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
||||
|
||||
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||
|
|
@ -313,13 +344,33 @@ function runSanityChecks(): void {
|
|||
'Error logging in: rate limited 429',
|
||||
{ kind: 'login_failed', reason: 'rate limited 429' },
|
||||
],
|
||||
// CAPTCHA — must pre-empt LOGIN_FAILED_RE because both match. The
|
||||
// suffix detector is independent of the leading verb so it catches the
|
||||
// case even if Discord changes the body wrapping.
|
||||
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||
// unpatched upstream v0.7.6.
|
||||
[
|
||||
'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead',
|
||||
{ kind: 'captcha_required' },
|
||||
],
|
||||
// Vojo-patched bridge — structured hCaptcha challenge. Sentinel prefix
|
||||
// is checked before any regex so the JSON body is never misclassified.
|
||||
// Use a realistic-shape rqtoken (JWT-style segments separated by `.`,
|
||||
// base64url payload with `_`/`-`/`=`) so a regression where the regex
|
||||
// accidentally trips on those characters is caught in CI.
|
||||
[
|
||||
'VOJO-CAPTCHA-CHALLENGE-V1 {"service":"hcaptcha","sitekey":"a9b5fb07-92ff-493f-86fe-352a2803b3df","session_id":"e971514e-4a6e-4a45-a869-01e61421327c","rqdata":"fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax","rqtoken":"IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A"}',
|
||||
{
|
||||
kind: 'captcha_challenge',
|
||||
service: 'hcaptcha',
|
||||
sitekey: 'a9b5fb07-92ff-493f-86fe-352a2803b3df',
|
||||
sessionId: 'e971514e-4a6e-4a45-a869-01e61421327c',
|
||||
rqdata:
|
||||
'fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax',
|
||||
rqtoken:
|
||||
'IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A',
|
||||
},
|
||||
],
|
||||
// Malformed challenge JSON falls through to `unknown` so a corrupted
|
||||
// bridge build doesn't crash the parser.
|
||||
['VOJO-CAPTCHA-CHALLENGE-V1 {not-json', { kind: 'unknown' }],
|
||||
[
|
||||
'Error connecting to login websocket: dial tcp i/o timeout',
|
||||
{ kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' },
|
||||
|
|
|
|||
|
|
@ -61,12 +61,23 @@ export type LoginEvent =
|
|||
// bodies: «Error logging in: ...» — we surface the verbatim Go-error tail
|
||||
// as a yellow warning on the QR panel.
|
||||
| { kind: 'login_failed'; reason?: string }
|
||||
// Special-cased login_failed branch: the bridge appends «CAPTCHAs are
|
||||
// currently not supported - use token login instead» when Discord
|
||||
// presents a captcha. Telegram never sees this; Discord's remoteauth
|
||||
// throws CAPTCHA roughly proportionally to the user's account age and
|
||||
// login frequency. Promotes to a hint-with-explanation banner instead
|
||||
// of a raw stack-trace tail.
|
||||
// Vojo-patched bridge replies with a sentinel-prefixed JSON line
|
||||
// («VOJO-CAPTCHA-CHALLENGE-V1 {...}») when Discord demands an hCaptcha
|
||||
// before completing remote-auth. The widget renders a hCaptcha iframe
|
||||
// with the supplied sitekey + rqdata, then sends the solved token back
|
||||
// via `<commandPrefix> login-captcha <token>` to retry the login.
|
||||
| {
|
||||
kind: 'captcha_challenge';
|
||||
service: string;
|
||||
sitekey: string;
|
||||
sessionId: string;
|
||||
rqdata: string;
|
||||
rqtoken: string;
|
||||
}
|
||||
// Legacy «CAPTCHAs are currently not supported - use token login instead»
|
||||
// path — only fires against an UNPATCHED upstream v0.7.6 bridge. Kept so
|
||||
// a deployment that forgets to ship the Vojo bridge image still surfaces
|
||||
// a useful hint instead of a generic Go-error tail.
|
||||
| { kind: 'captcha_required' }
|
||||
// bridge sets up a websocket against Discord's remoteauth gateway; this is
|
||||
// the «we couldn't even reach Discord» error — different from
|
||||
|
|
|
|||
|
|
@ -44,10 +44,19 @@ export const EN: Record<StringKey, string> = {
|
|||
'auth-card.qr.step-1': 'Open the Discord mobile app.',
|
||||
'auth-card.qr.step-2': 'Open Settings → Scan QR Code.',
|
||||
'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.',
|
||||
'auth-card.captcha.title': 'Confirm you’re not a robot',
|
||||
'auth-card.captcha.hint':
|
||||
'Discord asked for a CAPTCHA. Solve it below — sign-in will continue automatically once you’re done.',
|
||||
'auth-card.captcha.load-error':
|
||||
'Could not load the CAPTCHA. Check your network, tap Cancel and try signing in again.',
|
||||
'auth-card.cancel': 'Cancel',
|
||||
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||
'auth-error.captcha-required':
|
||||
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
||||
'auth-error.captcha-send-failed':
|
||||
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
||||
'auth-error.captcha-expired':
|
||||
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||
|
|
@ -72,6 +81,7 @@ export const EN: Record<StringKey, string> = {
|
|||
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
||||
'diag.qr-issued': 'QR code issued.',
|
||||
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
||||
'diag.captcha-issued': 'Discord requested a CAPTCHA — solve it in the form above.',
|
||||
'bootstrap.failed': 'Widget failed to start',
|
||||
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
||||
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
|
||||
|
|
|
|||
|
|
@ -62,6 +62,17 @@ export const RU = {
|
|||
'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.',
|
||||
'auth-card.qr.step-2': 'Откройте «Настройки → Сканировать QR-код».',
|
||||
'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.',
|
||||
// --- hCaptcha challenge -----------------------------------------------
|
||||
// Discord иногда требует решить hCaptcha перед завершением remoteauth —
|
||||
// anti-abuse-сигнал, не зависящий от конкретного юзера. Vojo-патч
|
||||
// отдаёт челлендж сюда в виджет; пользователь решает, токен уходит
|
||||
// обратно на мост и логин завершается. Текст не упоминает «бан»: это
|
||||
// обычный механизм Discord, а не санкция.
|
||||
'auth-card.captcha.title': 'Подтвердите, что вы не робот',
|
||||
'auth-card.captcha.hint':
|
||||
'Discord попросил решить капчу. Решите её ниже — после этого вход продолжится автоматически.',
|
||||
'auth-card.captcha.load-error':
|
||||
'Не удалось загрузить капчу. Проверьте сеть и нажмите «Отмена», затем войдите снова.',
|
||||
// --- Shared form chrome ------------------------------------------------
|
||||
// Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены
|
||||
// активного login-qr, поэтому кнопка просто возвращает виджет в
|
||||
|
|
@ -73,6 +84,10 @@ export const RU = {
|
|||
// --- Inline errors -----------------------------------------------------
|
||||
'auth-error.captcha-required':
|
||||
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||
'auth-error.captcha-send-failed':
|
||||
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||
'auth-error.captcha-expired':
|
||||
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||
|
|
@ -104,6 +119,7 @@ export const RU = {
|
|||
// Поэтому в логе только нейтральные диагностические строки.
|
||||
'diag.qr-issued': 'QR-код выдан.',
|
||||
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
||||
'diag.captcha-issued': 'Discord прислал CAPTCHA — решите её на форме выше.',
|
||||
// --- Bootstrap failure -------------------------------------------------
|
||||
'bootstrap.failed': 'Widget не запустился',
|
||||
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ import type { LoginEvent } from './bridge-protocol/types';
|
|||
export type LoginErrorFlag =
|
||||
| { kind: 'login_failed'; reason?: string }
|
||||
| { kind: 'captcha_required' }
|
||||
// Local rollback flag — fired when the widget couldn't deliver
|
||||
// `login-captcha <token>` to the bridge (transport / Matrix-API failure
|
||||
// before the bridge even saw the command). Distinct from
|
||||
// `login_failed` (which IS a bridge reply) so the UX can read «sign-in
|
||||
// didn't reach the bot, retry» instead of hinting at a Discord-side
|
||||
// problem the user can't act on.
|
||||
| { kind: 'captcha_send_failed' }
|
||||
// The captcha challenge timed out (rqtoken expiry on Discord's side)
|
||||
// before the user solved it. Surfaced as a soft warning to retry
|
||||
// login-qr from scratch.
|
||||
| { kind: 'captcha_expired' }
|
||||
| { kind: 'login_websocket_failed'; reason?: string }
|
||||
| { kind: 'connect_after_login_failed'; reason?: string }
|
||||
| { kind: 'prepare_login_failed'; reason?: string }
|
||||
|
|
@ -47,6 +58,20 @@ export type PendingFormState = {
|
|||
lastError?: LoginErrorFlag;
|
||||
};
|
||||
|
||||
// hCaptcha challenge surfaced by the Vojo-patched bridge after Discord
|
||||
// returned 400+captcha-required. The widget renders the hCaptcha iframe
|
||||
// from `sitekey` + `rqdata`; on solve, the App sends `login-captcha
|
||||
// <token>` and we transition to `qr_verifying` until the bridge replies.
|
||||
export type CaptchaSolveState = {
|
||||
kind: 'awaiting_captcha_solve';
|
||||
service: string;
|
||||
sitekey: string;
|
||||
sessionId: string;
|
||||
rqdata: string;
|
||||
rqtoken: string;
|
||||
firstShownAt: number;
|
||||
};
|
||||
|
||||
export type LoginState =
|
||||
// Pre-handshake / pre-ping. Status pill: --faint.
|
||||
| { kind: 'unknown' }
|
||||
|
|
@ -57,6 +82,11 @@ export type LoginState =
|
|||
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
|
||||
// event. Status pill: --amber.
|
||||
| PendingFormState
|
||||
// hCaptcha challenge from Discord — Vojo-patched bridge surfaced the
|
||||
// sitekey + rqdata via `VOJO-CAPTCHA-CHALLENGE-V1` notice. The widget
|
||||
// renders the hCaptcha iframe; on solve we send `login-captcha <token>`
|
||||
// and transition to `qr_verifying`. Status pill: --amber.
|
||||
| CaptchaSolveState
|
||||
// QR was redacted (i.e. the bridge accepted a scan), but we don't yet
|
||||
// know whether login succeeded. Held as an intermediate spinner until
|
||||
// the next bridge signal arrives. Status pill: --amber.
|
||||
|
|
@ -85,12 +115,14 @@ export type LoginState =
|
|||
|
||||
// States that the hydrate path can restore after a reload. The QR panel
|
||||
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
|
||||
// timeline; `qr_verifying` covers the post-scan pre-success interstitial.
|
||||
// Other transient states (logging_out, reconnecting) deliberately don't
|
||||
// survive — those are tied to live in-flight commands and would feel
|
||||
// stuck on reload; the hydrate path falls through to live ping.
|
||||
// timeline; `qr_verifying` covers the post-scan pre-success interstitial;
|
||||
// `awaiting_captcha_solve` covers the case where the user reloads while
|
||||
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||
// transient states (logging_out, reconnecting) deliberately don't survive.
|
||||
export type HydrateRestoredState =
|
||||
| PendingFormState
|
||||
| CaptchaSolveState
|
||||
| { kind: 'qr_verifying' };
|
||||
|
||||
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||
|
|
@ -113,12 +145,34 @@ export type LoginAction =
|
|||
// kept symmetrical with TG's reducer for shape consistency, but
|
||||
// dispatching it doesn't trigger any send.
|
||||
| { kind: 'cancel_pending' }
|
||||
// User finished an hCaptcha challenge — token is non-empty. Optimistic
|
||||
// transition to `qr_verifying`; the App fires `login-captcha <token>`
|
||||
// and the bridge's reply (`login_success` / chained `captcha_challenge`
|
||||
// / `login_failed`) lands via the live event stream.
|
||||
| { kind: 'submit_captcha_token' }
|
||||
// Rollback for `submit_captcha_token` — the App couldn't deliver the
|
||||
// command to the bridge. Routes back to disconnected with a localized
|
||||
// error so the user sees what happened.
|
||||
| { kind: 'captcha_send_failed' }
|
||||
// hCaptcha challenge expired (server rqtoken TTL or local 90s timer).
|
||||
// Routes to disconnected with a localized warn.
|
||||
| { kind: 'captcha_expired' }
|
||||
| { kind: 'hydrate'; state: HydrateRestoredState };
|
||||
|
||||
export const initialLoginState: LoginState = { kind: 'unknown' };
|
||||
|
||||
const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';
|
||||
|
||||
// States that a fresh captcha challenge can clobber: the QR scan has
|
||||
// landed (or is mid-flight), or we're already showing a previous
|
||||
// challenge that Discord chained on top.
|
||||
const isCaptchaAcceptingState = (
|
||||
s: LoginState
|
||||
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
||||
s.kind === 'awaiting_qr_scan' ||
|
||||
s.kind === 'qr_verifying' ||
|
||||
s.kind === 'awaiting_captcha_solve';
|
||||
|
||||
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||
if (action.kind === 'hydrate') {
|
||||
// hydrate is a one-shot mount-time seed. If a live event already
|
||||
|
|
@ -157,6 +211,36 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
|
||||
return { kind: 'disconnected' };
|
||||
}
|
||||
if (action.kind === 'submit_captcha_token') {
|
||||
// User solved hCaptcha and we're about to fire login-captcha. Hold
|
||||
// `qr_verifying` until the bridge replies (success / chained challenge
|
||||
// / generic login_failed). Only honour from the captcha state — this
|
||||
// action is App-emitted right after the hCaptcha callback, so it
|
||||
// shouldn't ever fire from anywhere else, but defensive: a stale send
|
||||
// shouldn't suddenly paint a verifying spinner over a connected pill.
|
||||
if (state.kind !== 'awaiting_captcha_solve') return state;
|
||||
return { kind: 'qr_verifying' };
|
||||
}
|
||||
if (action.kind === 'captcha_send_failed') {
|
||||
// App couldn't ship the `login-captcha` command (transport / Matrix
|
||||
// API failure before the bridge saw it). Roll the optimistic
|
||||
// `qr_verifying` back to disconnected with a localized error.
|
||||
//
|
||||
// Honour ONLY from `qr_verifying` — narrowed from also accepting
|
||||
// `awaiting_captcha_solve` to avoid clobbering a fresh chained
|
||||
// captcha that may have arrived between the optimistic dispatch and
|
||||
// the failed-send rollback. If the live state already moved to a
|
||||
// newer challenge, the stale send-failure should be silently dropped.
|
||||
if (state.kind !== 'qr_verifying') return state;
|
||||
return { kind: 'disconnected', lastError: { kind: 'captcha_send_failed' } };
|
||||
}
|
||||
if (action.kind === 'captcha_expired') {
|
||||
// Local 90s timer or hCaptcha's expired-callback fired — the rqtoken
|
||||
// is dead, the user has nothing to solve. Route to disconnected with
|
||||
// a localized warn so they retry login-qr from scratch.
|
||||
if (state.kind !== 'awaiting_captcha_solve') return state;
|
||||
return { kind: 'disconnected', lastError: { kind: 'captcha_expired' } };
|
||||
}
|
||||
|
||||
const event = action.event;
|
||||
switch (event.kind) {
|
||||
|
|
@ -286,28 +370,50 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
case 'login_failed':
|
||||
// Generic Discord-side login failure — bridge replies «Error logging
|
||||
// in: <go-error>». Routes back to disconnected with the verbatim
|
||||
// reason as a warn line. Only honour when a QR flow is in flight;
|
||||
// otherwise it's stale (e.g. an old failure replaying after page
|
||||
// reload while the user is already connected).
|
||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||
// reason as a warn line. Only honour when a QR flow is in flight,
|
||||
// OR while the user is solving a captcha (the Vojo-patched bridge
|
||||
// can also reply «Error logging in: …» AFTER `login-captcha` if
|
||||
// Discord rejects the post-solve replay). Otherwise it's stale
|
||||
// (e.g. an old failure replaying after page reload while the user
|
||||
// is already connected).
|
||||
if (!isCaptchaAcceptingState(state)) return state;
|
||||
return {
|
||||
kind: 'disconnected',
|
||||
lastError: { kind: 'login_failed', reason: event.reason },
|
||||
};
|
||||
|
||||
case 'captcha_required':
|
||||
// Discord presented a captcha during remoteauth — QR flow is dead
|
||||
// for this attempt. Surface as a hint suggesting token-login (which
|
||||
// we don't expose in the widget; users can do it via chat-fallback).
|
||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||
// UNPATCHED bridge fallback: «CAPTCHAs are currently not supported».
|
||||
// Surface as a hint suggesting token-login (chat-fallback only). On
|
||||
// a Vojo-patched bridge this branch never fires — see captcha_challenge.
|
||||
if (!isCaptchaAcceptingState(state)) return state;
|
||||
return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };
|
||||
|
||||
case 'captcha_challenge':
|
||||
// Vojo-patched bridge surfaced an hCaptcha challenge — pivot the
|
||||
// widget to the captcha screen. Accept from awaiting_qr_scan
|
||||
// (challenge landed before the QR was redacted), qr_verifying
|
||||
// (challenge landed while we were still in the post-redact spinner)
|
||||
// or awaiting_captcha_solve (Discord chained another challenge after
|
||||
// the previous solve). Other states drop the event silently — a
|
||||
// stale challenge from an abandoned flow shouldn't repaint UI.
|
||||
if (!isCaptchaAcceptingState(state)) return state;
|
||||
return {
|
||||
kind: 'awaiting_captcha_solve',
|
||||
service: event.service,
|
||||
sitekey: event.sitekey,
|
||||
sessionId: event.sessionId,
|
||||
rqdata: event.rqdata,
|
||||
rqtoken: event.rqtoken,
|
||||
firstShownAt: Date.now(),
|
||||
};
|
||||
|
||||
case 'login_websocket_failed':
|
||||
// Pre-QR failure: couldn't reach Discord remoteauth. The QR was
|
||||
// never displayed in the first place. State `awaiting_qr_scan` with
|
||||
// empty discordUrl is the placeholder set by `start_qr_login`;
|
||||
// this fires before the first qr_displayed lands.
|
||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||
if (!isCaptchaAcceptingState(state)) return state;
|
||||
return {
|
||||
kind: 'disconnected',
|
||||
lastError: { kind: 'login_websocket_failed', reason: event.reason },
|
||||
|
|
@ -318,14 +424,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
// connect to Discord with it. The bridge has the token cached and
|
||||
// might recover on next ping; we still route to disconnected so the
|
||||
// user can retry.
|
||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||
if (!isCaptchaAcceptingState(state)) return state;
|
||||
return {
|
||||
kind: 'disconnected',
|
||||
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
|
||||
};
|
||||
|
||||
case 'prepare_login_failed':
|
||||
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||
if (!isCaptchaAcceptingState(state)) return state;
|
||||
return {
|
||||
kind: 'disconnected',
|
||||
lastError: { kind: 'prepare_login_failed', reason: event.reason },
|
||||
|
|
@ -441,6 +547,12 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
// min, which is why the TG widget uses 10 min — Discord's single-shot
|
||||
// remoteauth needs the tighter window.
|
||||
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
|
||||
// hCaptcha rqtoken is even more short-lived than the QR ticket — Discord
|
||||
// invalidates after ~90s in practice. If the user reloads while staring
|
||||
// at a captcha challenge older than this, restoring the captcha screen
|
||||
// only sets them up for a server-side rejection on solve. Drop the state
|
||||
// instead and let live ping reconcile.
|
||||
const CAPTCHA_HYDRATE_FRESHNESS_MS = 90 * 1000;
|
||||
|
||||
export type HydrateInput = {
|
||||
ev: LoginEvent;
|
||||
|
|
@ -520,6 +632,41 @@ const stepHydrate = (
|
|||
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
|
||||
}
|
||||
|
||||
case 'captcha_challenge': {
|
||||
// SECURITY: only accept a captcha challenge if the same chain has
|
||||
// already seen a `qr_displayed` (i.e. WE initiated the login).
|
||||
// Otherwise a malicious / compromised homeserver could craft an
|
||||
// m.notice with the sentinel JSON pointing at an attacker-controlled
|
||||
// sitekey, the user solves it on reload, and the resulting hCaptcha
|
||||
// token is sent verbatim to the bridge — useful free captcha-solving
|
||||
// labour for the attacker, and a phishing surface. The live reducer
|
||||
// already gates on `isCaptchaAcceptingState` (which requires we're
|
||||
// mid-flow), but the hydrate path replays raw timeline events
|
||||
// without the live state — drop unsolicited challenges here.
|
||||
if (acc.state.kind !== 'awaiting_qr_scan' && acc.state.kind !== 'qr_verifying') {
|
||||
return acc;
|
||||
}
|
||||
// Vojo-patched bridge surfaced an hCaptcha — keep the chain open so a
|
||||
// later `login_success` / `login_failed` still lands as terminal.
|
||||
// The rqdata/rqtoken are short-lived on Discord's side (~2 min);
|
||||
// the captcha-specific freshness gate in `hydrateFromTimeline`
|
||||
// (CAPTCHA_HYDRATE_FRESHNESS_MS) drops stale states before they
|
||||
// surface to the user.
|
||||
return {
|
||||
state: {
|
||||
kind: 'awaiting_captcha_solve',
|
||||
service: ev.service,
|
||||
sitekey: ev.sitekey,
|
||||
sessionId: ev.sessionId,
|
||||
rqdata: ev.rqdata,
|
||||
rqtoken: ev.rqtoken,
|
||||
firstShownAt: ts,
|
||||
},
|
||||
pendingTs: ts,
|
||||
terminated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Terminal events — collapse the chain. State becomes whatever the
|
||||
// bot confirmed last; the caller returns null and lets live `ping`
|
||||
// reconcile.
|
||||
|
|
@ -570,8 +717,16 @@ export const hydrateFromTimeline = (
|
|||
|
||||
if (acc.terminated) return null;
|
||||
if (acc.pendingTs === null) return null;
|
||||
if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null;
|
||||
// Tighter freshness gate for captcha state — rqtoken expires faster
|
||||
// than the QR ticket. This protects the user from a "solved captcha,
|
||||
// bridge rejects, user confused" UX after a slow reload.
|
||||
const freshness =
|
||||
acc.state.kind === 'awaiting_captcha_solve'
|
||||
? CAPTCHA_HYDRATE_FRESHNESS_MS
|
||||
: HYDRATE_FRESHNESS_MS;
|
||||
if (now - acc.pendingTs > freshness) return null;
|
||||
if (acc.state.kind === 'qr_verifying') return acc.state;
|
||||
if (acc.state.kind === 'awaiting_captcha_solve') return acc.state;
|
||||
if (!isFormState(acc.state)) return null;
|
||||
return acc.state;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -582,6 +582,34 @@ body {
|
|||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* ── hCaptcha challenge panel ───────────────────────────────────── */
|
||||
|
||||
.auth-card-captcha {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* hCaptcha renders its own iframe inside this host. We just provide a
|
||||
* minimum frame height so the layout doesn't collapse during script load,
|
||||
* and centre the iframe horizontally. The hCaptcha widget is a fixed-
|
||||
* width 304px element by default; on narrow viewports we let it overflow
|
||||
* its container's flex line rather than try to scale the iframe (the
|
||||
* upstream SDK handles its own responsive variants). */
|
||||
.auth-card-captcha-frame {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 88px;
|
||||
}
|
||||
.auth-card-captcha-host {
|
||||
/* hCaptcha injects a 304x78 (normal) or 156x144 (compact) iframe. The
|
||||
* default rendering is 'normal' — we size accordingly and let the SDK
|
||||
* place its own iframe inside. */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.auth-card-row {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,19 @@ const config: CapacitorConfig = {
|
|||
// request.isForMainFrame(), so any cross-origin iframe URL not in
|
||||
// appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW
|
||||
// and the iframe stays blank. Same-origin /widgets/... is safe (resolves
|
||||
// to https://localhost under Capacitor). For HTTPS-hosted bot widgets,
|
||||
// uncomment and edit the host list below BEFORE shipping a config.json
|
||||
// that points at them:
|
||||
// to https://localhost under Capacitor).
|
||||
//
|
||||
// server: { allowNavigation: ['app.vojo.chat', '*.vojo.chat'] },
|
||||
// The Discord widget renders a nested hCaptcha iframe inside the
|
||||
// widgets.vojo.chat frame; without `*.hcaptcha.com` in the allowlist
|
||||
// the captcha challenge stays blank on Android and login is dead-ended.
|
||||
server: {
|
||||
allowNavigation: [
|
||||
'widgets.vojo.chat',
|
||||
'js.hcaptcha.com',
|
||||
'newassets.hcaptcha.com',
|
||||
'*.hcaptcha.com',
|
||||
],
|
||||
},
|
||||
android: {
|
||||
// Keep default: resolveServiceWorkerRequests = true
|
||||
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue