Skip to content

Commit ff05b07

Browse files
esinxkangmingtay
authored andcommitted
feat: Add new Kakao Provider (#834)
## 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](#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]>
1 parent 3af7df2 commit ff05b07

File tree

8 files changed

+355
-0
lines changed

8 files changed

+355
-0
lines changed

example.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=""
9696
GOTRUE_EXTERNAL_GITHUB_SECRET=""
9797
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI="http://localhost:9999/callback"
9898

99+
# Kakao OAuth config
100+
GOTRUE_EXTERNAL_KAKAO_ENABLED="false"
101+
GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=""
102+
GOTRUE_EXTERNAL_KAKAO_SECRET=""
103+
GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback"
104+
99105
# Facebook OAuth config
100106
GOTRUE_EXTERNAL_FACEBOOK_ENABLED="false"
101107
GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=""

hack/test.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ GOTRUE_EXTERNAL_GITHUB_ENABLED=true
3939
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid
4040
GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret
4141
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=https://identity.services.netlify.com/callback
42+
GOTRUE_EXTERNAL_KAKAO_ENABLED=true
43+
GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=testclientid
44+
GOTRUE_EXTERNAL_KAKAO_SECRET=testsecret
45+
GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI=https://identity.services.netlify.com/callback
4246
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED=true
4347
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid
4448
GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret

internal/api/external.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
526526
return provider.NewGitlabProvider(config.External.Gitlab, scopes)
527527
case "google":
528528
return provider.NewGoogleProvider(config.External.Google, scopes)
529+
case "kakao":
530+
return provider.NewKakaoProvider(config.External.Kakao, scopes)
529531
case "keycloak":
530532
return provider.NewKeycloakProvider(config.External.Keycloak, scopes)
531533
case "linkedin":

internal/api/external_kakao_test.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"time"
10+
11+
jwt "github.com/golang-jwt/jwt"
12+
"github.com/stretchr/testify/require"
13+
"github.com/supabase/gotrue/internal/api/provider"
14+
"github.com/supabase/gotrue/internal/models"
15+
)
16+
17+
func (ts *ExternalTestSuite) TestSignupExternalKakao() {
18+
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=kakao", nil)
19+
w := httptest.NewRecorder()
20+
ts.API.handler.ServeHTTP(w, req)
21+
ts.Require().Equal(http.StatusFound, w.Code)
22+
u, err := url.Parse(w.Header().Get("Location"))
23+
ts.Require().NoError(err, "redirect url parse failed")
24+
q := u.Query()
25+
ts.Equal(ts.Config.External.Kakao.RedirectURI, q.Get("redirect_uri"))
26+
ts.Equal(ts.Config.External.Kakao.ClientID, q.Get("client_id"))
27+
ts.Equal("code", q.Get("response_type"))
28+
29+
claims := ExternalProviderClaims{}
30+
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
31+
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
32+
return []byte(ts.Config.JWT.Secret), nil
33+
})
34+
ts.Require().NoError(err)
35+
36+
ts.Equal("kakao", claims.Provider)
37+
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
38+
}
39+
40+
func KakaoTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server {
41+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+
switch r.URL.Path {
43+
case "/oauth/token":
44+
*tokenCount++
45+
ts.Equal(code, r.FormValue("code"))
46+
ts.Equal("authorization_code", r.FormValue("grant_type"))
47+
ts.Equal(ts.Config.External.Kakao.RedirectURI, r.FormValue("redirect_uri"))
48+
w.Header().Add("Content-Type", "application/json")
49+
fmt.Fprint(w, `{"access_token":"kakao_token","expires_in":100000}`)
50+
case "/v2/user/me":
51+
*userCount++
52+
var emailList []provider.Email
53+
if err := json.Unmarshal([]byte(emails), &emailList); err != nil {
54+
ts.Fail("Invalid email json %s", emails)
55+
}
56+
57+
var email *provider.Email
58+
59+
for i, e := range emailList {
60+
if len(e.Email) > 0 {
61+
email = &emailList[i]
62+
break
63+
}
64+
}
65+
66+
if email == nil {
67+
w.WriteHeader(400)
68+
return
69+
}
70+
71+
w.Header().Add("Content-Type", "application/json")
72+
fmt.Fprintf(w, `
73+
{
74+
"id":123,
75+
"kakao_account": {
76+
"profile": {
77+
"nickname":"Kakao Test",
78+
"profile_image_url":"http://example.com/avatar"
79+
},
80+
"email": "%v",
81+
"is_email_valid": %v,
82+
"is_email_verified": %v
83+
}
84+
}`, email.Email, email.Verified, email.Verified)
85+
default:
86+
w.WriteHeader(500)
87+
ts.Fail("unknown kakao oauth call %s", r.URL.Path)
88+
}
89+
}))
90+
ts.Config.External.Kakao.URL = server.URL
91+
return server
92+
}
93+
94+
func (ts *ExternalTestSuite) TestSignupExternalKakao_AuthorizationCode() {
95+
tokenCount, userCount := 0, 0
96+
code := "authcode"
97+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
98+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
99+
defer server.Close()
100+
u := performAuthorization(ts, "kakao", code, "")
101+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
102+
}
103+
104+
func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenNoUser() {
105+
ts.Config.DisableSignup = true
106+
tokenCount, userCount := 0, 0
107+
code := "authcode"
108+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
109+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
110+
defer server.Close()
111+
112+
u := performAuthorization(ts, "kakao", code, "")
113+
114+
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "[email protected]")
115+
}
116+
117+
func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenEmptyEmail() {
118+
ts.Config.DisableSignup = true
119+
tokenCount, userCount := 0, 0
120+
code := "authcode"
121+
emails := `[{"primary": true, "verified": true}]`
122+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
123+
defer server.Close()
124+
125+
u := performAuthorization(ts, "kakao", code, "")
126+
127+
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "[email protected]")
128+
}
129+
130+
func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupSuccessWithPrimaryEmail() {
131+
ts.Config.DisableSignup = true
132+
133+
ts.createUser("123", "[email protected]", "Kakao Test", "http://example.com/avatar", "")
134+
135+
tokenCount, userCount := 0, 0
136+
code := "authcode"
137+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
138+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
139+
defer server.Close()
140+
141+
u := performAuthorization(ts, "kakao", code, "")
142+
143+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
144+
}
145+
146+
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoSuccessWhenMatchingToken() {
147+
// name and avatar should be populated from Kakao API
148+
ts.createUser("123", "[email protected]", "", "", "invite_token")
149+
150+
tokenCount, userCount := 0, 0
151+
code := "authcode"
152+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
153+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
154+
defer server.Close()
155+
156+
u := performAuthorization(ts, "kakao", code, "invite_token")
157+
158+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
159+
}
160+
161+
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenNoMatchingToken() {
162+
tokenCount, userCount := 0, 0
163+
code := "authcode"
164+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
165+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
166+
defer server.Close()
167+
168+
w := performAuthorizationRequest(ts, "kakao", "invite_token")
169+
ts.Require().Equal(http.StatusNotFound, w.Code)
170+
}
171+
172+
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenWrongToken() {
173+
ts.createUser("123", "[email protected]", "", "", "invite_token")
174+
175+
tokenCount, userCount := 0, 0
176+
code := "authcode"
177+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
178+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
179+
defer server.Close()
180+
181+
w := performAuthorizationRequest(ts, "kakao", "wrong_token")
182+
ts.Require().Equal(http.StatusNotFound, w.Code)
183+
}
184+
185+
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenEmailDoesntMatch() {
186+
ts.createUser("123", "[email protected]", "", "", "invite_token")
187+
188+
tokenCount, userCount := 0, 0
189+
code := "authcode"
190+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
191+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
192+
defer server.Close()
193+
194+
u := performAuthorization(ts, "kakao", code, "invite_token")
195+
196+
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
197+
}
198+
199+
func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenVerifiedFalse() {
200+
tokenCount, userCount := 0, 0
201+
code := "authcode"
202+
emails := `[{"email":"[email protected]", "primary": true, "verified": false}]`
203+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
204+
defer server.Close()
205+
206+
u := performAuthorization(ts, "kakao", code, "")
207+
208+
v, err := url.ParseQuery(u.Fragment)
209+
ts.Require().NoError(err)
210+
ts.Equal("unauthorized_client", v.Get("error"))
211+
ts.Equal("401", v.Get("error_code"))
212+
ts.Equal("Unverified email with kakao", v.Get("error_description"))
213+
assertAuthorizationFailure(ts, u, "", "", "")
214+
}
215+
216+
func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenUserBanned() {
217+
tokenCount, userCount := 0, 0
218+
code := "authcode"
219+
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
220+
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
221+
defer server.Close()
222+
223+
u := performAuthorization(ts, "kakao", code, "")
224+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Kakao Test", "123", "http://example.com/avatar")
225+
226+
user, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
227+
require.NoError(ts.T(), err)
228+
t := time.Now().Add(24 * time.Hour)
229+
user.BannedUntil = &t
230+
require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
231+
232+
u = performAuthorization(ts, "kakao", code, "")
233+
assertAuthorizationFailure(ts, u, "User is unauthorized", "unauthorized_client", "")
234+
}

