Sign in through the service's own login page
Replaces the password form, which could not be made to work and cost account standing every time it was tried. A real browser window loads the service's real sign-in page, so its captcha, its two-factor and its device checks all simply work - there is nothing to reimplement and nothing to claim falsely, because it is genuinely Chromium loading their page. Built as a table rather than a Discord special case, since the next service will want the same thing: a flow names where to start, which requests are worth watching, and how the credential shows up - a request header, a cookie, or a parameter on a redirect. Those three cover Discord, forum software and SSO respectively, which is most of what is left to add. Two properties the design is built around. The password is typed into the service's form, so moho never sees one - the renderer no longer even has a field for it. And the session is memory-only and wiped when the window closes, so an abandoned sign-in leaves nothing behind. The captured value goes straight from the window to the daemon without passing through the renderer, which has no reason to hold a token. The window carries the host in its title and will not let the page rename it: there is no address bar, and a password typed into a page you cannot identify is the shape of every phishing attack there is. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 3b18e3e081...ac75dd5f09
@@ -0,0 +1,249 @@
|
||||
import { BrowserWindow, session } from 'electron'
|
||||
import type { Session } from 'electron'
|
||||
|
||||
/**
|
||||
* Signing in by letting the service's own login page do it.
|
||||
*
|
||||
* The alternative is posting credentials at an auth endpoint ourselves, which
|
||||
* fails for every service worth supporting and fails in the worst way: a
|
||||
* captcha we cannot render, a device check we cannot answer, an MFA prompt we
|
||||
* would have to reimplement, and - because a bare HTTP client submitting
|
||||
* passwords looks exactly like one trying a list of them - a black mark on the
|
||||
* account for trying. Discord refuses outright and files a warning against the
|
||||
* account each time.
|
||||
*
|
||||
* A real browser window sidesteps all of it by not being a workaround. The
|
||||
* page is the service's own, so its captcha, its MFA and its device
|
||||
* verification simply work, and the fingerprint it presents is genuine because
|
||||
* it is genuinely Chromium loading their page. Nothing here claims to be
|
||||
* anything it is not.
|
||||
*
|
||||
* Two properties matter and are worth stating plainly, because the design is
|
||||
* built around them: the password is typed into the service's form and this
|
||||
* process never sees it, and the session is thrown away afterwards so nothing
|
||||
* is left on disk to steal.
|
||||
*/
|
||||
|
||||
/** How a service's credential shows up once the sign-in succeeds. */
|
||||
export type Capture =
|
||||
/** A request header the page starts sending - Discord's Authorization. */
|
||||
| { kind: 'header'; name: string }
|
||||
/** A cookie the service sets - the usual shape for forum software. */
|
||||
| { kind: 'cookie'; name: string; url: string }
|
||||
/** A parameter on a redirect the service lands on - SSO, mostly. */
|
||||
| { kind: 'query'; param: string }
|
||||
|
||||
export interface LoginFlow {
|
||||
/** Shown in the window's title bar, so it is obvious whose page this is. */
|
||||
label: string
|
||||
/** Where the sign-in starts. */
|
||||
url: string
|
||||
/**
|
||||
* Which requests to inspect, as Electron URL patterns.
|
||||
*
|
||||
* Deliberately narrow, and narrower than where the window may navigate: a
|
||||
* login can legitimately bounce through a captcha provider or an identity
|
||||
* provider, and those are none of our business to read.
|
||||
*/
|
||||
watch: string[]
|
||||
capture: Capture
|
||||
/**
|
||||
* The daemon call that turns the captured value into a signed-in account,
|
||||
* and the parameter to hand it over as.
|
||||
*
|
||||
* Named here so the value can go straight from the login window to the
|
||||
* daemon without passing through the renderer. It is a credential; the
|
||||
* fewer places it exists, the better.
|
||||
*/
|
||||
finish: { method: string; param: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* The services this can sign in to.
|
||||
*
|
||||
* One table so adding the next one is an entry rather than a new code path.
|
||||
* It lives in main rather than in the daemon despite being protocol knowledge,
|
||||
* because it is only actionable by something that can open a window - a
|
||||
* headless frontend could not use it if it were handed over.
|
||||
*/
|
||||
export const LOGIN_FLOWS: Record<string, LoginFlow> = {
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
url: 'https://discord.com/login',
|
||||
// Only Discord's own API. The login page loads hCaptcha and a CDN, and
|
||||
// neither carries anything worth looking at.
|
||||
watch: ['https://discord.com/api/*'],
|
||||
// The web client attaches the token to every API call it makes once
|
||||
// signed in, so the first authenticated request is the answer.
|
||||
capture: { kind: 'header', name: 'authorization' },
|
||||
finish: { method: 'addDiscordAccountToken', param: 'token' }
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginOutcome {
|
||||
ok: boolean
|
||||
/** Present only on success. A credential: never log it, never persist it. */
|
||||
value?: string
|
||||
/** Why it did not succeed, for showing to a person. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Long enough to find a password manager and answer an MFA prompt. */
|
||||
const TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
let openWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* Opens the service's login page and resolves with whatever the flow captures.
|
||||
*
|
||||
* One at a time: two of these racing would leave two windows both claiming to
|
||||
* be signing in, and the second would inherit the first's half-finished
|
||||
* session.
|
||||
*/
|
||||
export async function browserLogin(service: string): Promise<LoginOutcome> {
|
||||
const flow = LOGIN_FLOWS[service]
|
||||
if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` }
|
||||
if (openWindow && !openWindow.isDestroyed()) {
|
||||
openWindow.focus()
|
||||
return { ok: false, error: 'A sign-in window is already open' }
|
||||
}
|
||||
|
||||
// A partition with no "persist:" prefix is memory-only: cookies, storage and
|
||||
// cache exist while the window does and are gone with it. That is the whole
|
||||
// security story here - an abandoned sign-in leaves nothing behind, and
|
||||
// neither does a completed one.
|
||||
const partition = `moho-login-${service}-${Date.now()}`
|
||||
const ses = session.fromPartition(partition)
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 760,
|
||||
title: `Sign in to ${flow.label}`,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#101418',
|
||||
webPreferences: {
|
||||
partition,
|
||||
// Untrusted third-party content. No preload, no node, and its own
|
||||
// renderer process - this window can reach nothing of moho's.
|
||||
preload: undefined,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
})
|
||||
openWindow = win
|
||||
|
||||
// This window has no address bar, so the title is the only thing that says
|
||||
// where you are - which matters, because typing a password into a page you
|
||||
// cannot identify is the shape of every phishing attack there is. The page
|
||||
// is not allowed to rename it, and the host is shown alongside so a redirect
|
||||
// somewhere unexpected is visible rather than silent.
|
||||
const retitle = (): void => {
|
||||
let host = ''
|
||||
try {
|
||||
host = new URL(win.webContents.getURL()).host
|
||||
} catch {
|
||||
/* nothing loaded yet */
|
||||
}
|
||||
if (!win.isDestroyed()) win.setTitle(host ? `Sign in to ${flow.label} — ${host}` : `Sign in to ${flow.label}`)
|
||||
}
|
||||
win.on('page-title-updated', (e) => {
|
||||
e.preventDefault()
|
||||
retitle()
|
||||
})
|
||||
win.webContents.on('did-navigate', retitle)
|
||||
win.webContents.on('did-navigate-in-page', retitle)
|
||||
retitle()
|
||||
|
||||
return await new Promise<LoginOutcome>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (outcome: LoginOutcome): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
detach(ses)
|
||||
if (!win.isDestroyed()) win.destroy()
|
||||
openWindow = null
|
||||
// Belt and braces alongside the memory-only partition: an explicit wipe
|
||||
// means a future change to a persistent partition cannot silently start
|
||||
// leaving a live session on disk.
|
||||
void ses.clearStorageData()
|
||||
resolve(outcome)
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish({ ok: false, error: 'Timed out waiting for the sign-in to finish' }),
|
||||
TIMEOUT_MS
|
||||
)
|
||||
|
||||
watch(ses, flow, (value) => finish({ ok: true, value }))
|
||||
|
||||
// Closing the window is a decision, not a failure - reported so the caller
|
||||
// can go quiet rather than showing an error nobody caused.
|
||||
win.on('closed', () => finish({ ok: false, error: 'cancelled' }))
|
||||
|
||||
win.loadURL(flow.url).catch((e: Error) =>
|
||||
finish({ ok: false, error: `Could not open ${flow.label}'s sign-in page: ${e.message}` })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function watch(ses: Session, flow: LoginFlow, found: (value: string) => void): void {
|
||||
if (flow.capture.kind === 'header') {
|
||||
const wanted = flow.capture.name.toLowerCase()
|
||||
ses.webRequest.onBeforeSendHeaders({ urls: flow.watch }, (details, callback) => {
|
||||
// Pass the request through untouched whatever happens: this is an
|
||||
// observer, and a login that breaks because we interfered with it would
|
||||
// be a poor sort of sign-in window.
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
for (const [name, value] of Object.entries(details.requestHeaders)) {
|
||||
if (name.toLowerCase() !== wanted) continue
|
||||
const token = String(value).trim()
|
||||
// The page makes unauthenticated API calls before sign-in too, and
|
||||
// some carry an empty or placeholder header.
|
||||
if (token && token.toLowerCase() !== 'undefined' && token.toLowerCase() !== 'null') {
|
||||
found(token)
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (flow.capture.kind === 'cookie') {
|
||||
const { name, url } = flow.capture
|
||||
const check = (): void => {
|
||||
void ses.cookies.get({ url, name }).then((cookies) => {
|
||||
const value = cookies[0]?.value
|
||||
if (value) found(value)
|
||||
})
|
||||
}
|
||||
ses.cookies.on('changed', check)
|
||||
check()
|
||||
return
|
||||
}
|
||||
|
||||
const { param } = flow.capture
|
||||
ses.webRequest.onBeforeRequest({ urls: flow.watch }, (details, callback) => {
|
||||
callback({})
|
||||
try {
|
||||
const value = new URL(details.url).searchParams.get(param)
|
||||
if (value) found(value)
|
||||
} catch {
|
||||
/* not a URL we can parse; nothing to take from it */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listening.
|
||||
*
|
||||
* Electron takes one handler per session for each of these, and passing null
|
||||
* is how it is removed - left attached, a finished flow would go on reading
|
||||
* headers from a session that is about to be wiped.
|
||||
*/
|
||||
function detach(ses: Session): void {
|
||||
ses.webRequest.onBeforeSendHeaders(null)
|
||||
ses.webRequest.onBeforeRequest(null)
|
||||
ses.cookies.removeAllListeners('changed')
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { defaultSocketPath, NobilisClient } from './nobilis-client'
|
||||
import { NobilisProcess } from './nobilis-process'
|
||||
import { Prefs } from './prefs'
|
||||
import { Notifier } from './notifications'
|
||||
import { browserLogin, LOGIN_FLOWS } from './browser-login'
|
||||
import { IPC } from '../shared/ipc'
|
||||
import { allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
|
||||
import { saveMedia } from './downloads'
|
||||
@@ -350,6 +351,30 @@ function wireIpc(): void {
|
||||
available: nobilis.available(),
|
||||
linkUp: client.linkUp
|
||||
}))
|
||||
/**
|
||||
* Sign in through the service's own login page.
|
||||
*
|
||||
* The captured credential goes straight from the login window to the daemon
|
||||
* and is never returned here, so it never enters the renderer at all - only
|
||||
* whether it worked comes back. Nothing in this path is logged: an error
|
||||
* from the daemon is passed through, but the token never appears in one.
|
||||
*/
|
||||
ipcMain.handle(IPC.browserLogin, async (_e, service: string, accountId?: string) => {
|
||||
const flow = LOGIN_FLOWS[service]
|
||||
if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` }
|
||||
const outcome = await browserLogin(service)
|
||||
if (!outcome.ok || !outcome.value) return { ok: false, error: outcome.error }
|
||||
try {
|
||||
await client.request(flow.finish.method, {
|
||||
[flow.finish.param]: outcome.value,
|
||||
...(accountId ? { accountId } : {})
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.smiliesDir, () => smiliesPath())
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@ const api = {
|
||||
return res.result as T
|
||||
},
|
||||
|
||||
/**
|
||||
* Signs in through the service's own login page, in a real browser window.
|
||||
*
|
||||
* Only the outcome comes back. The credential the window captures goes
|
||||
* straight from main to the daemon and is deliberately never returned here -
|
||||
* the renderer has no reason to hold a token, so it never does.
|
||||
*/
|
||||
browserLogin(service: string, accountId?: string): Promise<{ ok: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(IPC.browserLogin, service, accountId)
|
||||
},
|
||||
|
||||
onEvent(cb: (frame: NobilisEvent) => void): () => void {
|
||||
const handler = (_e: unknown, frame: NobilisEvent): void => cb(frame)
|
||||
ipcRenderer.on(IPC.event, handler)
|
||||
|
||||
@@ -468,9 +468,9 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element {
|
||||
const qrPath = useChat((s) => s.discordQrPath)
|
||||
const status = useChat((s) => s.discordLoginStatus)
|
||||
const mfa = useChat((s) => s.discordMfa)
|
||||
const [method, setMethod] = useState<'qr' | 'password'>('qr')
|
||||
const [login, setLogin] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [method, setMethod] = useState<'qr' | 'browser'>('qr')
|
||||
// No password state any more, deliberately: the password is typed into
|
||||
// Discord's own page in the sign-in window, so this process never holds one.
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -551,10 +551,10 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={method === 'password' ? 'active' : undefined}
|
||||
onClick={() => setMethod('password')}
|
||||
className={method === 'browser' ? 'active' : undefined}
|
||||
onClick={() => setMethod('browser')}
|
||||
>
|
||||
Password
|
||||
Browser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -583,49 +583,34 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field-row">
|
||||
<label className="field">
|
||||
<span className="small muted">Email or phone</span>
|
||||
<input
|
||||
className="text-field"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="small muted">Password</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
Opens Discord's own sign-in page in a browser window. Your password goes into
|
||||
their form and never passes through moho — and because it is their page, their
|
||||
captcha, two-factor and device checks all work normally. The window is thrown away
|
||||
afterwards, session and all.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy || !login || !password}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBusy(true)
|
||||
store.setDiscordLoginStatus('Waiting for the sign-in window…')
|
||||
void window.moho
|
||||
.rpc('addDiscordAccountPassword', { login, password, ...target })
|
||||
.then(() => {
|
||||
setPassword('')
|
||||
.browserLogin('discord', accountId || undefined)
|
||||
.then((r) => {
|
||||
setBusy(false)
|
||||
// Closing the window is a decision, not a failure worth
|
||||
// shouting about.
|
||||
if (r.ok) store.setDiscordLoginStatus('Signed in.')
|
||||
else if (r.error === 'cancelled') store.setDiscordLoginStatus('')
|
||||
else fail(new Error(r.error || 'Sign-in failed'))
|
||||
})
|
||||
.catch(fail)
|
||||
}}
|
||||
>
|
||||
{accountId ? 'Re-authenticate' : 'Sign in'}
|
||||
{busy ? 'Waiting…' : accountId ? 'Re-authenticate in a browser' : 'Sign in with a browser'}
|
||||
</button>
|
||||
<p className="small warn-text">
|
||||
Discord generally will not accept a password from a third-party client — it answers
|
||||
with a captcha or an “Invalid Form Body” rejection — and every attempt
|
||||
counts against the account, which is how sign-in warnings appear on it. QR login is
|
||||
the way in: approving on a device you are already signed in on is the proof Discord is
|
||||
asking for, and it costs the account nothing.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{status && <p className="small muted">{status}</p>}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const IPC = {
|
||||
daemonStatus: 'moho:daemonStatus',
|
||||
smiliesDir: 'moho:smiliesDir',
|
||||
markBufferRead: 'moho:markBufferRead',
|
||||
browserLogin: 'moho:browserLogin',
|
||||
|
||||
// main -> renderer (send)
|
||||
event: 'moho:event',
|
||||
|
||||
Reference in New Issue
Block a user