Skip to content

Commit

Permalink
implement SASL OAUTHBEARER and draft/bearer (#2122)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
slingamn committed Feb 13, 2024
1 parent 8475b62 commit ee7f818
Show file tree
Hide file tree
Showing 58 changed files with 2,867 additions and 974 deletions.
34 changes: 34 additions & 0 deletions default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,40 @@ accounts:
# how many scripts are allowed to run at once? 0 for no limit:
max-concurrency: 64

# support for login via OAuth2 bearer tokens
oauth2:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# enable this to use auth-script for validation:
auth-script: false
introspection-url: "https://example.com/api/oidc/introspection"
introspection-timeout: 10s
# omit for auth method `none`; required for auth method `client_secret_basic`:
client-id: "ergo"
client-secret: "4TA0I7mJ3fUUcW05KJiODg"

# support for login via JWT bearer tokens
jwt-auth:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# any of these token definitions can be accepted, allowing for key rotation
tokens:
-
algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
# hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys;
# either way, the key can be specified either as a YAML string:
key: "nANiZ1De4v6WnltCHN2H7Q"
# or as a path to the file containing the key:
#key-file: "jwt_pubkey.pem"
# list of JWT claim names to search for the user's account name (make sure the format
# is what you expect, especially if using "sub"):
account-claims: ["preferred_username"]
# if a claim is formatted as an email address, require it to have the following domain,
# and then strip off the domain and use the local-part as the account name:
#strip-domain: "example.com"

# channel options
channels:
# modes that are set when new channels are created
Expand Down
6 changes: 6 additions & 0 deletions gencapdefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@
url="https://github.com/ircv3/ircv3-specifications/pull/527",
standard="proposed IRCv3",
),
CapDef(
identifier="Bearer",
name="draft/bearer",
url="https://gist.github.com/slingamn/4fabc7a3d5f335da7bb313a7f0648f37",
standard="proposed IRCv3",
),
]

def validate_defs():
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ require (
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
github.com/ergochat/irc-go v0.4.0
github.com/ergochat/irc-go v0.5.0-rc1
github.com/go-sql-driver/mysql v1.7.0
github.com/go-test/deep v1.0.6 // indirect
github.com/gofrs/flock v0.8.1
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gorilla/websocket v1.4.2
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd
github.com/onsi/ginkgo v1.12.0 // indirect
Expand All @@ -27,6 +26,8 @@ require (
gopkg.in/yaml.v2 v2.4.0
)

require github.com/golang-jwt/jwt/v5 v5.2.0

require (
github.com/tidwall/btree v1.4.2 // indirect
github.com/tidwall/gjson v1.14.3 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.4.0 h1:0YibCKfAAtwxQdNjLQd9xpIEPisLcJ45f8FNsMHAuZc=
github.com/ergochat/irc-go v0.4.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k=
github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
Expand All @@ -23,8 +25,8 @@ github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
Expand Down
79 changes: 77 additions & 2 deletions irc/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package irc

import (
"context"
"crypto/rand"
"crypto/x509"
"encoding/json"
Expand All @@ -19,10 +20,12 @@ import (
"github.com/tidwall/buntdb"
"github.com/xdg-go/scram"

"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/email"
"github.com/ergochat/ergo/irc/migrations"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
)
Expand Down Expand Up @@ -1395,6 +1398,10 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
}
}

if strings.HasPrefix(accountName, caps.BearerTokenPrefix) {
return am.AuthenticateByBearerToken(client, strings.TrimPrefix(accountName, caps.BearerTokenPrefix), passphrase)
}

if throttled, remainingTime := client.checkLoginThrottle(); throttled {
return &ThrottleError{remainingTime}
}
Expand Down Expand Up @@ -1427,6 +1434,71 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
return err
}

func (am *AccountManager) AuthenticateByBearerToken(client *Client, tokenType, token string) (err error) {
switch tokenType {
case "oauth2":
return am.AuthenticateByOAuthBearer(client, oauth2.OAuthBearerOptions{Token: token})
case "jwt":
return am.AuthenticateByJWT(client, token)
default:
return errInvalidBearerTokenType
}
}

