diff --git a/server/constants/oauth_info_urls.go b/server/constants/oauth_info_urls.go index 1dcec3a..c9bad78 100644 --- a/server/constants/oauth_info_urls.go +++ b/server/constants/oauth_info_urls.go @@ -14,4 +14,6 @@ const ( // Ref: https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api LinkedInUserInfoURL = "https://api.linkedin.com/v2/me?projection=(id,localizedFirstName,localizedLastName,emailAddress,profilePicture(displayImage~:playableStreams))" LinkedInEmailURL = "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))" + + TwitterUserInfoURL = "https://api.twitter.com/2/users/me?user.fields=id,name,profile_image_url,username" ) diff --git a/server/handlers/oauth_callback.go b/server/handlers/oauth_callback.go index b5d8d1a..c1d425c 100644 --- a/server/handlers/oauth_callback.go +++ b/server/handlers/oauth_callback.go @@ -68,7 +68,7 @@ func OAuthCallbackHandler() gin.HandlerFunc { case constants.AuthRecipeMethodApple: user, err = processAppleUserInfo(code) case constants.AuthRecipeMethodTwitter: - user, err = processTwitterUserInfo(code) + user, err = processTwitterUserInfo(code, sessionState) default: log.Info("Invalid oauth provider") err = fmt.Errorf(`invalid oauth provider`) @@ -567,8 +567,69 @@ func processAppleUserInfo(code string) (models.User, error) { return user, err } -func processTwitterUserInfo(code string) (models.User, error) { +func processTwitterUserInfo(code, verifier string) (models.User, error) { user := models.User{} - // TODO exchange code and get user information + oauth2Token, err := oauth.OAuthProviders.TwitterConfig.Exchange(oauth2.NoContext, code, oauth2.SetAuthURLParam("code_verifier", verifier)) + if err != nil { + log.Debug("Failed to exchange code for token: ", err) + return user, fmt.Errorf("invalid twitter exchange code: %s", err.Error()) + } + + client := http.Client{} + req, err := http.NewRequest("GET", constants.TwitterUserInfoURL, nil) + if err != nil { + log.Debug("Failed to create Twitter user info request: ", err) + return user, fmt.Errorf("error creating Twitter user info request: %s", err.Error()) + } + req.Header = http.Header{ + "Authorization": []string{fmt.Sprintf("Bearer %s", oauth2Token.AccessToken)}, + } + + response, err := client.Do(req) + if err != nil { + log.Debug("Failed to request Twitter user info: ", err) + return user, err + } + + defer response.Body.Close() + body, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Debug("Failed to read Twitter user info response body: ", err) + return user, fmt.Errorf("failed to read Twitter response body: %s", err.Error()) + } + + if response.StatusCode >= 400 { + log.Debug("Failed to request Twitter user info: ", string(body)) + return user, fmt.Errorf("failed to request Twitter user info: %s", string(body)) + } + + responseRawData := make(map[string]interface{}) + json.Unmarshal(body, &responseRawData) + + userRawData := responseRawData["data"].(map[string]interface{}) + + log.Info(userRawData) + // Twitter API does not return E-Mail adresses by default. For that case special privileges have + // to be granted on a per-App basis. See https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/get-account-verify_credentials + + // Currently Twitter API only provides the full name of a user. To fill givenName and familyName + // the full name will be split at the first whitespace. This approach will not be valid for all name combinations + nameArr := strings.SplitAfterN(userRawData["name"].(string), " ", 2) + + firstName := nameArr[0] + lastName := "" + if len(nameArr) == 2 { + lastName = nameArr[1] + } + nickname := userRawData["username"].(string) + profilePicture := userRawData["profile_image_url"].(string) + + user = models.User{ + GivenName: &firstName, + FamilyName: &lastName, + Picture: &profilePicture, + Nickname: &nickname, + } + return user, nil } diff --git a/server/handlers/oauth_login.go b/server/handlers/oauth_login.go index 8312cdd..d7cd80d 100644 --- a/server/handlers/oauth_login.go +++ b/server/handlers/oauth_login.go @@ -12,6 +12,7 @@ import ( "github.com/authorizerdev/authorizer/server/memorystore" "github.com/authorizerdev/authorizer/server/oauth" "github.com/authorizerdev/authorizer/server/parsers" + "github.com/authorizerdev/authorizer/server/utils" "github.com/authorizerdev/authorizer/server/validators" ) @@ -175,7 +176,10 @@ func OAuthLoginHandler() gin.HandlerFunc { isProviderConfigured = false break } - err := memorystore.Provider.SetState(oauthStateString, constants.AuthRecipeMethodTwitter) + + verifier, challenge := utils.GenerateCodeChallenge() + + err := memorystore.Provider.SetState(oauthStateString, verifier) if err != nil { log.Debug("Error setting state: ", err) c.JSON(500, gin.H{ @@ -184,7 +188,7 @@ func OAuthLoginHandler() gin.HandlerFunc { return } oauth.OAuthProviders.TwitterConfig.RedirectURL = hostname + "/oauth_callback/" + constants.AuthRecipeMethodTwitter - url := oauth.OAuthProviders.TwitterConfig.AuthCodeURL(oauthStateString) + url := oauth.OAuthProviders.TwitterConfig.AuthCodeURL(oauthStateString, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256")) c.Redirect(http.StatusTemporaryRedirect, url) case constants.AuthRecipeMethodApple: if oauth.OAuthProviders.AppleConfig == nil { diff --git a/server/oauth/oauth.go b/server/oauth/oauth.go index 25ae695..7523271 100644 --- a/server/oauth/oauth.go +++ b/server/oauth/oauth.go @@ -134,7 +134,28 @@ func InitOAuth() error { } } - // TODO add support for twitter provider and update OAuthProviders.TwitterConfig + twitterClientID, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientID) + if err != nil { + twitterClientID = "" + } + twitterClientSecret, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientSecret) + if err != nil { + twitterClientSecret = "" + } + if twitterClientID != "" && twitterClientSecret != "" { + OAuthProviders.TwitterConfig = &oauth2.Config{ + ClientID: twitterClientID, + ClientSecret: twitterClientSecret, + RedirectURL: "/oauth_callback/twitter", + Endpoint: oauth2.Endpoint{ + // Endpoint is currently not yet part of oauth2-package. See https://go-review.googlesource.com/c/oauth2/+/350889 for status + AuthURL: "https://twitter.com/i/oauth2/authorize", + TokenURL: "https://api.twitter.com/2/oauth2/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: []string{"tweet.read", "users.read"}, + } + } return nil } diff --git a/server/utils/pkce.go b/server/utils/pkce.go new file mode 100644 index 0000000..d55aac6 --- /dev/null +++ b/server/utils/pkce.go @@ -0,0 +1,32 @@ +package utils + +import ( + "crypto/sha256" + b64 "encoding/base64" + "math/rand" + "strings" + "time" +) + +const ( + length = 32 +) + +// GenerateCodeChallenge creates PKCE-Code-Challenge +// and returns the verifier and challenge +func GenerateCodeChallenge() (string, string) { + // Generate Verifier + randGenerator := rand.New(rand.NewSource(time.Now().UnixNano())) + randomBytes := make([]byte, length) + for i := 0; i < length; i++ { + randomBytes[i] = byte(randGenerator.Intn(255)) + } + verifier := strings.Trim(b64.URLEncoding.EncodeToString(randomBytes), "=") + + // Generate Challenge + rawChallenge := sha256.New() + rawChallenge.Write([]byte(verifier)) + challenge := strings.Trim(b64.URLEncoding.EncodeToString(rawChallenge.Sum(nil)), "=") + + return verifier, challenge +}