internal/api/provider/kakao.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/supabase/gotrue/internal/conf"
9+
"golang.org/x/oauth2"
10+
)
11+
12+
const (
13+
defaultKakaoAuthBase = "kauth.kakao.com"
14+
defaultKakaoAPIBase = "kapi.kakao.com"
15+
)
16+
17+
type kakaoProvider struct {
18+
*oauth2.Config
19+
APIHost string
20+
}
21+
22+
type kakaoUser struct {
23+
ID int `json:"id"`
24+
Account struct {
25+
Profile struct {
26+
Name string `json:"nickname"`
27+
ProfileImageURL string `json:"profile_image_url"`
28+
} `json:"profile"`
29+
Email string `json:"email"`
30+
EmailValid bool `json:"is_email_valid"`
31+
EmailVerified bool `json:"is_email_verified"`
32+
} `json:"kakao_account"`
33+
}
34+
35+
func (p kakaoProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
36+
return p.Exchange(context.Background(), code)
37+
}
38+
39+
func (p kakaoProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
40+
var u kakaoUser
41+
42+
if err := makeRequest(ctx, tok, p.Config, p.APIHost+"/v2/user/me", &u); err != nil {
43+
return nil, err
44+
}
45+
46+
data := &UserProvidedData{
47+
Emails: []Email{
48+
{
49+
Email: u.Account.Email,
50+
Verified: u.Account.EmailVerified && u.Account.EmailValid,
51+
Primary: true,
52+
},
53+
},
54+
Metadata: &Claims{
55+
Issuer: p.APIHost,
56+
Subject: strconv.Itoa(u.ID),
57+
Email: u.Account.Email,
58+
EmailVerified: u.Account.EmailVerified && u.Account.EmailValid,
59+
60+
Name: u.Account.Profile.Name,
61+
PreferredUsername: u.Account.Profile.Name,
62+
63+
// To be deprecated
64+
AvatarURL: u.Account.Profile.ProfileImageURL,
65+
FullName: u.Account.Profile.Name,
66+
ProviderId: strconv.Itoa(u.ID),
67+
UserNameKey: u.Account.Profile.Name,
68+
},
69+
}
70+
return data, nil
71+
}
72+
73+
func NewKakaoProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
74+
if err := ext.Validate(); err != nil {
75+
return nil, err
76+
}
77+
78+
authHost := chooseHost(ext.URL, defaultKakaoAuthBase)
79+
apiHost := chooseHost(ext.URL, defaultKakaoAPIBase)
80+
81+
oauthScopes := []string{
82+
"account_email",
83+
"profile_image",
84+
"profile_nickname",
85+
}
86+
87+
if scopes != "" {
88+
oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
89+
}
90+
91+
return &kakaoProvider{
92+
Config: &oauth2.Config{
93+
ClientID: ext.ClientID,
94+
ClientSecret: ext.Secret,
95+
Endpoint: oauth2.Endpoint{
96+
AuthStyle: oauth2.AuthStyleInParams,
97+
AuthURL: authHost + "/oauth/authorize",
98+
TokenURL: authHost + "/oauth/token",
99+
},
100+
RedirectURL: ext.RedirectURI,
101+
Scopes: oauthScopes,
102+
},
103+
APIHost: apiHost,
104+
}, nil
105+
}

internal/api/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type ProviderSettings struct {
1111
GitLab bool `json:"gitlab"`
1212
Keycloak bool `json:"keycloak"`
1313
Google bool `json:"google"`
14+
Kakao bool `json:"kakao"`
1415
Linkedin bool `json:"linkedin"`
1516
Facebook bool `json:"facebook"`
1617
Notion bool `json:"notion"`
@@ -46,6 +47,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
4647
GitHub: config.External.Github.Enabled,
4748
GitLab: config.External.Gitlab.Enabled,
4849
Google: config.External.Google.Enabled,
50+
Kakao: config.External.Kakao.Enabled,
4951
Keycloak: config.External.Keycloak.Enabled,
5052
Linkedin: config.External.Linkedin.Enabled,
5153
Facebook: config.External.Facebook.Enabled,

internal/api/settings_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
3636
require.True(t, p.Spotify)
3737
require.True(t, p.Slack)
3838
require.True(t, p.Google)
39+
require.True(t, p.Kakao)
3940
require.True(t, p.Keycloak)
4041
require.True(t, p.Linkedin)
4142
require.True(t, p.GitHub)

0 commit comments

Comments
 (0)