feat: enable GitHub OAuth2 login by default on new deployments (#16662)

Third and final PR to address
https://github.com/coder/coder/issues/16230.

This PR enables GitHub OAuth2 login by default on new deployments.
Combined with https://github.com/coder/coder/pull/16629, this will allow
the first admin user to sign up with GitHub rather than email and
password.

We take care not to enable the default on deployments that would upgrade
to a Coder version with this change.

To disable the default provider an admin can set the
`CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER` env variable to false.
This commit is contained in:
Hugo Dutka
2025-02-25 16:31:33 +01:00
committed by GitHub
parent 67d89bb102
commit d3a56ae3ef
25 changed files with 544 additions and 83 deletions

View File

@ -688,24 +688,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
}
if vals.OAuth2.Github.ClientSecret != "" || vals.OAuth2.Github.DeviceFlow.Value() {
options.GithubOAuth2Config, err = configureGithubOAuth2(
oauthInstrument,
vals.AccessURL.Value(),
vals.OAuth2.Github.ClientID.String(),
vals.OAuth2.Github.ClientSecret.String(),
vals.OAuth2.Github.DeviceFlow.Value(),
vals.OAuth2.Github.AllowSignups.Value(),
vals.OAuth2.Github.AllowEveryone.Value(),
vals.OAuth2.Github.AllowedOrgs,
vals.OAuth2.Github.AllowedTeams,
vals.OAuth2.Github.EnterpriseBaseURL.String(),
)
if err != nil {
return xerrors.Errorf("configure github oauth2: %w", err)
}
}
// As OIDC clients can be confidential or public,
// we should only check for a client id being set.
// The underlying library handles the case of no
@ -793,6 +775,20 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("set deployment id: %w", err)
}
githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals)
if err != nil {
return xerrors.Errorf("get github oauth2 config params: %w", err)
}
if githubOAuth2ConfigParams != nil {
options.GithubOAuth2Config, err = configureGithubOAuth2(
oauthInstrument,
githubOAuth2ConfigParams,
)
if err != nil {
return xerrors.Errorf("configure github oauth2: %w", err)
}
}
options.RuntimeConfig = runtimeconfig.NewManager()
// This should be output before the logs start streaming.
@ -1843,25 +1839,101 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
return nil
}
// TODO: convert the argument list to a struct, it's easy to mix up the order of the arguments
//
const (
// Client ID for https://github.com/apps/coder
GithubOAuth2DefaultProviderClientID = "Iv1.6a2b4b4aec4f4fe7"
GithubOAuth2DefaultProviderAllowEveryone = true
GithubOAuth2DefaultProviderDeviceFlow = true
)
type githubOAuth2ConfigParams struct {
accessURL *url.URL
clientID string
clientSecret string
deviceFlow bool
allowSignups bool
allowEveryone bool
allowOrgs []string
rawTeams []string
enterpriseBaseURL string
}
func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *codersdk.DeploymentValues) (*githubOAuth2ConfigParams, error) {
params := githubOAuth2ConfigParams{
accessURL: vals.AccessURL.Value(),
clientID: vals.OAuth2.Github.ClientID.String(),
clientSecret: vals.OAuth2.Github.ClientSecret.String(),
deviceFlow: vals.OAuth2.Github.DeviceFlow.Value(),
allowSignups: vals.OAuth2.Github.AllowSignups.Value(),
allowEveryone: vals.OAuth2.Github.AllowEveryone.Value(),
allowOrgs: vals.OAuth2.Github.AllowedOrgs.Value(),
rawTeams: vals.OAuth2.Github.AllowedTeams.Value(),
enterpriseBaseURL: vals.OAuth2.Github.EnterpriseBaseURL.String(),
}
// If the user manually configured the GitHub OAuth2 provider,
// we won't add the default configuration.
if params.clientID != "" || params.clientSecret != "" || params.enterpriseBaseURL != "" {
return &params, nil
}
// Check if the user manually disabled the default GitHub OAuth2 provider.
if !vals.OAuth2.Github.DefaultProviderEnable.Value() {
return nil, nil //nolint:nilnil
}
// Check if the deployment is eligible for the default GitHub OAuth2 provider.
// We want to enable it only for new deployments, and avoid enabling it
// if a deployment was upgraded from an older version.
// nolint:gocritic // Requires system privileges
defaultEligible, err := db.GetOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx))
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, xerrors.Errorf("get github default eligible: %w", err)
}
defaultEligibleNotSet := errors.Is(err, sql.ErrNoRows)
if defaultEligibleNotSet {
// nolint:gocritic // User count requires system privileges
userCount, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx))
if err != nil {
return nil, xerrors.Errorf("get user count: %w", err)
}
// We check if a deployment is new by checking if it has any users.
defaultEligible = userCount == 0
// nolint:gocritic // Requires system privileges
if err := db.UpsertOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx), defaultEligible); err != nil {
return nil, xerrors.Errorf("upsert github default eligible: %w", err)
}
}
if !defaultEligible {
return nil, nil //nolint:nilnil
}
params.clientID = GithubOAuth2DefaultProviderClientID
params.allowEveryone = GithubOAuth2DefaultProviderAllowEveryone
params.deviceFlow = GithubOAuth2DefaultProviderDeviceFlow
return &params, nil
}
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) {
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
func configureGithubOAuth2(instrument *promoauth.Factory, params *githubOAuth2ConfigParams) (*coderd.GithubOAuth2Config, error) {
redirectURL, err := params.accessURL.Parse("/api/v2/users/oauth2/github/callback")
if err != nil {
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
}
if allowEveryone && len(allowOrgs) > 0 {
if params.allowEveryone && len(params.allowOrgs) > 0 {
return nil, xerrors.New("allow everyone and allowed orgs cannot be used together")
}
if allowEveryone && len(rawTeams) > 0 {
if params.allowEveryone && len(params.rawTeams) > 0 {
return nil, xerrors.New("allow everyone and allowed teams cannot be used together")
}
if !allowEveryone && len(allowOrgs) == 0 {
if !params.allowEveryone && len(params.allowOrgs) == 0 {
return nil, xerrors.New("allowed orgs is empty: must specify at least one org or allow everyone")
}
allowTeams := make([]coderd.GithubOAuth2Team, 0, len(rawTeams))
for _, rawTeam := range rawTeams {
allowTeams := make([]coderd.GithubOAuth2Team, 0, len(params.rawTeams))
for _, rawTeam := range params.rawTeams {
parts := strings.SplitN(rawTeam, "/", 2)
if len(parts) != 2 {
return nil, xerrors.Errorf("github team allowlist is formatted incorrectly. got %s; wanted <organization>/<team>", rawTeam)
@ -1873,8 +1945,8 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
}
endpoint := xgithub.Endpoint
if enterpriseBaseURL != "" {
enterpriseURL, err := url.Parse(enterpriseBaseURL)
if params.enterpriseBaseURL != "" {
enterpriseURL, err := url.Parse(params.enterpriseBaseURL)
if err != nil {
return nil, xerrors.Errorf("parse enterprise base url: %w", err)
}
@ -1893,8 +1965,8 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
}
instrumentedOauth := instrument.NewGithub("github-login", &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
ClientID: params.clientID,
ClientSecret: params.clientSecret,
Endpoint: endpoint,
RedirectURL: redirectURL.String(),
Scopes: []string{
@ -1906,17 +1978,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
createClient := func(client *http.Client, source promoauth.Oauth2Source) (*github.Client, error) {
client = instrumentedOauth.InstrumentHTTPClient(client, source)
if enterpriseBaseURL != "" {
return github.NewEnterpriseClient(enterpriseBaseURL, "", client)
if params.enterpriseBaseURL != "" {
return github.NewEnterpriseClient(params.enterpriseBaseURL, "", client)
}
return github.NewClient(client), nil
}
var deviceAuth *externalauth.DeviceAuth
if deviceFlow {
if params.deviceFlow {
deviceAuth = &externalauth.DeviceAuth{
Config: instrumentedOauth,
ClientID: clientID,
ClientID: params.clientID,
TokenURL: endpoint.TokenURL,
Scopes: []string{"read:user", "read:org", "user:email"},
CodeURL: endpoint.DeviceAuthURL,
@ -1925,9 +1997,9 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
return &coderd.GithubOAuth2Config{
OAuth2Config: instrumentedOauth,
AllowSignups: allowSignups,
AllowEveryone: allowEveryone,
AllowOrganizations: allowOrgs,
AllowSignups: params.allowSignups,
AllowEveryone: params.allowEveryone,
AllowOrganizations: params.allowOrgs,
AllowTeams: allowTeams,
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
api, err := createClient(client, promoauth.SourceGitAPIAuthUser)
@ -1966,19 +2038,20 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl
team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username)
return team, err
},
DeviceFlowEnabled: deviceFlow,
DeviceFlowEnabled: params.deviceFlow,
ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
if !deviceFlow {
if !params.deviceFlow {
return nil, xerrors.New("device flow is not enabled")
}
return deviceAuth.ExchangeDeviceCode(ctx, deviceCode)
},
AuthorizeDevice: func(ctx context.Context) (*codersdk.ExternalAuthDevice, error) {
if !deviceFlow {
if !params.deviceFlow {
return nil, xerrors.New("device flow is not enabled")
}
return deviceAuth.AuthorizeDevice(ctx)
},
DefaultProviderConfigured: params.clientID == GithubOAuth2DefaultProviderClientID,
}, nil
}