From 279a9b54d9ee829aae344302e1c1c41a535b0264 Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Mon, 19 Aug 2024 12:00:09 -0700 Subject: [PATCH 1/2] Changes: - add instagram to README - add instagram to external.go - add instagram to settings.go - add instagram to settings_test.go - add instagram to configuration.go - add external_instagram_test.go - add provider/instagram.go - add instagram to .env.example --- README.md | 5 +- example.env | 7 ++ internal/api/external.go | 2 + internal/api/external_instagram_test.go | 153 ++++++++++++++++++++++++ internal/api/provider/instagram.go | 76 ++++++++++++ internal/api/settings.go | 2 + internal/api/settings_test.go | 1 + internal/conf/configuration.go | 1 + 8 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 internal/api/external_instagram_test.go create mode 100644 internal/api/provider/instagram.go diff --git a/README.md b/README.md index 2a71886b31..b5c906ddcf 100644 --- a/README.md +++ b/README.md @@ -430,7 +430,7 @@ The default group to assign all new users to. ### External Authentication Providers -We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. +We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `instagram`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. Use the names as the keys underneath `external` to configure each separately. @@ -742,6 +742,7 @@ Returns the publicly available settings for this auth instance. "github": true, "gitlab": true, "google": true, + "instagram": true, "keycloak": true, "linkedin": true, "notion": true, @@ -1184,7 +1185,7 @@ Get access_token from external oauth provider query params: ``` -provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos +provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | instagram | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos scopes= ``` diff --git a/example.env b/example.env index e408dcb40b..745ab6ab28 100644 --- a/example.env +++ b/example.env @@ -108,6 +108,13 @@ GOTRUE_EXTERNAL_GITHUB_CLIENT_ID="" GOTRUE_EXTERNAL_GITHUB_SECRET="" GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI="http://localhost:9999/callback" +# Instagram OAuth config +GOTRUE_EXTERNAL_INSTAGRAM_ENABLED="false" +GOTRUE_EXTERNAL_INSTAGRAM_CLIENT_ID="" +GOTRUE_EXTERNAL_INSTAGRAM_SECRET="" +GOTRUE_EXTERNAL_INSTAGRAM_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_INSTAGRAM_URL="https://api.instagram.com/oauth/authorize" + # Kakao OAuth config GOTRUE_EXTERNAL_KAKAO_ENABLED="false" GOTRUE_EXTERNAL_KAKAO_CLIENT_ID="" diff --git a/internal/api/external.go b/internal/api/external.go index ef6032d9a7..792c4f7300 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -544,6 +544,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewGitlabProvider(config.External.Gitlab, scopes) case "google": return provider.NewGoogleProvider(ctx, config.External.Google, scopes) + case "instagram": + return provider.NewInstagramProvider(config.External.Instagram, scopes) case "kakao": return provider.NewKakaoProvider(config.External.Kakao, scopes) case "keycloak": diff --git a/internal/api/external_instagram_test.go b/internal/api/external_instagram_test.go new file mode 100644 index 0000000000..2c202f75f4 --- /dev/null +++ b/internal/api/external_instagram_test.go @@ -0,0 +1,153 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt/v5" +) + +const ( + instagramUser string = `{"id":"instagramTestId","username":"instagram_test"}` + instagramUserWrongID string = `{"id":"wrongTestId","username":"instagram_test"}` +) + +func (ts *ExternalTestSuite) TestSignupExternalInstagram() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=instagram", 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.Instagram.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Instagram.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("user_profile", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.NewParser(jwt.WithValidMethods([]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("instagram", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func InstagramTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/access_token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.Instagram.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"instagram_token","user_id":"instagramTestId"}`) + case "/me": + *userCount++ + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(500) + ts.Fail("unknown instagram oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Instagram.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalInstagram_AuthorizationCode() { + ts.Config.DisableSignup = false + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUser) + defer server.Close() + + u := performAuthorization(ts, "instagram", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "", "instagram_test", "instagramTestId", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalInstagramDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUser) + defer server.Close() + + u := performAuthorization(ts, "instagram", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalInstagramDisableSignupSuccessWithExistingUser() { + ts.Config.DisableSignup = true + + ts.createUser("instagramTestId", "", "instagram_test", "", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUser) + defer server.Close() + + u := performAuthorization(ts, "instagram", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "", "instagram_test", "instagramTestId", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalInstagramSuccessWhenMatchingToken() { + ts.createUser("instagramTestId", "", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUser) + defer server.Close() + + u := performAuthorization(ts, "instagram", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "", "instagram_test", "instagramTestId", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalInstagramErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "instagram", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalInstagramErrorWhenWrongToken() { + ts.createUser("instagramTestId", "", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "instagram", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalInstagramErrorWhenIDDoesntMatch() { + ts.createUser("instagramTestId", "", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := InstagramTestSignupSetup(ts, &tokenCount, &userCount, code, instagramUserWrongID) + defer server.Close() + + u := performAuthorization(ts, "instagram", code, "invite_token") + + assertAuthorizationFailure(ts, u, "User ID from external provider doesn't match invited user", "invalid_request", "") +} diff --git a/internal/api/provider/instagram.go b/internal/api/provider/instagram.go new file mode 100644 index 0000000000..8a02470249 --- /dev/null +++ b/internal/api/provider/instagram.go @@ -0,0 +1,76 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +// Instagram +type instagramProvider struct { + *oauth2.Config +} + +type instagramUser struct { + ID string `json:"id"` + Username string `json:"username"` +} + +// NewInstagramProvider creates an Instagram account provider. +func NewInstagramProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + return &instagramProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://api.instagram.com/oauth/authorize", + TokenURL: "https://api.instagram.com/oauth/access_token", + }, + RedirectURL: ext.RedirectURI, + Scopes: []string{"user_profile"}, + }, + }, nil +} + +func (g instagramProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g instagramProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + client := g.Client(ctx, tok) + resp, err := client.Get("https://graph.instagram.com/me?fields=id,username") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var u instagramUser + if err := json.Unmarshal(body, &u); err != nil { + return nil, err + } + + data := &UserProvidedData{ + Metadata: &Claims{ + Issuer: "https://instagram.com", + Subject: u.ID, + Name: u.Username, + ProviderId: u.ID, + }, + } + + return data, nil +} diff --git a/internal/api/settings.go b/internal/api/settings.go index 16817db108..1c14871cb7 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -14,6 +14,7 @@ type ProviderSettings struct { GitHub bool `json:"github"` GitLab bool `json:"gitlab"` Google bool `json:"google"` + Instagram bool `json:"instagram"` Keycloak bool `json:"keycloak"` Kakao bool `json:"kakao"` Linkedin bool `json:"linkedin"` @@ -56,6 +57,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { GitHub: config.External.Github.Enabled, GitLab: config.External.Gitlab.Enabled, Google: config.External.Google.Enabled, + Instagram: config.External.Instagram.Enabled, Kakao: config.External.Kakao.Enabled, Keycloak: config.External.Keycloak.Enabled, Linkedin: config.External.Linkedin.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 767bcf7846..25926aadbd 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -37,6 +37,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Slack) require.True(t, p.SlackOIDC) require.True(t, p.Google) + require.True(t, p.Instagram) require.True(t, p.Kakao) require.True(t, p.Keycloak) require.True(t, p.Linkedin) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 81d058c296..1acef8765d 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -315,6 +315,7 @@ type ProviderConfiguration struct { Github OAuthProviderConfiguration `json:"github"` Gitlab OAuthProviderConfiguration `json:"gitlab"` Google OAuthProviderConfiguration `json:"google"` + Instagram OAuthProviderConfiguration `json:"instagram"` Kakao OAuthProviderConfiguration `json:"kakao"` Notion OAuthProviderConfiguration `json:"notion"` Keycloak OAuthProviderConfiguration `json:"keycloak"` From 64d7d2c9e1485b1e5b1ba34967eeb3e599c6c14a Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Tue, 20 Aug 2024 13:35:52 -0700 Subject: [PATCH 2/2] update instagram.go --- internal/api/provider/instagram.go | 74 +++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/internal/api/provider/instagram.go b/internal/api/provider/instagram.go index 8a02470249..91c3ed3c5a 100644 --- a/internal/api/provider/instagram.go +++ b/internal/api/provider/instagram.go @@ -3,30 +3,33 @@ package provider import ( "context" "encoding/json" - "errors" - "io/ioutil" + "fmt" + "io" "net/http" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/utilities" "golang.org/x/oauth2" ) -// Instagram -type instagramProvider struct { - *oauth2.Config -} +const ( + defaultInstagramApiBase = "https://graph.instagram.com" +) type instagramUser struct { ID string `json:"id"` Username string `json:"username"` + Email string `json:"email,omitempty"` + Picture string `json:"profile_picture,omitempty"` } -// NewInstagramProvider creates an Instagram account provider. -func NewInstagramProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) { +func NewInstagramProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { if err := ext.ValidateOAuth(); err != nil { return nil, err } + authHost := chooseHost(ext.URL, defaultInstagramApiBase) + return &instagramProvider{ Config: &oauth2.Config{ ClientID: ext.ClientID[0], @@ -36,41 +39,68 @@ func NewInstagramProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, e TokenURL: "https://api.instagram.com/oauth/access_token", }, RedirectURL: ext.RedirectURI, - Scopes: []string{"user_profile"}, + Scopes: []string{"user_profile", "user_media"}, }, + APIPath: authHost, }, nil } +type instagramProvider struct { + *oauth2.Config + APIPath string +} + func (g instagramProvider) GetOAuthToken(code string) (*oauth2.Token, error) { return g.Exchange(context.Background(), code) } func (g instagramProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { - client := g.Client(ctx, tok) - resp, err := client.Get("https://graph.instagram.com/me?fields=id,username") + var u instagramUser + + req, err := http.NewRequest("GET", g.APIPath+"/me?fields=id,username,email,profile_picture&access_token="+tok.AccessToken, nil) if err != nil { return nil, err } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + client := &http.Client{Timeout: defaultTimeout} + resp, err := client.Do(req) if err != nil { return nil, err } + defer utilities.SafeClose(resp.Body) - var u instagramUser - if err := json.Unmarshal(body, &u); err != nil { + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("a %v error occurred while retrieving user from Instagram", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &u) + if err != nil { return nil, err } - data := &UserProvidedData{ - Metadata: &Claims{ - Issuer: "https://instagram.com", - Subject: u.ID, - Name: u.Username, - ProviderId: u.ID, - }, + data := &UserProvidedData{} + if u.Email != "" { + data.Emails = []Email{{ + Email: u.Email, + Verified: true, + Primary: true, + }} } + data.Metadata = &Claims{ + Issuer: g.APIPath, + Subject: u.ID, + Name: u.Username, + Picture: u.Picture, + + // To be deprecated + AvatarURL: u.Picture, + FullName: u.Username, + ProviderId: u.ID, + } return data, nil }