mirror of
https://github.com/jeremyd/ergo.git
synced 2026-08-02 20:23:06 -07:00
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:
committed by
GitHub
parent
69d0dc7935
commit
08d8145f26
+26
-5
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user