Skip to content

Commit be40fa8

Browse files
committed
feat: Add allowlist of GitHub teams for OAuth
Fixes #2848.
1 parent 4f1e9da commit be40fa8

File tree

4 files changed

+123
-3
lines changed

4 files changed

+123
-3
lines changed

cli/cliflag/cliflag.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shortha
4747
def = strings.Split(val, ",")
4848
}
4949
}
50-
flagset.StringArrayVarP(ptr, name, shorthand, def, usage)
50+
flagset.StringArrayVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
5151
}
5252

5353
// Uint8VarP sets a uint8 flag on the given flag set.

cli/server.go

+15-2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func server() *cobra.Command {
8282
oauth2GithubClientID string
8383
oauth2GithubClientSecret string
8484
oauth2GithubAllowedOrganizations []string
85+
oauth2GithubAllowedTeams []string
8586
oauth2GithubAllowSignups bool
8687
telemetryEnable bool
8788
telemetryURL string
@@ -264,7 +265,7 @@ func server() *cobra.Command {
264265
}
265266

266267
if oauth2GithubClientSecret != "" {
267-
options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed, oauth2GithubClientID, oauth2GithubClientSecret, oauth2GithubAllowSignups, oauth2GithubAllowedOrganizations)
268+
options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed, oauth2GithubClientID, oauth2GithubClientSecret, oauth2GithubAllowSignups, oauth2GithubAllowedOrganizations, oauth2GithubAllowedTeams)
268269
if err != nil {
269270
return xerrors.Errorf("configure github oauth2: %w", err)
270271
}
@@ -535,6 +536,8 @@ func server() *cobra.Command {
535536
"Specifies a client secret to use for oauth2 with GitHub.")
536537
cliflag.StringArrayVarP(root.Flags(), &oauth2GithubAllowedOrganizations, "oauth2-github-allowed-orgs", "", "CODER_OAUTH2_GITHUB_ALLOWED_ORGS", nil,
537538
"Specifies organizations the user must be a member of to authenticate with GitHub.")
539+
cliflag.StringArrayVarP(root.Flags(), &oauth2GithubAllowedTeams, "oauth2-github-allowed-teams", "", "CODER_OAUTH2_GITHUB_ALLOWED_TEAMS", nil,
540+
"Specifies teams inside organizations the user must be a member of to authenticate with GitHub. Formatted as: <organization-name>/<team-slug>.")
538541
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
539542
"Specifies whether new users can sign up with GitHub.")
540543
cliflag.BoolVarP(root.Flags(), &telemetryEnable, "telemetry", "", "CODER_TELEMETRY", true, "Specifies whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.")
@@ -719,7 +722,7 @@ func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFi
719722
return tls.NewListener(listener, tlsConfig), nil
720723
}
721724

722-
func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, allowSignups bool, allowOrgs []string) (*coderd.GithubOAuth2Config, error) {
725+
func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, allowSignups bool, allowOrgs []string, allowTeams []string) (*coderd.GithubOAuth2Config, error) {
723726
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
724727
if err != nil {
725728
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
@@ -738,6 +741,7 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
738741
},
739742
AllowSignups: allowSignups,
740743
AllowOrganizations: allowOrgs,
744+
AllowTeams: allowTeams,
741745
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
742746
user, _, err := github.NewClient(client).Users.Get(ctx, "")
743747
return user, err
@@ -749,9 +753,18 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
749753
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
750754
memberships, _, err := github.NewClient(client).Organizations.ListOrgMemberships(ctx, &github.ListOrgMembershipsOptions{
751755
State: "active",
756+
ListOptions: github.ListOptions{
757+
PerPage: 100,
758+
},
752759
})
753760
return memberships, err
754761
},
762+
ListTeams: func(ctx context.Context, client *http.Client, org string) ([]*github.Team, error) {
763+
teams, _, err := github.NewClient(client).Teams.ListTeams(ctx, org, &github.ListOptions{
764+
PerPage: 100,
765+
})
766+
return teams, err
767+
},
755768
}, nil
756769
}
757770

coderd/userauth.go

+46
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"net/http"
9+
"strings"
910

1011
"github.com/google/go-github/v43/github"
1112
"github.com/google/uuid"
@@ -23,9 +24,11 @@ type GithubOAuth2Config struct {
2324
AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error)
2425
ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error)
2526
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
27+
ListTeams func(ctx context.Context, client *http.Client, org string) ([]*github.Team, error)
2628

2729
AllowSignups bool
2830
AllowOrganizations []string
31+
AllowTeams []string
2932
}
3033

