Skip to content

Commit dff6e97

Browse files
authored
feat: Add allowlist of GitHub teams for OAuth (#2849)
Fixes #2848.
1 parent c801da4 commit dff6e97

File tree

5 files changed

+134
-3
lines changed

5 files changed

+134
-3
lines changed

cli/cliflag/cliflag.go

Lines changed: 1 addition & 1 deletion
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

Lines changed: 26 additions & 2 deletions
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,11 +722,22 @@ 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, rawTeams []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)
726729
}
730+
allowTeams := make([]coderd.GithubOAuth2Team, 0, len(rawTeams))
731+
for _, rawTeam := range rawTeams {
732+
parts := strings.SplitN(rawTeam, "/", 2)
733+
if len(parts) != 2 {
734+
return nil, xerrors.Errorf("github team allowlist is formatted incorrectly. got %s; wanted <organization>/<team>", rawTeam)
735+
}
736+
allowTeams = append(allowTeams, coderd.GithubOAuth2Team{
737+
Organization: parts[0],
738+
Slug: parts[1],
739+
})
740+
}
727741
return &coderd.GithubOAuth2Config{
728742
OAuth2Config: &oauth2.Config{
729743
ClientID: clientID,
@@ -738,6 +752,7 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
738752
},
739753
AllowSignups: allowSignups,
740754
AllowOrganizations: allowOrgs,
755+
AllowTeams: allowTeams,
741756
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
742757
user, _, err := github.NewClient(client).Users.Get(ctx, "")
743758
return user, err
@@ -749,9 +764,18 @@ func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, al
749764
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
750765
memberships, _, err := github.NewClient(client).Organizations.ListOrgMemberships(ctx, &github.ListOrgMembershipsOptions{
751766
State: "active",
767+
ListOptions: github.ListOptions{
768+
PerPage: 100,
769+
},
752770
})
753771
return memberships, err
754772
},
773+
ListTeams: func(ctx context.Context, client *http.Client, org string) ([]*github.Team, error) {
774+
teams, _, err := github.NewClient(client).Teams.ListTeams(ctx, org, &github.ListOptions{
775+
PerPage: 100,
776+
})
777+
return teams, err
778+
},
755779
}, nil
756780
}
757781

coderd/database/db_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414

1515
func TestNestedInTx(t *testing.T) {
1616
t.Parallel()
17+
if testing.Short() {
18+
t.SkipNow()
19+
}
1720

1821
uid := uuid.New()
1922
sqlDB := testSQLDB(t)

coderd/userauth.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,23 @@ import (
1717
"github.com/coder/coder/codersdk"
1818
)
1919

20+
// GithubOAuth2Team represents a team scoped to an organization.
21+
type GithubOAuth2Team struct {
22+
Organization string
23+
Slug string
24+
}
25+
2026
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
2127
type GithubOAuth2Config struct {
2228
httpmw.OAuth2Config
2329
AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error)
2430
ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error)
2531
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
32+
ListTeams func(ctx context.Context, client *http.Client, org string) ([]*github.Team, error)
2633

2734
AllowSignups bool
2835
AllowOrganizations []string
36+
AllowTeams []GithubOAuth2Team
2937
}
3038

3139
func (api *API) userAuthMethods(rw http.ResponseWriter, _ *http.Request) {
@@ -64,6 +72,41 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
6472
return
6573
}
6674

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

coderd/userauth_test.go

Lines changed: 61 additions & 0 deletions
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: []coderd.GithubOAuth2Team{{"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: []coderd.GithubOAuth2Team{{"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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy