diff --git a/CLAUDE.md b/CLAUDE.md index 50bc1ce6..d6c3455a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,7 @@ Data flow: `transport.ts` (`WsTransport`: one line per frame, PING/PONG, reconne - Event names, payload shapes and store expectations are fixed by `docs/resources/bus-contract.md`; when adding a new event, extend `shared/types/socket-events.ts` and a `socket-events/*` consumer together. - Replies, reactions, deletion and edits (bus-contract §1.4): `Channel.idByMsgid` / `idOf()` is the msgid → id map; `client.react()` / `redact()` / `editMessage()` send, `handlers/tagmsg.ts` / `handlers/redact.ts` / `privmsg.ts` (`+seance/edit`) dispatch `msg:react` / `msg:redact` / `msg:edit`. Anything that refers to another message by msgid must go through `client.afterReplay(fn)` so that inside a chathistory replay it runs after the batch's messages have ids. Wire constants (`REPLY_TAG`, `EDIT_TAG`, `REACT_TAG`, `UNREACT_TAG`, `REDACTION_CAP`, `TYPING_TAG`) live in `wire.ts`. - **Reconnects are quiet, by content not by context.** A reattach can bring the server's join burst several times over (session restore, our re-JOIN, the bouncer's alias attach), so: `topic.ts` prints a 332/333 only when the topic differs from the one the channel shows (`Channel.topicAsked` is the `/topic` escape hatch), and a JOIN for a channel already JOINED produces no line and no second `CHATHISTORY`/`MARKREAD`/`MODE` (`Channel.rejoining`, `client.restoring` and the state check in `handleMessage`). The catch-up (`CHATHISTORY AFTER`) is what shows what actually happened. Keep new join-burst handlers consistent with this. +- **A SASL login that was asked for must succeed.** When `options.sasl` is set and the exchange does not end in 903, `IrcClient.saslFailed()` reports why in the lobby and — unless the deploy set `features.saslDisconnectOnFail: false` in `config.json` (`saslRequired`) — QUITs instead of registering unauthenticated. That covers missing credentials (caught in `onOpen`, before `CAP LS`), a server with no usable `sasl` cap (`startSasl`), a `CAP NAK`, the 902/904-907 numerics and the 12 s timeout. `handlers/cap.ts` drops whatever the negotiator had queued once `client.isQuitting`, so no `CAP END` follows the `QUIT`. See `docs/resources/nefarious2-websocket.md` § SASL for what the server does. - **The catch-up cursor is offered exactly once per connection, and only then.** `IrcClient.cursor` (the newest msgid shown on the network, by `@time`, persisted in `saved-networks.ts`) goes out as `PERSISTENCE ATTACH default ` from `saslProgress` on SASL _success_, before `CAP END` in the same flush — never without SASL (the server answers `FAIL PERSISTENCE ACCOUNT_REQUIRED`), never after 001, never without the `attach-cursor` token in the `draft/persistence` CAP 302 value, and never with a msgid of 64 bytes or more. While the server accepts it (`client.serverReplay`) the restoration JOINs must not schedule a `CHATHISTORY AFTER` of their own (`persistence.ts` `serverReplayCovers`) — the server's replay is that catch-up, and it arrives as an appended `chathistory` batch, not as `more`. - Typing notifications (bus-contract §1.5): `client.typing(chan, state)` sends `@+typing= TAGMSG` with the spec's 3 s per-target throttle (`TYPING_INTERVAL_MS`; session reset by `sendMessage`/`onClose`), the `typing` bus emit routes to it in `bus.ts`, and `handlers/tagmsg.ts` dispatches `typing {chan, nick, state}` for other users' tags (never own echo or replay). Tests: `test/irc/typing.ts` (sinon fake timers). diff --git a/client/js/branding.ts b/client/js/branding.ts index 987b9f64..3141f45c 100644 --- a/client/js/branding.ts +++ b/client/js/branding.ts @@ -39,6 +39,13 @@ export interface BrandingFeatures { * `false` hides the host/port/TLS fields and pins them to the default. */ allowCustomServer?: boolean; + /** + * When a network is set to log in with SASL, drop the connection unless + * that login succeeds, instead of registering unauthenticated. Default + * true. `false` restores the old behaviour: the failure is still + * reported in the lobby, but the connection goes ahead as a stranger. + */ + saslDisconnectOnFail?: boolean; } /** @@ -187,6 +194,7 @@ export const DEFAULT_BRANDING: BrandingConfig = { multiNetwork: true, saveNetworks: true, allowCustomServer: true, + saslDisconnectOnFail: true, }, strings: {}, }; @@ -233,6 +241,7 @@ export function brandingFeatures(config: BrandingConfig = current): Required { const result = client.caps.handle(msg); for (const line of result.send) { + if (client.isQuitting) { + // A required SASL login failed while the negotiator ran its + // `beforeEnd` hook (client.ts): the `CAP END` it queued behind + // that would only go out after our QUIT. + break; + } + client.send(line); } diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts index 57a7fdce..ef20a810 100644 --- a/client/js/irc/types.ts +++ b/client/js/irc/types.ts @@ -38,8 +38,10 @@ export type ConnectOptions = { saslAccount: string; saslPassword: string; /** - * Drop the connection when SASL fails instead of carrying on unauthenticated - * (irc-framework's `sasl_disconnect_on_fail`; the old server left it off). + * Drop the connection when a configured SASL login does not succeed, + * instead of carrying on unauthenticated. Unset means "ask the deploy": + * `config.json`'s `features.saslDisconnectOnFail`, which defaults to + * true (see `client/js/branding.ts` and client.ts `saslRequired`). */ saslDisconnectOnFail?: boolean; }; diff --git a/docs/resources/branding.md b/docs/resources/branding.md index 8cb96a6d..2458e712 100644 --- a/docs/resources/branding.md +++ b/docs/resources/branding.md @@ -70,6 +70,7 @@ Every field is optional; `{"appName": "Seance"}` (the shipped default) is a comp | `features.multiNetwork` | boolean | `true` | `false` hides the sidebar "connect" button once one network exists. | | `features.saveNetworks` | boolean | `true` | `false` hides the saved-networks picker, "remember password" and "connect automatically" on the connect form (see follow-ups). | | `features.allowCustomServer` | boolean | `true` | `false` behaves like `lockHost` and also ignores hosts from saved networks and `?host=` URL parameters. Requires `defaultNetwork`. | +| `features.saslDisconnectOnFail` | boolean | `true` | Drop the connection when a network set to log in with SASL does not manage to; see [Failed SASL logins](#failed-sasl-logins). | | `strings.` | string | built-in copy | Keys: `connect.title`, `connect.savedNetworks`, `connect.savedNetworksEmpty`, `connect.submit`, `help.about`, `help.website`, `help.documentation`, `help.privacy`. | | `uploads` | object | none (uploads off) | Network-provided file uploader; see [Uploads](#uploads). Dropped unless `endpoint` is an `https:` URL. | | `uploads.endpoint` | `https` URL | — | Receives a multipart `POST` per file. | @@ -81,6 +82,22 @@ Every field is optional; `{"appName": "Seance"}` (the shipped default) is a comp URL parameters (`?host=…&port=…&nick=…&join=…&autoconnect=1`, `?uri=web+irc://…`) still pre-fill the form and beat `defaultNetwork`, except for host/port/TLS when the host is locked. +## Failed SASL logins + +When a network is configured with "Username + password (SASL PLAIN)" and the login does not succeed, Seance reports why and **drops the connection** — it does not quietly register you as an unauthenticated stranger. `features.saslDisconnectOnFail: false` restores the old behaviour: the same report, but the connection carries on. + +"Does not succeed" is deliberately wide, because every one of these leaves the user logged out when they asked to be logged in: + +- no account name or password is configured (caught before `CAP LS`, so nothing is sent); +- the server does not offer the `sasl` capability, or offers it without `PLAIN` (the message names what it does offer); +- the server `NAK`s `sasl`; +- the server answers `902`, `904`, `905`, `906` or `907` — its text is quoted verbatim, so "invalid credentials", "service unavailable" or nefarious2's `FAIL AUTHENTICATE VERIFICATION_REQUIRED` reach the user; +- nothing arrives within 12 s, or the mechanism gets a challenge it cannot answer. + +The lobby then shows the reason, `Not connecting to without the login you asked for.` and what to try; the client sends `QUIT` and does not reconnect, so credentials can be fixed in the network's settings without a reconnect loop. Nothing about this reaches the wire beyond the `QUIT`: the ircd decides on its own whether an unauthenticated client may register at all. + +Leave it on for a network whose users expect an account (channel access, host masks, a bouncer session keyed to the account); turn it off for a public deploy where connecting anyway is more useful than not connecting at all. + ## Uploads Seance has no server of its own, so the file goes straight from the browser to an uploader the network runs. Files reach it by drag & drop anywhere on the page, by pasting an image into the input, or from the paperclip button. Running that service is the network's responsibility; Seance only needs it to honour this contract: diff --git a/docs/resources/nefarious2-websocket.md b/docs/resources/nefarious2-websocket.md index 56824e16..89abfbcb 100644 --- a/docs/resources/nefarious2-websocket.md +++ b/docs/resources/nefarious2-websocket.md @@ -345,6 +345,16 @@ lcarol << @batch=hist7AAF;time=2026-08-26T03:38:23.204Z;msgid=ABAAAAAaA8Iz[n :la ircu-family throttling applies to WebSocket clients exactly as to TCP ones. `ircd/parse.c` (~L1620) charges every command from a non-oper `lag = lagmin + len / lagfactor` seconds onto `cli_since` (defaults `2 + len/120`; `MFLG_SLOW` commands always), and `ircd/s_bsd.c` (~L1339) stops reading the socket while `cli_since - CurrentTime >= 10` (opers / `IsTrusted` exempt). Multiline batches are charged once at `BATCH -` instead of per line. The clock is reset when registration completes (`ircd/s_auth.c:1483`), so the CAP/NICK/USER/SASL exchange itself is free and only costs round trips — Seance pipelines it (`caps.ts`). So a client gets roughly five commands "for free" and is then held to one every 2 s; a burst of 65 commands (what Seance used to send for 15 autojoined channels) silenced the server for ~90 s and every later line, including the user's first message, waited behind it. Exceeding the recvq while held is "Excess Flood". Seance now sends one multi-target `JOIN`, no `MODE` on join, and paces history/marker fetches one channel per 4 s (`client/js/irc/catchup.ts`; `docs/projects/connect-burst.md`). Worth asking upstream whether read-only fetches (`CHATHISTORY`, `MARKREAD` query) could carry a smaller charge. +## SASL, read from source 2026-08-29 + +What the server does with `AUTHENTICATE`, and why Seance treats "SASL did not succeed" as a reason not to register at all (`features.saslDisconnectOnFail`, see [branding.md](branding.md#failed-sasl-logins)). + +- **`CAP END` does not skip the exchange.** `cap_end()` (`ircd/m_cap.c:798`) only clears `AR_CAP_PENDING` via `auth_cap_done()`; `AR_SASL_PENDING`, set by `auth_sasl_start()` on the first `AUTHENTICATE`, is cleared only by `auth_sasl_done()` from the success, failure, abort and timeout paths (`ircd/s_auth.c:1786-1830`). `check_auth_finished()` scans every `AR_*` flag, so a client that sends `CAP END` mid-exchange still waits for the numeric — and a client that quits mid-exchange is fine, `QUIT` is `MFLG_UNREG` (`ircd/parse.c:212`) and handled by `m_quit` before registration. +- **Three routes, tried in order** (`ircd/m_authenticate.c`): local Keycloak SASL (`sasl_auth.c`, `--enable-keycloak`, `SASL_LOCAL`), IAuth, then the P10 relay to `FEAT_SASL_SERVER` (X3). With none of them reachable the answer is `904 :SASL authentication failed: service unavailable` (plus `FAIL AUTHENTICATE SASL_FAIL` under `standard-replies`). The dev ircd has no services at all and does not even list `sasl` in `CAP LS` (`sasl_server_available()` gates the advertisement, `m_cap.c:569`). +- **Failure numerics carry text worth showing.** `ERR_SASLFAIL` is `":SASL authentication failed%s"` (`ircd/s_err.c:1843`), and the Keycloak path fills that `%s` in: `: malformed PLAIN data`, `: empty username or password`, `: username too long`, `: authzid too long`, `: no client certificate`, `: token rejected by server policy`, `: introspection unavailable`, `: request timed out`, plus the SCRAM ones. Wrong credentials give the bare form. Some paths also send a standard reply first — `FAIL AUTHENTICATE VERIFICATION_REQUIRED` for an unverified account (`sasl_auth.c:746`, `:1381`), `TOO_LONG` for a payload over 400 bytes, `ALREADY_AUTHENTICATED` for a second login. Seance quotes all of it (`handlers/sasl.ts`, `handlers/standard-replies.ts`). +- **Timeouts and aborts.** `FEAT_SASL_TIMEOUT` (default 10 s, `ircd_features.c:1056`) arms a timer on the first `AUTHENTICATE`; on expiry `abort_sasl(cptr, 1)` sends `904 :SASL authentication failed: request timed out` (`ircd/m_sasl.c:278`). `AUTHENTICATE *` from the client gets `906` (or `904` after a failed local session, RFC 7628 §3.2.3). If the services server disconnects mid-exchange, every pending session is aborted at once rather than left to time out (`ircd/list.c:354-372`). Seance's own backstop is 12 s (`sasl.ts SASL_TIMEOUT_MS`) so the server's more descriptive numeric wins. +- **Success is `900` then `903`** (`sasl_complete_login()`, `sasl_auth.c:578-614`). Only `903` ends the exchange; `900` alone means the login is not finished. `908 RPL_SASLMECHS` is informational (`m_sasl.c:273`), so a mechanism list on its own is not an outcome. + ## Session persistence (`draft/persistence`), read from source 2026-08-27 The branch has a built-in bouncer (`ircd/bouncer_session.c`, `m_bouncer.c`, `m_persistence.c`): with `BOUNCER_ENABLE` (default off; AfterNET has it on) a logged-in user's session is _held_ when the connection drops (`BOUNCER_DEFAULT_HOLD`, `BOUNCER_SESSION_HOLD` 4 h) and the next connection with the same account resumes it (`BOUNCER_AUTO_RESUME`). Accounts only, so nothing of this can be exercised against the dev ircd (no services); the behaviour below was seen live on AfterNET and read from the code. diff --git a/test/irc/attach-cursor.ts b/test/irc/attach-cursor.ts index 519716de..95e644ef 100644 --- a/test/irc/attach-cursor.ts +++ b/test/irc/attach-cursor.ts @@ -303,7 +303,9 @@ describe("PERSISTENCE ATTACH catch-up cursor (irc/persistence.ts)", function () it("does not send it when SASL failed, or when there is no SASL at all", function () { store({msgid: "m9", time: 123}); - const failed = saslClient(); + // A deploy that lets a failed login through anyway (otherwise the + // connection is dropped before `CAP END`, see client-sasl.ts). + const failed = saslClient({saslDisconnectOnFail: false}); authenticate(failed, SASL_CAPS, false); expect(failed.transport.sent.some((l) => l.startsWith("PERSISTENCE"))).to.equal(false); expect(failed.transport.sent).to.include("CAP END"); diff --git a/test/irc/branding.ts b/test/irc/branding.ts index e2173d54..d378bde4 100644 --- a/test/irc/branding.ts +++ b/test/irc/branding.ts @@ -55,6 +55,7 @@ describe("branding", function () { multiNetwork: true, saveNetworks: true, allowCustomServer: true, + saslDisconnectOnFail: true, }); }); @@ -79,6 +80,7 @@ describe("branding", function () { multiNetwork: true, saveNetworks: false, allowCustomServer: true, + saslDisconnectOnFail: true, }); expect(config.strings).to.deep.equal({"connect.submit": "Go"}); }); @@ -161,8 +163,13 @@ describe("branding", function () { multiNetwork: true, saveNetworks: true, allowCustomServer: false, + saslDisconnectOnFail: true, }); expect(brandingFeatures({appName: "x"}).allowCustomServer).to.equal(true); + expect( + brandingFeatures(normalizeBranding({features: {saslDisconnectOnFail: false}})) + .saslDisconnectOnFail + ).to.equal(false); }); }); diff --git a/test/irc/client-sasl.ts b/test/irc/client-sasl.ts index abcb2dc7..aa685544 100644 --- a/test/irc/client-sasl.ts +++ b/test/irc/client-sasl.ts @@ -1,13 +1,16 @@ /** - * IrcClient + SASL: the CAP REQ/ACK → AUTHENTICATE → CAP END wiring. The - * pure state machine is covered in test/irc/sasl.ts. + * IrcClient + SASL: the CAP REQ/ACK → AUTHENTICATE → CAP END wiring, and the + * "a login that was asked for must succeed" policy + * (`features.saslDisconnectOnFail`, on by default). The pure state machine is + * covered in test/irc/sasl.ts. */ import {expect} from "chai"; import sinon from "ts-sinon"; import socket from "../../client/js/socket"; -import {IrcClient, IrcClientOptions} from "../../client/js/irc/client"; +import {IrcClient, IrcClientOptions, SASL_REQUIRED_HINT} from "../../client/js/irc/client"; import {IdAllocator} from "../../client/js/irc/ids"; import {encodePlain, SASL_TIMEOUT_MS} from "../../client/js/irc/sasl"; +import {DEFAULT_BRANDING, resetBranding, setBranding} from "../../client/js/branding"; import type {Transport} from "../../client/js/irc/types"; import type {TransportEvent, TransportState} from "../../client/js/irc/transport"; import {MessageType, SharedMsg} from "../../shared/types/msg"; @@ -106,6 +109,28 @@ function errors(chanId: number): string[] { .map((m) => m.text ?? ""); } +function texts(chanId: number): string[] { + return messages(chanId).map((m) => m.text ?? ""); +} + +/** The QUIT a required-SASL abort sends, and nothing after it. */ +function expectAborted(transport: FakeTransport, client: IrcClient): void { + expect(transport.sent).to.not.include("CAP END"); + expect(transport.sent[transport.sent.length - 1]).to.equal("QUIT :SASL authentication failed"); + expect(transport.closeCalls).to.equal(1); + expect(client.isQuitting).to.equal(true); + expect(client.isConnected).to.equal(false); + // The reason, why it is fatal, and what to do about it. + expect(errors(client.lobby.id)).to.have.length(2); + expect(errors(client.lobby.id)[1]).to.equal( + "Not connecting to irc.test without the login you asked for." + ); + expect(texts(client.lobby.id)).to.include(SASL_REQUIRED_HINT); +} + +/** Options for the deploys that keep the old "connect anyway" behaviour. */ +const CARRY_ON: Partial = {saslDisconnectOnFail: false}; + function setup(overrides: Partial = {}): { client: IrcClient; transport: FakeTransport; @@ -150,7 +175,10 @@ function finishRegistration(transport: FakeTransport): void { describe("IrcClient SASL", function () { beforeEach(installSpy); - afterEach(removeSpy); + afterEach(function () { + removeSpy(); + resetBranding(); + }); it("requests sasl, authenticates with PLAIN and then sends CAP END", function () { const {client, transport} = setup(); @@ -184,10 +212,23 @@ describe("IrcClient SASL", function () { expect(client.isConnected).to.equal(true); }); - it("reports a 904 in the lobby and still completes registration", function () { + it("aborts the connection on a 904, quoting what the server said", function () { const {client, transport} = setup(); offer(transport, `${OFFERED_CAPS} sasl=PLAIN`); transport.line("AUTHENTICATE +"); + transport.line(":irc.test 904 alice :SASL authentication failed: invalid credentials"); + + expect(errors(client.lobby.id)[0]).to.equal( + "SASL authentication failed: SASL authentication failed: invalid credentials" + ); + expectAborted(transport, client); + expect(client.account).to.equal(""); + }); + + it("reports a 904 and still completes registration when the deploy allows it", function () { + const {client, transport} = setup(CARRY_ON); + offer(transport, `${OFFERED_CAPS} sasl=PLAIN`); + transport.line("AUTHENTICATE +"); transport.line(":irc.test 904 alice :SASL authentication failed"); expect(errors(client.lobby.id)).to.deep.equal([ @@ -202,37 +243,73 @@ describe("IrcClient SASL", function () { expect(client.state).to.equal("registered"); }); - it("disconnects on failure when saslDisconnectOnFail is set", function () { - const {client, transport} = setup({saslDisconnectOnFail: true}); + it("takes features.saslDisconnectOnFail from the deploy config", function () { + setBranding({ + ...DEFAULT_BRANDING, + features: {...DEFAULT_BRANDING.features, saslDisconnectOnFail: false}, + }); + + const {client, transport} = setup(); offer(transport, `${OFFERED_CAPS} sasl=PLAIN`); transport.line("AUTHENTICATE +"); transport.line(":irc.test 904 alice :SASL authentication failed"); expect(errors(client.lobby.id)).to.have.length(1); - expect(transport.sent).to.not.include("CAP END"); - expect(transport.sent[transport.sent.length - 1]).to.equal( - "QUIT :SASL authentication failed" + expect(transport.sent[transport.sent.length - 1]).to.equal("CAP END"); + expect(transport.closeCalls).to.equal(0); + }); + + it("aborts before CAP LS when SASL is on but there is nothing to log in with", function () { + const {client, transport} = setup({saslPassword: ""}); + + expect(errors(client.lobby.id)[0]).to.equal( + "SASL authentication failed: no account name or password is configured" ); - expect(transport.closeCalls).to.equal(1); - expect(client.isQuitting).to.equal(true); + expect(transport.sent).to.not.include("CAP LS 302"); + expectAborted(transport, client); }); - it("skips SASL silently when the server does not offer the cap", function () { + it("aborts when the server does not offer the sasl cap", function () { const {client, transport} = setup(); + // The failure happens while the negotiator is still building its + // reply, so not even the `CAP REQ` goes out: we QUIT instead. + transport.line(`:irc.test CAP * LS :${OFFERED_CAPS}`); + + expect(transport.sent).to.not.include("AUTHENTICATE PLAIN"); + expect(transport.sent.some((l) => l.startsWith("CAP REQ"))).to.equal(false); + expect(client.sasl).to.equal(null); + expect(errors(client.lobby.id)[0]).to.equal( + "SASL authentication failed: the server does not offer SASL" + ); + expectAborted(transport, client); + }); + + it("reports the missing cap and connects anyway when the deploy allows it", function () { + const {client, transport} = setup(CARRY_ON); const requested = offer(transport, OFFERED_CAPS); expect(requested).to.not.include("sasl"); - expect(transport.sent).to.not.include("AUTHENTICATE PLAIN"); expect(transport.sent[transport.sent.length - 1]).to.equal("CAP END"); - expect(client.sasl).to.equal(null); - expect(messages(client.lobby.id).some((m) => /SASL/i.test(m.text ?? ""))).to.equal(false); + expect(errors(client.lobby.id)).to.deep.equal([ + "SASL authentication failed: the server does not offer SASL", + ]); finishRegistration(transport); expect(client.isConnected).to.equal(true); }); - it("does not request sasl when PLAIN is not among the advertised mechanisms", function () { - const {transport} = setup(); + it("aborts, naming the mechanisms, when PLAIN is not among the advertised ones", function () { + const {client, transport} = setup(); + transport.line(`:irc.test CAP * LS :${OFFERED_CAPS} sasl=EXTERNAL,SCRAM-SHA-256`); + + expect(errors(client.lobby.id)[0]).to.equal( + "SASL authentication failed: the server offers SASL EXTERNAL,SCRAM-SHA-256, not PLAIN" + ); + expectAborted(transport, client); + }); + + it("does not request sasl when PLAIN is not advertised, and may still connect", function () { + const {transport} = setup(CARRY_ON); const requested = offer(transport, `${OFFERED_CAPS} sasl=EXTERNAL`); expect(requested).to.not.include("sasl"); @@ -256,7 +333,7 @@ describe("IrcClient SASL", function () { }); it("pipelines AUTHENTICATE behind the REQ, and aborts to CAP END when the server NAKs sasl", function () { - const {client, transport} = setup(); + const {client, transport} = setup(CARRY_ON); transport.line(`:irc.test CAP * LS :${OFFERED_CAPS} sasl=PLAIN`); const req = transport.sent.find((l) => l.startsWith("CAP REQ :")) as string; const names = req.slice("CAP REQ :".length).split(" "); @@ -275,11 +352,26 @@ describe("IrcClient SASL", function () { expect(client.sasl).to.equal(null); }); + it("aborts the connection when the server NAKs sasl", function () { + const {client, transport} = setup(); + transport.line(`:irc.test CAP * LS :${OFFERED_CAPS} sasl=PLAIN`); + const req = transport.sent.find((l) => l.startsWith("CAP REQ :")) as string; + transport.line(`:irc.test CAP alice NAK :${req.slice("CAP REQ :".length)}`); + transport.line(":irc.test CAP alice NAK :sasl"); + + expect(errors(client.lobby.id)[0]).to.equal( + "SASL authentication failed: the server refused the sasl capability" + ); + // The abort still goes out: the server is told before we quit. + expect(transport.sent).to.include("AUTHENTICATE *"); + expectAborted(transport, client); + }); + it("aborts with AUTHENTICATE * after the timeout and continues", function () { const clock = sinon.useFakeTimers(); try { - const {client, transport} = setup(); + const {client, transport} = setup(CARRY_ON); offer(transport, `${OFFERED_CAPS} sasl=PLAIN`); expect(transport.sent[transport.sent.length - 1]).to.equal("AUTHENTICATE PLAIN"); @@ -303,6 +395,25 @@ describe("IrcClient SASL", function () { } }); + it("drops the connection when the exchange times out", function () { + const clock = sinon.useFakeTimers(); + + try { + const {client, transport} = setup(); + offer(transport, `${OFFERED_CAPS} sasl=PLAIN`); + + clock.tick(SASL_TIMEOUT_MS + 1); + + expect(errors(client.lobby.id)[0]).to.equal( + "SASL authentication failed: timed out waiting for the server" + ); + expect(transport.sent).to.include("AUTHENTICATE *"); + expectAborted(transport, client); + } finally { + clock.restore(); + } + }); + it("does not fire the timeout once the exchange completed", function () { const clock = sinon.useFakeTimers(); diff --git a/test/irc/sasl.live.ts b/test/irc/sasl.live.ts index 6487afa0..949ce342 100644 --- a/test/irc/sasl.live.ts +++ b/test/irc/sasl.live.ts @@ -6,13 +6,16 @@ * TS_NODE_PROJECT=./test/tsconfig.json npx mocha --config=test/.mocharc.yml \ * test/irc/sasl.live.ts * - * The dev ircd has no services, so SASL cannot succeed. Two things are + * The dev ircd has no services, so SASL cannot succeed. Three things are * checked and printed (`[sasl.live]` lines): * - * 1. IrcClient with an account configured registers normally. As of - * 2026-08 the dev ircd does not even list `sasl` in CAP LS, so the - * client skips SASL silently; if it ever does, one failure is reported. - * 2. A raw WebSocket forces `CAP REQ :sasl` (nefarious2 ACKs it anyway) + * 1. IrcClient with `saslDisconnectOnFail: false` registers anyway, with + * the failure reported in the lobby. As of 2026-08 the dev ircd does + * not even list `sasl` in CAP LS, so the report is "the server does + * not offer SASL". + * 2. The same client with the default policy never registers: it reports + * the failure and QUITs (`features.saslDisconnectOnFail`). + * 3. A raw WebSocket forces `CAP REQ :sasl` (nefarious2 ACKs it anyway) * and drives {@link SaslAuth} against the real answers, to record the * numeric the server uses without services: `904 :SASL authentication * failed: request timed out`, ~10s after `AUTHENTICATE PLAIN`, with no @@ -188,7 +191,7 @@ describeLive("IrcClient SASL (live nefarious2)", function () { ).to.equal(true); }); - it("IrcClient with a bad account still registers", async function () { + it("IrcClient with a bad account still registers when the deploy allows it", async function () { allowSelfSignedForLocalhost(url as string); // ircu IPcheck throttles a quick reconnect after a short-lived connection, so the // ~10s raw exchange above runs first; still give the server a moment. @@ -206,6 +209,7 @@ describeLive("IrcClient SASL (live nefarious2)", function () { sasl: "plain", saslAccount: process.env.SEANCE_SASL_ACCOUNT ?? "seance-nobody", saslPassword: process.env.SEANCE_SASL_PASSWORD ?? "definitely-wrong", + saslDisconnectOnFail: false, ids: new IdAllocator(), reconnect: {enabled: false, initialDelayMs: 1, maxDelayMs: 1, factor: 1, jitter: false}, }); @@ -238,15 +242,59 @@ describeLive("IrcClient SASL (live nefarious2)", function () { (m) => m.type === MessageType.ERROR && /^SASL authentication failed/.test(m.text ?? "") ); - if (enabled) { - expect(saslErrors, "one SASL failure reported in the lobby").to.have.length(1); - log(`client: ${saslErrors[0].text}`); - } else { - expect(saslErrors).to.deep.equal([]); - } + // Either way the user is told; only the disconnect is optional. + expect(saslErrors, "one SASL failure reported in the lobby").to.have.length(1); + log(`client: ${saslErrors[0].text}`); expect(client.account).to.equal(""); client.disconnect("live sasl test done"); await waitFor("close", () => client.state === "disconnected"); }); + + it("IrcClient with a bad account does not register under the default policy", async function () { + allowSelfSignedForLocalhost(url as string); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const parsed = new URL(url as string); + const tag = Math.floor(1000 + Math.random() * 9000); + dispatch = sinon.stub(socket, "dispatch").returns(false); + + const client = new IrcClient({ + host: parsed.hostname + (parsed.pathname === "/" ? "" : parsed.pathname), + port: parseInt(parsed.port, 10) || (parsed.protocol === "wss:" ? 443 : 80), + tls: parsed.protocol === "wss:", + nick: `seancereq${tag}`, + join: "", + sasl: "plain", + saslAccount: process.env.SEANCE_SASL_ACCOUNT ?? "seance-nobody", + saslPassword: process.env.SEANCE_SASL_PASSWORD ?? "definitely-wrong", + ids: new IdAllocator(), + reconnect: {enabled: false, initialDelayMs: 1, maxDelayMs: 1, factor: 1, jitter: false}, + }); + client.transport.on((ev) => { + if (ev.type === "line") { + rawLines.push(ev.line); + } + }); + + client.connect(); + await waitFor("the client to give up", () => client.isQuitting, 40_000); + + const reported = messages(client.lobby.id) + .filter((m) => m.type === MessageType.ERROR) + .map((m) => m.text ?? ""); + log(`required: ${JSON.stringify(reported)}`); + + expect(reported[0], "the reason").to.match(/^SASL authentication failed: /); + expect(reported).to.include( + `Not connecting to ${client.options.host} without the login you asked for.` + ); + expect(payloads("init"), "never registered").to.have.length(0); + expect(client.isConnected).to.equal(false); + expect( + rawLines.some((l) => / 001 /.test(l)), + "no welcome" + ).to.equal(false); + + await waitFor("close", () => client.state === "disconnected", 20_000); + }); });