diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa456e2..b2ffc50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [#2615](https://github.com/oauth2-proxy/oauth2-proxy/pull/2615) feat(cookies): add option to set a limit on the number of per-request CSRF cookies oauth2-proxy sets (@bh-tt) - [#2605](https://github.com/oauth2-proxy/oauth2-proxy/pull/2605) fix: show login page on broken cookie (@Primexz) - [#2743](https://github.com/oauth2-proxy/oauth2-proxy/pull/2743) feat: allow use more possible google admin-sdk api scopes (@BobDu) +- [#2359](https://github.com/oauth2-proxy/oauth2-proxy/pull/2359) feat: add SourceHut (sr.ht) provider(@bitfehler) # V7.10.0 diff --git a/docs/docs/configuration/providers/sourcehut.md b/docs/docs/configuration/providers/sourcehut.md new file mode 100644 index 00000000..88d14622 --- /dev/null +++ b/docs/docs/configuration/providers/sourcehut.md @@ -0,0 +1,25 @@ +--- +id: sourcehut +title: SourceHut +--- + +1. Create a new OAuth client: https://meta.sr.ht/oauth2 +2. Under `Redirection URI` enter the correct URL, i.e. + `https://internal.yourcompany.com/oauth2/callback` + +To use the provider, start with `--provider=sourcehut`. + +If you are hosting your own SourceHut instance, make sure you set the following +to the appropriate URLs: + +```shell + --login-url="https:///oauth2/authorize" + --redeem-url="https:///oauth2/access-token" + --profile-url="https:///query" + --validate-url="https:///profile" +``` + +The default configuration allows everyone with an account to authenticate. +Restricting access is currently only supported by +[email](#email-authentication). + diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 280b1ce0..ac5652ca 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -147,6 +147,9 @@ const ( // OIDCProvider is the provider type for OIDC OIDCProvider ProviderType = "oidc" + + // SourceHutProvider is the provider type for SourceHut + SourceHutProvider ProviderType = "sourcehut" ) type KeycloakOptions struct { diff --git a/providers/providers.go b/providers/providers.go index 3a125a24..8bc5ff88 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -67,6 +67,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) { return NewNextcloudProvider(providerData), nil case options.OIDCProvider: return NewOIDCProvider(providerData, providerConfig.OIDCConfig), nil + case options.SourceHutProvider: + return NewSourceHutProvider(providerData), nil default: return nil, fmt.Errorf("unknown provider type %q", providerConfig.Type) } @@ -183,7 +185,8 @@ func parseCodeChallengeMethod(providerConfig options.Provider) string { func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) { switch providerType { case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider, - options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, options.NextCloudProvider: + options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, + options.NextCloudProvider, options.SourceHutProvider: return false, nil case options.ADFSProvider, options.AzureProvider, options.GitLabProvider, options.KeycloakOIDCProvider, options.OIDCProvider, options.MicrosoftEntraIDProvider: return true, nil diff --git a/providers/srht.go b/providers/srht.go new file mode 100644 index 00000000..aa72229c --- /dev/null +++ b/providers/srht.go @@ -0,0 +1,108 @@ +package providers + +import ( + "bytes" + "context" + "fmt" + "net/url" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" +) + +type SourceHutProvider struct { + *ProviderData +} + +var _ Provider = (*SourceHutProvider)(nil) + +const ( + SourceHutProviderName = "SourceHut" + SourceHutDefaultScope = "meta.sr.ht/PROFILE:RO" +) + +var ( + // Default Login URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/oauth2/authorize. + SourceHutDefaultLoginURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/oauth2/authorize", + } + + // Default Redeem URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/oauth2/access-token. + SourceHutDefaultRedeemURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/oauth2/access-token", + } + + // Default Profile URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/query. + SourceHutDefaultProfileURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/query", + } + + // Default Validation URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/profile. + SourceHutDefaultValidateURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/profile", + } +) + +// NewSourceHutProvider creates a SourceHutProvider using the passed ProviderData +func NewSourceHutProvider(p *ProviderData) *SourceHutProvider { + p.setProviderDefaults(providerDefaults{ + name: SourceHutProviderName, + loginURL: SourceHutDefaultLoginURL, + redeemURL: SourceHutDefaultRedeemURL, + profileURL: SourceHutDefaultProfileURL, + validateURL: SourceHutDefaultValidateURL, + scope: SourceHutDefaultScope, + }) + + return &SourceHutProvider{ProviderData: p} +} + +// EnrichSession uses the SourceHut userinfo endpoint to populate the session's +// email and username. +func (p *SourceHutProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + json, err := requests.New(p.ProfileURL.String()). + WithContext(ctx). + WithMethod("POST"). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", "Bearer "+s.AccessToken). + WithBody(bytes.NewBufferString(`{"query": "{ me { username, email } }"}`)). + Do(). + UnmarshalSimpleJSON() + if err != nil { + logger.Errorf("failed making request %v", err) + return err + } + + email, err := json.GetPath("data", "me", "email").String() + if err != nil { + return fmt.Errorf("unable to extract email from userinfo endpoint: %v", err) + } + s.Email = email + + username, err := json.GetPath("data", "me", "username").String() + if err != nil { + return fmt.Errorf("unable to extract username from userinfo endpoint: %v", err) + } + s.PreferredUsername = username + s.User = username + + return nil +} + +// ValidateSession validates the AccessToken +func (p *SourceHutProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { + return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) +} diff --git a/providers/srht_test.go b/providers/srht_test.go new file mode 100644 index 00000000..fd51bf7f --- /dev/null +++ b/providers/srht_test.go @@ -0,0 +1,77 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func testSourceHutProvider(hostname string) *SourceHutProvider { + p := NewSourceHutProvider( + &ProviderData{ + ProviderName: "SourceHut", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: ""}, + ) + p.ProviderName = "SourceHut" + + if hostname != "" { + updateURL(p.Data().LoginURL, hostname) + updateURL(p.Data().RedeemURL, hostname) + updateURL(p.Data().ProfileURL, hostname) + updateURL(p.Data().ValidateURL, hostname) + } + return p +} + +func testSourceHutBackend(payloads map[string][]string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + index := 0 + payload, ok := payloads[r.URL.Path] + if !ok { + w.WriteHeader(404) + } else if payload[index] == "" { + w.WriteHeader(204) + } else { + w.WriteHeader(200) + w.Write([]byte(payload[index])) + } + })) +} + +func TestSourceHutProvider_ValidateSessionWithBaseUrl(t *testing.T) { + b := testSourceHutBackend(map[string][]string{}) + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testSourceHutProvider(bURL.Host) + + session := CreateAuthorizedSession() + + valid := p.ValidateSession(context.Background(), session) + assert.False(t, valid) +} + +func TestSourceHutProvider_ValidateSessionWithUserEmails(t *testing.T) { + b := testSourceHutBackend(map[string][]string{ + "/query": {`{"data":{"me":{"username":"bitfehler","email":"ch@bitfehler.net"}}}`}, + "/profile": {`ok`}, + }) + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testSourceHutProvider(bURL.Host) + + session := CreateAuthorizedSession() + + valid := p.ValidateSession(context.Background(), session) + assert.True(t, valid) +}