diff --git a/example.env b/example.env index c18f85c01e..8b5a34a219 100644 --- a/example.env +++ b/example.env @@ -114,6 +114,12 @@ GOTRUE_EXTERNAL_KAKAO_CLIENT_ID="" GOTRUE_EXTERNAL_KAKAO_SECRET="" GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback" +# Naver OAuth config +GOTRUE_EXTERNAL_NAVER_ENABLED="false" +GOTRUE_EXTERNAL_NAVER_CLIENT_ID="" +GOTRUE_EXTERNAL_NAVER_SECRET="" +GOTRUE_EXTERNAL_NAVER_REDIRECT_URI="http://localhost:9999/callback" + # Notion OAuth config GOTRUE_EXTERNAL_NOTION_ENABLED="false" GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 51a487caa5..f713381746 100644 --- a/hack/test.env +++ b/hack/test.env @@ -64,6 +64,10 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED=true GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_NAVER_ENABLED=true +GOTRUE_EXTERNAL_NAVER_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_NAVER_SECRET=testsecret +GOTRUE_EXTERNAL_NAVER_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_NOTION_ENABLED=true GOTRUE_EXTERNAL_NOTION_CLIENT_ID=testclientid GOTRUE_EXTERNAL_NOTION_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 7ad21f851f..827d94924d 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -540,6 +540,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewKeycloakProvider(config.External.Keycloak, scopes) case "linkedin": return provider.NewLinkedinProvider(config.External.Linkedin, scopes) + case "naver": + return provider.NewNaverProvider(config.External.Naver, scopes) case "notion": return provider.NewNotionProvider(config.External.Notion) case "spotify": diff --git a/internal/api/external_naver_test.go b/internal/api/external_naver_test.go new file mode 100644 index 0000000000..9f800daa66 --- /dev/null +++ b/internal/api/external_naver_test.go @@ -0,0 +1,184 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt" +) + +const ( + naverResponse string = `{"resultcode":"resultcode","message":"message","response":{"id":"123","nickname":"Naver Test","name":"Naver Test","email":"naver@example.com","gender":"gender","age":"age","birthday":"birthday","profile_image":"http://example.com/avatar","birthyear":"birthyear","mobile":"mobile"}}` + naverResponseAnotherEmail string = `{"resultcode":"resultcode","message":"message","response":{"id":"123","nickname":"Naver Test","name":"Naver Test","email":"another@example.com","gender":"gender","age":"age","birthday":"birthday","profile_image":"http://example.com/avatar","birthyear":"birthyear","mobile":"mobile"}}` + naverResponseNoEmail string = `{"resultcode":"resultcode","message":"message","response":{"id":"123","nickname":"Naver Test","name":"Naver Test","gender":"gender","age":"age","birthday":"birthday","profile_image":"http://example.com/avatar","birthyear":"birthyear","mobile":"mobile"}}` +) + +func (ts *ExternalTestSuite) TestSignupExternalNaver() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=naver", 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.Naver.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Naver.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + + 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("naver", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func NaverTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, response string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2.0/token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.Naver.RedirectURI, r.FormValue("redirect_uri")) + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"naver_token","expires_in":100000}`) + case "/v1/nid/me": + *userCount++ + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, response) + default: + w.WriteHeader(500) + ts.Fail("unknown naver oauth call %s", r.URL.Path) + } + })) + ts.Config.External.Naver.URL = server.URL + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalNaver_AuthorizationCode() { + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponse + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + u := performAuthorization(ts, "naver", code, "") + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "naver@example.com", "Naver Test", "123", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestSignupExternalNaverDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponse + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + u := performAuthorization(ts, "naver", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "naver@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalNaverDisableSignupErrorWhenEmptyEmail() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponseNoEmail + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + u := performAuthorization(ts, "naver", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "naver@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalNaverDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("123", "naver@example.com", "Naver Test", "http://example.com/avatar", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponse + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + u := performAuthorization(ts, "naver", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "naver@example.com", "Naver Test", "123", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNaverSuccessWhenMatchingToken() { + // name and avatar should be populated from Naver API + ts.createUser("123", "naver@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponse + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + u := performAuthorization(ts, "naver", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "naver@example.com", "Naver Test", "123", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNaverErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponse + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + w := performAuthorizationRequest(ts, "naver", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNaverErrorWhenWrongToken() { + ts.createUser("123", "naver@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponse + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + w := performAuthorizationRequest(ts, "naver", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNaverErrorWhenEmailDoesntMatch() { + ts.createUser("123", "naver@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + response := naverResponseAnotherEmail + server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, response) + defer server.Close() + + u := performAuthorization(ts, "naver", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} + +// func (ts *ExternalTestSuite) TestSignupExternalNaverErrorWhenVerifiedFalse() { +// tokenCount, userCount := 0, 0 +// code := "authcode" +// emails := `[{"email":"naver@example.com", "primary": true, "verified": false}]` +// server := NaverTestSignupSetup(ts, &tokenCount, &userCount, code, emails) +// defer server.Close() + +// u := performAuthorization(ts, "naver", code, "") + +// v, err := url.ParseQuery(u.Fragment) +// ts.Require().NoError(err) +// ts.Equal("unauthorized_client", v.Get("error")) +// ts.Equal("401", v.Get("error_code")) +// ts.Equal("Unverified email with naver", v.Get("error_description")) +// assertAuthorizationFailure(ts, u, "", "", "") +// } diff --git a/internal/api/provider/naver.go b/internal/api/provider/naver.go new file mode 100644 index 0000000000..349bb97585 --- /dev/null +++ b/internal/api/provider/naver.go @@ -0,0 +1,112 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/supabase/gotrue/internal/conf" + "golang.org/x/oauth2" +) + +const ( + defaultNaverAuthBase = "nid.naver.com" + defaultNaverAPIBase = "openapi.naver.com" +) + +type naverProvider struct { + *oauth2.Config + APIHost string +} + +type naverResponse struct { + Resultcode string `json:"resultcode"` + Message string `json:"message"` + Response struct { + ID string `json:"id"` + Nickname string `json:"nickname"` + Name string `json:"name"` + Email string `json:"email"` + Gender string `json:"gender"` + Age string `json:"age"` + Birthday string `json:"birthday"` + ProfileImage string `json:"profile_image"` + Birthyear string `json:"birthyear"` + Mobile string `json:"mobile"` + } `json:"response"` +} + +func (p naverProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code) +} + +func (p naverProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var r naverResponse + + if err := makeRequest(ctx, tok, p.Config, p.APIHost+"/v1/nid/me", &r); err != nil { + return nil, err + } + + if r.Response.Email == "" { + return nil, errors.New("unable to find email with Naver provider") + } + + data := &UserProvidedData{ + Emails: []Email{ + { + Email: r.Response.Email, + Verified: true, // Naver dosen't provide data on if email is verified. + Primary: true, + }, + }, + Metadata: &Claims{ + Issuer: p.APIHost, + Subject: r.Response.ID, + Email: r.Response.Email, + EmailVerified: true, // Naver dosen't provide data on if email is verified. + + Name: r.Response.Name, + PreferredUsername: r.Response.Name, + + // To be deprecated + AvatarURL: r.Response.ProfileImage, + FullName: r.Response.Name, + ProviderId: r.Response.ID, + UserNameKey: r.Response.Name, + }, + } + return data, nil +} + +func NewNaverProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + authHost := chooseHost(ext.URL, defaultNaverAuthBase) + apiHost := chooseHost(ext.URL, defaultNaverAPIBase) + + oauthScopes := []string{ + "email", + "profile_image", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &naverProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthStyle: oauth2.AuthStyleInParams, + AuthURL: authHost + "/oauth2.0/authorize", + TokenURL: authHost + "/oauth2.0/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + APIHost: apiHost, + }, nil +} diff --git a/internal/api/settings.go b/internal/api/settings.go index a24690eb31..4b53371ca0 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -15,6 +15,7 @@ type ProviderSettings struct { Keycloak bool `json:"keycloak"` Kakao bool `json:"kakao"` Linkedin bool `json:"linkedin"` + Naver bool `json:"naver"` Notion bool `json:"notion"` Spotify bool `json:"spotify"` Slack bool `json:"slack"` @@ -53,6 +54,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Kakao: config.External.Kakao.Enabled, Keycloak: config.External.Keycloak.Enabled, Linkedin: config.External.Linkedin.Enabled, + Naver: config.External.Naver.Enabled, Notion: config.External.Notion.Enabled, Spotify: config.External.Spotify.Enabled, Slack: config.External.Slack.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index b066f4348e..109b44e86b 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -44,6 +44,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Twitch) require.True(t, p.WorkOS) require.True(t, p.Zoom) + require.True(t, p.Naver) } func TestSettings_EmailDisabled(t *testing.T) { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 588b53ccae..6a4dd0698f 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -193,6 +193,7 @@ type ProviderConfiguration struct { Gitlab OAuthProviderConfiguration `json:"gitlab"` Google OAuthProviderConfiguration `json:"google"` Kakao OAuthProviderConfiguration `json:"kakao"` + Naver OAuthProviderConfiguration `json:"naver"` Notion OAuthProviderConfiguration `json:"notion"` Keycloak OAuthProviderConfiguration `json:"keycloak"` Linkedin OAuthProviderConfiguration `json:"linkedin"`