func (am *AccountManager) AuthenticateByOAuthBearer(client *Client, opts oauth2.OAuthBearerOptions) (err error) {
config := am.server.Config()

// we need to check this here since we can get here via SASL PLAIN:
if !config.Accounts.OAuth2.Enabled {
return errFeatureDisabled
}

var username string
if config.Accounts.AuthScript.Enabled && config.Accounts.OAuth2.AuthScript {
username, err = am.authenticateByOAuthBearerScript(client, config, opts)
} else {
username, err = config.Accounts.OAuth2.Introspect(context.Background(), opts.Token)
}
if err != nil {
return err
}

account, err := am.loadWithAutocreation(username, config.Accounts.OAuth2.Autocreate)
if err == nil {
am.Login(client, account)
}
return err
}

func (am *AccountManager) AuthenticateByJWT(client *Client, token string) (err error) {
config := am.server.Config()
// enabled check is encapsulated here:
accountName, err := config.Accounts.JWTAuth.Validate(token)
if err != nil {
am.server.logger.Debug("accounts", "invalid JWT token", err.Error())
return errAccountInvalidCredentials
}
account, err := am.loadWithAutocreation(accountName, config.Accounts.JWTAuth.Autocreate)
if err == nil {
am.Login(client, account)
}
return err
}

func (am *AccountManager) authenticateByOAuthBearerScript(client *Client, config *Config, opts oauth2.OAuthBearerOptions) (username string, err error) {
output, err := CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{OAuthBearer: &opts, IP: client.IP().String()})

if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
return "", oauth2.ErrInvalidToken
} else if output.Success {
return output.AccountName, nil
} else {
return "", oauth2.ErrInvalidToken
}
}

// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
func (am *AccountManager) AllNicks() (result []string) {
accountNamePrefix := fmt.Sprintf(keyAccountName, "")
Expand Down Expand Up @@ -1939,8 +2011,10 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
return err
}

if authzid != "" && authzid != account {
return errAuthzidAuthcidMismatch
if authzid != "" {
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch
}
}

// ok, we found an account corresponding to their certificate
Expand Down Expand Up @@ -2145,6 +2219,7 @@ var (
"PLAIN": authPlainHandler,
"EXTERNAL": authExternalHandler,
"SCRAM-SHA-256": authScramHandler,
"OAUTHBEARER": authOauthBearerHandler,
}
)

Expand Down
4 changes: 3 additions & 1 deletion irc/authscript.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"net"

"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/utils"
)

Expand All @@ -20,7 +21,8 @@ type AuthScriptInput struct {
Certfp string `json:"certfp,omitempty"`
PeerCerts []string `json:"peerCerts,omitempty"`
peerCerts []*x509.Certificate
IP string `json:"ip,omitempty"`
IP string `json:"ip,omitempty"`
OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"`
}

