Add support for external OAuth 2.0 authentication

This commit is contained in:
Simon Ser 2022-09-11 15:46:27 +02:00
parent 63ca247354
commit 22a88079c2
4 changed files with 182 additions and 0 deletions

View file

@ -15,6 +15,8 @@ func New(driver, source string) (PlainAuthenticator, error) {
switch driver { switch driver {
case "internal": case "internal":
return NewInternal(), nil return NewInternal(), nil
case "oauth2":
return newOAuth2(source)
default: default:
return nil, fmt.Errorf("unknown auth driver %q", driver) return nil, fmt.Errorf("unknown auth driver %q", driver)
} }

170
auth/oauth2.go Normal file
View file

@ -0,0 +1,170 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"git.sr.ht/~emersion/soju/database"
)
type oauth2 struct {
introspectionURL *url.URL
clientID string
clientSecret string
}
func newOAuth2(authURL string) (PlainAuthenticator, error) {
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
defer cancel()
u, err := url.Parse(authURL)
if err != nil {
return nil, fmt.Errorf("failed to parse OAuth 2.0 server URL: %v", err)
}
var clientID, clientSecret string
if u.User != nil {
clientID = u.User.Username()
clientSecret, _ = u.User.Password()
}
discoveryURL := *u
discoveryURL.User = nil
discoveryURL.Path = path.Join("/.well-known/oauth-authorization-server", u.Path)
server, err := discoverOAuth2(ctx, discoveryURL.String())
if err != nil {
return nil, fmt.Errorf("OAuth 2.0 discovery failed: %v", err)
}
if server.IntrospectionEndpoint == "" {
return nil, fmt.Errorf("OAuth 2.0 server doesn't support token introspection")
}
introspectionURL, err := url.Parse(server.IntrospectionEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse OAuth 2.0 introspection URL")
}
if server.IntrospectionEndpointAuthMethodsSupported != nil {
var supportsNone, supportsBasic bool
for _, name := range server.IntrospectionEndpointAuthMethodsSupported {
switch name {
case "none":
supportsNone = true
case "client_secret_basic":
supportsBasic = true
}
}
if clientID == "" && !supportsNone {
return nil, fmt.Errorf("OAuth 2.0 server requires authentication for introspection")
}
if clientID != "" && !supportsBasic {
return nil, fmt.Errorf("OAuth 2.0 server doesn't support Basic HTTP authentication for introspection")
}
}
return &oauth2{
introspectionURL: introspectionURL,
clientID: clientID,
clientSecret: clientSecret,
}, nil
}
func (auth *oauth2) AuthPlain(ctx context.Context, db database.Database, username, password string) error {
reqValues := make(url.Values)
reqValues.Set("token", password)
reqBody := strings.NewReader(reqValues.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, auth.introspectionURL.String(), reqBody)
if err != nil {
return fmt.Errorf("failed to create OAuth 2.0 introspection request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if auth.clientID != "" {
req.SetBasicAuth(url.QueryEscape(auth.clientID), url.QueryEscape(auth.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 fmt.Errorf("invalid access token")
}
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")
}
if username != data.Username {
return fmt.Errorf("username mismatch (OAuth 2.0 server returned %q)", data.Username)
}
return nil
}
type oauth2Introspection struct {
Active bool `json:"active"`
Username string `json:"username"`
}
type oauth2Server struct {
Issuer string `json:"issuer"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"`
}
type oauth2HTTPError string
func (err oauth2HTTPError) Error() string {
return fmt.Sprintf("OAuth 2.0 HTTP error: %v", string(err))
}
func discoverOAuth2(ctx context.Context, discoveryURL string) (*oauth2Server, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, oauth2HTTPError(resp.Status)
}
var data oauth2Server
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to decode response: %v", err)
}
if data.Issuer == "" {
return nil, fmt.Errorf("missing issuer in response")
}
return &data, nil
}

View file

@ -164,6 +164,10 @@ func parse(cfg scfg.Block) (*Server, error) {
switch srv.Auth.Driver { switch srv.Auth.Driver {
case "internal": case "internal":
srv.Auth.Source = "" srv.Auth.Source = ""
case "oauth2":
if err := d.ParseParams(nil, &srv.Auth.Source); err != nil {
return nil, err
}
default: default:
return nil, fmt.Errorf("directive %q: unknown driver %q", d.Name, srv.Auth.Driver) return nil, fmt.Errorf("directive %q: unknown driver %q", d.Name, srv.Auth.Driver)
} }

View file

@ -192,6 +192,12 @@ The following directives are supported:
*auth internal* *auth internal*
Use internal authentication. Use internal authentication.
*auth oauth2* <url>
Use external OAuth 2.0 authentication. The authorization server URL must
be provided. The client ID and client secret can be provided as username
and password in the URL. The authorization server must support OAuth 2.0
Authorization Server Metadata (RFC 8414) and OAuth 2.0 Token
Introspection (RFC 7662).
# IRC SERVICE # IRC SERVICE