forked from supabase/auth
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add new Kakao Provider (supabase#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](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
1 parent
c562ff9
commit 6d229d6
Showing
8 changed files
with
355 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", "") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.