diff --git a/tools/auth/auth.go b/tools/auth/auth.go index 4b61cfc6..1af34a0a 100644 --- a/tools/auth/auth.go +++ b/tools/auth/auth.go @@ -9,11 +9,13 @@ import ( // AuthUser defines a standardized oauth2 user data structure. type AuthUser struct { - Id string `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - Email string `json:"email"` - AvatarUrl string `json:"avatarUrl"` + Id string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` + RawUser map[string]any `json:"rawUser"` + AccessToken string `json:"accessToken"` } // Provider defines a common interface for an OAuth2 client. @@ -73,7 +75,7 @@ type Provider interface { // FetchRawUserData requests and marshalizes into `result` the // the OAuth user api response. - FetchRawUserData(token *oauth2.Token, result any) error + FetchRawUserData(token *oauth2.Token) ([]byte, error) // FetchAuthUser is similar to FetchRawUserData, but normalizes and // marshalizes the user api response into a standardized AuthUser struct. diff --git a/tools/auth/base_provider.go b/tools/auth/base_provider.go index 1fab0e9c..99085b6c 100644 --- a/tools/auth/base_provider.go +++ b/tools/auth/base_provider.go @@ -2,7 +2,6 @@ package auth import ( "context" - "encoding/json" "fmt" "io/ioutil" "net/http" @@ -107,42 +106,41 @@ func (p *baseProvider) Client(token *oauth2.Token) *http.Client { } // FetchRawUserData implements Provider.FetchRawUserData interface. -func (p *baseProvider) FetchRawUserData(token *oauth2.Token, result any) error { +func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) { req, err := http.NewRequest("GET", p.userApiUrl, nil) if err != nil { - return err + return nil, err } - return p.sendRawUserDataRequest(req, token, result) + return p.sendRawUserDataRequest(req, token) } -// sendRawUserDataRequest sends the specified request and -// unmarshal the response body into result. -func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token, result any) error { +// sendRawUserDataRequest sends the specified user data request and return its raw response body. +func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token) ([]byte, error) { client := p.Client(token) response, err := client.Do(req) if err != nil { - return err + return nil, err } defer response.Body.Close() - content, err := ioutil.ReadAll(response.Body) + result, err := ioutil.ReadAll(response.Body) if err != nil { - return err + return nil, err } // http.Client.Get doesn't treat non 2xx responses as error if response.StatusCode >= 400 { - return fmt.Errorf( + return nil, fmt.Errorf( "Failed to fetch OAuth2 user profile via %s (%d):\n%s", p.userApiUrl, response.StatusCode, - string(content), + string(result), ) } - return json.Unmarshal(content, &result) + return result, nil } // oauth2Config constructs a oauth2.Config instance based on the provider settings. diff --git a/tools/auth/discord.go b/tools/auth/discord.go index be52d7f3..5385ea96 100644 --- a/tools/auth/discord.go +++ b/tools/auth/discord.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "fmt" "golang.org/x/oauth2" @@ -29,33 +30,48 @@ func NewDiscordProvider() *Discord { } // FetchAuthUser returns an AuthUser instance from Discord's user api. +// +// API reference: https://discord.com/developers/docs/resources/user#user-object func (p *Discord) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://discord.com/developers/docs/resources/user#user-object - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Id string `json:"id"` Username string `json:"username"` Discriminator string `json:"discriminator"` Email string `json:"email"` + Verified bool `json:"verified"` Avatar string `json:"avatar"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } // Build a full avatar URL using the avatar hash provided in the API response // https://discord.com/developers/docs/reference#image-formatting - avatarUrl := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", rawData.Id, rawData.Avatar) + avatarUrl := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", extracted.Id, extracted.Avatar) // Concatenate the user's username and discriminator into a single username string - username := fmt.Sprintf("%s#%s", rawData.Username, rawData.Discriminator) + username := fmt.Sprintf("%s#%s", extracted.Username, extracted.Discriminator) user := &AuthUser{ - Id: rawData.Id, - Name: username, - Username: rawData.Username, - Email: rawData.Email, - AvatarUrl: avatarUrl, + Id: extracted.Id, + Name: username, + Username: extracted.Username, + AvatarUrl: avatarUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, + } + if extracted.Verified { + user.Email = extracted.Email } return user, nil diff --git a/tools/auth/facebook.go b/tools/auth/facebook.go index 3889d5cd..f4e4f66e 100644 --- a/tools/auth/facebook.go +++ b/tools/auth/facebook.go @@ -1,6 +1,8 @@ package auth import ( + "encoding/json" + "golang.org/x/oauth2" "golang.org/x/oauth2/facebook" ) @@ -26,9 +28,20 @@ func NewFacebookProvider() *Facebook { } // FetchAuthUser returns an AuthUser instance based on the Facebook's user api. +// +// API reference: https://developers.facebook.com/docs/graph-api/reference/user/ func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://developers.facebook.com/docs/graph-api/reference/user/ - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Id string Name string Email string @@ -36,16 +49,17 @@ func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Data struct{ Url string } } }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: rawData.Id, - Name: rawData.Name, - Email: rawData.Email, - AvatarUrl: rawData.Picture.Data.Url, + Id: extracted.Id, + Name: extracted.Name, + Email: extracted.Email, + AvatarUrl: extracted.Picture.Data.Url, + RawUser: rawUser, + AccessToken: token.AccessToken, } return user, nil diff --git a/tools/auth/github.go b/tools/auth/github.go index acaed4fd..7e10873a 100644 --- a/tools/auth/github.go +++ b/tools/auth/github.go @@ -30,26 +30,38 @@ func NewGithubProvider() *Github { } // FetchAuthUser returns an AuthUser instance based the Github's user api. +// +// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://docs.github.com/en/rest/reference/users#get-the-authenticated-user - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Login string `json:"login"` Id int `json:"id"` Name string `json:"name"` Email string `json:"email"` AvatarUrl string `json:"avatar_url"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(rawData.Id), - Name: rawData.Name, - Username: rawData.Login, - Email: rawData.Email, - AvatarUrl: rawData.AvatarUrl, + Id: strconv.Itoa(extracted.Id), + Name: extracted.Name, + Username: extracted.Login, + Email: extracted.Email, + AvatarUrl: extracted.AvatarUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, } // in case user set "Keep my email address private", diff --git a/tools/auth/gitlab.go b/tools/auth/gitlab.go index 4700a78d..86aab02b 100644 --- a/tools/auth/gitlab.go +++ b/tools/auth/gitlab.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "strconv" "golang.org/x/oauth2" @@ -27,26 +28,38 @@ func NewGitlabProvider() *Gitlab { } // FetchAuthUser returns an AuthUser instance based the Gitlab's user api. +// +// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://docs.gitlab.com/ee/api/users.html#for-admin - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Id int `json:"id"` Name string `json:"name"` Username string `json:"username"` Email string `json:"email"` AvatarUrl string `json:"avatar_url"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(rawData.Id), - Name: rawData.Name, - Username: rawData.Username, - Email: rawData.Email, - AvatarUrl: rawData.AvatarUrl, + Id: strconv.Itoa(extracted.Id), + Name: extracted.Name, + Username: extracted.Username, + Email: extracted.Email, + AvatarUrl: extracted.AvatarUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, } return user, nil diff --git a/tools/auth/google.go b/tools/auth/google.go index f8846d80..2fe1d95d 100644 --- a/tools/auth/google.go +++ b/tools/auth/google.go @@ -1,6 +1,8 @@ package auth import ( + "encoding/json" + "golang.org/x/oauth2" ) @@ -29,22 +31,33 @@ func NewGoogleProvider() *Google { // FetchAuthUser returns an AuthUser instance based the Google's user api. func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - rawData := struct { - Id string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Picture string `json:"picture"` - }{} + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } - if err := p.FetchRawUserData(token, &rawData); err != nil { + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Id string + Name string + Email string + Picture string + }{} + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: rawData.Id, - Name: rawData.Name, - Email: rawData.Email, - AvatarUrl: rawData.Picture, + Id: extracted.Id, + Name: extracted.Name, + Email: extracted.Email, + AvatarUrl: extracted.Picture, + RawUser: rawUser, + AccessToken: token.AccessToken, } return user, nil diff --git a/tools/auth/kakao.go b/tools/auth/kakao.go index e1250c5b..6c66ba32 100644 --- a/tools/auth/kakao.go +++ b/tools/auth/kakao.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "strconv" "golang.org/x/oauth2" @@ -28,9 +29,20 @@ func NewKakaoProvider() *Kakao { } // FetchAuthUser returns an AuthUser instance based on the Kakao's user api. +// +// API reference: https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Id int `json:"id"` Profile struct { Nickname string `json:"nickname"` @@ -42,18 +54,19 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { IsEmailValid bool `json:"is_email_valid"` } `json:"kakao_account"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(rawData.Id), - Username: rawData.Profile.Nickname, - AvatarUrl: rawData.Profile.ImageUrl, + Id: strconv.Itoa(extracted.Id), + Username: extracted.Profile.Nickname, + AvatarUrl: extracted.Profile.ImageUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, } - if rawData.KakaoAccount.IsEmailValid && rawData.KakaoAccount.IsEmailVerified { - user.Email = rawData.KakaoAccount.Email + if extracted.KakaoAccount.IsEmailValid && extracted.KakaoAccount.IsEmailVerified { + user.Email = extracted.KakaoAccount.Email } return user, nil diff --git a/tools/auth/microsoft.go b/tools/auth/microsoft.go index 6305739e..67e2d4d0 100644 --- a/tools/auth/microsoft.go +++ b/tools/auth/microsoft.go @@ -1,6 +1,8 @@ package auth import ( + "encoding/json" + "golang.org/x/oauth2" "golang.org/x/oauth2/microsoft" ) @@ -27,23 +29,35 @@ func NewMicrosoftProvider() *Microsoft { } // FetchAuthUser returns an AuthUser instance based on the Microsoft's user api. +// +// API reference: https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo +// Graph explorer: https://developer.microsoft.com/en-us/graph/graph-explorer func (p *Microsoft) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo - // explore graph: https://developer.microsoft.com/en-us/graph/graph-explorer - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Id string `json:"id"` Name string `json:"displayName"` Email string `json:"mail"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: rawData.Id, - Name: rawData.Name, - Email: rawData.Email, + Id: extracted.Id, + Name: extracted.Name, + Email: extracted.Email, + RawUser: rawUser, + AccessToken: token.AccessToken, } return user, nil diff --git a/tools/auth/spotify.go b/tools/auth/spotify.go index f267a3c2..8414c902 100644 --- a/tools/auth/spotify.go +++ b/tools/auth/spotify.go @@ -1,6 +1,8 @@ package auth import ( + "encoding/json" + "golang.org/x/oauth2" "golang.org/x/oauth2/spotify" ) @@ -30,9 +32,20 @@ func NewSpotifyProvider() *Spotify { } // FetchAuthUser returns an AuthUser instance based on the Spotify's user api. +// +// API reference: https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Id string `json:"id"` Name string `json:"display_name"` Images []struct { @@ -43,17 +56,18 @@ func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { // that it actually belongs to the user // Email string `json:"email"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: rawData.Id, - Name: rawData.Name, + Id: extracted.Id, + Name: extracted.Name, + RawUser: rawUser, + AccessToken: token.AccessToken, } - if len(rawData.Images) > 0 { - user.AvatarUrl = rawData.Images[0].Url + if len(extracted.Images) > 0 { + user.AvatarUrl = extracted.Images[0].Url } return user, nil diff --git a/tools/auth/twitch.go b/tools/auth/twitch.go index b0cb2a8a..d7400cfd 100644 --- a/tools/auth/twitch.go +++ b/tools/auth/twitch.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "errors" "net/http" @@ -29,9 +30,20 @@ func NewTwitchProvider() *Twitch { } // FetchAuthUser returns an AuthUser instance based the Twitch's user api. +// +// API reference: https://dev.twitch.tv/docs/api/reference#get-users func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://dev.twitch.tv/docs/api/reference#get-users - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Data []struct { Id string `json:"id"` Login string `json:"login"` @@ -40,21 +52,22 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { ProfileImageUrl string `json:"profile_image_url"` } `json:"data"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } - if len(rawData.Data) == 0 { + if len(extracted.Data) == 0 { return nil, errors.New("Failed to fetch AuthUser data") } user := &AuthUser{ - Id: rawData.Data[0].Id, - Name: rawData.Data[0].DisplayName, - Username: rawData.Data[0].Login, - Email: rawData.Data[0].Email, - AvatarUrl: rawData.Data[0].ProfileImageUrl, + Id: extracted.Data[0].Id, + Name: extracted.Data[0].DisplayName, + Username: extracted.Data[0].Login, + Email: extracted.Data[0].Email, + AvatarUrl: extracted.Data[0].ProfileImageUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, } return user, nil @@ -63,13 +76,13 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { // FetchRawUserData implements Provider.FetchRawUserData interface. // // This differ from baseProvider because Twitch requires the `Client-Id` header. -func (p *Twitch) FetchRawUserData(token *oauth2.Token, result any) error { +func (p *Twitch) FetchRawUserData(token *oauth2.Token) ([]byte, error) { req, err := http.NewRequest("GET", p.userApiUrl, nil) if err != nil { - return err + return nil, err } req.Header.Set("Client-Id", p.clientId) - return p.sendRawUserDataRequest(req, token, result) + return p.sendRawUserDataRequest(req, token) } diff --git a/tools/auth/twitter.go b/tools/auth/twitter.go index c1128aff..092df656 100644 --- a/tools/auth/twitter.go +++ b/tools/auth/twitter.go @@ -1,6 +1,8 @@ package auth import ( + "encoding/json" + "golang.org/x/oauth2" ) @@ -31,9 +33,20 @@ func NewTwitterProvider() *Twitter { } // FetchAuthUser returns an AuthUser instance based on the Twitter's user api. +// +// API reference: https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - // https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me - rawData := struct { + data, err := p.FetchRawUserData(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { Data struct { Id string `json:"id"` Name string `json:"name"` @@ -42,20 +55,20 @@ func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { // NB! At the time of writing, Twitter OAuth2 doesn't support returning the user email address // (see https://twittercommunity.com/t/which-api-to-get-user-after-oauth2-authorization/162417/33) - Email string `json:"email"` + // Email string `json:"email"` } `json:"data"` }{} - - if err := p.FetchRawUserData(token, &rawData); err != nil { + if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: rawData.Data.Id, - Name: rawData.Data.Name, - Username: rawData.Data.Username, - Email: rawData.Data.Email, - AvatarUrl: rawData.Data.ProfileImageUrl, + Id: extracted.Data.Id, + Name: extracted.Data.Name, + Username: extracted.Data.Username, + AvatarUrl: extracted.Data.ProfileImageUrl, + RawUser: rawUser, + AccessToken: token.AccessToken, } return user, nil