Add support for external OAuth 2.0 authentication
This commit is contained in:
parent
63ca247354
commit
22a88079c2
@ -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
170
auth/oauth2.go
Normal 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
|
||||||
|
}
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user