retrieve user email address via the GitHub user email API when logging in with GitHub

This commit is contained in:
MaysWind
2025-10-24 01:45:16 +08:00
parent 85b05f9e7e
commit beea6fe733
7 changed files with 193 additions and 82 deletions
@@ -36,7 +36,7 @@ type CommonOAuth2DataSource interface {
GetScopes() []string
// ParseUserInfo returns the user info by parsing the response body
ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error)
ParseUserInfo(c core.Context, body []byte, oauth2Client *http.Client) (*data.OAuth2UserInfo, error)
}
// GetOAuth2AuthUrl returns the authentication url of the common OAuth 2.0 provider
@@ -76,7 +76,7 @@ func (p *CommonOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.T
return nil, errs.ErrFailedToRequestRemoteApi
}
return p.dataSource.ParseUserInfo(c, body)
return p.dataSource.ParseUserInfo(c, body, oauth2Client)
}
// GetDataSource returns the data source of the common OAuth 2.0 provider
@@ -56,7 +56,7 @@ func (s *GiteaOAuth2DataSource) GetScopes() []string {
}
// ParseUserInfo returns the user info by parsing the response body
func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte, oauth2Client *http.Client) (*data.OAuth2UserInfo, error) {
userInfoResp := &giteaUserInfoResponse{}
err := json.Unmarshal(body, &userInfoResp)
@@ -1,6 +1,7 @@
package gitea
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -47,7 +48,7 @@ func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
"full_name": "User",
"email": "user1@example.com"
}`
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{})
assert.Nil(t, err)
assert.Equal(t, "user1", info.UserName)
@@ -57,7 +58,7 @@ func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
datasource := &GiteaOAuth2DataSource{}
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"), &http.Client{})
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -65,7 +66,7 @@ func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
func TestGiteaOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) {
datasource := &GiteaOAuth2DataSource{}
responseContent := `{"login": ""}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{})
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -2,44 +2,163 @@ package github
import (
"encoding/json"
"io"
"net/http"
"golang.org/x/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const githubOAuth2AuthUrl = "https://github.com/login/oauth/authorize" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
const githubOAuth2TokenUrl = "https://github.com/login/oauth/access_token" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
const githubUserProfileApiUrl = "https://api.github.com/user" // Reference: https://docs.github.com/en/rest/users/users
const githubUserEmailApiUrl = "https://api.github.com/user/emails" // Reference: https://docs.github.com/en/rest/users/emails
var githubOAuth2Scopes = []string{"user:email"}
type githubUserProfileResponse struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
}
// GithubOAuth2DataSource represents Github OAuth 2.0 data source
type GithubOAuth2DataSource struct {
common.CommonOAuth2DataSource
type githubUserEmailsResponse struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
// GetAuthUrl returns the authentication url of the Github data source
func (s *GithubOAuth2DataSource) GetAuthUrl() string {
// Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
return "https://github.com/login/oauth/authorize"
// GithubOAuth2Provider represents Github OAuth 2.0 provider
type GithubOAuth2Provider struct {
provider.OAuth2Provider
oauth2Config *oauth2.Config
}
// GetTokenUrl returns the token url of the Github data source
func (s *GithubOAuth2DataSource) GetTokenUrl() string {
// Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
return "https://github.com/login/oauth/access_token"
// GetOAuth2AuthUrl returns the authentication url of the GitHub OAuth 2.0 provider
func (p *GithubOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, challenge string) (string, error) {
return p.oauth2Config.AuthCodeURL(state), nil
}
// GetUserInfoRequest returns the user info request of the Github data source
func (s *GithubOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
// Reference: https://docs.github.com/en/rest/users/users
req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
// GetOAuth2Token returns the OAuth 2.0 token of the GitHub OAuth 2.0 provider
func (p *GithubOAuth2Provider) GetOAuth2Token(c core.Context, code string, verifier string) (*oauth2.Token, error) {
return p.oauth2Config.Exchange(c, code)
}
// GetUserInfo returns the user info by the Github OAuth 2.0 provider
func (p *GithubOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
// first get user name and nick name from user profile
req, err := p.buildAPIRequest(githubUserProfileApiUrl)
if err != nil {
log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user info request, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
resp, err := oauth2Client.Do(req)
if err != nil {
log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user info response, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
log.Debugf(c, "[github_oauth2_datasource_test.GetUserInfo] user profile response is %s", body)
if resp.StatusCode != 200 {
log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
userProfileResp, err := p.parseUserProfile(c, body)
if err != nil {
return nil, err
}
// then get user primary email
req, err = p.buildAPIRequest(githubUserEmailApiUrl)
if err != nil {
log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user emails request, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
resp, err = oauth2Client.Do(req)
if err != nil {
log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user emails response, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
log.Debugf(c, "[github_oauth2_datasource_test.GetUserInfo] user emails response is %s", body)
if resp.StatusCode != 200 {
log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user emails response, because response code is %d", resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
email, err := p.parsePrimaryEmail(c, body)
if err != nil {
return nil, err
}
return &data.OAuth2UserInfo{
UserName: userProfileResp.Login,
Email: email,
NickName: userProfileResp.Name,
}, nil
}
func (p *GithubOAuth2Provider) parseUserProfile(c core.Context, body []byte) (*githubUserProfileResponse, error) {
userProfileResp := &githubUserProfileResponse{}
err := json.Unmarshal(body, &userProfileResp)
if err != nil {
log.Warnf(c, "[github_oauth2_datasource.parseUserProfile] failed to parse user profile response body, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userProfileResp.Login == "" {
log.Warnf(c, "[github_oauth2_datasource.parseUserProfile] invalid user profile response body")
return nil, errs.ErrCannotRetrieveUserInfo
}
return userProfileResp, nil
}
func (p *GithubOAuth2Provider) parsePrimaryEmail(c core.Context, body []byte) (string, error) {
emailsResp := make([]githubUserEmailsResponse, 0)
err := json.Unmarshal(body, &emailsResp)
if err != nil {
log.Warnf(c, "[github_oauth2_datasource.parsePrimaryEmail] failed to parse user emails response body, because %s", err.Error())
return "", errs.ErrCannotRetrieveUserInfo
}
for _, emailEntry := range emailsResp {
if emailEntry.Primary && emailEntry.Verified {
return emailEntry.Email, nil
}
}
return "", nil
}
func (p *GithubOAuth2Provider) buildAPIRequest(url string) (*http.Request, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
@@ -49,34 +168,20 @@ func (s *GithubOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
return req, nil
}
// GetScopes returns the scopes required by the Github provider
func (p *GithubOAuth2DataSource) GetScopes() []string {
return []string{"read:user"}
}
// ParseUserInfo returns the user info by parsing the response body
func (p *GithubOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
userInfoResp := &githubUserProfileResponse{}
err := json.Unmarshal(body, &userInfoResp)
if err != nil {
log.Warnf(c, "[github_oauth2_datasource.ParseUserInfo] failed to parse user profile response body, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.Login == "" {
log.Warnf(c, "[github_oauth2_datasource.ParseUserInfo] invalid user profile response body")
return nil, errs.ErrCannotRetrieveUserInfo
}
return &data.OAuth2UserInfo{
UserName: userInfoResp.Login,
Email: userInfoResp.Email,
NickName: userInfoResp.Name,
}, nil
}
// NewGithubOAuth2Provider creates a new Github OAuth 2.0 provider instance
func NewGithubOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
return common.NewCommonOAuth2Provider(config, redirectUrl, &GithubOAuth2DataSource{}), nil
oauth2Config := &oauth2.Config{
ClientID: config.OAuth2ClientID,
ClientSecret: config.OAuth2ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: githubOAuth2AuthUrl,
TokenURL: githubOAuth2TokenUrl,
},
RedirectURL: redirectUrl,
Scopes: githubOAuth2Scopes,
}
return &GithubOAuth2Provider{
oauth2Config: oauth2Config,
}, nil
}
@@ -9,18 +9,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestGithubOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
datasource := &GithubOAuth2DataSource{}
req, err := datasource.GetUserInfoRequest()
assert.Nil(t, err)
assert.Equal(t, "GET", req.Method)
assert.Equal(t, "https://api.github.com/user", req.URL.String())
assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept"))
}
func TestGithubOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
datasource := &GithubOAuth2DataSource{}
func TestGithubOAuth2Datasource_ParseUserProfile_Success(t *testing.T) {
datasource := &GithubOAuth2Provider{}
responseContent := `{
"login": "octocat",
"id": 1,
@@ -67,25 +57,39 @@ func TestGithubOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
"collaborators": 0
}
}`
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
info, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "octocat", info.UserName)
assert.Equal(t, "octocat@github.com", info.Email)
assert.Equal(t, "monalisa octocat", info.NickName)
assert.Equal(t, "octocat", info.Login)
assert.Equal(t, "monalisa octocat", info.Name)
}
func TestGithubOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
datasource := &GithubOAuth2DataSource{}
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestGithubOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) {
datasource := &GithubOAuth2DataSource{}
func TestGithubOAuth2Datasource_ParseUserProfile_EmptyLogin(t *testing.T) {
datasource := &GithubOAuth2Provider{}
responseContent := `{"login": ""}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
_, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestGithubOAuth2Datasource_ParsePrimaryEmail(t *testing.T) {
datasource := &GithubOAuth2Provider{}
responseContent := `[
{
"email": "foo@bar.com",
"primary": false,
"verified": true,
"visibility": null
},
{
"email": "octocat@github.com",
"primary": true,
"verified": true,
"visibility": "public"
}
]`
email, err := datasource.parsePrimaryEmail(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "octocat@github.com", email)
}
@@ -65,7 +65,7 @@ func (s *NextcloudOAuth2DataSource) GetScopes() []string {
}
// ParseUserInfo returns the user info by parsing the response body
func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte, oauth2Client *http.Client) (*data.OAuth2UserInfo, error) {
userInfoResp := &nextcloudUserInfoResponse{}
err := json.Unmarshal(body, &userInfoResp)
@@ -1,6 +1,7 @@
package nextcloud
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -56,7 +57,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
}
}
}`
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{})
assert.Nil(t, err)
assert.Equal(t, "user1", info.UserName)
@@ -66,7 +67,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"), &http.Client{})
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -74,7 +75,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{"ocs": {}}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{})
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -90,7 +91,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T)
"data": {}
}
}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{})
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -110,7 +111,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) {
}
}
}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{})
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}