diff --git a/cli/server.go b/cli/server.go index f9ef1aaa65c8c..31a2b63a49660 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2259,6 +2259,12 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder provider.DisplayName = v.Value case "DISPLAY_ICON": provider.DisplayIcon = v.Value + case "SLACK_AUTHED_USER_TOKEN": + b, err := strconv.ParseBool(v.Value) + if err != nil { + return nil, xerrors.Errorf("parse bool: %s", v.Value) + } + provider.SlackAuthedUserToken = b } providers[providerNum] = provider } diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index 6f060aea2c6b6..8daf6a63720db 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -68,6 +68,7 @@ type FakeIDP struct { // "Authorized Redirect URLs". This can be used to emulate that. hookValidRedirectURL func(redirectURL string) error hookUserInfo func(email string) (jwt.MapClaims, error) + hookExtra func(email string) map[string]interface{} fakeCoderd func(req *http.Request) (*http.Response, error) hookOnRefresh func(email string) error // Custom authentication for the client. This is useful if you want @@ -112,6 +113,12 @@ func WithRefresh(hook func(email string) error) func(*FakeIDP) { } } +func WithExtra(extra func(email string) map[string]interface{}) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookExtra = extra + } +} + func WithCustomClientAuth(hook func(t testing.TB, req *http.Request) (url.Values, error)) func(*FakeIDP) { return func(f *FakeIDP) { f.hookAuthenticateClient = hook @@ -621,6 +628,11 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { "expires_in": int64((time.Minute * 5).Seconds()), "id_token": f.encodeClaims(t, claims), } + if f.hookExtra != nil { + for k, v := range f.hookExtra(email) { + token[k] = v + } + } // Store the claims for the next refresh f.refreshIDTokenClaims.Store(refreshToken, claims) diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 34bd7e9253ee7..92a2aa28cb75c 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -67,6 +67,10 @@ type Config struct { // AppInstallationsURL is an API endpoint that returns a list of // installations for the user. This is used for GitHub Apps. AppInstallationsURL string + + // SlackAuthedUserToken is true if the user token should be returned + // instead of the bot token. + SlackAuthedUserToken bool } // RefreshToken automatically refreshes the token if expired and permitted. @@ -101,6 +105,22 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu // we aren't trying to surface an error, we're just trying to obtain a valid token. return externalAuthLink, false, nil } + + // Slack's new OAuth2 flow has the user access token in a different field. + // It's weird and unfortunate, but the only way to access the user token. + // See: https://api.slack.com/authentication/oauth-v2#exchanging + if c.Type == string(codersdk.EnhancedExternalAuthProviderSlack) && c.SlackAuthedUserToken { + rawMap, ok := token.Extra("authed_user").(map[string]interface{}) + if !ok { + return externalAuthLink, false, xerrors.Errorf("slack: could not obtain user access token from payload: %+v", token.Extra("authed_user")) + } + accessToken, ok := rawMap["access_token"].(string) + if !ok { + return externalAuthLink, false, xerrors.Errorf("slack: could not obtain user access token from payload: %+v", token.Extra("authed_user")) + } + token.AccessToken = accessToken + } + r := retry.New(50*time.Millisecond, 200*time.Millisecond) // See the comment below why the retry and cancel is required. retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second) @@ -424,16 +444,17 @@ func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([ } cfg := &Config{ - OAuth2Config: oauthConfig, - ID: entry.ID, - Regex: regex, - Type: entry.Type, - NoRefresh: entry.NoRefresh, - ValidateURL: entry.ValidateURL, - AppInstallationsURL: entry.AppInstallationsURL, - AppInstallURL: entry.AppInstallURL, - DisplayName: entry.DisplayName, - DisplayIcon: entry.DisplayIcon, + OAuth2Config: oauthConfig, + ID: entry.ID, + Regex: regex, + Type: entry.Type, + NoRefresh: entry.NoRefresh, + ValidateURL: entry.ValidateURL, + AppInstallationsURL: entry.AppInstallationsURL, + AppInstallURL: entry.AppInstallURL, + DisplayName: entry.DisplayName, + DisplayIcon: entry.DisplayIcon, + SlackAuthedUserToken: entry.SlackAuthedUserToken, } if entry.DeviceFlow { @@ -539,6 +560,12 @@ var defaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.ExternalAuthCo DeviceCodeURL: "https://github.com/login/device/code", AppInstallationsURL: "https://api.github.com/user/installations", }, + codersdk.EnhancedExternalAuthProviderSlack: { + AuthURL: "https://slack.com/oauth/v2/authorize", + TokenURL: "https://slack.com/api/oauth.v2.access", + DisplayName: "Slack", + DisplayIcon: "/icon/slack.svg", + }, } // jwtConfig is a new OAuth2 config that uses a custom diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 418d143d16e7e..04d67736489cd 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -43,7 +43,7 @@ func TestRefreshToken(t *testing.T) { return nil, xerrors.New("should not be called") }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { cfg.NoRefresh = true }, }) @@ -74,7 +74,7 @@ func TestRefreshToken(t *testing.T) { return jwt.MapClaims{}, nil }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { cfg.NoRefresh = true }, }) @@ -117,7 +117,7 @@ func TestRefreshToken(t *testing.T) { return jwt.MapClaims{}, xerrors.New(staticError) }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { }, }) @@ -142,7 +142,7 @@ func TestRefreshToken(t *testing.T) { return jwt.MapClaims{}, oidctest.StatusError(http.StatusUnauthorized, xerrors.New(staticError)) }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { }, }) @@ -175,7 +175,7 @@ func TestRefreshToken(t *testing.T) { return jwt.MapClaims{}, oidctest.StatusError(http.StatusUnauthorized, xerrors.New(staticError)) }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() }, }) @@ -205,7 +205,7 @@ func TestRefreshToken(t *testing.T) { return jwt.MapClaims{}, nil }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() }, }) @@ -236,7 +236,7 @@ func TestRefreshToken(t *testing.T) { return jwt.MapClaims{}, nil }), }, - GitConfigOpt: func(cfg *externalauth.Config) { + ExternalAuthOpt: func(cfg *externalauth.Config) { cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() }, DB: db, @@ -260,6 +260,38 @@ func TestRefreshToken(t *testing.T) { require.NoError(t, err) require.Equal(t, updated.OAuthAccessToken, dbLink.OAuthAccessToken, "token is updated in the DB") }) + + t.Run("SlackUserToken", func(t *testing.T) { + t.Parallel() + + db := dbfake.New() + fake, config, link := setupOauth2Test(t, testConfig{ + FakeIDPOpts: []oidctest.FakeIDPOpt{ + oidctest.WithExtra(func(email string) map[string]interface{} { + return map[string]interface{}{ + "authed_user": map[string]interface{}{ + "access_token": "slack-user-token", + }, + } + }), + }, + ExternalAuthOpt: func(cfg *externalauth.Config) { + cfg.Type = codersdk.EnhancedExternalAuthProviderSlack.String() + cfg.SlackAuthedUserToken = true + cfg.ValidateURL = "" + }, + DB: db, + }) + + ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil)) + // Force a refresh + link.OAuthExpiry = expired + + updated, ok, err := config.RefreshToken(ctx, db, link) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "slack-user-token", updated.OAuthAccessToken) + }) } func TestConvertYAML(t *testing.T) { @@ -344,7 +376,7 @@ func TestConvertYAML(t *testing.T) { type testConfig struct { FakeIDPOpts []oidctest.FakeIDPOpt CoderOIDCConfigOpts []func(cfg *coderd.OIDCConfig) - GitConfigOpt func(cfg *externalauth.Config) + ExternalAuthOpt func(cfg *externalauth.Config) // If DB is passed in, the link will be inserted into the DB. DB database.Store } @@ -367,7 +399,7 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext ID: providerID, ValidateURL: fake.WellknownConfig().UserInfoURL, } - settings.GitConfigOpt(config) + settings.ExternalAuthOpt(config) oauthToken, err := fake.GenerateAuthenticatedToken(jwt.MapClaims{ "email": "test@coder.com", diff --git a/codersdk/deployment.go b/codersdk/deployment.go index db9113e63dc67..56412fbe39033 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -349,6 +349,12 @@ type ExternalAuthConfig struct { DisplayName string `json:"display_name"` // DisplayIcon is a URL to an icon to display in the UI. DisplayIcon string `json:"display_icon"` + + // SlackAuthedUserToken is a Slack-specific field that controls + // whether the Bot or User token is returned from the OAuth exchange. + // Slack returns multiple OAuth tokens as part of it's flow. + // See: https://api.slack.com/authentication/oauth-v2#exchanging + SlackAuthedUserToken bool `json:"slack_authed_user_token"` } type ProvisionerConfig struct { diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index 6aff5ad63bf76..0167ca8156259 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -34,6 +34,7 @@ const ( EnhancedExternalAuthProviderGitHub EnhancedExternalAuthProvider = "github" EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab" EnhancedExternalAuthProviderBitBucket EnhancedExternalAuthProvider = "bitbucket" + EnhancedExternalAuthProviderSlack EnhancedExternalAuthProvider = "slack" ) type ExternalAuth struct { diff --git a/site/static/icon/slack.svg b/site/static/icon/slack.svg new file mode 100644 index 0000000000000..fb55f7245df5b --- /dev/null +++ b/site/static/icon/slack.svg @@ -0,0 +1,6 @@ + + + + + +