draft/AUTHTOKEN support (#2385)

Includes two compatibility breaks:

* New, incompatible configuration format for extjwt
* JWTs accepted with IRCV3BEARER MUST have an expiration time
This commit is contained in:
Shivaram Lingamneni
2026-06-30 12:13:56 -07:00
committed by GitHub
parent 69d0dc7935
commit 08d8145f26
16 changed files with 988 additions and 146 deletions
+26 -5
View File
@@ -663,6 +663,9 @@ accounts:
# 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"
# optional list of `aud` claims that are acceptable
# (if omitted, the aud claim is not validated):
#validate-aud: ["irc.mydomain.com"]
# channel options
channels:
@@ -1027,16 +1030,34 @@ extjwt:
# # default service config (for `EXTJWT #channel`).
# # expiration time for the token:
# expiration: 45s
# # you can configure tokens to be signed either with HMAC and a symmetric secret:
# secret: "65PHvk0K1_sM-raTsCEhatVkER_QD8a0zVV8gG2EWcI"
# # or with an RSA private key:
# #rsa-private-key-file: "extjwt.pem"
# algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
# # hmac takes a symmetric key, rsa and eddsa take PEM-encoded private 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_privkey.pem"
# # named services (for `EXTJWT #channel service_name`):
# services:
# "jitsi":
# expiration: 30s
# secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg"
# algorithm: "hmac"
# key: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg"
# experimental draft/AUTHTOKEN mechanism
authtoken:
enabled: false
# only these IPs can verify tokens
verification-ip-whitelist:
- "localhost"
#services:
# "FILEHOST":
# expiration: 5m
# url: "https://example.com/filehost"
# algorithm: "eddsa"
# key: "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHfpO1x835o9NIQA1kBkN7/Myd6wqE/m/EYJUHBC18hW\n-----END PRIVATE KEY-----"
# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
# various autoreplay features, and the resume extension
+7
View File
@@ -243,6 +243,13 @@ CAPDEFS = [
url="https://ircv3.net/specs/extensions/metadata",
standard="draft IRCv3",
),
CapDef(
identifier="AuthToken",
name="draft/authtoken",
url="https://github.com/ircv3/ircv3-specifications/pull/602",
standard="proposed IRCv3",
),
]
+4
View File
@@ -66,6 +66,10 @@ const (
ChathistoryTargetsBatchType = "draft/chathistory-targets"
ExtendedISupportBatchType = "draft/isupport"
ChathistoryEndOfPaginationTag = "draft/chathistory-end"
// authtoken draft: https://github.com/ircv3/ircv3-specifications/pull/602
AuthTokenBatchType = "draft/authtoken"
AuthToken005 = "draft/AUTHTOKEN"
)
func init() {
+6 -1
View File
@@ -7,7 +7,7 @@ package caps
const (
// number of recognized capabilities:
numCapabs = 38
numCapabs = 39
// length of the uint32 array that represents the bitset:
bitsetLen = 2
)
@@ -41,6 +41,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/435
AccountRegistration Capability = iota
// AuthToken is the proposed IRCv3 capability named "draft/authtoken":
// https://github.com/ircv3/ircv3-specifications/pull/602
AuthToken Capability = iota
// ChannelRename is the draft IRCv3 capability named "draft/channel-rename":
// https://ircv3.net/specs/extensions/channel-rename
ChannelRename Capability = iota
@@ -176,6 +180,7 @@ var (
"cap-notify",
"chghost",
"draft/account-registration",
"draft/authtoken",
"draft/channel-rename",
"draft/chathistory",
"draft/event-playback",
+15 -5
View File
@@ -214,12 +214,14 @@ type Session struct {
zncPlaybackTimes *zncPlaybackTimes
autoreplayMissedSince time.Time
batch MultilineBatch
multilineBatch MultilineBatch
webPushEndpoint string // goroutine-local: web push endpoint registered by the current session
metadataSubscriptions utils.HashSet[string]
metadataPreregVals map[string]string
tokenValidateBatch *TokenValidateBatch
}
// MultilineBatch tracks the state of a client-to-server multiline batch.
@@ -233,13 +235,20 @@ type MultilineBatch struct {
tags map[string]string
}
type TokenValidateBatch struct {
label string
responseLabel string
service string
buf strings.Builder
}
// Starts a multiline batch, failing if there's one already open
func (s *Session) StartMultilineBatch(label, target, responseLabel string, tags map[string]string) (err error) {
if s.batch.label != "" {
if s.multilineBatch.label != "" {
return errInvalidMultilineBatch
}
s.batch.label, s.batch.target, s.batch.responseLabel, s.batch.tags = label, target, responseLabel, tags
s.multilineBatch.label, s.multilineBatch.target, s.multilineBatch.responseLabel, s.multilineBatch.tags = label, target, responseLabel, tags
s.fakelag.Suspend()
return
}
@@ -247,8 +256,8 @@ func (s *Session) StartMultilineBatch(label, target, responseLabel string, tags
// Closes a multiline batch unconditionally; returns the batch and whether
// it was validly terminated (pass "" as the label if you don't care about the batch)
func (s *Session) EndMultilineBatch(label string) (batch MultilineBatch, err error) {
batch = s.batch
s.batch = MultilineBatch{}
batch = s.multilineBatch
s.multilineBatch = MultilineBatch{}
s.fakelag.Unsuspend()
// heuristics to estimate how much data they used while fakelag was suspended
@@ -402,6 +411,7 @@ func (server *Server) RunClient(conn IRCConn, cookies []RequestCookie) {
connID: connID,
cookies: cookies,
}
cookies = nil
session.sasl.Initialize()
client.sessions = []*Session{session}
+41 -32
View File
@@ -11,11 +11,10 @@ import (
// Command represents a command accepted from a client.
type Command struct {
handler func(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool
usablePreReg bool
allowedInBatch bool // allowed in client-to-server batches
minParams int
capabs []string
handler func(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool
usablePreReg bool
minParams int
capabs []string
}
// resolveCommand returns the command to execute in response to a user input line.
@@ -56,11 +55,17 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, rb.target.t("Not enough parameters"))
return false
}
if session.batch.label != "" && !cmd.allowedInBatch {
// C2S batch restrictions, custom per C2S batch type:
if session.multilineBatch.label != "" && !(msg.Command == "BATCH" || msg.Command == "PRIVMSG" || msg.Command == "NOTICE") {
rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Command not allowed during a multiline batch"))
session.EndMultilineBatch("")
return false
}
if session.tokenValidateBatch != nil && !(msg.Command == "BATCH" || msg.Command == "TOKEN") {
rb.Add(nil, server.name, "FAIL", "BATCH", "INVALID_PARAMS", client.t("Command not allowed during a TOKEN VALIDATE batch"))
session.tokenValidateBatch = nil
return false
}
return cmd.handler(server, client, msg, rb)
}()
@@ -112,9 +117,8 @@ func init() {
minParams: 0,
},
"BATCH": {
handler: batchHandler,
minParams: 1,
allowedInBatch: true,
handler: batchHandler,
minParams: 1,
},
"CAP": {
handler: capHandler,
@@ -236,9 +240,8 @@ func init() {
minParams: 1,
},
"NOTICE": {
handler: messageHandler,
minParams: 2,
allowedInBatch: true,
handler: messageHandler,
minParams: 2,
},
"NPC": {
handler: npcHandler,
@@ -276,19 +279,34 @@ func init() {
minParams: 1,
},
"PRIVMSG": {
handler: messageHandler,
minParams: 2,
allowedInBatch: true,
handler: messageHandler,
minParams: 2,
},
"RELAYMSG": {
handler: relaymsgHandler,
minParams: 3,
"QUIT": {
handler: quitHandler,
usablePreReg: true,
minParams: 0,
},
"REDACT": {
handler: redactHandler,
minParams: 2,
},
"REGISTER": {
handler: registerHandler,
minParams: 3,
usablePreReg: true,
},
"REHASH": {
handler: rehashHandler,
minParams: 0,
capabs: []string{"rehash"},
},
"RELAYMSG": {
handler: relaymsgHandler,
minParams: 3,
},
"RENAME": {
handler: renameHandler,
minParams: 2,
@@ -323,24 +341,15 @@ func init() {
handler: messageHandler,
minParams: 1,
},
"QUIT": {
handler: quitHandler,
usablePreReg: true,
minParams: 0,
},
"REDACT": {
handler: redactHandler,
minParams: 2,
},
"REHASH": {
handler: rehashHandler,
minParams: 0,
capabs: []string{"rehash"},
},
"TIME": {
handler: timeHandler,
minParams: 0,
},
"TOKEN": {
handler: tokenHandler,
minParams: 1,
usablePreReg: true,
},
"TOPIC": {
handler: topicHandler,
minParams: 1,
+15 -2
View File
@@ -627,6 +627,8 @@ type Config struct {
Services map[string]jwt.JwtServiceConfig `yaml:"services"`
}
AuthToken jwt.AuthTokensConfig `yaml:"authtoken"`
Languages struct {
Enabled bool
Path string
@@ -1005,7 +1007,7 @@ func (config *Config) processExtjwt() (err error) {
// first process the default service, which may be disabled
err = config.Extjwt.Default.Postprocess()
if err != nil {
return
return fmt.Errorf("invalid extjwt config for default service: %w", err)
}
// now process the named services. it is an error if any is disabled
// also, normalize the service names to lowercase
@@ -1013,7 +1015,7 @@ func (config *Config) processExtjwt() (err error) {
for service, sConf := range config.Extjwt.Services {
err := sConf.Postprocess()
if err != nil {
return err
return fmt.Errorf("invalid extjwt config for service %s: %w", service, err)
}
if !sConf.Enabled() {
return fmt.Errorf("no keys enabled for extjwt service %s", service)
@@ -1800,6 +1802,13 @@ func LoadConfig(filename string) (config *Config, err error) {
return nil, err
}
if err = config.AuthToken.Postprocess(); err != nil {
return nil, err
}
if !config.AuthToken.Enabled {
config.Server.supportedCaps.Disable(caps.AuthToken)
}
if config.WebPush.Enabled {
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled")
@@ -1941,6 +1950,10 @@ func (config *Config) generateISupport() (err error) {
isupport.Add("draft/ACCOUNTREQUIRED", "")
}
if config.AuthToken.Enabled {
isupport.Add(caps.AuthToken005, "")
}
for key, value := range config.Server.AdditionalISupport {
if !isupport.Contains(key) {
isupport.Add(key, value)
+296 -13
View File
@@ -547,12 +547,41 @@ func dispatchAwayNotify(client *Client, awayMessage string) {
// BATCH {+,-}reference-tag type [params...]
func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
tag := msg.Params[0]
if len(tag) != 0 {
switch tag[0] {
case '+':
// can't open a new C2S batch with one already open, even of a different type
if rb.session.multilineBatch.label == "" && rb.session.tokenValidateBatch == nil {
if len(msg.Params) >= 2 {
switch msg.Params[1] {
case caps.MultilineBatchType:
return batchHandlerMultiline(server, client, msg, rb)
case caps.AuthTokenBatchType:
return batchHandlerTokenStart(server, client, msg, rb)
}
}
}
case '-':
if rb.session.multilineBatch.label != "" {
return batchHandlerMultiline(server, client, msg, rb)
} else if rb.session.tokenValidateBatch != nil {
return batchHandlerTokenEnd(server, client, msg, rb)
}
}
}
failBatch(server, rb)
// reset any local state
rb.session.EndMultilineBatch("")
rb.session.tokenValidateBatch = nil
return false
}
func batchHandlerMultiline(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
tag := msg.Params[0]
fail := false
sendErrors := rb.session.batch.command != "NOTICE"
if len(tag) == 0 {
fail = true
} else if tag[0] == '+' {
sendErrors := rb.session.multilineBatch.command != "NOTICE"
if tag[0] == '+' {
if len(msg.Params) < 3 || msg.Params[1] != caps.MultilineBatchType {
fail = true
} else {
@@ -589,6 +618,49 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
return false
}
func batchHandlerTokenStart(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
if rb.session.tokenValidateBatch == nil {
if !tokenValidateCheckPermissions(server, server.Config(), client, rb) {
return false
}
if len(msg.Params) < 3 {
failBatch(server, rb)
return false
}
rb.session.tokenValidateBatch = &TokenValidateBatch{
label: msg.Params[0][1:],
responseLabel: rb.Label,
service: msg.Params[2],
}
rb.Label = "" // suppress ACK for initial BATCH line
} else {
rb.session.tokenValidateBatch = nil
failBatch(server, rb)
}
return false
}
func batchHandlerTokenEnd(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
tokenValidateBatch := rb.session.tokenValidateBatch
rb.session.tokenValidateBatch = nil
if tokenValidateBatch == nil {
return failBatch(server, rb)
}
if tokenValidateBatch.label != msg.Params[0][1:] {
return failBatch(server, rb)
}
rb.Label = tokenValidateBatch.responseLabel
performTokenValidate(server, server.Config(), client, tokenValidateBatch.service, tokenValidateBatch.buf.String(), rb)
return false
}
func failBatch(server *Server, rb *ResponseBuffer) bool {
rb.Add(nil, server.name, "FAIL", "BATCH", "INVALID_PARAMS", "Corrupt BATCH")
return false
}
// CAP <subcmd> [<caps>]
func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
details := client.Details()
@@ -1190,7 +1262,7 @@ func extjwtHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return false
}
tokenString, err := sConfig.Sign(claims)
tokenString, err := sConfig.SignEXTJWT(claims)
if err == nil {
maxTokenLength := maxLastArgLength
@@ -2261,32 +2333,32 @@ func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, ba
}
}()
if batchTag != rb.session.batch.label {
if batchTag != rb.session.multilineBatch.label {
failParams = []string{"MULTILINE_INVALID", client.t("Incorrect batch tag sent")}
return
} else if len(msg.Params) < 2 {
failParams = []string{"MULTILINE_INVALID", client.t("Invalid multiline batch")}
return
}
rb.session.batch.command = msg.Command
rb.session.multilineBatch.command = msg.Command
isConcat, _ := msg.GetTag(caps.MultilineConcatTag)
if isConcat && len(msg.Params[1]) == 0 {
failParams = []string{"MULTILINE_INVALID", client.t("Cannot send a blank line with the multiline concat tag")}
return
}
if !isConcat && len(rb.session.batch.message.Split) != 0 {
rb.session.batch.lenBytes++ // bill for the newline
if !isConcat && len(rb.session.multilineBatch.message.Split) != 0 {
rb.session.multilineBatch.lenBytes++ // bill for the newline
}
rb.session.batch.message.Append(msg.Params[1], isConcat)
rb.session.batch.lenBytes += len(msg.Params[1])
rb.session.multilineBatch.message.Append(msg.Params[1], isConcat)
rb.session.multilineBatch.lenBytes += len(msg.Params[1])
config := server.Config()
if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes {
if config.Limits.Multiline.MaxBytes < rb.session.multilineBatch.lenBytes {
failParams = []string{
"MULTILINE_MAX_BYTES",
strconv.Itoa(config.Limits.Multiline.MaxBytes),
fmt.Sprintf(client.t("Multiline batch byte limit %d exceeded"), config.Limits.Multiline.MaxBytes),
}
} else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() {
} else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.multilineBatch.message.LenLines() {
failParams = []string{
"MULTILINE_MAX_LINES",
strconv.Itoa(config.Limits.Multiline.MaxLines),
@@ -3730,6 +3802,217 @@ func timeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
return false
}
// TOKEN
func tokenHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
config := server.Config()
if !config.AuthToken.Enabled {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("TOKEN is disabled"))
return false
}
switch strings.ToUpper(msg.Params[0]) {
case "SERVICELIST":
if !client.registered {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("You must complete connection registration to list services"))
return false
}
tokenServicelistHandler(server, config, client, rb)
case "GENERATE":
if !client.registered {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("You must complete connection registration to issue a token"))
return false
}
tokenGenerateHandler(server, config, client, msg, rb)
case "VALIDATE":
tokenValidateHandler(server, config, client, msg, rb)
default:
rb.Add(nil, server.name, "FAIL", "TOKEN", "UNKNOWN_COMMAND", utils.SafeErrorParam(msg.Params[0]), client.t("Unknown subcommand"))
}
return false
}
func tokenServicelistHandler(server *Server, config *Config, client *Client, rb *ResponseBuffer) {
if len(config.AuthToken.Services) == 0 {
rb.Add(nil, server.name, "NOTE", "TOKEN", "NO_SERVICES", client.t("No services are defined for this network"))
return
}
batchID := rb.StartNestedBatch(nil, caps.AuthTokenBatchType, "*")
defer rb.EndNestedBatch(batchID)
for srv, conf := range config.AuthToken.Services {
rb.Add(nil, server.name, "TOKEN", "SERVICE", srv, conf.URL, conf.Description)
}
}
func tokenGenerateHandler(server *Server, config *Config, client *Client, msg ircmsg.Message, rb *ResponseBuffer) {
if !rb.session.capabilities.Has(caps.Batch) {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NEED_CAPABILITY", "batch", client.t("TOKEN GENERATE requires the batch capability"))
return
}
if len(msg.Params) < 2 {
rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_PARAMS", "GENERATE", client.t("Service is a required argument to TOKEN GENERATE"))
return
}
service := strings.ToUpper(msg.Params[1])
var scope string
if len(msg.Params) > 2 {
scope = msg.Params[2]
}
details := client.Details()
if details.account == "" {
rb.Add(nil, server.name, "FAIL", "TOKEN", "ACCOUNT_REQUIRED", client.t("You must be logged into an account to issue a token"))
return
}
if details.nick != details.accountName {
// [evil laugh]
rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", client.t("You must use your account name as your nickname to issue a token"))
return
}
claims := jwt.AuthToken{
ServerName: server.name,
Service: service,
Scope: scope,
AccountName: details.accountName,
}
if channel := server.channels.Get(scope); channel != nil {
if m := channel.HighestUserMode(client); m != 0 {
claims.ChannelMode = string(m)
}
}
token, err := config.AuthToken.Issue(claims)
if err != nil {
switch err {
case jwt.ErrNoService:
rb.Add(nil, server.name, "FAIL", "TOKEN", "UNKNOWN_SERVICE", utils.SafeErrorParam(service), client.t("Unknown service"))
default:
// unexpected
server.logger.Error("internal", "failed to issue AUTHTOKEN", err.Error())
rb.Add(nil, server.name, "FAIL", "TOKEN", "INTERNAL_ERROR", client.t("An error occurred"))
}
return
}
const tokenChunkLength = 400
// always send a batch; if we don't we have to try and fit service name
// and the entire token on the same line
batchID := rb.StartNestedBatch(nil, caps.AuthTokenBatchType, service)
defer rb.EndNestedBatch(batchID)
for i := 0; i < len(token); i += tokenChunkLength {
end := min(len(token), i+tokenChunkLength)
chunk := token[i:end]
rb.Add(nil, "", "TOKEN", "GENERATE", "*", chunk)
}
}
// tokenValidateCheckPermissions is the check to allow a client to validate,
// or start validating, an authtoken. we may eventually add an optional
// PASS requirement or similar.
func tokenValidateCheckPermissions(server *Server, config *Config, client *Client, rb *ResponseBuffer) bool {
if !config.AuthToken.Enabled {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("TOKEN is disabled"))
return false
}
if !config.AuthToken.AllowIP(client.IP()) {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("Your IP address is not allowed to validate auth tokens"))
return false
}
return true
}
func tokenValidateHandler(server *Server, config *Config, client *Client, msg ircmsg.Message, rb *ResponseBuffer) {
if !rb.session.capabilities.Has(caps.Batch) {
rb.Add(nil, server.name, "FAIL", "TOKEN", "NEED_CAPABILITY", "batch", client.t("TOKEN VALIDATE requires the batch capability"))
return
}
// batch case, one parameter per TOKEN VALIDATE line (the token chunk)
if present, batchLabel := msg.GetTag("batch"); present {
if len(msg.Params) < 2 {
rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_PARAMS", "VALIDATE", client.t("Insufficient parameters"))
return
}
tokenChunk := msg.Params[1]
if rb.session.tokenValidateBatch != nil && rb.session.tokenValidateBatch.label == batchLabel {
newLen := rb.session.tokenValidateBatch.buf.Len() + len(tokenChunk)
if newLen <= jwt.MaxAuthTokenLength {
// success, absorb into batch and wait for batch end
rb.session.tokenValidateBatch.buf.WriteString(tokenChunk)
} else {
rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_TOKEN", client.t("Token exceeds maximum allowable length"))
rb.session.tokenValidateBatch = nil
}
}
return
}
// single command case, 3 parameters per TOKEN VALIDATE line (service, URL, token chunk)
if !tokenValidateCheckPermissions(server, config, client, rb) {
return
}
if len(msg.Params) < 3 {
rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_PARAMS", "VALIDATE", client.t("Insufficient parameters"))
return
}
performTokenValidate(server, server.Config(), client, msg.Params[1], msg.Params[2], rb)
}
func performTokenValidate(server *Server, config *Config, client *Client, service, token string, rb *ResponseBuffer) {
service = strings.ToUpper(service)
claims, err := config.AuthToken.Verify(service, token)
if err != nil {
rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_TOKEN", client.t("Invalid token"))
return
}
subject := server.clients.Get(claims.AccountName)
if subject == nil || subject.AccountName() != claims.AccountName {
// the original issuing client is offline, or "nick equals account" is disabled
// in one of several possible ways (force-nick-equals-account is off, or even
// strict nickname reservation is off), in which case we are going to refuse
// to validate any claims
rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_TOKEN", client.t("Could not verify user presence"))
return
}
batchID := rb.StartNestedBatch(nil, caps.AuthTokenBatchType, service)
defer rb.EndNestedBatch(batchID)
rb.Add(nil, server.name, "TOKEN", "CLAIM", "name", claims.AccountName)
rb.Add(nil, server.name, "TOKEN", "CLAIM", "account", claims.AccountName)
if claims.Scope != "" {
rb.Add(nil, server.name, "TOKEN", "CLAIM", "scope", claims.Scope)
}
var memberOf, operatorOf utils.TokenLineBuilder
memberOf.Initialize(300, " ")
operatorOf.Initialize(300, " ")
for _, channel := range subject.Channels() {
chname := channel.Name()
memberOf.Add(chname)
if channel.ClientIsAtLeast(subject, modes.ChannelOperator) {
operatorOf.Add(chname)
}
}
playMultilineClaim := func(claim string, lines []string) {
for i, line := range lines {
if i != 0 {
// "The server produces a leading space in the second line of
// the member_of claim because the client must concatenate the lines
// together with no separators."
line = " " + line
}
rb.Add(nil, server.name, "TOKEN", "CLAIM", claim, line)
}
}
playMultilineClaim("member_of", memberOf.Lines())
playMultilineClaim("operator_of", operatorOf.Lines())
return
}
// TOPIC <channel> [<topic>]
func topicHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
channel := server.channels.Get(msg.Params[0])
+6
View File
@@ -532,6 +532,12 @@ Reloads the config file and updates TLS certificates on listeners`,
text: `TIME [server]
Shows the time of the current, or the given, server.`,
},
"token": {
text: `TOKEN <subcommand> [args]
TOKEN issues and validates tokens for use by external services.
It is not intended for direct use by end users.`,
},
"topic": {
text: `TOPIC <channel> [topic]
+184
View File
@@ -0,0 +1,184 @@
package jwt
import (
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/ergochat/ergo/irc/utils"
jwt "github.com/golang-jwt/jwt/v5"
)
const (
MaxAuthTokenLength = 2048 // TODO check this
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrNoService = errors.New("invalid authtoken service")
parser = jwt.NewParser(jwt.WithExpirationRequired())
)
type AuthTokensConfig struct {
Enabled bool
VerificationIPWhitelist []string `yaml:"verification-ip-whitelist"`
verificationIPWhitelist []net.IPNet
Services map[string]JwtServiceConfig
}
// AuthToken is the internal representation of an auth token's data,
// implemented as a stateless signed JWT.
type AuthToken struct {
ServerName string
Service string
URL string
AccountName string
Scope string
ChannelMode string
}
func (t *AuthTokensConfig) Postprocess() (err error) {
if !t.Enabled {
t.Services = nil // simplify diffing later
return nil
}
t.verificationIPWhitelist, err = utils.ParseNetList(t.VerificationIPWhitelist)
if err != nil {
return err
}
services := make(map[string]JwtServiceConfig, len(t.Services))
for srv, conf := range t.Services {
if err := conf.Postprocess(); err != nil {
return fmt.Errorf("TOKEN service %s is misconfigured: %w", srv, err)
}
if !conf.Enabled() {
return fmt.Errorf("TOKEN service %s lacks necessary configuration", srv)
}
if conf.URL == "" {
return fmt.Errorf("TOKEN service %s lacks a URL", srv)
}
services[strings.ToUpper(srv)] = conf
}
t.Services = services
return nil
}
func (oldConf *AuthTokensConfig) GetDifference(newConf AuthTokensConfig) (result [][]string) {
for srv := range oldConf.Services {
if _, ok := newConf.Services[srv]; !ok {
result = append(result, []string{"DEL", srv})
}
}
for srv, conf := range newConf.Services {
if oldConf, ok := oldConf.Services[srv]; !ok || conf.URL != oldConf.URL {
result = append(result, []string{"NEW", srv, conf.URL})
}
}
return
}
func (t *AuthTokensConfig) getService(service string) (result JwtServiceConfig, err error) {
if !t.Enabled {
err = ErrNoService
return
}
result, ok := t.Services[service]
if !ok || !result.Enabled() {
err = ErrNoService
return
}
return result, nil
}
func (t *AuthTokensConfig) AllowIP(ip net.IP) bool {
return utils.IPInNets(ip, t.verificationIPWhitelist)
}
func (t *AuthTokensConfig) Issue(token AuthToken) (result string, err error) {
service := strings.ToUpper(token.Service)
conf, err := t.getService(service)
if err != nil {
return
}
claims := make(jwt.MapClaims)
// standard claims:
claims["iss"] = token.ServerName
claims["exp"] = time.Now().Unix() + int64(conf.Expiration/time.Second)
claims["aud"] = conf.URL
// ergo-specific claims
claims["srv"] = service
claims["acc"] = token.AccountName
if token.Scope != "" {
claims["scope"] = token.Scope
}
if token.ChannelMode != "" {
claims["chmode"] = token.ChannelMode
}
// TODO include operclass if available?
j := jwt.NewWithClaims(conf.signingMethod, jwt.MapClaims(claims))
return j.SignedString(conf.signingKey)
}
func (t *AuthTokensConfig) Verify(service, token string) (result AuthToken, err error) {
service = strings.ToUpper(service)
conf, err := t.getService(service)
if err != nil {
return
}
// since we looked up the service, we now know the correct signing key
tok, err := parser.Parse(token, conf.verifyKeyFunc)
if err != nil {
return
}
// validate the exact signing method just in case (although it should be impossible
// to, e.g. validate a HS256 token with a *rsa.PrivateKey signing key)
if tok.Method != conf.signingMethod {
err = ErrInvalidToken
return
}
mc := tok.Claims.(jwt.MapClaims)
srvClaim := extractStringClaim(mc, "srv")
if service != srvClaim {
err = ErrInvalidToken
return
}
audClaim := extractStringClaim(mc, "aud")
if conf.URL != audClaim {
err = ErrInvalidToken
return
}
return AuthToken{
// don't care about iss / ServerName
Service: srvClaim,
URL: audClaim,
AccountName: extractStringClaim(mc, "acc"),
Scope: extractStringClaim(mc, "scope"),
// don't return channel mode, revalidate it from runtime data
}, nil
}
func extractStringClaim(claims jwt.MapClaims, key string) string {
if result, ok := claims[key]; ok {
if strResult, ok := result.(string); ok {
return strResult
}
}
return ""
}
+119
View File
@@ -0,0 +1,119 @@
package jwt
import (
"reflect"
"testing"
"time"
)
func TestAuthTokenRoundTrip(t *testing.T) {
conf := AuthTokensConfig{
Enabled: true,
Services: map[string]JwtServiceConfig{
"FILEHOST": {
Expiration: 10 * time.Minute,
URL: "https://example.com",
Algorithm: "rsa",
KeyString: rsaTestPrivKey,
},
},
}
err := conf.Postprocess()
if err != nil {
t.Fatalf("couldn't parse config: %v", err)
}
tok := AuthToken{
ServerName: "irc.ergo.chat",
Service: "FILEHOST",
AccountName: "slingamn",
Scope: "#ergo",
ChannelMode: "o",
}
jtok, err := conf.Issue(tok)
if err != nil {
t.Fatalf("couldn't issue token: %v", err)
}
result, err := conf.Verify("FILEHOST", jtok)
if err != nil {
t.Errorf("couldn't validate token: %v", err)
}
if result.AccountName != "slingamn" || result.Scope != "#ergo" {
t.Errorf("didn't recover required fields from token: %#v", result)
}
_, err = conf.Verify("FILEHOST", jtok[:len(jtok)-1])
if err == nil {
t.Errorf("validated token with bad signature")
}
}
func TestAuthTokenDiff(t *testing.T) {
oldConf := AuthTokensConfig{
Enabled: true,
Services: map[string]JwtServiceConfig{
"FILEHOST": {
Expiration: 10 * time.Minute,
URL: "https://example.com/filehost",
Algorithm: "rsa",
KeyString: rsaTestPrivKey,
},
"QDB": {
Expiration: 10 * time.Minute,
URL: "https://example.com/qdb",
Algorithm: "hmac",
KeyString: "MbKjh6CTqLMPZV9XLYmACw",
},
"JITSI": {
Expiration: 10 * time.Minute,
URL: "https://example.com/jitsi",
Algorithm: "hmac",
KeyString: "uaKzJTbuqjHlbGrvwku2kw",
},
},
}
err := oldConf.Postprocess()
if err != nil {
t.Fatalf("couldn't parse config: %v", err)
}
newConf := AuthTokensConfig{
Enabled: true,
Services: map[string]JwtServiceConfig{
// change the filehost URL
"FILEHOST": {
Expiration: 10 * time.Minute,
URL: "https://filehost.com/filehost",
Algorithm: "rsa",
KeyString: rsaTestPrivKey,
},
// QDB is deleted
// jitsi is at the same URL with a different key
"JITSI": {
Expiration: 10 * time.Minute,
URL: "https://example.com/jitsi",
Algorithm: "rsa",
KeyString: rsaTestPrivKey,
},
},
}
err = newConf.Postprocess()
if err != nil {
t.Fatalf("couldn't parse config: %v", err)
}
expectedDiff := [][]string{
{"DEL", "QDB"},
{"NEW", "FILEHOST", "https://filehost.com/filehost"},
}
diff := oldConf.GetDifference(newConf)
if !reflect.DeepEqual(diff, expectedDiff) {
t.Fatalf("incorrect diff: %#v", diff)
}
}
+57 -16
View File
@@ -5,26 +5,27 @@ package jwt
import (
"fmt"
"io"
"os"
"strings"
"github.com/ergochat/ergo/irc/utils"
jwt "github.com/golang-jwt/jwt/v5"
)
var (
ErrAuthDisabled = fmt.Errorf("JWT authentication is disabled")
ErrNoValidAccountClaim = fmt.Errorf("JWT token did not contain an acceptable account name claim")
ErrNoValidAudClaim = fmt.Errorf("JWT token did not contain an acceptable aud claim")
)
// JWTAuthConfig is the config for Ergo to accept JWTs via draft/bearer
type JWTAuthConfig struct {
Enabled bool `yaml:"enabled"`
Autocreate bool `yaml:"autocreate"`
Tokens []JWTAuthTokenConfig `yaml:"tokens"`
Enabled bool `yaml:"enabled"`
Autocreate bool `yaml:"autocreate"`
Tokens []JWTBearerTokenConfig `yaml:"tokens"`
}
type JWTAuthTokenConfig struct {
type JWTBearerTokenConfig struct {
Algorithm string `yaml:"algorithm"`
KeyString string `yaml:"key"`
KeyFile string `yaml:"key-file"`
@@ -32,6 +33,8 @@ type JWTAuthTokenConfig struct {
parser *jwt.Parser
AccountClaims []string `yaml:"account-claims"`
StripDomain string `yaml:"strip-domain"`
ValidateAud []string `yaml:"validate-aud"`
allowedAuds utils.HashSet[string]
}
func (j *JWTAuthConfig) Postprocess() error {
@@ -52,7 +55,7 @@ func (j *JWTAuthConfig) Postprocess() error {
return nil
}
func (j *JWTAuthTokenConfig) Postprocess() error {
func (j *JWTBearerTokenConfig) Postprocess() error {
keyBytes, err := j.keyBytes()
if err != nil {
return err
@@ -82,13 +85,18 @@ func (j *JWTAuthTokenConfig) Postprocess() error {
default:
return fmt.Errorf("invalid jwt algorithm: %s", j.Algorithm)
}
j.parser = jwt.NewParser(jwt.WithValidMethods(methods))
j.parser = jwt.NewParser(jwt.WithValidMethods(methods), jwt.WithExpirationRequired())
if len(j.AccountClaims) == 0 {
return fmt.Errorf("JWT auth enabled, but no account-claims specified")
}
j.StripDomain = strings.ToLower(j.StripDomain)
if len(j.ValidateAud) != 0 {
j.allowedAuds = utils.SetLiteral(j.ValidateAud...)
}
return nil
}
@@ -106,14 +114,9 @@ func (j *JWTAuthConfig) Validate(t string) (accountName string, err error) {
return
}
func (j *JWTAuthTokenConfig) keyBytes() (result []byte, err error) {
func (j *JWTBearerTokenConfig) keyBytes() (result []byte, err error) {
if j.KeyFile != "" {
o, err := os.Open(j.KeyFile)
if err != nil {
return nil, err
}
defer o.Close()
return io.ReadAll(o)
return os.ReadFile(j.KeyFile)
}
if j.KeyString != "" {
return []byte(j.KeyString), nil
@@ -122,11 +125,11 @@ func (j *JWTAuthTokenConfig) keyBytes() (result []byte, err error) {
}
// implements jwt.Keyfunc
func (j *JWTAuthTokenConfig) keyFunc(_ *jwt.Token) (interface{}, error) {
func (j *JWTBearerTokenConfig) keyFunc(_ *jwt.Token) (interface{}, error) {
return j.key, nil
}
func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error) {
func (j *JWTBearerTokenConfig) Validate(t string) (accountName string, err error) {
token, err := j.parser.Parse(t, j.keyFunc)
if err != nil {
return "", err
@@ -138,6 +141,10 @@ func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error)
return "", fmt.Errorf("unexpected type from parsed token claims: %T", claims)
}
if !j.validateAudClaim(claims) {
return "", ErrNoValidAudClaim
}
for _, c := range j.AccountClaims {
if v, ok := claims[c]; ok {
if vstr, ok := v.(string); ok {
@@ -156,3 +163,37 @@ func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error)
return "", ErrNoValidAccountClaim
}
func (j *JWTBearerTokenConfig) validateAudClaim(claims jwt.MapClaims) bool {
if j.allowedAuds == nil {
return true // no validate-aud means any aud is allowed
}
audClaim, ok := claims["aud"]
if !ok {
return false
}
switch aud := audClaim.(type) {
case string:
return j.allowedAuds.Has(aud)
case []any:
for _, a := range aud {
if aStr, ok := a.(string); ok {
if j.allowedAuds.Has(aStr) {
return true
}
}
}
return false
case []string:
for _, a := range aud {
if j.allowedAuds.Has(a) {
return true
}
}
return false
default:
return false
}
}
+91 -30
View File
@@ -2,6 +2,7 @@ package jwt
import (
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
)
@@ -50,11 +51,11 @@ s/uzBKNwWf9UPTeIt+4JScg=
func TestJWTBearerAuth(t *testing.T) {
j := JWTAuthConfig{
Enabled: true,
Tokens: []JWTAuthTokenConfig{
Tokens: []JWTBearerTokenConfig{
{
Algorithm: "rsa",
KeyString: rsaTestPubKey,
AccountClaims: []string{"preferred_username", "email"},
AccountClaims: []string{"preferred_username", "email", "account"},
StripDomain: "example.com",
},
},
@@ -65,7 +66,7 @@ func TestJWTBearerAuth(t *testing.T) {
}
// fixed test vector signed with the RSA privkey:
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzbGluZ2FtbiJ9.caPZw2Dl4KZN-SErD5-WZB_lPPveHXaMCoUHxNebb94G9w3VaWDIRdngVU99JKx5nE_yRtpewkHHvXsQnNA_M63GBXGK7afXB8e-kV33QF3v9pXALMP5SzRwMgokyxas0RgHu4e4L0d7dn9o_nkdXp34GX3Pn1MVkUGBH6GdlbOdDHrs04pPQ0Qj-O2U0AIpnZq-X_GQs9ECJo4TlPKWR7Jlq5l9bS0dBnohea4FuqJr232je-dlRVkbCa7nrnFmsIsezsgA3Jb_j9Zu_iv460t_d2eaytbVp9P-DOVfzUfkBsKs-81URQEnTjW6ut445AJz2pxjX92X0GdmORpAkQ"
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50Ijoic2xpbmdhbW4iLCJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tL2ZpbGVob3N0IiwiZXhwIjo4MDgzODY1NDkyLCJpc3MiOiJlcmdvLnRlc3QiLCJzcnYiOiJGSUxFSE9TVCJ9.d_tMt4UWuuq3KDgKF4wCyL0tKaeKTCqrKgFZdogOetqmp9qVxi05sMlXawmheWAf3cjQG1ZxCvoc0TovI8H5d5DsVW5txNAXEhYlFKp8Vbd86J04VH2fn32brv5BH9oMPu60bnaEyv_vkKuFMANJzNgQOlMbNTo1IBKYmppi0dVbaBPtylMfL2jTQBwNj6m2_Bv_7N3tf9IgTIRX-Z2VbniHjTB9sEZaFgk6mxj-kwjxqu-lTAxmsPy4H5CBQb-Ea47LBFPmoLt6caxA4VCZyDq1chxcU5DLtv8ec9Sk1XvrGlyWtZ6pD9rT93jpSN6e5r5ceirkvgh20sUIWOOsHg"
accountName, err := j.Validate(token)
if err != nil {
t.Errorf("could not validate valid token: %v", err)
@@ -79,11 +80,8 @@ func TestJWTBearerAuth(t *testing.T) {
if err != nil {
t.Fatal(err)
}
jTok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
exp := time.Now().Add(time.Hour).Unix()
token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": exp}))
accountName, err = j.Validate(token)
if err != nil {
t.Errorf("could not validate valid token: %v", err)
@@ -93,46 +91,28 @@ func TestJWTBearerAuth(t *testing.T) {
}
// test expiration
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": 1675740865}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": 1675740865}))
accountName, err = j.Validate(token)
if err == nil {
t.Errorf("validated expired token")
}
// test for the infamous algorithm confusion bug
jTok = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
token, err = jTok.SignedString([]byte(rsaTestPubKey))
if err != nil {
t.Fatal(err)
}
token = signTokenForTesting(jwt.SigningMethodHS256, []byte(rsaTestPubKey), jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
accountName, err = j.Validate(token)
if err == nil {
t.Errorf("validated HS256 token despite RSA being required")
}
// test no valid claims
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"sub": "slingamn"}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"sub": "slingamn", "exp": exp}))
accountName, err = j.Validate(token)
if err != ErrNoValidAccountClaim {
t.Errorf("expected ErrNoValidAccountClaim, got: %v", err)
}
// test email addresses
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"email": "Slingamn@example.com"}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"email": "Slingamn@example.com", "exp": exp}))
accountName, err = j.Validate(token)
if err != nil {
t.Errorf("could not validate valid token: %v", err)
@@ -141,3 +121,84 @@ func TestJWTBearerAuth(t *testing.T) {
t.Errorf("incorrect account name for token: `%s`", accountName)
}
}
func signTokenForTesting(method jwt.SigningMethod, key any, claims jwt.MapClaims) (token string) {
jTok := jwt.NewWithClaims(method, claims)
token, err := jTok.SignedString(key)
if err != nil {
panic(err)
}
return token
}
func TestJWTBearerAudValidation(t *testing.T) {
key := []byte("MowTTyXKkN58DG2uNMsoCgAa6CM6ElFlcq_7Ocl6wsU")
j := JWTAuthConfig{
Enabled: true,
Tokens: []JWTBearerTokenConfig{
{
Algorithm: "hmac",
KeyString: string(key),
AccountClaims: []string{"account"},
ValidateAud: []string{"irc.ergo.chat", "https://irc.ergo.chat"},
},
},
}
if err := j.Postprocess(); err != nil {
t.Fatal(err)
}
exp := time.Now().Add(time.Hour).Unix()
token := signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{"account": "slingamn", "exp": exp}))
if _, err := j.Validate(token); err == nil {
t.Errorf("validated token with missing aud")
}
token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{"account": "slingamn", "exp": exp, "aud": "irc.ergo.chat"}))
if _, err := j.Validate(token); err != nil {
t.Errorf("failed to validate token with string aud: %v", err)
}
token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{"account": "slingamn", "exp": exp, "aud": "ergo.chat"}))
if _, err := j.Validate(token); err == nil {
t.Errorf("validated token with invalid string aud")
}
token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{
"account": "slingamn",
"exp": exp,
"aud": []string{"https://example.com", "irc.ergo.chat"},
}))
if _, err := j.Validate(token); err != nil {
t.Errorf("failed to validate token with list aud: %v", err)
}
token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{
"account": "slingamn",
"exp": exp,
"aud": []string{"https://example.com", "ergo.chat"},
}))
if _, err := j.Validate(token); err == nil {
t.Errorf("validated token with invalid list aud")
}
token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{
"account": "slingamn",
"exp": exp,
"aud": make([]string, 0),
}))
if _, err := j.Validate(token); err == nil {
t.Errorf("validated token with invalid list aud")
}
token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{
"account": "slingamn",
"exp": exp,
"aud": []int{1, 2},
}))
if _, err := j.Validate(token); err == nil {
t.Errorf("validated token with invalid list aud")
}
}
+71 -28
View File
@@ -5,9 +5,11 @@
package jwt
import (
"crypto/rsa"
"crypto/ed25519"
"errors"
"fmt"
"os"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
@@ -20,43 +22,84 @@ var (
type MapClaims jwt.MapClaims
type JwtServiceConfig struct {
Expiration time.Duration
Secret string
secretBytes []byte
RSAPrivateKeyFile string `yaml:"rsa-private-key-file"`
rsaPrivateKey *rsa.PrivateKey
Expiration time.Duration
Description string
URL string `yaml:"url"`
Algorithm string `yaml:"algorithm"`
KeyString string `yaml:"key"`
KeyFile string `yaml:"key-file"`
signingMethod jwt.SigningMethod
signingKey any
verifyKey any
}
func (t *JwtServiceConfig) Postprocess() (err error) {
t.secretBytes = []byte(t.Secret)
t.Secret = ""
if t.RSAPrivateKeyFile != "" {
keyBytes, err := os.ReadFile(t.RSAPrivateKeyFile)
if err != nil {
return err
}
t.rsaPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
if err != nil {
return err
}
if t.Algorithm == "" {
// disabled
return
}
var keyBytes []byte
if t.KeyFile != "" {
keyBytes, err = os.ReadFile(t.KeyFile)
if err != nil {
return
}
} else if t.KeyString != "" {
keyBytes = []byte(t.KeyString)
} else {
return ErrNoKeys
}
switch strings.ToLower(t.Algorithm) {
case "hmac":
t.signingKey = keyBytes
t.verifyKey = keyBytes
t.signingMethod = jwt.SigningMethodHS256
case "rsa":
rsaPrivkey, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
if err != nil {
return err
}
t.signingKey = rsaPrivkey
t.verifyKey = rsaPrivkey.Public()
t.signingMethod = jwt.SigningMethodRS256
case "eddsa":
ecPrivkey, err := jwt.ParseEdPrivateKeyFromPEM(keyBytes)
if err != nil {
return err
}
t.signingKey = ecPrivkey
ed25519PrivKey, ok := ecPrivkey.(ed25519.PrivateKey)
if !ok {
// impossible due to golang-jwt enforcement:
return errors.New("unexpected non-ed25519 private key found")
}
t.verifyKey = ed25519PrivKey.Public()
t.signingMethod = jwt.SigningMethodEdDSA
default:
return fmt.Errorf("invalid JWT algorithm: %s", t.Algorithm)
}
return nil
}
func (t *JwtServiceConfig) Enabled() bool {
return t.Expiration != 0 && (len(t.secretBytes) != 0 || t.rsaPrivateKey != nil)
return t.Expiration != 0 && t.signingMethod != nil
}
func (t *JwtServiceConfig) Sign(claims MapClaims) (result string, err error) {
func (t *JwtServiceConfig) verifyKeyFunc(_ *jwt.Token) (key any, err error) {
return t.verifyKey, nil
}
func (t *JwtServiceConfig) SignEXTJWT(claims MapClaims) (result string, err error) {
if !t.Enabled() {
err = ErrNoKeys
return
}
claims["exp"] = time.Now().Unix() + int64(t.Expiration/time.Second)
if t.rsaPrivateKey != nil {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims))
return token.SignedString(t.rsaPrivateKey)
} else if len(t.secretBytes) != 0 {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims))
return token.SignedString(t.secretBytes)
} else {
return "", ErrNoKeys
}
token := jwt.NewWithClaims(t.signingMethod, jwt.MapClaims(claims))
return token.SignedString(t.signingKey)
}
+20 -9
View File
@@ -53,6 +53,8 @@ const (
chanTypes = "#"
throttleMessage = "You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect."
rawIOWarningMessage = "This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."
)
var (
@@ -522,6 +524,9 @@ func (server *Server) playRegistrationBurst(session *Session) {
if d.account != "" && session.capabilities.Has(caps.Persistence) {
reportPersistenceStatus(c, rb, false)
}
if session.capabilities.Has(caps.AuthToken) {
tokenServicelistHandler(server, config, c, rb)
}
server.Lusers(c, rb)
server.MOTD(c, rb)
rb.Send(true)
@@ -534,7 +539,7 @@ func (server *Server) playRegistrationBurst(session *Session) {
c.attemptAutoOper(session)
if server.logger.IsLoggingRawIO() {
session.Send(nil, c.server.name, "NOTICE", d.nick, c.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
session.Send(nil, c.server.name, "NOTICE", d.nick, c.t(rawIOWarningMessage))
}
}
@@ -901,8 +906,10 @@ func (server *Server) applyConfig(config *Config) (err error) {
// set RPL_ISUPPORT
var newISupportReplies [][]string
var authTokenDiff [][]string
if oldConfig != nil {
newISupportReplies = oldConfig.Server.isupport.GetDifference(&config.Server.isupport)
authTokenDiff = oldConfig.AuthToken.GetDifference(config.AuthToken)
}
if len(config.Server.ProxyAllowedFrom) != 0 {
@@ -917,21 +924,25 @@ func (server *Server) applyConfig(config *Config) (err error) {
sdnotify.Ready()
}
if !initial {
// send 005 updates (somewhat rare)
if len(newISupportReplies) != 0 {
for _, sClient := range server.clients.AllClients() {
for _, session := range sClient.Sessions() {
rb := NewResponseBuffer(session)
if !initial && (len(newISupportReplies) > 0 || len(authTokenDiff) > 0) {
for _, sClient := range server.clients.AllClients() {
for _, session := range sClient.Sessions() {
rb := NewResponseBuffer(session)
if len(newISupportReplies) > 0 {
server.sendRplISupportLines(sClient, rb, newISupportReplies)
rb.Send(false)
}
if len(authTokenDiff) > 0 && session.capabilities.Has(caps.AuthToken) {
for _, line := range authTokenDiff {
rb.Add(nil, server.name, "TOKEN", line...)
}
}
rb.Send(false)
}
}
if sendRawOutputNotice {
for _, sClient := range server.clients.AllClients() {
sClient.Notice(sClient.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
sClient.Notice(sClient.t(rawIOWarningMessage))
}
}
}
+30 -5
View File
@@ -291,6 +291,8 @@ server:
# constant list of args to pass to the command; the actual query
# and result are transmitted over stdin/stdout:
args: []
# alternatively, pass the input to a persistent process over unix domain socket:
#socket: "/tmp/ergo_ip_check_sidecar"
# timeout for process execution, after which we send a SIGTERM:
timeout: 9s
# how long after the SIGTERM before we follow up with a SIGKILL:
@@ -589,6 +591,8 @@ accounts:
# constant list of args to pass to the command; the actual authentication
# data is transmitted over stdin/stdout:
args: []
# alternatively, pass the input to a persistent process over unix domain socket:
#socket: "/tmp/ergo_auth_sidecar"
# should we automatically create users if the plugin returns success?
autocreate: true
# timeout for process execution, after which we send a SIGTERM:
@@ -631,6 +635,9 @@ accounts:
# 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"
# optional list of `aud` claims that are acceptable
# (if omitted, the aud claim is not validated):
#validate-aud: ["irc.mydomain.com"]
# channel options
channels:
@@ -994,16 +1001,34 @@ extjwt:
# # default service config (for `EXTJWT #channel`).
# # expiration time for the token:
# expiration: 45s
# # you can configure tokens to be signed either with HMAC and a symmetric secret:
# secret: "65PHvk0K1_sM-raTsCEhatVkER_QD8a0zVV8gG2EWcI"
# # or with an RSA private key:
# #rsa-private-key-file: "extjwt.pem"
# algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
# # hmac takes a symmetric key, rsa and eddsa take PEM-encoded private 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_privkey.pem"
# # named services (for `EXTJWT #channel service_name`):
# services:
# "jitsi":
# expiration: 30s
# secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg"
# algorithm: "hmac"
# key: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg"
# experimental draft/AUTHTOKEN mechanism
authtoken:
enabled: false
# only these IPs can verify tokens
verification-ip-whitelist:
- "localhost"
#services:
# "FILEHOST":
# expiration: 5m
# url: "https://example.com/filehost"
# algorithm: "eddsa"
# key: "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHfpO1x835o9NIQA1kBkN7/Myd6wqE/m/EYJUHBC18hW\n-----END PRIVATE KEY-----"
# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
# various autoreplay features, and the resume extension