Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth/github): add organization membership check to GitHub #2508

Merged
merged 12 commits into from
Dec 10, 2023
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 @@
"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 @@
"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 @@
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 @@
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 @@
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 @@
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
}

Check warning on line 197 in internal/server/auth/method/github/server.go

View check run for this annotation

Codecov / codecov/patch

internal/server/auth/method/github/server.go#L196-L197

Added lines #L196 - L197 were not covered by tests

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
}

Check warning on line 205 in internal/server/auth/method/github/server.go

View check run for this annotation

Codecov / codecov/patch

internal/server/auth/method/github/server.go#L204-L205

Added lines #L204 - L205 were not covered by tests

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