diff --git a/config/flipt.schema.cue b/config/flipt.schema.cue index 5a938c836a..66d98bc305 100644 --- a/config/flipt.schema.cue +++ b/config/flipt.schema.cue @@ -73,6 +73,7 @@ import "strings" client_id?: string redirect_address?: string scopes?: [...string] + allowed_organizations?: [...] | string } } diff --git a/config/flipt.schema.json b/config/flipt.schema.json index 5801574613..614f64cd8a 100644 --- a/config/flipt.schema.json +++ b/config/flipt.schema.json @@ -197,6 +197,10 @@ "scopes": { "type": ["array", "null"], "items": { "type": "string" } + }, + "allowed_organizations": { + "type": ["array", "null"], + "additionalProperties": false } }, "title": "Github", diff --git a/internal/config/authentication.go b/internal/config/authentication.go index 2d68a5aa57..db156442cf 100644 --- a/internal/config/authentication.go +++ b/internal/config/authentication.go @@ -3,6 +3,7 @@ package config import ( "fmt" "net/url" + "slices" "strings" "testing" "time" @@ -133,6 +134,7 @@ func (c *AuthenticationConfig) SessionEnabled() bool { func (c *AuthenticationConfig) validate() error { var sessionEnabled bool + for _, info := range c.Methods.AllMethods() { sessionEnabled = sessionEnabled || (info.Enabled && info.SessionCompatible) if info.Cleanup == nil { @@ -169,6 +171,12 @@ func (c *AuthenticationConfig) validate() error { c.Session.Domain = host } + for _, info := range c.Methods.AllMethods() { + if err := info.validate(); err != nil { + return err + } + } + return nil } @@ -245,6 +253,8 @@ type StaticAuthenticationMethodInfo struct { // used for bootstrapping defaults setDefaults func(map[string]any) + // used for auth method specific validation + validate func() error // used for testing purposes to ensure all methods // are appropriately cleaned up via the background process. @@ -284,6 +294,7 @@ func (a AuthenticationMethodInfo) Name() string { type AuthenticationMethodInfoProvider interface { setDefaults(map[string]any) info() AuthenticationMethodInfo + validate() error } // AuthenticationMethod is a container for authentication methods. @@ -309,6 +320,7 @@ func (a *AuthenticationMethod[C]) info() StaticAuthenticationMethodInfo { Cleanup: a.Cleanup, setDefaults: a.setDefaults, + validate: a.validate, setEnabled: func() { a.Enabled = true }, @@ -318,6 +330,14 @@ func (a *AuthenticationMethod[C]) info() StaticAuthenticationMethodInfo { } } +func (a *AuthenticationMethod[C]) validate() error { + if !a.Enabled { + return nil + } + + return a.Method.validate() +} + // AuthenticationMethodTokenConfig contains fields used to configure the authentication // method "token". // This authentication method supports the ability to create static tokens via the @@ -336,6 +356,8 @@ func (a AuthenticationMethodTokenConfig) info() AuthenticationMethodInfo { } } +func (a AuthenticationMethodTokenConfig) validate() error { return nil } + // AuthenticationMethodTokenBootstrapConfig contains fields used to configure the // bootstrap process for the authentication method "token". type AuthenticationMethodTokenBootstrapConfig struct { @@ -380,6 +402,8 @@ func (a AuthenticationMethodOIDCConfig) info() AuthenticationMethodInfo { return info } +func (a AuthenticationMethodOIDCConfig) validate() error { return nil } + // AuthenticationOIDCProvider configures provider credentials type AuthenticationMethodOIDCProvider struct { IssuerURL string `json:"issuerURL,omitempty" mapstructure:"issuer_url" yaml:"issuer_url,omitempty"` @@ -426,13 +450,16 @@ func (a AuthenticationMethodKubernetesConfig) info() AuthenticationMethodInfo { } } +func (a AuthenticationMethodKubernetesConfig) validate() error { return nil } + // AuthenticationMethodGithubConfig contains configuration and information for completing an OAuth // 2.0 flow with GitHub as a provider. type AuthenticationMethodGithubConfig struct { - ClientId string `json:"-" mapstructure:"client_id" yaml:"-"` - ClientSecret string `json:"-" mapstructure:"client_secret" yaml:"-"` - RedirectAddress string `json:"redirectAddress,omitempty" mapstructure:"redirect_address" yaml:"redirect_address,omitempty"` - Scopes []string `json:"scopes,omitempty" mapstructure:"scopes" yaml:"scopes,omitempty"` + ClientId string `json:"-" mapstructure:"client_id" yaml:"-"` + ClientSecret string `json:"-" mapstructure:"client_secret" yaml:"-"` + RedirectAddress string `json:"redirectAddress,omitempty" mapstructure:"redirect_address" yaml:"redirect_address,omitempty"` + Scopes []string `json:"scopes,omitempty" mapstructure:"scopes" yaml:"scopes,omitempty"` + AllowedOrganizations []string `json:"allowedOrganizations,omitempty" mapstructure:"allowed_organizations" yaml:"allowed_organizations,omitempty"` } func (a AuthenticationMethodGithubConfig) setDefaults(defaults map[string]any) {} @@ -453,3 +480,12 @@ func (a AuthenticationMethodGithubConfig) info() AuthenticationMethodInfo { return info } + +func (a AuthenticationMethodGithubConfig) validate() error { + // ensure scopes contain read:org if allowed organizations is not empty + if len(a.AllowedOrganizations) > 0 && !slices.Contains(a.Scopes, "read:org") { + return fmt.Errorf("scopes must contain read:org when allowed_organizations is not empty") + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 743561b225..b62d15872f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -445,6 +445,11 @@ func TestLoad(t *testing.T) { return cfg }, }, + { + name: "authentication github requires read:org scope when allowing orgs", + path: "./testdata/authentication/github_no_org_scope.yml", + wantErr: errors.New("scopes must contain read:org when allowed_organizations is not empty"), + }, { name: "advanced", path: "./testdata/advanced.yml", diff --git a/internal/config/testdata/authentication/github_no_org_scope.yml b/internal/config/testdata/authentication/github_no_org_scope.yml new file mode 100644 index 0000000000..55006effbe --- /dev/null +++ b/internal/config/testdata/authentication/github_no_org_scope.yml @@ -0,0 +1,12 @@ +authentication: + required: true + session: + domain: "http://localhost:8080" + secure: false + methods: + github: + enabled: true + scopes: + - "user:email" + allowed_organizations: + - "github.com/flipt-io" diff --git a/internal/server/auth/method/github/server.go b/internal/server/auth/method/github/server.go index f997eeace5..06015a913a 100644 --- a/internal/server/auth/method/github/server.go +++ b/internal/server/auth/method/github/server.go @@ -5,12 +5,14 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "strings" "time" "go.flipt.io/flipt/errors" "go.flipt.io/flipt/internal/config" "go.flipt.io/flipt/internal/server/auth/method" + authmiddlewaregrpc "go.flipt.io/flipt/internal/server/auth/middleware/grpc" storageauth "go.flipt.io/flipt/internal/storage/auth" "go.flipt.io/flipt/rpc/flipt/auth" "go.uber.org/zap" @@ -20,8 +22,12 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +type endpoint string + const ( - githubUserAPI = "https://api.github.com/user" + githubAPI = "https://api.github.com" + githubUser endpoint = "/user" + githubUserOrganizations endpoint = "/user/orgs" ) // OAuth2Client is our abstraction of communication with an OAuth2 Provider. @@ -112,31 +118,6 @@ func (s *Server) Callback(ctx context.Context, r *auth.CallbackRequest) (*auth.C return nil, errors.New("invalid token") } - c := &http.Client{ - Timeout: 5 * time.Second, - } - - userReq, err := http.NewRequestWithContext(ctx, "GET", githubUserAPI, nil) - if err != nil { - return nil, err - } - - userReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) - userReq.Header.Set("Accept", "application/vnd.github+json") - - userResp, err := c.Do(userReq) - if err != nil { - return nil, err - } - - defer func() { - userResp.Body.Close() - }() - - if userResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("github user info response status: %q", userResp.Status) - } - var githubUserResponse struct { Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` @@ -145,7 +126,7 @@ func (s *Server) Callback(ctx context.Context, r *auth.CallbackRequest) (*auth.C ID uint64 `json:"id,omitempty"` } - if err := json.NewDecoder(userResp.Body).Decode(&githubUserResponse); err != nil { + if err = api(ctx, token, githubUser, &githubUserResponse); err != nil { return nil, err } @@ -171,6 +152,20 @@ func (s *Server) Callback(ctx context.Context, r *auth.CallbackRequest) (*auth.C metadata[storageMetadataGitHubPreferredUsername] = githubUserResponse.Login } + if len(s.config.Methods.Github.Method.AllowedOrganizations) != 0 { + var githubUserOrgsResponse []githubSimpleOrganization + if err = api(ctx, token, githubUserOrganizations, &githubUserOrgsResponse); err != nil { + return nil, err + } + if !slices.ContainsFunc(s.config.Methods.Github.Method.AllowedOrganizations, func(org string) bool { + return slices.ContainsFunc(githubUserOrgsResponse, func(githubOrg githubSimpleOrganization) bool { + return githubOrg.Login == org + }) + }) { + return nil, authmiddlewaregrpc.ErrUnauthenticated + } + } + clientToken, a, err := s.store.CreateAuthentication(ctx, &storageauth.CreateAuthenticationRequest{ Method: auth.Method_METHOD_GITHUB, ExpiresAt: timestamppb.New(time.Now().UTC().Add(s.config.Session.TokenLifetime)), @@ -185,3 +180,36 @@ func (s *Server) Callback(ctx context.Context, r *auth.CallbackRequest) (*auth.C Authentication: a, }, nil } + +type githubSimpleOrganization struct { + Login string +} + +// api calls Github API, decodes and stores successful response in the value pointed to by v. +func api(ctx context.Context, token *oauth2.Token, endpoint endpoint, v any) error { + c := &http.Client{ + Timeout: 5 * time.Second, + } + + userReq, err := http.NewRequestWithContext(ctx, "GET", string(githubAPI+endpoint), nil) + if err != nil { + return err + } + + userReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + userReq.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.Do(userReq) + if err != nil { + return err + } + + defer func() { + resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("github %s info response status: %q", endpoint, resp.Status) + } + return json.NewDecoder(resp.Body).Decode(v) +} diff --git a/internal/server/auth/method/github/server_test.go b/internal/server/auth/method/github/server_test.go index 1b21dac979..0c7e586e91 100644 --- a/internal/server/auth/method/github/server_test.go +++ b/internal/server/auth/method/github/server_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/json" "net" "net/http" "net/url" @@ -19,6 +20,8 @@ import ( "go.uber.org/zap/zaptest" "golang.org/x/oauth2" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" ) @@ -146,9 +149,70 @@ func Test_Server(t *testing.T) { Reply(400) _, err = client.Callback(ctx, &auth.CallbackRequest{Code: "github_code"}) - assert.EqualError(t, err, "rpc error: code = Internal desc = github user info response status: \"400 Bad Request\"") + assert.EqualError(t, err, "rpc error: code = Internal desc = github /user info response status: \"400 Bad Request\"") gock.Off() + + // check allowed organizations successfully + s.config.Methods.Github.Method.AllowedOrganizations = []string{"flipt-io"} + gock.New("https://api.github.com"). + MatchHeader("Authorization", "Bearer AccessToken"). + MatchHeader("Accept", "application/vnd.github+json"). + Get("/user"). + Reply(200). + JSON(map[string]any{"name": "fliptuser", "email": "user@flipt.io", "avatar_url": "https://thispicture.com", "id": 1234567890}) + + gock.New("https://api.github.com"). + MatchHeader("Authorization", "Bearer AccessToken"). + MatchHeader("Accept", "application/vnd.github+json"). + Get("/user/orgs"). + Reply(200). + JSON([]githubSimpleOrganization{{Login: "flipt-io"}}) + + c, err = client.Callback(ctx, &auth.CallbackRequest{Code: "github_code"}) + require.NoError(t, err) + assert.NotEmpty(t, c.ClientToken) + gock.Off() + + // check allowed organizations unsuccessfully + s.config.Methods.Github.Method.AllowedOrganizations = []string{"flipt-io"} + gock.New("https://api.github.com"). + MatchHeader("Authorization", "Bearer AccessToken"). + MatchHeader("Accept", "application/vnd.github+json"). + Get("/user"). + Reply(200). + JSON(map[string]any{"name": "fliptuser", "email": "user@flipt.io", "avatar_url": "https://thispicture.com", "id": 1234567890}) + + gock.New("https://api.github.com"). + MatchHeader("Authorization", "Bearer AccessToken"). + MatchHeader("Accept", "application/vnd.github+json"). + Get("/user/orgs"). + Reply(200). + JSON([]githubSimpleOrganization{{Login: "github"}}) + + _, err = client.Callback(ctx, &auth.CallbackRequest{Code: "github_code"}) + require.ErrorIs(t, err, status.Error(codes.Unauthenticated, "request was not authenticated")) + gock.Off() + + // check allowed organizations with error + s.config.Methods.Github.Method.AllowedOrganizations = []string{"flipt-io"} + gock.New("https://api.github.com"). + MatchHeader("Authorization", "Bearer AccessToken"). + MatchHeader("Accept", "application/vnd.github+json"). + Get("/user"). + Reply(200). + JSON(map[string]any{"name": "fliptuser", "email": "user@flipt.io", "avatar_url": "https://thispicture.com", "id": 1234567890}) + + gock.New("https://api.github.com"). + MatchHeader("Authorization", "Bearer AccessToken"). + MatchHeader("Accept", "application/vnd.github+json"). + Get("/user/orgs"). + Reply(429). + BodyString("too many requests") + + _, err = client.Callback(ctx, &auth.CallbackRequest{Code: "github_code"}) + require.EqualError(t, err, "rpc error: code = Internal desc = github /user/orgs info response status: \"429 Too Many Requests\"") + gock.Off() } func Test_Server_SkipsAuthentication(t *testing.T) { @@ -163,3 +227,26 @@ func TestCallbackURL(t *testing.T) { callback = callbackURL("https://flipt.io/") assert.Equal(t, callback, "https://flipt.io/auth/v1/method/github/callback") } + +func TestGithubSimpleOrganizationDecode(t *testing.T) { + var body = `[{ + "login": "github", + "id": 1, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", + "url": "https://api.github.com/orgs/github", + "repos_url": "https://api.github.com/orgs/github/repos", + "events_url": "https://api.github.com/orgs/github/events", + "hooks_url": "https://api.github.com/orgs/github/hooks", + "issues_url": "https://api.github.com/orgs/github/issues", + "members_url": "https://api.github.com/orgs/github/members{/member}", + "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "description": "A great organization" + }]` + + var githubUserOrgsResponse []githubSimpleOrganization + err := json.Unmarshal([]byte(body), &githubUserOrgsResponse) + require.NoError(t, err) + require.Len(t, githubUserOrgsResponse, 1) + require.Equal(t, "github", githubUserOrgsResponse[0].Login) +}