Skip to content

Commit

Permalink
feat: Add new Kakao Provider (supabase#834)
Browse files Browse the repository at this point in the history
## 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](supabase#451 (comment)),
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.

---------

Co-authored-by: Kang Ming <[email protected]>
  • Loading branch information
esinx and kangmingtay authored May 11, 2023
1 parent c562ff9 commit 6d229d6
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 0 deletions.
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
4 changes: 4 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
234 changes: 234 additions & 0 deletions internal/api/external_kakao_test.go
Original file line number Diff line number Diff line change
@@ -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":"[email protected]", "primary": true, "verified": true}]`
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()
u := performAuthorization(ts, "kakao", code, "")
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenNoUser() {
ts.Config.DisableSignup = true
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "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", "[email protected]")
}

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

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

ts.createUser("123", "[email protected]", "Kakao Test", "http://example.com/avatar", "")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "kakao", code, "")

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoSuccessWhenMatchingToken() {
// name and avatar should be populated from Kakao API
ts.createUser("123", "[email protected]", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "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, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenNoMatchingToken() {
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "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", "[email protected]", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "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", "[email protected]", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "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":"[email protected]", "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":"[email protected]", "primary": true, "verified": true}]`
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "kakao", code, "")
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")

user, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", 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", "")
}
105 changes: 105 additions & 0 deletions internal/api/provider/kakao.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
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 @@ -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)
Expand Down
Loading

0 comments on commit 6d229d6

Please sign in to comment.