forked from External/ergo
implement SASL OAUTHBEARER and draft/bearer (#2122)
* implement SASL OAUTHBEARER and draft/bearer * Upgrade JWT lib * Fix an edge case in SASL EXTERNAL * Accept longer SASL responses * review fix: allow multiple token definitions * enhance tests * use SASL utilities from irc-go * test expired tokens
This commit is contained in:
parent
8475b62da4
commit
ee7f818674
58 changed files with 2868 additions and 975 deletions
108
irc/oauth2/oauth2.go
Normal file
108
irc/oauth2/oauth2.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright 2022-2023 Simon Ser <contact@emersion.fr>
|
||||
// Derived from https://git.sr.ht/~emersion/soju/tree/36d6cb19a4f90d217d55afb0b15318321baaad09/item/auth/oauth2.go
|
||||
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
|
||||
// Modifications copyright 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// Released under the MIT license
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthDisabled = fmt.Errorf("OAuth 2.0 authentication is disabled")
|
||||
|
||||
// all cases where the infrastructure is working correctly, but we determined
|
||||
// that the user supplied an invalid token
|
||||
ErrInvalidToken = fmt.Errorf("OAuth 2.0 bearer token invalid")
|
||||
)
|
||||
|
||||
type OAuth2BearerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Autocreate bool `yaml:"autocreate"`
|
||||
AuthScript bool `yaml:"auth-script"`
|
||||
IntrospectionURL string `yaml:"introspection-url"`
|
||||
IntrospectionTimeout time.Duration `yaml:"introspection-timeout"`
|
||||
// omit for `none`, required for `client_secret_basic`
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
}
|
||||
|
||||
func (o *OAuth2BearerConfig) Postprocess() error {
|
||||
if !o.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if o.IntrospectionTimeout == 0 {
|
||||
return fmt.Errorf("a nonzero oauthbearer introspection timeout is required (try 10s)")
|
||||
}
|
||||
|
||||
if _, err := url.Parse(o.IntrospectionURL); err != nil {
|
||||
return fmt.Errorf("invalid introspection-url: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OAuth2BearerConfig) Introspect(ctx context.Context, token string) (username string, err error) {
|
||||
if !o.Enabled {
|
||||
return "", ErrAuthDisabled
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, o.IntrospectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
reqValues := make(url.Values)
|
||||
reqValues.Set("token", token)
|
||||
|
||||
reqBody := strings.NewReader(reqValues.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.IntrospectionURL, reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create OAuth 2.0 introspection request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if o.ClientID != "" {
|
||||
req.SetBasicAuth(url.QueryEscape(o.ClientID), url.QueryEscape(o.ClientSecret))
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send OAuth 2.0 introspection request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("OAuth 2.0 introspection error: %v", resp.Status)
|
||||
}
|
||||
|
||||
var data oauth2Introspection
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("failed to decode OAuth 2.0 introspection response: %v", err)
|
||||
}
|
||||
|
||||
if !data.Active {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
if data.Username == "" {
|
||||
// We really need the username here, otherwise an OAuth 2.0 user can
|
||||
// impersonate any other user.
|
||||
return "", fmt.Errorf("missing username in OAuth 2.0 introspection response")
|
||||
}
|
||||
|
||||
return data.Username, nil
|
||||
}
|
||||
|
||||
type oauth2Introspection struct {
|
||||
Active bool `json:"active"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
172
irc/oauth2/sasl.go
Normal file
172
irc/oauth2/sasl.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package oauth2
|
||||
|
||||
/*
|
||||
https://github.com/emersion/go-sasl/blob/e73c9f7bad438a9bf3f5b28e661b74d752ecafdd/oauthbearer.go
|
||||
|
||||
Copyright 2019-2022 Simon Ser, Frode Aannevik, Max Mazurov
|
||||
Released under the MIT license
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnexpectedClientResponse = errors.New("unexpected client response")
|
||||
)
|
||||
|
||||
// The OAUTHBEARER mechanism name.
|
||||
const OAuthBearer = "OAUTHBEARER"
|
||||
|
||||
type OAuthBearerError struct {
|
||||
Status string `json:"status"`
|
||||
Schemes string `json:"schemes"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type OAuthBearerOptions struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
func (err *OAuthBearerError) Error() string {
|
||||
return fmt.Sprintf("OAUTHBEARER authentication error (%v)", err.Status)
|
||||
}
|
||||
|
||||
type OAuthBearerAuthenticator func(opts OAuthBearerOptions) *OAuthBearerError
|
||||
|
||||
type OAuthBearerServer struct {
|
||||
done bool
|
||||
failErr error
|
||||
authenticate OAuthBearerAuthenticator
|
||||
}
|
||||
|
||||
func (a *OAuthBearerServer) fail(descr string) ([]byte, bool, error) {
|
||||
blob, err := json.Marshal(OAuthBearerError{
|
||||
Status: "invalid_request",
|
||||
Schemes: "bearer",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err) // wtf
|
||||
}
|
||||
a.failErr = errors.New(descr)
|
||||
return blob, false, nil
|
||||
}
|
||||
|
||||
func (a *OAuthBearerServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||
// Per RFC, we cannot just send an error, we need to return JSON-structured
|
||||
// value as a challenge and then after getting dummy response from the
|
||||
// client stop the exchange.
|
||||
if a.failErr != nil {
|
||||
// Server libraries (go-smtp, go-imap) will not call Next on
|
||||
// protocol-specific SASL cancel response ('*'). However, GS2 (and
|
||||
// indirectly OAUTHBEARER) defines a protocol-independent way to do so
|
||||
// using 0x01.
|
||||
if len(response) != 1 && response[0] != 0x01 {
|
||||
return nil, true, errors.New("unexpected response")
|
||||
}
|
||||
return nil, true, a.failErr
|
||||
}
|
||||
|
||||
if a.done {
|
||||
err = ErrUnexpectedClientResponse
|
||||
return
|
||||
}
|
||||
|
||||
// Generate empty challenge.
|
||||
if response == nil {
|
||||
return []byte{}, false, nil
|
||||
}
|
||||
|
||||
a.done = true
|
||||
|
||||
// Cut n,a=username,\x01host=...\x01auth=...
|
||||
// into
|
||||
// n
|
||||
// a=username
|
||||
// \x01host=...\x01auth=...\x01\x01
|
||||
parts := bytes.SplitN(response, []byte{','}, 3)
|
||||
if len(parts) != 3 {
|
||||
return a.fail("Invalid response")
|
||||
}
|
||||
flag := parts[0]
|
||||
authzid := parts[1]
|
||||
if !bytes.Equal(flag, []byte{'n'}) {
|
||||
return a.fail("Invalid response, missing 'n' in gs2-cb-flag")
|
||||
}
|
||||
opts := OAuthBearerOptions{}
|
||||
if len(authzid) > 0 {
|
||||
if !bytes.HasPrefix(authzid, []byte("a=")) {
|
||||
return a.fail("Invalid response, missing 'a=' in gs2-authzid")
|
||||
}
|
||||
opts.Username = string(bytes.TrimPrefix(authzid, []byte("a=")))
|
||||
}
|
||||
|
||||
// Cut \x01host=...\x01auth=...\x01\x01
|
||||
// into
|
||||
// *empty*
|
||||
// host=...
|
||||
// auth=...
|
||||
// *empty*
|
||||
//
|
||||
// Note that this code does not do a lot of checks to make sure the input
|
||||
// follows the exact format specified by RFC.
|
||||
params := bytes.Split(parts[2], []byte{0x01})
|
||||
for _, p := range params {
|
||||
// Skip empty fields (one at start and end).
|
||||
if len(p) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pParts := bytes.SplitN(p, []byte{'='}, 2)
|
||||
if len(pParts) != 2 {
|
||||
return a.fail("Invalid response, missing '='")
|
||||
}
|
||||
|
||||
switch string(pParts[0]) {
|
||||
case "host":
|
||||
opts.Host = string(pParts[1])
|
||||
case "port":
|
||||
port, err := strconv.ParseUint(string(pParts[1]), 10, 16)
|
||||
if err != nil {
|
||||
return a.fail("Invalid response, malformed 'port' value")
|
||||
}
|
||||
opts.Port = int(port)
|
||||
case "auth":
|
||||
const prefix = "bearer "
|
||||
strValue := string(pParts[1])
|
||||
// Token type is case-insensitive.
|
||||
if !strings.HasPrefix(strings.ToLower(strValue), prefix) {
|
||||
return a.fail("Unsupported token type")
|
||||
}
|
||||
opts.Token = strValue[len(prefix):]
|
||||
default:
|
||||
return a.fail("Invalid response, unknown parameter: " + string(pParts[0]))
|
||||
}
|
||||
}
|
||||
|
||||
authzErr := a.authenticate(opts)
|
||||
if authzErr != nil {
|
||||
blob, err := json.Marshal(authzErr)
|
||||
if err != nil {
|
||||
panic(err) // wtf
|
||||
}
|
||||
a.failErr = authzErr
|
||||
return blob, false, nil
|
||||
}
|
||||
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
func NewOAuthBearerServer(auth OAuthBearerAuthenticator) *OAuthBearerServer {
|
||||
return &OAuthBearerServer{
|
||||
authenticate: auth,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue