Skip to content

Commit 5281ddd

Browse files
authored
Merge pull request #13 from silverbucket/sasl-oauthbearer
feat: add SASL OAUTHBEARER mechanism support (RFC 7628)
2 parents b4b4428 + 6f2509e commit 5281ddd

4 files changed

Lines changed: 489 additions & 14 deletions

File tree

README.md

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ The configuration options are as follows.
102102

103103
- `password`: Password used to connect to the network. Most networks don't have one.
104104

105-
- `saslPassword`: Will be used for SASL authentication if the sasl property is also true.
105+
- `saslMechanism`: SASL mechanism to use. Either `"PLAIN"` (default) or `"OAUTHBEARER"`.
106106

107-
- `saslUsername`: Will be used for SASL authentication. (Defaults to username if not set).
107+
- `saslPassword`: The credential sent during SASL. For `PLAIN`, this is the account password. For `OAUTHBEARER`, this is the OAuth 2.0 access token.
108+
109+
- `saslUsername`: Account name sent during SASL authentication. Defaults to `username` if not set. Required for `PLAIN`; sent but not used on the wire for `OAUTHBEARER`.
108110

109111
- `proxy`: WEBIRC details if your connection is acting as a (probably web-based) proxy.
110112

@@ -165,18 +167,57 @@ client.connect().then(function (res) {
165167
```
166168

167169
## SASL ##
168-
Supporting SASL authentication.
170+
171+
SASL authentication negotiates a `sasl` capability during the IRCv3 handshake, then runs one of two mechanisms:
172+
173+
- **`PLAIN`** (default) — sends username + password, base64-encoded per [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616).
174+
- **`OAUTHBEARER`** — sends an OAuth 2.0 access token, base64-encoded per [RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628).
175+
176+
Providing `saslPassword` triggers SASL. Always request the `sasl` capability so the server negotiates it:
177+
178+
```javascript
179+
capabilities: { requires: ["sasl"] }
180+
```
181+
182+
### PLAIN
169183

170184
```javascript
171185
const client = IrcSocket({
172-
capabilities: {
173-
requires: ["sasl"]
174-
},
175-
saslUsername: 'exampleuser', // will default to `username` if not specified
176-
saslPassword: 'foo bar'
186+
capabilities: { requires: ["sasl"] },
187+
saslUsername: 'exampleuser', // defaults to `username` if omitted
188+
saslPassword: 'correct horse battery staple'
177189
});
178190
```
179191

192+
### OAUTHBEARER
193+
194+
Supported by networks such as SourceHut's `chat.sr.ht`. Use this when you have an OAuth 2.0 access token rather than a password:
195+
196+
```javascript
197+
const client = IrcSocket({
198+
capabilities: { requires: ["sasl"] },
199+
saslMechanism: 'OAUTHBEARER',
200+
saslUsername: 'exampleuser',
201+
saslPassword: ACCESS_TOKEN // your OAuth 2.0 access token
202+
});
203+
```
204+
205+
### Security
206+
207+
The bearer token (or password) is sent over the wire. Connect over TLS — typically port `6697` with a `tls.Socket` as the underlying socket — so credentials are not exposed in transit.
208+
209+
### Failure handling
210+
211+
If the server rejects authentication (numerics `902`, `904`, `905`, `906`, or `907`), the connect promise resolves with `Fail(IrcSocket.connectFailures.saslAuthenticationFailed)` and the socket is closed with `QUIT`. The handshake finalizes on `903` (RPL_SASLSUCCESS); `900` (RPL_LOGGEDIN) is informational. For OAUTHBEARER, when the server responds with an RFC 7628 error challenge instead of the `+` prompt, the client acknowledges with `AUTHENTICATE AQ==` so the server can emit the failure numeric.
212+
213+
### Large payloads
214+
215+
`AUTHENTICATE` payloads are automatically split into 400-byte chunks per IRCv3 SASL 3.1. You don't need to size credentials manually — long OAuth access tokens are handled transparently.
216+
217+
### Unsupported mechanism
218+
219+
Passing any `saslMechanism` other than `"PLAIN"` or `"OAUTHBEARER"` throws synchronously at construction.
220+
180221
## Writing to the Server ##
181222
To send messages to the server, use socket.raw(). It accepts either a
182223
string or an array of Strings. The message '''must''' follow the

irc-socket.js

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,23 @@ const endsWith = function (string, postfix) {
2929
return string.lastIndexOf(postfix) === string.length - postfix.length;
3030
};
3131

32+
const encodeSaslCredential = function (mechanism, username, secret) {
33+
if (mechanism === 'OAUTHBEARER') {
34+
// RFC 7628: gs2-header "n,," then \x01 auth=Bearer <token> \x01 \x01
35+
return Buffer.from('n,,\x01auth=Bearer ' + secret + '\x01\x01').toString('base64');
36+
}
37+
// PLAIN (RFC 4616): [authzid]\0[authcid]\0[passwd]
38+
return Buffer.from(username + '\0' + username + '\0' + secret).toString('base64');
39+
};
40+
3241
const failures = {
3342
killed: 'killed',
3443
nicknamesUnavailable: 'nicknames unavailable',
3544
badProxyConfiguration: 'bad proxy configuration',
3645
missingRequiredCapabilities: 'missing required capabilities',
3746
badPassword: 'bad password',
38-
socketEnded: 'socket ended'
47+
socketEnded: 'socket ended',
48+
saslAuthenticationFailed: 'sasl authentication failed'
3949
};
4050

4151
class IrcSocket extends EventEmitter {
@@ -64,6 +74,10 @@ class IrcSocket extends EventEmitter {
6474
if (config.saslPassword) {
6575
this.saslUsername = config.saslUsername || config.username;
6676
this.saslPassword = config.saslPassword;
77+
this.saslMechanism = (config.saslMechanism || 'PLAIN').toUpperCase();
78+
if (this.saslMechanism !== 'PLAIN' && this.saslMechanism !== 'OAUTHBEARER') {
79+
throw new Error('Unsupported SASL mechanism: ' + config.saslMechanism);
80+
}
6781
}
6882
this.username = config.username;
6983
this.realname = config.realname;
@@ -154,6 +168,12 @@ class IrcSocket extends EventEmitter {
154168
this.emit("connect");
155169
timeout = setTimeout(onSilence, timeoutPeriod);
156170
let serverCapabilities, acknowledgedCapabilities, sentRequests, respondedRequests, allRequestsSent, nickname;
171+
// SASL challenge state: saslResponseSent flips once we've replied to
172+
// the initial AUTHENTICATE + prompt; saslChallenge accumulates
173+
// server-sent challenge chunks per IRCv3 SASL 3.1 (400-byte chunks,
174+
// terminated by a short chunk or a trailing AUTHENTICATE +).
175+
let saslResponseSent = false;
176+
let saslChallenge = '';
157177

158178
if (this.capabilities) {
159179
this.capabilities.requires = this.capabilities.requires || [];
@@ -251,7 +271,7 @@ class IrcSocket extends EventEmitter {
251271
acknowledgedCapabilities.push(capability);
252272
}
253273
if (acknowledgedCapabilities.includes('sasl')) {
254-
this.raw(['AUTHENTICATE', 'PLAIN']);
274+
this.raw(['AUTHENTICATE', this.saslMechanism || 'PLAIN']);
255275
}
256276
}
257277

@@ -274,12 +294,58 @@ class IrcSocket extends EventEmitter {
274294
}
275295

276296
} else if (parts[0] === "AUTHENTICATE") {
277-
if (parts[1] === '+') {
278-
const encPW = Buffer.from(this.saslUsername + '\0' + this.saslUsername + '\0' + this.saslPassword).toString('base64');
279-
this.raw(['AUTHENTICATE', encPW]);
297+
const chunkSize = 400;
298+
if (!saslResponseSent) {
299+
// Initial server prompt. Send our credential in 400-byte
300+
// AUTHENTICATE chunks; if the final chunk is exactly 400
301+
// bytes (or the payload is empty), append a trailing
302+
// AUTHENTICATE + per IRCv3 SASL 3.1.
303+
if (parts[1] !== '+') {
304+
return;
305+
}
306+
const payload = encodeSaslCredential(this.saslMechanism, this.saslUsername, this.saslPassword);
307+
if (payload.length === 0) {
308+
this.raw(['AUTHENTICATE', '+']);
309+
} else {
310+
for (let i = 0; i < payload.length; i += chunkSize) {
311+
this.raw(['AUTHENTICATE', payload.slice(i, i + chunkSize)]);
312+
}
313+
if (payload.length % chunkSize === 0) {
314+
this.raw(['AUTHENTICATE', '+']);
315+
}
316+
}
317+
saslResponseSent = true;
318+
} else {
319+
// Post-response server challenge. Accumulate chunks until
320+
// we see either a short chunk or a trailing "+" (after
321+
// 400-byte chunks). For OAUTHBEARER, RFC 7628 §3.2.3
322+
// requires AUTHENTICATE AQ== to ack the error challenge
323+
// before the server emits 904/905.
324+
let challengeComplete = false;
325+
if (parts[1] === '+') {
326+
challengeComplete = true;
327+
} else {
328+
saslChallenge += parts[1];
329+
if (parts[1].length < chunkSize) {
330+
challengeComplete = true;
331+
}
332+
}
333+
if (challengeComplete) {
334+
saslChallenge = '';
335+
if (this.saslMechanism === 'OAUTHBEARER') {
336+
this.raw(['AUTHENTICATE', 'AQ==']);
337+
}
338+
}
280339
}
281340
} else if (numeric === '903') {
341+
// RPL_SASLSUCCESS — advance past CAP. 900 RPL_LOGGEDIN is
342+
// informational and may arrive before 903; ignore it.
282343
this.raw("CAP END");
344+
} else if (numeric === '902' || numeric === '904' || numeric === '905' || numeric === '906' || numeric === '907') {
345+
// SASL aborted/failed/too-long/already/mech-unavailable
346+
this.raw("QUIT");
347+
this.resolvePromise(Fail(failures.saslAuthenticationFailed));
348+
return;
283349
} else if (numeric === "001") {
284350
this.status = "running";
285351

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "irc-socket-sasl",
3-
"version": "4.0.0",
3+
"version": "4.1.0",
44
"description": "Simple IRC Socket with SSL and SASL support, for usage with IRC libraries. Forked from irc-socket.",
55
"main": "irc-socket.js",
66
"private": false,

0 commit comments

Comments
 (0)