From 5bb6ba5ebb8924f23abc20190947869c11996940 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Wed, 12 Jun 2024 14:09:26 -0400 Subject: [PATCH] feat: add support for Slack OAuth V2 (#1591) ## What kind of change does this PR introduce? - Updates the Slack OAuth provider with the new Sign In With Slack V2. - Creates a test for Slack, improving test coverage - Moves the old Slack provider to slack_legacy. Some users might still rely on this provider after the creation of legacy apps is disallowed on June 4th. ## What is the current behavior? Fixes #1294 Current behavior uses the original Slack OAuth V1 which is sunsetting June 4th according to [the changelog](https://api.slack.com/changelog/2024-04-discontinuing-new-creation-of-classic-slack-apps-and-custom-bots) ## What is the new behavior? New behavior now leverages the new [Sign In With Slack](https://api.slack.com/authentication/sign-in-with-slack) (SIWS) on OAuth V2 for Slack authentication. ## Additional context A ticket should be created for ending support on slack_legacy. --------- Co-authored-by: Kang Ming --- CONTRIBUTING.md | 1 + hack/test.env | 8 ++ internal/api/external.go | 2 + internal/api/external_slack_oidc_test.go | 33 ++++++++ internal/api/provider/slack.go | 4 +- internal/api/provider/slack_oidc.go | 99 ++++++++++++++++++++++++ internal/api/settings.go | 2 + internal/api/settings_test.go | 2 + internal/conf/configuration.go | 1 + 9 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 internal/api/external_slack_oidc_test.go create mode 100644 internal/api/provider/slack_oidc.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96091c1451..f65eca2990 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -253,6 +253,7 @@ To see the current settings, make a request to `http://localhost:9999/settings` "facebook": false, "spotify": false, "slack": false, + "slack_oidc": false, "twitch": true, "twitter": false, "email": true, diff --git a/hack/test.env b/hack/test.env index 4099403142..35e4b61c81 100644 --- a/hack/test.env +++ b/hack/test.env @@ -60,6 +60,10 @@ GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_LINKEDIN_OIDC_ENABLED=true +GOTRUE_EXTERNAL_LINKEDIN_OIDC_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_LINKEDIN_OIDC_SECRET=testsecret +GOTRUE_EXTERNAL_LINKEDIN_OIDC_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_GITLAB_ENABLED=true GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=testclientid GOTRUE_EXTERNAL_GITLAB_SECRET=testsecret @@ -80,6 +84,10 @@ GOTRUE_EXTERNAL_SLACK_ENABLED=true GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid GOTRUE_EXTERNAL_SLACK_SECRET=testsecret GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_SLACK_OIDC_ENABLED=true +GOTRUE_EXTERNAL_SLACK_OIDC_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_SLACK_OIDC_SECRET=testsecret +GOTRUE_EXTERNAL_SLACK_OIDC_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_WORKOS_ENABLED=true GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index cf1736f032..a8048fb264 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -558,6 +558,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewSpotifyProvider(config.External.Spotify, scopes) case "slack": return provider.NewSlackProvider(config.External.Slack, scopes) + case "slack_oidc": + return provider.NewSlackOIDCProvider(config.External.SlackOIDC, scopes) case "twitch": return provider.NewTwitchProvider(config.External.Twitch, scopes) case "twitter": diff --git a/internal/api/external_slack_oidc_test.go b/internal/api/external_slack_oidc_test.go new file mode 100644 index 0000000000..9090581d0f --- /dev/null +++ b/internal/api/external_slack_oidc_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt" +) + +func (ts *ExternalTestSuite) TestSignupExternalSlackOIDC() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=slack_oidc", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.Slack.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Slack.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("profile email openid", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("slack_oidc", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} diff --git a/internal/api/provider/slack.go b/internal/api/provider/slack.go index efe3188131..40377b0aaf 100644 --- a/internal/api/provider/slack.go +++ b/internal/api/provider/slack.go @@ -23,7 +23,7 @@ type slackUser struct { TeamID string `json:"https://slack.com/team_id"` } -// NewSlackProvider creates a Slack account provider. +// NewSlackProvider creates a Slack account provider with Legacy Slack OAuth. func NewSlackProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { if err := ext.ValidateOAuth(); err != nil { return nil, err @@ -71,7 +71,7 @@ func (g slackProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use if u.Email != "" { data.Emails = []Email{{ Email: u.Email, - Verified: true, // Slack dosen't provide data on if email is verified. + Verified: true, // Slack doesn't provide data on if email is verified. Primary: true, }} } diff --git a/internal/api/provider/slack_oidc.go b/internal/api/provider/slack_oidc.go new file mode 100644 index 0000000000..3c7a5eb62d --- /dev/null +++ b/internal/api/provider/slack_oidc.go @@ -0,0 +1,99 @@ +package provider + +import ( + "context" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const defaultSlackOIDCApiBase = "slack.com" + +type slackOIDCProvider struct { + *oauth2.Config + APIPath string +} + +type slackOIDCUser struct { + ID string `json:"https://slack.com/user_id"` + TeamID string `json:"https://slack.com/team_id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + AvatarURL string `json:"picture"` +} + +// NewSlackOIDCProvider creates a Slack account provider with Sign in with Slack. +func NewSlackOIDCProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + apiPath := chooseHost(ext.URL, defaultSlackOIDCApiBase) + "/api" + authPath := chooseHost(ext.URL, defaultSlackOIDCApiBase) + "/openid" + + // these are required scopes for slack's OIDC flow + // see https://api.slack.com/authentication/sign-in-with-slack#implementation + oauthScopes := []string{ + "profile", + "email", + "openid", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &slackOIDCProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authPath + "/connect/authorize", + TokenURL: apiPath + "/openid.connect.token", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + APIPath: apiPath, + }, nil +} + +func (g slackOIDCProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g slackOIDCProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u slackOIDCUser + if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/openid.connect.userInfo", &u); err != nil { + return nil, err + } + + data := &UserProvidedData{} + if u.Email != "" { + data.Emails = []Email{{ + Email: u.Email, + // email_verified is returned as part of the response + // see: https://api.slack.com/authentication/sign-in-with-slack#response + Verified: u.EmailVerified, + Primary: true, + }} + } + + data.Metadata = &Claims{ + Issuer: g.APIPath, + Subject: u.ID, + Name: u.Name, + Picture: u.AvatarURL, + CustomClaims: map[string]interface{}{ + "https://slack.com/team_id": u.TeamID, + }, + + // To be deprecated + AvatarURL: u.AvatarURL, + FullName: u.Name, + ProviderId: u.ID, + } + return data, nil +} diff --git a/internal/api/settings.go b/internal/api/settings.go index 9ea93edb72..16817db108 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -21,6 +21,7 @@ type ProviderSettings struct { Notion bool `json:"notion"` Spotify bool `json:"spotify"` Slack bool `json:"slack"` + SlackOIDC bool `json:"slack_oidc"` WorkOS bool `json:"workos"` Twitch bool `json:"twitch"` Twitter bool `json:"twitter"` @@ -62,6 +63,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Notion: config.External.Notion.Enabled, Spotify: config.External.Spotify.Enabled, Slack: config.External.Slack.Enabled, + SlackOIDC: config.External.SlackOIDC.Enabled, Twitch: config.External.Twitch.Enabled, Twitter: config.External.Twitter.Enabled, WorkOS: config.External.WorkOS.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 42a5d97841..767bcf7846 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -35,10 +35,12 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Notion) require.True(t, p.Spotify) require.True(t, p.Slack) + require.True(t, p.SlackOIDC) require.True(t, p.Google) require.True(t, p.Kakao) require.True(t, p.Keycloak) require.True(t, p.Linkedin) + require.True(t, p.LinkedinOIDC) require.True(t, p.GitHub) require.True(t, p.GitLab) require.True(t, p.Twitch) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 99f0f1879b..d3ba720a01 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -298,6 +298,7 @@ type ProviderConfiguration struct { LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` Spotify OAuthProviderConfiguration `json:"spotify"` Slack OAuthProviderConfiguration `json:"slack"` + SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` WorkOS OAuthProviderConfiguration `json:"workos"`