3134
func (api *API) userAuthMethods(rw http.ResponseWriter, _ *http.Request) {
@@ -64,6 +67,49 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
6467
return
6568
}
6669

70+
// The default if no teams are specified is to allow all.
71+
if len(api.GithubOAuth2Config.AllowTeams) > 0 {
72+
teams, err := api.GithubOAuth2Config.ListTeams(r.Context(), oauthClient, *selectedMembership.Organization.Login)
73+
if err != nil {
74+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
75+
Message: "Failed to fetch teams from GitHub.",
76+
Detail: err.Error(),
77+
})
78+
return
79+
}
80+
81+
var allowedTeam *github.Team
82+
for _, team := range teams {
83+
for _, organizationAndTeam := range api.GithubOAuth2Config.AllowTeams {
84+
parts := strings.SplitN(organizationAndTeam, "/", 2)
85+
if len(parts) != 2 {
86+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
87+
Message: "Team allowlist isn't formatted correctly.",
88+
Detail: fmt.Sprintf("Got %s, wanted <organization>/<team>", organizationAndTeam),
89+
})
90+
return
91+
}
92+
if parts[0] != *selectedMembership.Organization.Login {
93+
// This needs to continue because multiple organizations
94+
// could exist in the allow/team listings.
95+
continue
96+
}
97+
if parts[1] != *team.Slug {
98+
continue
99+
}
100+
allowedTeam = team
101+
break
102+
}
103+
}
104+
105+
if allowedTeam == nil {
106+
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
107+
Message: fmt.Sprintf("You aren't a member of an authorized team in the %s Github organization!", *selectedMembership.Organization.Login),
108+
})
109+
return
110+
}
111+
}
112+
67113
emails, err := api.GithubOAuth2Config.ListEmails(r.Context(), oauthClient)
68114
if err != nil {
69115
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{

coderd/userauth_test.go

+61
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,30 @@ func TestUserOAuth2Github(t *testing.T) {
7373
resp := oauth2Callback(t, client)
7474
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
7575
})
76+
t.Run("NotInAllowedTeam", func(t *testing.T) {
77+
t.Parallel()
78+
client := coderdtest.New(t, &coderdtest.Options{
79+
GithubOAuth2Config: &coderd.GithubOAuth2Config{
80+
AllowOrganizations: []string{"coder"},
81+
AllowTeams: []string{"another/something", "coder/frontend"},
82+
OAuth2Config: &oauth2Config{},
83+
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
84+
return []*github.Membership{{
85+
Organization: &github.Organization{
86+
Login: github.String("coder"),
87+
},
88+
}}, nil
89+
},
90+
ListTeams: func(ctx context.Context, client *http.Client, org string) ([]*github.Team, error) {
91+
return []*github.Team{{
92+
Slug: github.String("nope"),
93+
}}, nil
94+
},
95+
},
96+
})
97+
resp := oauth2Callback(t, client)
98+
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
99+
})
76100
t.Run("UnverifiedEmail", func(t *testing.T) {
77101
t.Parallel()
78102
client := coderdtest.New(t, &coderdtest.Options{
@@ -184,6 +208,43 @@ func TestUserOAuth2Github(t *testing.T) {
184208
resp := oauth2Callback(t, client)
185209
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
186210
})
211+
t.Run("SignupAllowedTeam", func(t *testing.T) {
212+
t.Parallel()
213+
client := coderdtest.New(t, &coderdtest.Options{
214+
GithubOAuth2Config: &coderd.GithubOAuth2Config{
215+
AllowSignups: true,
216+
AllowOrganizations: []string{"coder"},
217+
AllowTeams: []string{"coder/frontend"},
218+
OAuth2Config: &oauth2Config{},
219+
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
220+
return []*github.Membership{{
221+
Organization: &github.Organization{
222+
Login: github.String("coder"),
223+
},
224+
}}, nil
225+
},
226+
ListTeams: func(ctx context.Context, client *http.Client, org string) ([]*github.Team, error) {
227+
return []*github.Team{{
228+
Slug: github.String("frontend"),
229+
}}, nil
230+
},
231+
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
232+
return &github.User{
233+
Login: github.String("kyle"),
234+
}, nil
235+
},
236+
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
237+
return []*github.UserEmail{{
238+
Email: github.String("kyle@coder.com"),
239+
Verified: github.Bool(true),
240+
Primary: github.Bool(true),
241+
}}, nil
242+
},
243+
},
244+
})
245+
resp := oauth2Callback(t, client)
246+
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
247+
})
187248
}
188249

189250
func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response {

0 commit comments

Comments
 (0)