mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd): explain default GitHub app org visibility on login rejection (#27374)
## Problem On a fresh deployment with no custom GitHub OAuth app, Coder falls back to the default Coder-managed GitHub app. That app can only see organization memberships in organizations where it has been installed. If `CODER_OAUTH2_GITHUB_ALLOWED_ORGS` is set but the app isn't installed in the allowed organizations, the membership list comes back empty and every login, including the first admin login, is rejected with a bare "You aren't a member of the authorized Github organizations!" with no hint about the actual cause. This leaves fresh deployments in an apparently broken state. ## Fix * Append a remediation hint to the login rejection when the default provider is configured, pointing at the [app installation page](<https://github.com/apps/coder/installations/select_target>) and at configuring a custom GitHub OAuth app. * Log a startup warning when the default provider is combined with `CODER_OAUTH2_GITHUB_ALLOWED_ORGS`, listing the allowed orgs and the install URL. * Document the installation requirement next to the `CODER_OAUTH2_GITHUB_ALLOWED_ORGS` step in the GitHub auth docs. Access-control behavior is unchanged; the org check still rejects logins as before, it just explains why and how to fix it. ## Testing * New `TestUserOAuth2Github/NotInAllowedOrganizationDefaultProvider` asserts the hint appears when `DefaultProviderConfigured` is set; the existing `NotInAllowedOrganization` subtest asserts it does not leak into the custom-app path. Fixes coder/coder#17752
This commit is contained in:
+12
-2
@@ -1006,7 +1006,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
|||||||
}
|
}
|
||||||
options.WebPushDispatcher = webpusher
|
options.WebPushDispatcher = webpusher
|
||||||
|
|
||||||
githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals)
|
githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Logger, options.Database, vals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("get github oauth2 config params: %w", err)
|
return xerrors.Errorf("get github oauth2 config params: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2213,7 +2213,7 @@ func maybeAppendDefaultGithubExternalAuthProvider(
|
|||||||
}), nil
|
}), 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{
|
params := githubOAuth2ConfigParams{
|
||||||
accessURL: vals.AccessURL.Value(),
|
accessURL: vals.AccessURL.Value(),
|
||||||
clientID: vals.OAuth2.Github.ClientID.String(),
|
clientID: vals.OAuth2.Github.ClientID.String(),
|
||||||
@@ -2250,6 +2250,16 @@ func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *c
|
|||||||
params.deviceFlow = GithubOAuth2DefaultProviderDeviceFlow
|
params.deviceFlow = GithubOAuth2DefaultProviderDeviceFlow
|
||||||
if len(params.allowOrgs) == 0 {
|
if len(params.allowOrgs) == 0 {
|
||||||
params.allowEveryone = GithubOAuth2DefaultProviderAllowEveryone
|
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
|
return ¶ms, nil
|
||||||
|
|||||||
@@ -772,6 +772,20 @@ type GithubOAuth2Config struct {
|
|||||||
DefaultProviderConfigured bool
|
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 {
|
func (*GithubOAuth2Config) PKCESupported() []promoauth.Oauth2PKCEChallengeMethod {
|
||||||
return []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256}
|
return []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256}
|
||||||
}
|
}
|
||||||
@@ -931,6 +945,13 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
|||||||
if len(selectedMemberships) == 0 {
|
if len(selectedMemberships) == 0 {
|
||||||
status := http.StatusUnauthorized
|
status := http.StatusUnauthorized
|
||||||
msg := "You aren't a member of the authorized Github organizations!"
|
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 {
|
if api.GithubOAuth2Config.DeviceFlowEnabled {
|
||||||
// In the device flow, the error is rendered client-side.
|
// In the device flow, the error is rendered client-side.
|
||||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||||
@@ -977,6 +998,12 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if allowedTeam == nil {
|
if allowedTeam == nil {
|
||||||
msg := fmt.Sprintf("You aren't a member of an authorized team in the %v Github organization(s)!", organizationNames)
|
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
|
status := http.StatusUnauthorized
|
||||||
if api.GithubOAuth2Config.DeviceFlowEnabled {
|
if api.GithubOAuth2Config.DeviceFlowEnabled {
|
||||||
// In the device flow, the error is rendered client-side.
|
// In the device flow, the error is rendered client-side.
|
||||||
|
|||||||
@@ -221,6 +221,63 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||||||
|
|
||||||
resp := oauth2Callback(t, client)
|
resp := oauth2Callback(t, client)
|
||||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
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.Run("NotInAllowedTeam", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ To use the default configuration:
|
|||||||
CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org"
|
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
|
## 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)
|
You can disable the default GitHub app by [configuring your own app](#step-1-configure-the-oauth-application-in-github)
|
||||||
|
|||||||
Reference in New Issue
Block a user