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
9 changes: 5 additions & 4 deletions internal/config/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,11 @@ func (a AuthenticationMethodKubernetesConfig) info() AuthenticationMethodInfo {
// 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:"allowed_organizations,omitempty" mapstructure:"allowed_organizations" yaml:"allowed_organizations,omitempty"`
}

func (a AuthenticationMethodGithubConfig) setDefaults(defaults map[string]any) {}
Expand Down
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 = s.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 = s.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 (s *Server) 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)
}
89 changes: 88 additions & 1 deletion internal/server/auth/method/github/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package github

import (
"context"
"encoding/json"
"net"
"net/http"
"net/url"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}