implement SASL OAUTHBEARER and draft/bearer (#2122)

* implement SASL OAUTHBEARER and draft/bearer
* Upgrade JWT lib
* Fix an edge case in SASL EXTERNAL
* Accept longer SASL responses
* review fix: allow multiple token definitions
* enhance tests
* use SASL utilities from irc-go
* test expired tokens
This commit is contained in:
Shivaram Lingamneni 2024-02-13 18:58:32 -05:00 committed by GitHub
parent 8475b62da4
commit ee7f818674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2868 additions and 975 deletions

View file

@ -8,7 +8,6 @@ package irc
import (
"bytes"
"encoding/base64"
"fmt"
"net"
"os"
@ -31,6 +30,7 @@ import (
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/jwt"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
)
@ -178,6 +178,10 @@ func acceptHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return false
}
const (
saslMaxResponseLength = 8192 // implementation-defined sanity check, long enough for bearer tokens
)
// AUTHENTICATE [<mechanism>|<data>|*]
func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
session := rb.session
@ -201,7 +205,7 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
return false
}
// start new sasl session
// start new sasl session: parameter is the authentication mechanism
if session.sasl.mechanism == "" {
throttled, remainingTime := client.loginThrottle.Touch()
if throttled {
@ -213,6 +217,16 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
mechanism := strings.ToUpper(msg.Params[0])
_, mechanismIsEnabled := EnabledSaslMechanisms[mechanism]
// The spec says: "The AUTHENTICATE command MUST be used before registration
// is complete and with the sasl capability enabled." Enforcing this universally
// would simplify the implementation somewhat, but we've never enforced it before
// and I don't want to break working clients that use PLAIN or EXTERNAL
// and violate this MUST (e.g. by sending CAP END too early).
if client.registered && !(mechanism == "PLAIN" || mechanism == "EXTERNAL") {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL is only allowed before connection registration"))
return false
}
if mechanismIsEnabled {
session.sasl.mechanism = mechanism
if !config.Server.Compatibility.SendUnprefixedSasl {
@ -230,46 +244,28 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
return false
}
// continue existing sasl session
rawData := msg.Params[0]
// https://ircv3.net/specs/extensions/sasl-3.1:
// "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks,
// and each chunk is sent as a separate AUTHENTICATE command."
saslMaxArgLength := 400
if len(rawData) > saslMaxArgLength {
// continue existing sasl session: parameter is a message chunk
done, value, err := session.sasl.value.Add(msg.Params[0])
if err == nil {
if done {
// call actual handler
handler := EnabledSaslMechanisms[session.sasl.mechanism]
return handler(server, client, session, value, rb)
} else {
return false // wait for continuation line
}
}
// else: error handling
switch err {
case ircutils.ErrSASLTooLong:
rb.Add(nil, server.name, ERR_SASLTOOLONG, details.nick, client.t("SASL message too long"))
session.sasl.Clear()
return false
} else if len(rawData) == saslMaxArgLength {
// allow 4 'continuation' lines before rejecting for length
if len(session.sasl.value) >= saslMaxArgLength*4 {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long"))
session.sasl.Clear()
return false
}
session.sasl.value += rawData
return false
case ircutils.ErrSASLLimitExceeded:
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long"))
default:
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding"))
}
if rawData != "+" {
session.sasl.value += rawData
}
var data []byte
var err error
if session.sasl.value != "+" {
data, err = base64.StdEncoding.DecodeString(session.sasl.value)
session.sasl.value = ""
if err != nil {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding"))
session.sasl.Clear()
return false
}
}
// call actual handler
handler := EnabledSaslMechanisms[session.sasl.mechanism]
return handler(server, client, session, data, rb)
session.sasl.Clear()
return false
}
// AUTHENTICATE PLAIN
@ -331,7 +327,7 @@ func authErrorToMessage(server *Server, err error) (msg string) {
}
switch err {
case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended:
case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended, oauth2.ErrInvalidToken:
return err.Error()
default:
// don't expose arbitrary error messages to the user
@ -351,28 +347,18 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
// EXTERNAL doesn't carry an authentication ID (this is determined from the
// certificate), but does carry an optional authorization ID.
var authzid string
authzid := string(value)
var deviceID string
var err error
if len(value) != 0 {
authzid, err = CasefoldName(string(value))
if err != nil {
err = errAuthzidAuthcidMismatch
}
// see #843: strip the device ID for the benefit of clients that don't
// distinguish user/ident from account name
if strudelIndex := strings.IndexByte(authzid, '@'); strudelIndex != -1 {
authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:]
}
if err == nil {
// see #843: strip the device ID for the benefit of clients that don't
// distinguish user/ident from account name
if strudelIndex := strings.IndexByte(authzid, '@'); strudelIndex != -1 {
var deviceID string
authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:]
if !client.registered {
rb.session.deviceID = deviceID
}
}
err = server.accounts.AuthenticateByCertificate(client, rb.session.certfp, rb.session.peerCerts, authzid)
}
if err != nil {
sendAuthErrorResponse(client, rb, err)
return false
@ -381,6 +367,9 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
}
sendSuccessfulAccountAuth(nil, client, rb, true)
if !client.registered && deviceID != "" {
rb.session.deviceID = deviceID
}
return false
}
@ -418,9 +407,8 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
account, err := server.accounts.LoadAccount(authcid)
if err == nil {
server.accounts.Login(client, account)
if fixupNickEqualsAccount(client, rb, server.Config(), "") {
sendSuccessfulAccountAuth(nil, client, rb, true)
}
// fixupNickEqualsAccount is not needed for unregistered clients
sendSuccessfulAccountAuth(nil, client, rb, true)
} else {
server.logger.Error("internal", "SCRAM succeeded but couldn't load account", authcid, err.Error())
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed"))
@ -433,7 +421,7 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
response, err := session.sasl.scramConv.Step(string(value))
if err == nil {
rb.Add(nil, server.name, "AUTHENTICATE", base64.StdEncoding.EncodeToString([]byte(response)))
sendSASLChallenge(server, rb, []byte(response))
} else {
continueAuth = false
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), err.Error())
@ -443,6 +431,58 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
return false
}
// AUTHENTICATE OAUTHBEARER
func authOauthBearerHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool {
if !server.Config().Accounts.OAuth2.Enabled {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), "SASL authentication failed: mechanism not enabled")
return false
}
if session.sasl.oauthConv == nil {
session.sasl.oauthConv = oauth2.NewOAuthBearerServer(
func(opts oauth2.OAuthBearerOptions) *oauth2.OAuthBearerError {
err := server.accounts.AuthenticateByOAuthBearer(client, opts)
switch err {
case nil:
return nil
case oauth2.ErrInvalidToken:
return &oauth2.OAuthBearerError{Status: "invalid_token", Schemes: "bearer"}
case errFeatureDisabled:
return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"}
default:
// this is probably a misconfiguration or infrastructure error so we should log it
server.logger.Error("internal", "failed to validate OAUTHBEARER token", err.Error())
// tell the client it was their fault even though it probably wasn't:
return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"}
}
},
)
}
challenge, done, err := session.sasl.oauthConv.Next(value)
if done {
if err == nil {
sendSuccessfulAccountAuth(nil, client, rb, true)
} else {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), ircutils.SanitizeText(err.Error(), 350))
}
session.sasl.Clear()
} else {
// ignore `err`, we need to relay the challenge (which may contain a JSON-encoded error)
// to the client
sendSASLChallenge(server, rb, challenge)
}
return false
}
// helper to b64 a sasl response and chunk it into 400-byte lines
// as per https://ircv3.net/specs/extensions/sasl-3.1
func sendSASLChallenge(server *Server, rb *ResponseBuffer, challenge []byte) {
for _, chunk := range ircutils.EncodeSASLResponse(challenge) {
rb.Add(nil, server.name, "AUTHENTICATE", chunk)
}
}
// AWAY [<message>]
func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
// #1996: `AWAY :` is treated the same as `AWAY`