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 new Kakao Provider #834

Merged
merged 26 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
51d0407
feat(kakao):add Kakao Provider
esinx Nov 27, 2022
689da5f
fix: typo in env
esinx Nov 28, 2022
c78c965
fix(kakao): remove unused logs
esinx Nov 28, 2022
9ba5ad9
feat(kakao): add kakao test env
esinx Nov 28, 2022
5932567
feat(kakao): add kakao tests
esinx Nov 29, 2022
1396ade
Merge branch 'master' into kakao-provider
esinx Nov 29, 2022
fc79067
Merge branch 'supabase:master' into kakao-provider
esinx Dec 3, 2022
c483b51
Merge branch 'supabase:master' into kakao-provider
esinx Dec 10, 2022
01523c6
Merge branch 'supabase:master' into kakao-provider
esinx Dec 27, 2022
8798d6f
fix(scope): Kakao OAuth scope
esinx Jan 27, 2023
8e6515e
Merge branch 'supabase:master' into kakao-provider
esinx Jan 27, 2023
2db0fa0
fix(typo): remove extra space
esinx Jan 27, 2023
7076061
Merge branch 'supabase:master' into kakao-provider
esinx Jan 30, 2023
15a10eb
Merge branch 'supabase:master' into kakao-provider
esinx Feb 1, 2023
1f3a0d0
Merge remote-tracking branch 'upstream/master' into kakao-provider
esinx Feb 9, 2023
b13d546
fix(convention): remove kakao api from /api (using internal/api instead)
esinx Feb 9, 2023
06c7c9c
Merge remote-tracking branch 'upstream/master' into kakao-provider
esinx Feb 14, 2023
33fdbf6
Merge remote-tracking branch 'upstream/master' into kakao-provider
esinx Feb 20, 2023
f32df7e
fix(import): kakao imports
esinx Feb 20, 2023
c38306c
Merge branch 'master' into kakao-provider
kangmingtay May 11, 2023
6028053
Update internal/conf/configuration.go
kangmingtay May 11, 2023
4a6bfeb
Update internal/conf/configuration.go
kangmingtay May 11, 2023
dcbdc89
Update internal/conf/configuration.go
kangmingtay May 11, 2023
3ea6fb1
Update internal/conf/configuration.go
kangmingtay May 11, 2023
00a93ac
Update internal/conf/configuration.go
kangmingtay May 11, 2023
157a7c9
fix fmt
kangmingtay May 11, 2023
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 @@ -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