From adfa402200ec159ead4a062c305b57d7457609ad Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 21 Jul 2026 10:07:58 +0000 Subject: [PATCH 1/2] fix(coderd): explain default GitHub app org visibility on login rejection When a fresh deployment falls back to the default Coder GitHub app and CODER_OAUTH2_GITHUB_ALLOWED_ORGS is set, the app can only see memberships in organizations that have installed it. Logins from allowed organizations were rejected with a bare "You aren't a member" error, bricking first login with no hint about the missing app installation. Add a remediation hint to the login rejection when the default provider is configured, warn at server startup when the default provider is combined with allowed orgs, and document the installation requirement. Fixes coder/coder#17752 --- cli/server.go | 14 ++++++++++++-- coderd/userauth.go | 8 ++++++++ coderd/userauth_test.go | 27 +++++++++++++++++++++++++++ docs/admin/users/github-auth.md | 7 +++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/cli/server.go b/cli/server.go index dfd2db1dca9..e2670ded332 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", "https://github.com/apps/coder/installations/select_target"), + ) } return ¶ms, nil diff --git a/coderd/userauth.go b/coderd/userauth.go index 4babef1be8f..e2e05ffacce 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -931,6 +931,14 @@ 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 += " The default GitHub OAuth provider can only see memberships in organizations that have installed the Coder GitHub app." + + " Install it from https://github.com/apps/coder for each authorized organization, or configure a custom GitHub OAuth app." + } if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the error is rendered client-side. httpapi.Write(ctx, rw, status, codersdk.Response{ diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 463ce836512..2e67ddc9b63 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -221,6 +221,33 @@ 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("NotInAllowedTeam", func(t *testing.T) { t.Parallel() diff --git a/docs/admin/users/github-auth.md b/docs/admin/users/github-auth.md index 4d07abb1e2e..5dc3a85dffa 100644 --- a/docs/admin/users/github-auth.md +++ b/docs/admin/users/github-auth.md @@ -44,6 +44,13 @@ 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. + ## 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) From f05a943b571d7af7ea6280965d05c2cb77d2d4cb Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 21 Jul 2026 15:03:42 +0000 Subject: [PATCH 2/2] fix(coderd): dedupe default GitHub app remediation and extend to teams Extract the install URL and remediation sentence into shared constants so the login rejection, server startup warning, and docs stay in sync. Add the same remediation hint to the team-membership rejection, which has the same root cause when the default GitHub app isn't installed in an organization. Cover the device-flow rejection branch with a test asserting the hint is returned in the response Detail. --- cli/server.go | 2 +- coderd/userauth.go | 23 +++++++++++++++++++++-- coderd/userauth_test.go | 30 ++++++++++++++++++++++++++++++ docs/admin/users/github-auth.md | 8 +++----- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/cli/server.go b/cli/server.go index e2670ded332..1002966e1fd 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2258,7 +2258,7 @@ func getGithubOAuth2ConfigParams(ctx context.Context, logger slog.Logger, db dat 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", "https://github.com/apps/coder/installations/select_target"), + slog.F("install_url", coderd.GithubOAuth2DefaultProviderInstallURL), ) } diff --git a/coderd/userauth.go b/coderd/userauth.go index e2e05ffacce..24bb95b7445 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} } @@ -936,8 +950,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { // 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 += " The default GitHub OAuth provider can only see memberships in organizations that have installed the Coder GitHub app." + - " Install it from https://github.com/apps/coder for each authorized organization, or configure a custom GitHub OAuth app." + msg += " " + githubOAuth2DefaultProviderRemediation } if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the error is rendered client-side. @@ -985,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 2e67ddc9b63..bba1f93e107 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -249,6 +249,36 @@ func TestUserOAuth2Github(t *testing.T) { 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() client := coderdtest.New(t, &coderdtest.Options{ diff --git a/docs/admin/users/github-auth.md b/docs/admin/users/github-auth.md index 5dc3a85dffa..9125c6217b4 100644 --- a/docs/admin/users/github-auth.md +++ b/docs/admin/users/github-auth.md @@ -45,11 +45,9 @@ To use the default configuration: ``` > [!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. + > 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