diff --git a/cli/server.go b/cli/server.go index dfd2db1dca9d8..1002966e1fd23 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1006,7 +1006,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } options.WebPushDispatcher = webpusher - githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals) + githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Logger, options.Database, vals) if err != nil { return xerrors.Errorf("get github oauth2 config params: %w", err) } @@ -2213,7 +2213,7 @@ func maybeAppendDefaultGithubExternalAuthProvider( }), nil } -func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *codersdk.DeploymentValues) (*githubOAuth2ConfigParams, error) { +func getGithubOAuth2ConfigParams(ctx context.Context, logger slog.Logger, db database.Store, vals *codersdk.DeploymentValues) (*githubOAuth2ConfigParams, error) { params := githubOAuth2ConfigParams{ accessURL: vals.AccessURL.Value(), clientID: vals.OAuth2.Github.ClientID.String(), @@ -2250,6 +2250,16 @@ func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *c params.deviceFlow = GithubOAuth2DefaultProviderDeviceFlow if len(params.allowOrgs) == 0 { params.allowEveryone = GithubOAuth2DefaultProviderAllowEveryone + } else { + // The default provider is a GitHub App, which can only see memberships + // in organizations that have installed it. If the app isn't installed + // in an allowed organization, every login from that organization is + // rejected as "not a member". + logger.Warn(ctx, "the default GitHub OAuth provider can only see memberships in organizations that have installed the Coder GitHub app; "+ + "users cannot log in until the app is installed in each allowed organization, or a custom GitHub OAuth app is configured", + slog.F("allowed_orgs", params.allowOrgs), + slog.F("install_url", coderd.GithubOAuth2DefaultProviderInstallURL), + ) } return ¶ms, nil diff --git a/coderd/userauth.go b/coderd/userauth.go index 4babef1be8f24..24bb95b7445d2 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -772,6 +772,20 @@ type GithubOAuth2Config struct { DefaultProviderConfigured bool } +const ( + // GithubOAuth2DefaultProviderInstallURL is where admins install the + // default Coder GitHub app so it can see organization and team + // memberships. + GithubOAuth2DefaultProviderInstallURL = "https://github.com/apps/coder/installations/select_target" + + // githubOAuth2DefaultProviderRemediation explains why the default GitHub + // app can fail org and team membership checks, and how to fix it. It is + // appended to login rejection messages and mirrored by the server startup + // warning and the GitHub auth docs, so keep those in sync. + githubOAuth2DefaultProviderRemediation = "The default GitHub OAuth provider can only see organizations and teams that have installed the Coder GitHub app. " + + "Install it from " + GithubOAuth2DefaultProviderInstallURL + " for each authorized organization, or configure a custom GitHub OAuth app." +) + func (*GithubOAuth2Config) PKCESupported() []promoauth.Oauth2PKCEChallengeMethod { return []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256} } @@ -931,6 +945,13 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { if len(selectedMemberships) == 0 { status := http.StatusUnauthorized msg := "You aren't a member of the authorized Github organizations!" + if api.GithubOAuth2Config.DefaultProviderConfigured { + // The default provider is a GitHub App, so it can only report + // memberships in organizations that have installed it. Without + // this hint, users in an allowed organization see a confusing + // rejection with no way to discover the missing installation. + msg += " " + githubOAuth2DefaultProviderRemediation + } if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the error is rendered client-side. httpapi.Write(ctx, rw, status, codersdk.Response{ @@ -977,6 +998,12 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { } if allowedTeam == nil { msg := fmt.Sprintf("You aren't a member of an authorized team in the %v Github organization(s)!", organizationNames) + if api.GithubOAuth2Config.DefaultProviderConfigured { + // Team visibility has the same limitation as org visibility: + // the default GitHub App cannot see teams in organizations + // where it isn't installed. + msg += " " + githubOAuth2DefaultProviderRemediation + } status := http.StatusUnauthorized if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the error is rendered client-side. diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 463ce8365128b..bba1f93e1072b 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -221,6 +221,63 @@ func TestUserOAuth2Github(t *testing.T) { resp := oauth2Callback(t, client) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + location, err := resp.Location() + require.NoError(t, err) + require.NotContains(t, location.Query().Get("message"), "Coder GitHub app") + }) + t.Run("NotInAllowedOrganizationDefaultProvider", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &testutil.OAuth2Config{}, + DefaultProviderConfigured: true, + AllowOrganizations: []string{"coder"}, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + // The default provider is a GitHub App, so it reports no + // memberships for organizations it isn't installed in. + return []*github.Membership{}, nil + }, + }, + }) + + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + // The error must tell the user how to fix the likely cause: the Coder + // GitHub app isn't installed in the allowed organization. + location, err := resp.Location() + require.NoError(t, err) + require.Contains(t, location.Query().Get("message"), "Coder GitHub app") + require.Contains(t, location.Query().Get("message"), "https://github.com/apps/coder") + }) + t.Run("NotInAllowedOrganizationDefaultProviderDeviceFlow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &testutil.OAuth2Config{}, + DefaultProviderConfigured: true, + AllowOrganizations: []string{"coder"}, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + return []*github.Membership{}, nil + }, + DeviceFlowEnabled: true, + ExchangeDeviceCode: func(_ context.Context, _ string) (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: "access_token", + RefreshToken: "refresh_token", + Expiry: time.Now().Add(time.Hour), + }, nil + }, + }, + }) + + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + // In the device flow the error is rendered client-side, so the hint + // must arrive in the response body Detail rather than the redirect. + var apiErr codersdk.Response + require.NoError(t, json.NewDecoder(resp.Body).Decode(&apiErr)) + require.Contains(t, apiErr.Detail, "Coder GitHub app") + require.Contains(t, apiErr.Detail, "https://github.com/apps/coder") }) t.Run("NotInAllowedTeam", func(t *testing.T) { t.Parallel() diff --git a/docs/admin/users/github-auth.md b/docs/admin/users/github-auth.md index 4d07abb1e2e18..9125c6217b441 100644 --- a/docs/admin/users/github-auth.md +++ b/docs/admin/users/github-auth.md @@ -44,6 +44,11 @@ To use the default configuration: CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" ``` + > [!IMPORTANT] + > The default GitHub app can only see memberships in organizations where it is installed. + > If you set `CODER_OAUTH2_GITHUB_ALLOWED_ORGS` without installing the app in each allowed organization, all logins fail with "You aren't a member of the authorized Github organizations!", including the first admin login on a fresh deployment. + > Install the app for each organization at the [Coder app on GitHub](https://github.com/apps/coder/installations/select_target). + ## Disable the Default GitHub App You can disable the default GitHub app by [configuring your own app](#step-1-configure-the-oauth-application-in-github)