Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add Naver To Provider #1251

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
4 changes: 4 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
184 changes: 184 additions & 0 deletions internal/api/external_naver_test.go
Original file line number Diff line number Diff line change
@@ -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":"[email protected]","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":"[email protected]","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, "[email protected]", "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", "[email protected]")
}

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", "[email protected]")
}

func (ts *ExternalTestSuite) TestSignupExternalNaverDisableSignupSuccessWithPrimaryEmail() {
ts.Config.DisableSignup = true

ts.createUser("123", "[email protected]", "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, "[email protected]", "Naver Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalNaverSuccessWhenMatchingToken() {
// name and avatar should be populated from Naver API
ts.createUser("123", "[email protected]", "", "", "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, "[email protected]", "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", "[email protected]", "", "", "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", "[email protected]", "", "", "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":"[email protected]", "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, "", "", "")
// }
Comment on lines +169 to +184
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the commented-out code

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I just commented it out.

I'll remove it.

112 changes: 112 additions & 0 deletions internal/api/provider/naver.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down