Repository: supabase/gotrue
Pull request #834 (2022-11-29 02:35:37)

feat: Add new Kakao Provider
## What kind of change does this PR introduce?

This PR adds Kakao(https://accounts.kakao.com/) as an external provider.

## What is the current behavior?

This provider did not exist before.

## What is the new behavior?

Based on Kakao developer docs(https://developers.kakao.com/), this PR creates a provider & test suite for Kakao external provider.

## Additional context

Please let me know if there are any changes needed, I do acknowledge that this was once mentioned in another [comment](https://github.com/supabase/gotrue/issues/451#issuecomment-1101928384), but it seemed like the PR had been frozen since then. I wrote my own version to make sure the tests do pass and the features work properly.


esinx: > 

Will post a loom within the next few days :) Thank you so much for reviewing this PR!
esinx: Here's a loom of me retrieving a new client ID & secret from the kakao developers page!
@kangmingtay @artifacts3 
https://www.loom.com/share/11bc4df13df848d7b4730f5e3906ca48
igmyung: May I know when the kakao login feature will be available for supabase auth? I need Kakao Login for a project in progress, and I want to refer to it for my release plan.
esinx: @kangmingtay I think this PR is ready to be reviewed-- is there an update on the feedbacks you left? I just pushed a sync that applies the project organization change(folder structure change).

@igmyung 
Meanwhile, you can use [this docker image](https://hub.docker.com/r/esinx/gotrue) if you intend to use Kakao as one of your providers. The tag you are looking for is `esinx/gotrue:kakao`.
kangmingtay: hi @esinx, i think this is good to go! can you please merge in the master branch to fix the failing staticcheck and we can merge it in
kangmingtay: oh right, can you also do up some docs on how developers can use kakao as an oauth integration here (https://supabase.com/docs/guides/auth/overview#providers)? should be good as long as it's in the similar format as the rest! thanks @esinx !
esinx: Awesome! I'll do a short documentation write up asap.
kangmingtay: hi @esinx, just circling back on this, have you had time to do up some docs for setting up oauth through kakao?
J0: Hey @esinx, 

Let us know if you need any help with pushing this through docs-wise - feel free to react ou via Discord (J0#7392) if there's any way we can be helpful

Jo
esinx: Hey! Sorry for being inactive-- I've been pretty busy with other work lately. I'll post an update on docs and also ask for some help on Discord for guidance. Thank you so much for all the support!
hf: @kangmingtay Could you try to review this one final pass and merge it?
kangmingtay: yup @hf we're waiting on the docs PR to be up before we merge it 
hwr12: Hi @kangmingtay 
I wonder when will this PR be merged. 
And after it merges, when would I get to use this feature on supabase?
kangmingtay: Hi @hwr12, it will be merged once @esinx finishes the docs PR - feel free to help with this as well!
kangmingtay: Hi @hwr12, @esinx seems busy at the moment so feel free to pick up this task if you're interested. You can create the guide in this section of the supabase repo: https://github.com/supabase/supabase/tree/master/apps/docs/pages/guides/auth/social-login. Once you're done, please tag me in the pull request so that i can review it. 
esinx: My apologies for the delay. I just came back from my mandatory military service, and didn't have much time to go over the changes and the documentations.

@hwr12, thank you for taking the time to complete the documentation! This PR took longer than I've expected due to my unavailability, but I really do appreciate the community effort in trying to complete this PR.

Thank you @kangmingtay also for guiding us on how this PR should be made. Can't wait to see this feature make its way into supabase. Would be really amazing to have Kakao login available for all the Korean users out there.

Patch:
diff --git a/example.env b/example.env
index b798478a..5ad051c5 100644
--- a/example.env
+++ b/example.env
@@ -96,6 +96,12 @@ GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=""
 GOTRUE_EXTERNAL_GITHUB_SECRET=""
 GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI="http://localhost:9999/callback"
 
+# Kakao OAuth config
+GOTRUE_EXTERNAL_KAKAO_ENABLED="false"
+GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=""
+GOTRUE_EXTERNAL_KAKAO_SECRET=""
+GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback"
+
 # Facebook OAuth config
 GOTRUE_EXTERNAL_FACEBOOK_ENABLED="false"
 GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=""
diff --git a/internal/api/external.go b/internal/api/external.go
index 4351d994..5bf1d038 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -526,6 +526,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(config.External.Google, scopes)
+	case "kakao":
+		return provider.NewKakaoProvider(config.External.Kakao, scopes)
 	case "keycloak":
 		return provider.NewKeycloakProvider(config.External.Keycloak, scopes)
 	case "linkedin":
diff --git a/internal/api/provider/kakao.go b/internal/api/provider/kakao.go
new file mode 100644
index 00000000..917facff
--- /dev/null
+++ b/internal/api/provider/kakao.go
@@ -0,0 +1,105 @@
+package provider
+
+import (
+	"context"
+	"strconv"
+	"strings"
+
+	"github.com/supabase/gotrue/internal/conf"
+	"golang.org/x/oauth2"
+)
+
+const (
+	defaultKakaoAuthBase = "kauth.kakao.com"
+	defaultKakaoAPIBase  = "kapi.kakao.com"
+)
+
+type kakaoProvider struct {
+	*oauth2.Config
+	APIHost string
+}
+
+type kakaoUser struct {
+	ID      int `json:"id"`
+	Account struct {
+		Profile struct {
+			Name            string `json:"nickname"`
+			ProfileImageURL string `json:"profile_image_url"`
+		} `json:"profile"`
+		Email         string `json:"email"`
+		EmailValid    bool   `json:"is_email_valid"`
+		EmailVerified bool   `json:"is_email_verified"`
+	} `json:"kakao_account"`
+}
+
+func (p kakaoProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return p.Exchange(context.Background(), code)
+}
+
+func (p kakaoProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	var u kakaoUser
+
+	if err := makeRequest(ctx, tok, p.Config, p.APIHost+"/v2/user/me", &u); err != nil {
+		return nil, err
+	}
+
+	data := &UserProvidedData{
+		Emails: []Email{
+			{
+				Email:    u.Account.Email,
+				Verified: u.Account.EmailVerified && u.Account.EmailValid,
+				Primary:  true,
+			},
+		},
+		Metadata: &Claims{
+			Issuer:        p.APIHost,
+			Subject:       strconv.Itoa(u.ID),
+			Email:         u.Account.Email,
+			EmailVerified: u.Account.EmailVerified && u.Account.EmailValid,
+
+			Name:              u.Account.Profile.Name,
+			PreferredUsername: u.Account.Profile.Name,
+
+			// To be deprecated
+			AvatarURL:   u.Account.Profile.ProfileImageURL,
+			FullName:    u.Account.Profile.Name,
+			ProviderId:  strconv.Itoa(u.ID),
+			UserNameKey: u.Account.Profile.Name,
+		},
+	}
+	return data, nil
+}
+
+func NewKakaoProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
+	if err := ext.Validate(); err != nil {
+		return nil, err
+	}
+
+	authHost := chooseHost(ext.URL, defaultKakaoAuthBase)
+	apiHost := chooseHost(ext.URL, defaultKakaoAPIBase)
+
+	oauthScopes := []string{
+		"account_email",
+		"profile_image",
+		"profile_nickname",
+	}
+
+	if scopes != "" {
+		oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
+	}
+
+	return &kakaoProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID,
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthStyle: oauth2.AuthStyleInParams,
+				AuthURL:   authHost + "/oauth/authorize",
+				TokenURL:  authHost + "/oauth/token",
+			},
+			RedirectURL: ext.RedirectURI,
+			Scopes:      oauthScopes,
+		},
+		APIHost: apiHost,
+	}, nil
+}
diff --git a/internal/api/settings.go b/internal/api/settings.go
index 29c96137..93be22b8 100644
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -11,6 +11,7 @@ type ProviderSettings struct {
 	GitLab    bool `json:"gitlab"`
 	Keycloak  bool `json:"keycloak"`
 	Google    bool `json:"google"`
+	Kakao     bool `json:"kakao"`
 	Linkedin  bool `json:"linkedin"`
 	Facebook  bool `json:"facebook"`
 	Notion    bool `json:"notion"`
@@ -46,6 +47,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,
+			Kakao:     config.External.Kakao.Enabled,
 			Keycloak:  config.External.Keycloak.Enabled,
 			Linkedin:  config.External.Linkedin.Enabled,
 			Facebook:  config.External.Facebook.Enabled,
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index 576cd920..c6de717a 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -145,6 +145,7 @@ type ProviderConfiguration struct {
 	Github                  OAuthProviderConfiguration `json:"github"`
 	Gitlab                  OAuthProviderConfiguration `json:"gitlab"`
 	Google                  OAuthProviderConfiguration `json:"google"`
+	Kakao                   OAuthProviderConfiguration `json:"kakao"`
 	Notion                  OAuthProviderConfiguration `json:"notion"`
 	Keycloak                OAuthProviderConfiguration `json:"keycloak"`
 	Linkedin                OAuthProviderConfiguration `json:"linkedin"`


Test patch:
diff --git a/hack/test.env b/hack/test.env
index 41c0210c..2bee21ed 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -39,6 +39,10 @@ GOTRUE_EXTERNAL_GITHUB_ENABLED=true
 GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret
 GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_KAKAO_ENABLED=true
+GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_KAKAO_SECRET=testsecret
+GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_KEYCLOAK_ENABLED=true
 GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret
diff --git a/internal/api/external_kakao_test.go b/internal/api/external_kakao_test.go
new file mode 100644
index 00000000..7cfb51b3
--- /dev/null
+++ b/internal/api/external_kakao_test.go
@@ -0,0 +1,234 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"time"
+
+	jwt "github.com/golang-jwt/jwt"
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/gotrue/internal/api/provider"
+	"github.com/supabase/gotrue/internal/models"
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalKakao() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=kakao", 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.Kakao.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.Kakao.ClientID, 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("kakao", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func KakaoTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/oauth/token":
+			*tokenCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.Kakao.RedirectURI, r.FormValue("redirect_uri"))
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"kakao_token","expires_in":100000}`)
+		case "/v2/user/me":
+			*userCount++
+			var emailList []provider.Email
+			if err := json.Unmarshal([]byte(emails), &emailList); err != nil {
+				ts.Fail("Invalid email json %s", emails)
+			}
+
+			var email *provider.Email
+
+			for i, e := range emailList {
+				if len(e.Email) > 0 {
+					email = &emailList[i]
+					break
+				}
+			}
+
+			if email == nil {
+				w.WriteHeader(400)
+				return
+			}
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprintf(w, `
+				{
+					"id":123, 
+					"kakao_account": {
+						"profile": {
+							"nickname":"Kakao Test",
+							"profile_image_url":"http://example.com/avatar"
+						},
+						"email": "%v",
+						"is_email_valid": %v,
+						"is_email_verified": %v
+					}
+				}`, email.Email, email.Verified, email.Verified)
+		default:
+			w.WriteHeader(500)
+			ts.Fail("unknown kakao oauth call %s", r.URL.Path)
+		}
+	}))
+	ts.Config.External.Kakao.URL = server.URL
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakao_AuthorizationCode() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+	u := performAuthorization(ts, "kakao", code, "")
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "kakao@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "kakao@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("123", "kakao@example.com", "Kakao Test", "http://example.com/avatar", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoSuccessWhenMatchingToken() {
+	// name and avatar should be populated from Kakao API
+	ts.createUser("123", "kakao@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "invite_token")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "kakao", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenWrongToken() {
+	ts.createUser("123", "kakao@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "kakao", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenEmailDoesntMatch() {
+	ts.createUser("123", "kakao@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"other@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenVerifiedFalse() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": false}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", 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 kakao", v.Get("error_description"))
+	assertAuthorizationFailure(ts, u, "", "", "")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenUserBanned() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+
+	user, err := models.FindUserByEmailAndAudience(ts.API.db, "kakao@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	t := time.Now().Add(24 * time.Hour)
+	user.BannedUntil = &t
+	require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
+
+	u = performAuthorization(ts, "kakao", code, "")
+	assertAuthorizationFailure(ts, u, "User is unauthorized", "unauthorized_client", "")
+}
diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go
index 58398266..b066f434 100644
--- a/internal/api/settings_test.go
+++ b/internal/api/settings_test.go
@@ -36,6 +36,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
 	require.True(t, p.Spotify)
 	require.True(t, p.Slack)
 	require.True(t, p.Google)
+	require.True(t, p.Kakao)
 	require.True(t, p.Keycloak)
 	require.True(t, p.Linkedin)
 	require.True(t, p.GitHub)