type AuthScriptOutput struct {
Expand Down
4 changes: 4 additions & 0 deletions irc/caps/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const (
BotTagName = "bot"
// https://ircv3.net/specs/extensions/chathistory
ChathistoryTargetsBatchType = "draft/chathistory-targets"

// draft/bearer defines this prefix namespace for authcids, enabling tunneling bearer tokens
// in SASL PLAIN:
BearerTokenPrefix = "*bearer*"
)

func init() {
Expand Down
7 changes: 6 additions & 1 deletion irc/caps/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package caps

const (
// number of recognized capabilities:
numCapabs = 34
numCapabs = 35
// length of the uint32 array that represents the bitset:
bitsetLen = 2
)
Expand Down Expand Up @@ -41,6 +41,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/435
AccountRegistration Capability = iota

// Bearer is the proposed IRCv3 capability named "draft/bearer":
// https://gist.github.com/slingamn/4fabc7a3d5f335da7bb313a7f0648f37
Bearer Capability = iota

// ChannelRename is the draft IRCv3 capability named "draft/channel-rename":
// https://ircv3.net/specs/extensions/channel-rename
ChannelRename Capability = iota
Expand Down Expand Up @@ -160,6 +164,7 @@ var (
"cap-notify",
"chghost",
"draft/account-registration",
"draft/bearer",
"draft/channel-rename",
"draft/chathistory",
"draft/event-playback",
Expand Down
15 changes: 13 additions & 2 deletions irc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import (
"github.com/ergochat/irc-go/ircfmt"
"github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/irc-go/ircreader"
"github.com/ergochat/irc-go/ircutils"
"github.com/xdg-go/scram"

"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/flatip"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
)
Expand Down Expand Up @@ -119,12 +121,20 @@ type Client struct {

type saslStatus struct {
mechanism string
value string
value ircutils.SASLBuffer
scramConv *scram.ServerConversation
oauthConv *oauth2.OAuthBearerServer
}

func (s *saslStatus) Initialize() {
s.value.Initialize(saslMaxResponseLength)
}

func (s *saslStatus) Clear() {
*s = saslStatus{}
s.mechanism = ""
s.value.Clear()
s.scramConv = nil
s.oauthConv = nil
}

// what stage the client is at w.r.t. the PASS command:
Expand Down Expand Up @@ -362,6 +372,7 @@ func (server *Server) RunClient(conn IRCConn) {
isTor: wConn.Tor,
hideSTS: wConn.Tor || wConn.HideSTS,
}
session.sasl.Initialize()
client.sessions = []*Session{session}

session.resetFakelag()
Expand Down
46 changes: 39 additions & 7 deletions irc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
)
Expand Down Expand Up @@ -331,7 +332,9 @@ type AccountConfig struct {
Multiclient MulticlientConfig
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
VHosts VHostConfig
AuthScript AuthScriptConfig `yaml:"auth-script"`
AuthScript AuthScriptConfig `yaml:"auth-script"`
OAuth2 oauth2.OAuth2BearerConfig `yaml:"oauth2"`
JWTAuth jwt.JWTAuthConfig `yaml:"jwt-auth"`
}

type ScriptConfig struct {
Expand Down Expand Up @@ -1391,15 +1394,44 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
}

saslCapValue := "PLAIN,EXTERNAL,SCRAM-SHA-256"
if !config.Accounts.AdvertiseSCRAM {
saslCapValue = "PLAIN,EXTERNAL"
}
config.Server.capValues[caps.SASL] = saslCapValue
if !config.Accounts.AuthenticationEnabled {
if config.Accounts.AuthenticationEnabled {
saslCapValues := []string{"PLAIN", "EXTERNAL"}
if config.Accounts.AdvertiseSCRAM {
saslCapValues = append(saslCapValues, "SCRAM-SHA-256")
}
if config.Accounts.OAuth2.Enabled {
saslCapValues = append(saslCapValues, "OAUTHBEARER")
}
config.Server.capValues[caps.SASL] = strings.Join(saslCapValues, ",")
} else {
config.Server.supportedCaps.Disable(caps.SASL)
}

if err := config.Accounts.OAuth2.Postprocess(); err != nil {
return nil, err
}

if err := config.Accounts.JWTAuth.Postprocess(); err != nil {
return nil, err
}

if config.Accounts.OAuth2.Enabled && config.Accounts.OAuth2.AuthScript && !config.Accounts.AuthScript.Enabled {
return nil, fmt.Errorf("oauth2 is enabled with auth-script, but no auth-script is enabled")
}

var bearerCapValues []string
if config.Accounts.OAuth2.Enabled {
bearerCapValues = append(bearerCapValues, "oauth2")
}
if config.Accounts.JWTAuth.Enabled {
bearerCapValues = append(bearerCapValues, "jwt")
}
if len(bearerCapValues) != 0 {
config.Server.capValues[caps.Bearer] = strings.Join(bearerCapValues, ",")
} else {
config.Server.supportedCaps.Disable(caps.Bearer)
}

if !config.Accounts.Registration.Enabled {
config.Server.supportedCaps.Disable(caps.AccountRegistration)
} else {
Expand Down
Loading

0 comments on commit ee7f818

Please sign in to comment.