Skip to content

Commit

Permalink
feat(auth/github): add organization membership check to GitHub (flipt…
Browse files Browse the repository at this point in the history
…-io#2508)

* feat(auth/github): add organization membership check to GitHub
authentication method

fixes flipt-io#2065

* rename configuration option

* cleanup

* cleanup

* improve code test coverage

* Update internal/config/authentication.go

Co-authored-by: Mark Phelps <209477+markphelps@users.noreply.github.com>

* address PR review feedback

* cleanup

* feat: support specific auth method validation (#4)

---------

Co-authored-by: Mark Phelps <209477+markphelps@users.noreply.github.com>
  • Loading branch information
erka and markphelps authored Dec 10, 2023
1 parent 37f13e8 commit 72a06bb
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 32 deletions.
1 change: 1 addition & 0 deletions config/flipt.schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import "strings"
client_id?: string
redirect_address?: string
scopes?: [...string]
allowed_organizations?: [...] | string
}
}

Expand Down
4 changes: 4 additions & 0 deletions config/flipt.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
"scopes": {
"type": ["array", "null"],
"items": { "type": "string" }
},
"allowed_organizations": {
"type": ["array", "null"],
"additionalProperties": false
}
},
"title": "Github",
Expand Down
44 changes: 40 additions & 4 deletions internal/config/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"fmt"
"net/url"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -309,6 +320,7 @@ func (a *AuthenticationMethod[C]) info() StaticAuthenticationMethodInfo {
Cleanup: a.Cleanup,

setDefaults: a.setDefaults,
validate: a.validate,
setEnabled: func() {
a.Enabled = true
},
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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) {}
Expand All @@ -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
}
5 changes: 5 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions internal/config/testdata/authentication/github_no_org_scope.yml
Original file line number Diff line number Diff line change
@@ -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"
82 changes: 55 additions & 27 deletions internal/server/auth/method/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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"`
Expand All @@ -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
}

Expand All @@ -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)),
Expand All @@ -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)
}
Loading

0 comments on commit 72a06bb

Please sign in to comment.