From 22a88079c218115c191dcf9164087d291844ab7d Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Sun, 11 Sep 2022 15:46:27 +0200 Subject: [PATCH] Add support for external OAuth 2.0 authentication --- auth/auth.go | 2 + auth/oauth2.go | 170 +++++++++++++++++++++++++++++++++++++++++++++++ config/config.go | 4 ++ doc/soju.1.scd | 6 ++ 4 files changed, 182 insertions(+) create mode 100644 auth/oauth2.go diff --git a/auth/auth.go b/auth/auth.go index 21058f1..2ccf3d9 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -15,6 +15,8 @@ func New(driver, source string) (PlainAuthenticator, error) { switch driver { case "internal": return NewInternal(), nil + case "oauth2": + return newOAuth2(source) default: return nil, fmt.Errorf("unknown auth driver %q", driver) } diff --git a/auth/oauth2.go b/auth/oauth2.go new file mode 100644 index 0000000..29722b2 --- /dev/null +++ b/auth/oauth2.go @@ -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 +} diff --git a/config/config.go b/config/config.go index 585d189..a2bc36d 100644 --- a/config/config.go +++ b/config/config.go @@ -164,6 +164,10 @@ func parse(cfg scfg.Block) (*Server, error) { switch srv.Auth.Driver { case "internal": srv.Auth.Source = "" + case "oauth2": + if err := d.ParseParams(nil, &srv.Auth.Source); err != nil { + return nil, err + } default: return nil, fmt.Errorf("directive %q: unknown driver %q", d.Name, srv.Auth.Driver) } diff --git a/doc/soju.1.scd b/doc/soju.1.scd index ba67522..5ebced0 100644 --- a/doc/soju.1.scd +++ b/doc/soju.1.scd @@ -192,6 +192,12 @@ The following directives are supported: *auth internal* Use internal authentication. + *auth oauth2* + 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