diff --git a/dashboard/src/components/EnvComponents/OAuthConfig.tsx b/dashboard/src/components/EnvComponents/OAuthConfig.tsx index 4537997..8b1b3c8 100644 --- a/dashboard/src/components/EnvComponents/OAuthConfig.tsx +++ b/dashboard/src/components/EnvComponents/OAuthConfig.tsx @@ -15,6 +15,7 @@ import { FaFacebookF, FaLinkedin, FaApple, + FaTwitter, } from 'react-icons/fa'; import { TextInputType, HiddenInputType } from '../../constants'; @@ -264,6 +265,44 @@ const OAuthConfig = ({ /> + +
+ +
+
+ +
+
+ +
+
diff --git a/dashboard/src/constants.ts b/dashboard/src/constants.ts index db7a964..3911a40 100644 --- a/dashboard/src/constants.ts +++ b/dashboard/src/constants.ts @@ -9,6 +9,7 @@ export const TextInputType = { FACEBOOK_CLIENT_ID: 'FACEBOOK_CLIENT_ID', LINKEDIN_CLIENT_ID: 'LINKEDIN_CLIENT_ID', APPLE_CLIENT_ID: 'APPLE_CLIENT_ID', + TWITTER_CLIENT_ID: 'TWITTER_CLIENT_ID', JWT_ROLE_CLAIM: 'JWT_ROLE_CLAIM', REDIS_URL: 'REDIS_URL', SMTP_HOST: 'SMTP_HOST', @@ -35,6 +36,7 @@ export const HiddenInputType = { FACEBOOK_CLIENT_SECRET: 'FACEBOOK_CLIENT_SECRET', LINKEDIN_CLIENT_SECRET: 'LINKEDIN_CLIENT_SECRET', APPLE_CLIENT_SECRET: 'APPLE_CLIENT_SECRET', + TWITTER_CLIENT_SECRET: 'TWITTER_CLIENT_SECRET', JWT_SECRET: 'JWT_SECRET', SMTP_PASSWORD: 'SMTP_PASSWORD', ADMIN_SECRET: 'ADMIN_SECRET', @@ -110,6 +112,8 @@ export interface envVarTypes { LINKEDIN_CLIENT_SECRET: string; APPLE_CLIENT_ID: string; APPLE_CLIENT_SECRET: string; + TWITTER_CLIENT_ID: string; + TWITTER_CLIENT_SECRET: string; ROLES: [string] | []; DEFAULT_ROLES: [string] | []; PROTECTED_ROLES: [string] | []; diff --git a/dashboard/src/graphql/queries/index.ts b/dashboard/src/graphql/queries/index.ts index 4823682..977cff8 100644 --- a/dashboard/src/graphql/queries/index.ts +++ b/dashboard/src/graphql/queries/index.ts @@ -30,6 +30,8 @@ export const EnvVariablesQuery = ` LINKEDIN_CLIENT_SECRET APPLE_CLIENT_ID APPLE_CLIENT_SECRET + TWITTER_CLIENT_ID + TWITTER_CLIENT_SECRET DEFAULT_ROLES PROTECTED_ROLES ROLES diff --git a/dashboard/src/pages/Environment.tsx b/dashboard/src/pages/Environment.tsx index e9eafc8..045b997 100644 --- a/dashboard/src/pages/Environment.tsx +++ b/dashboard/src/pages/Environment.tsx @@ -50,6 +50,8 @@ const Environment = () => { LINKEDIN_CLIENT_SECRET: '', APPLE_CLIENT_ID: '', APPLE_CLIENT_SECRET: '', + TWITTER_CLIENT_ID: '', + TWITTER_CLIENT_SECRET: '', ROLES: [], DEFAULT_ROLES: [], PROTECTED_ROLES: [], @@ -92,6 +94,7 @@ const Environment = () => { FACEBOOK_CLIENT_SECRET: false, LINKEDIN_CLIENT_SECRET: false, APPLE_CLIENT_SECRET: false, + TWITTER_CLIENT_SECRET: false, JWT_SECRET: false, SMTP_PASSWORD: false, ADMIN_SECRET: false, diff --git a/server/constants/auth_methods.go b/server/constants/auth_methods.go index 272bf53..59f0b1e 100644 --- a/server/constants/auth_methods.go +++ b/server/constants/auth_methods.go @@ -15,4 +15,6 @@ const ( AuthRecipeMethodLinkedIn = "linkedin" // AuthRecipeMethodApple is the apple auth method AuthRecipeMethodApple = "apple" + // AuthRecipeMethodTwitter is the twitter auth method + AuthRecipeMethodTwitter = "twitter" ) diff --git a/server/constants/env.go b/server/constants/env.go index bd3afaf..a32f64d 100644 --- a/server/constants/env.go +++ b/server/constants/env.go @@ -85,6 +85,10 @@ const ( EnvKeyAppleClientID = "APPLE_CLIENT_ID" // EnvKeyAppleClientSecret key for env variable APPLE_CLIENT_SECRET EnvKeyAppleClientSecret = "APPLE_CLIENT_SECRET" + // EnvKeyTwitterClientID key for env variable TWITTER_CLIENT_ID + EnvKeyTwitterClientID = "TWITTER_CLIENT_ID" + // EnvKeyTwitterClientSecret key for env variable TWITTER_CLIENT_SECRET + EnvKeyTwitterClientSecret = "TWITTER_CLIENT_SECRET" // EnvKeyOrganizationName key for env variable ORGANIZATION_NAME EnvKeyOrganizationName = "ORGANIZATION_NAME" // EnvKeyOrganizationLogo key for env variable ORGANIZATION_LOGO 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/env/env.go b/server/env/env.go index cf40eb7..1d30cfe 100644 --- a/server/env/env.go +++ b/server/env/env.go @@ -72,6 +72,8 @@ func InitAllEnv() error { osLinkedInClientSecret := os.Getenv(constants.EnvKeyLinkedInClientSecret) osAppleClientID := os.Getenv(constants.EnvKeyAppleClientID) osAppleClientSecret := os.Getenv(constants.EnvKeyAppleClientSecret) + osTwitterClientID := os.Getenv(constants.EnvKeyTwitterClientID) + osTwitterClientSecret := os.Getenv(constants.EnvKeyTwitterClientSecret) osResetPasswordURL := os.Getenv(constants.EnvKeyResetPasswordURL) osOrganizationName := os.Getenv(constants.EnvKeyOrganizationName) osOrganizationLogo := os.Getenv(constants.EnvKeyOrganizationLogo) @@ -380,6 +382,20 @@ func InitAllEnv() error { envData[constants.EnvKeyAppleClientSecret] = osAppleClientSecret } + if val, ok := envData[constants.EnvKeyTwitterClientID]; !ok || val == "" { + envData[constants.EnvKeyTwitterClientID] = osTwitterClientID + } + if osTwitterClientID != "" && envData[constants.EnvKeyTwitterClientID] != osTwitterClientID { + envData[constants.EnvKeyTwitterClientID] = osTwitterClientID + } + + if val, ok := envData[constants.EnvKeyTwitterClientSecret]; !ok || val == "" { + envData[constants.EnvKeyTwitterClientSecret] = osTwitterClientSecret + } + if osTwitterClientSecret != "" && envData[constants.EnvKeyTwitterClientSecret] != osTwitterClientSecret { + envData[constants.EnvKeyTwitterClientSecret] = osTwitterClientSecret + } + if val, ok := envData[constants.EnvKeyResetPasswordURL]; !ok || val == "" { envData[constants.EnvKeyResetPasswordURL] = strings.TrimPrefix(osResetPasswordURL, "/") } diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index e5b3d7d..85fb406 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -119,6 +119,8 @@ type ComplexityRoot struct { SMTPPort func(childComplexity int) int SMTPUsername func(childComplexity int) int SenderEmail func(childComplexity int) int + TwitterClientID func(childComplexity int) int + TwitterClientSecret func(childComplexity int) int } Error struct { @@ -145,6 +147,7 @@ type ComplexityRoot struct { IsMultiFactorAuthEnabled func(childComplexity int) int IsSignUpEnabled func(childComplexity int) int IsStrongPasswordEnabled func(childComplexity int) int + IsTwitterLoginEnabled func(childComplexity int) int Version func(childComplexity int) int } @@ -813,6 +816,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Env.SenderEmail(childComplexity), true + case "Env.TWITTER_CLIENT_ID": + if e.complexity.Env.TwitterClientID == nil { + break + } + + return e.complexity.Env.TwitterClientID(childComplexity), true + + case "Env.TWITTER_CLIENT_SECRET": + if e.complexity.Env.TwitterClientSecret == nil { + break + } + + return e.complexity.Env.TwitterClientSecret(childComplexity), true + case "Error.message": if e.complexity.Error.Message == nil { break @@ -932,6 +949,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Meta.IsStrongPasswordEnabled(childComplexity), true + case "Meta.is_twitter_login_enabled": + if e.complexity.Meta.IsTwitterLoginEnabled == nil { + break + } + + return e.complexity.Meta.IsTwitterLoginEnabled(childComplexity), true + case "Meta.version": if e.complexity.Meta.Version == nil { break @@ -1893,6 +1917,7 @@ type Meta { is_github_login_enabled: Boolean! is_linkedin_login_enabled: Boolean! is_apple_login_enabled: Boolean! + is_twitter_login_enabled: Boolean! is_email_verification_enabled: Boolean! is_basic_authentication_enabled: Boolean! is_magic_link_login_enabled: Boolean! @@ -2014,6 +2039,8 @@ type Env { LINKEDIN_CLIENT_SECRET: String APPLE_CLIENT_ID: String APPLE_CLIENT_SECRET: String + TWITTER_CLIENT_ID: String + TWITTER_CLIENT_SECRET: String ORGANIZATION_NAME: String ORGANIZATION_LOGO: String } @@ -2118,6 +2145,8 @@ input UpdateEnvInput { LINKEDIN_CLIENT_SECRET: String APPLE_CLIENT_ID: String APPLE_CLIENT_SECRET: String + TWITTER_CLIENT_ID: String + TWITTER_CLIENT_SECRET: String ORGANIZATION_NAME: String ORGANIZATION_LOGO: String } @@ -5054,6 +5083,70 @@ func (ec *executionContext) _Env_APPLE_CLIENT_SECRET(ctx context.Context, field return ec.marshalOString2ᚖstring(ctx, field.Selections, res) } +func (ec *executionContext) _Env_TWITTER_CLIENT_ID(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Env", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TwitterClientID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _Env_TWITTER_CLIENT_SECRET(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Env", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TwitterClientSecret, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + func (ec *executionContext) _Env_ORGANIZATION_NAME(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5529,6 +5622,41 @@ func (ec *executionContext) _Meta_is_apple_login_enabled(ctx context.Context, fi return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _Meta_is_twitter_login_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Meta", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsTwitterLoginEnabled, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _Meta_is_email_verification_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -11720,6 +11848,22 @@ func (ec *executionContext) unmarshalInputUpdateEnvInput(ctx context.Context, ob if err != nil { return it, err } + case "TWITTER_CLIENT_ID": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TWITTER_CLIENT_ID")) + it.TwitterClientID, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "TWITTER_CLIENT_SECRET": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TWITTER_CLIENT_SECRET")) + it.TwitterClientSecret, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } case "ORGANIZATION_NAME": var err error @@ -12421,6 +12565,10 @@ func (ec *executionContext) _Env(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._Env_APPLE_CLIENT_ID(ctx, field, obj) case "APPLE_CLIENT_SECRET": out.Values[i] = ec._Env_APPLE_CLIENT_SECRET(ctx, field, obj) + case "TWITTER_CLIENT_ID": + out.Values[i] = ec._Env_TWITTER_CLIENT_ID(ctx, field, obj) + case "TWITTER_CLIENT_SECRET": + out.Values[i] = ec._Env_TWITTER_CLIENT_SECRET(ctx, field, obj) case "ORGANIZATION_NAME": out.Values[i] = ec._Env_ORGANIZATION_NAME(ctx, field, obj) case "ORGANIZATION_LOGO": @@ -12542,6 +12690,11 @@ func (ec *executionContext) _Meta(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { invalids++ } + case "is_twitter_login_enabled": + out.Values[i] = ec._Meta_is_twitter_login_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } case "is_email_verification_enabled": out.Values[i] = ec._Meta_is_email_verification_enabled(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index f4f125f..19fd6d9 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -106,6 +106,8 @@ type Env struct { LinkedinClientSecret *string `json:"LINKEDIN_CLIENT_SECRET"` AppleClientID *string `json:"APPLE_CLIENT_ID"` AppleClientSecret *string `json:"APPLE_CLIENT_SECRET"` + TwitterClientID *string `json:"TWITTER_CLIENT_ID"` + TwitterClientSecret *string `json:"TWITTER_CLIENT_SECRET"` OrganizationName *string `json:"ORGANIZATION_NAME"` OrganizationLogo *string `json:"ORGANIZATION_LOGO"` } @@ -164,6 +166,7 @@ type Meta struct { IsGithubLoginEnabled bool `json:"is_github_login_enabled"` IsLinkedinLoginEnabled bool `json:"is_linkedin_login_enabled"` IsAppleLoginEnabled bool `json:"is_apple_login_enabled"` + IsTwitterLoginEnabled bool `json:"is_twitter_login_enabled"` IsEmailVerificationEnabled bool `json:"is_email_verification_enabled"` IsBasicAuthenticationEnabled bool `json:"is_basic_authentication_enabled"` IsMagicLinkLoginEnabled bool `json:"is_magic_link_login_enabled"` @@ -297,6 +300,8 @@ type UpdateEnvInput struct { LinkedinClientSecret *string `json:"LINKEDIN_CLIENT_SECRET"` AppleClientID *string `json:"APPLE_CLIENT_ID"` AppleClientSecret *string `json:"APPLE_CLIENT_SECRET"` + TwitterClientID *string `json:"TWITTER_CLIENT_ID"` + TwitterClientSecret *string `json:"TWITTER_CLIENT_SECRET"` OrganizationName *string `json:"ORGANIZATION_NAME"` OrganizationLogo *string `json:"ORGANIZATION_LOGO"` } diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 425be3c..d7d58d6 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -20,6 +20,7 @@ type Meta { is_github_login_enabled: Boolean! is_linkedin_login_enabled: Boolean! is_apple_login_enabled: Boolean! + is_twitter_login_enabled: Boolean! is_email_verification_enabled: Boolean! is_basic_authentication_enabled: Boolean! is_magic_link_login_enabled: Boolean! @@ -141,6 +142,8 @@ type Env { LINKEDIN_CLIENT_SECRET: String APPLE_CLIENT_ID: String APPLE_CLIENT_SECRET: String + TWITTER_CLIENT_ID: String + TWITTER_CLIENT_SECRET: String ORGANIZATION_NAME: String ORGANIZATION_LOGO: String } @@ -245,6 +248,8 @@ input UpdateEnvInput { LINKEDIN_CLIENT_SECRET: String APPLE_CLIENT_ID: String APPLE_CLIENT_SECRET: String + TWITTER_CLIENT_ID: String + TWITTER_CLIENT_SECRET: String ORGANIZATION_NAME: String ORGANIZATION_LOGO: String } diff --git a/server/handlers/oauth_callback.go b/server/handlers/oauth_callback.go index 7e316a3..c1d425c 100644 --- a/server/handlers/oauth_callback.go +++ b/server/handlers/oauth_callback.go @@ -67,6 +67,8 @@ func OAuthCallbackHandler() gin.HandlerFunc { user, err = processLinkedInUserInfo(code) case constants.AuthRecipeMethodApple: user, err = processAppleUserInfo(code) + case constants.AuthRecipeMethodTwitter: + user, err = processTwitterUserInfo(code, sessionState) default: log.Info("Invalid oauth provider") err = fmt.Errorf(`invalid oauth provider`) @@ -564,3 +566,70 @@ func processAppleUserInfo(code string) (models.User, error) { return user, err } + +func processTwitterUserInfo(code, verifier string) (models.User, error) { + user := models.User{} + 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 4109162..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" ) @@ -95,7 +96,7 @@ func OAuthLoginHandler() gin.HandlerFunc { } - oauthStateString := state + "___" + redirectURI + "___" + roles + "___" + strings.Join(scope, ",") + oauthStateString := state + "___" + redirectURI + "___" + roles + "___" + strings.Join(scope, " ") provider := c.Param("oauth_provider") isProviderConfigured := true @@ -169,6 +170,26 @@ func OAuthLoginHandler() gin.HandlerFunc { oauth.OAuthProviders.LinkedInConfig.RedirectURL = hostname + "/oauth_callback/" + constants.AuthRecipeMethodLinkedIn url := oauth.OAuthProviders.LinkedInConfig.AuthCodeURL(oauthStateString) c.Redirect(http.StatusTemporaryRedirect, url) + case constants.AuthRecipeMethodTwitter: + if oauth.OAuthProviders.TwitterConfig == nil { + log.Debug("Twitter OAuth provider is not configured") + isProviderConfigured = false + break + } + + verifier, challenge := utils.GenerateCodeChallenge() + + err := memorystore.Provider.SetState(oauthStateString, verifier) + if err != nil { + log.Debug("Error setting state: ", err) + c.JSON(500, gin.H{ + "error": "internal server error", + }) + return + } + oauth.OAuthProviders.TwitterConfig.RedirectURL = hostname + "/oauth_callback/" + constants.AuthRecipeMethodTwitter + 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 { log.Debug("Apple OAuth provider is not configured") diff --git a/server/memorystore/providers/inmemory/store.go b/server/memorystore/providers/inmemory/store.go index ebd0c06..3dbf234 100644 --- a/server/memorystore/providers/inmemory/store.go +++ b/server/memorystore/providers/inmemory/store.go @@ -34,6 +34,7 @@ func (c *provider) DeleteAllUserSessions(userId string) error { constants.AuthRecipeMethodGithub, constants.AuthRecipeMethodGoogle, constants.AuthRecipeMethodLinkedIn, + constants.AuthRecipeMethodTwitter, } for _, namespace := range namespaces { diff --git a/server/memorystore/providers/redis/store.go b/server/memorystore/providers/redis/store.go index f57e1ca..9135f1a 100644 --- a/server/memorystore/providers/redis/store.go +++ b/server/memorystore/providers/redis/store.go @@ -71,6 +71,7 @@ func (c *provider) DeleteAllUserSessions(userID string) error { constants.AuthRecipeMethodGithub, constants.AuthRecipeMethodGoogle, constants.AuthRecipeMethodLinkedIn, + constants.AuthRecipeMethodTwitter, } for _, namespace := range namespaces { err := c.store.Del(c.ctx, namespace+":"+userID).Err() diff --git a/server/oauth/oauth.go b/server/oauth/oauth.go index 9172890..7523271 100644 --- a/server/oauth/oauth.go +++ b/server/oauth/oauth.go @@ -20,6 +20,7 @@ type OAuthProvider struct { FacebookConfig *oauth2.Config LinkedInConfig *oauth2.Config AppleConfig *oauth2.Config + TwitterConfig *oauth2.Config } // OIDCProviders is a struct that contains reference all the OpenID providers @@ -133,5 +134,28 @@ func InitOAuth() error { } } + 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/resolvers/env.go b/server/resolvers/env.go index cd78f98..71bf53c 100644 --- a/server/resolvers/env.go +++ b/server/resolvers/env.go @@ -143,6 +143,13 @@ func EnvResolver(ctx context.Context) (*model.Env, error) { if val, ok := store[constants.EnvKeyAppleClientSecret]; ok { res.AppleClientSecret = refs.NewStringRef(val.(string)) } + if val, ok := store[constants.EnvKeyTwitterClientID]; ok { + res.TwitterClientID = refs.NewStringRef(val.(string)) + } + if val, ok := store[constants.EnvKeyTwitterClientSecret]; ok { + res.TwitterClientSecret = refs.NewStringRef(val.(string)) + } + if val, ok := store[constants.EnvKeyOrganizationName]; ok { res.OrganizationName = refs.NewStringRef(val.(string)) } diff --git a/server/resolvers/meta.go b/server/resolvers/meta.go index f53437c..8a11f32 100644 --- a/server/resolvers/meta.go +++ b/server/resolvers/meta.go @@ -77,6 +77,18 @@ func MetaResolver(ctx context.Context) (*model.Meta, error) { githubClientSecret = "" } + twitterClientID, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientID) + if err != nil { + log.Debug("Failed to get Twitter Client ID from environment variable", err) + twitterClientID = "" + } + + twitterClientSecret, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientSecret) + if err != nil { + log.Debug("Failed to get Twitter Client Secret from environment variable", err) + twitterClientSecret = "" + } + isBasicAuthDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableBasicAuthentication) if err != nil { log.Debug("Failed to get Disable Basic Authentication from environment variable", err) @@ -121,6 +133,7 @@ func MetaResolver(ctx context.Context) (*model.Meta, error) { IsFacebookLoginEnabled: facebookClientID != "" && facebookClientSecret != "", IsLinkedinLoginEnabled: linkedClientID != "" && linkedInClientSecret != "", IsAppleLoginEnabled: appleClientID != "" && appleClientSecret != "", + IsTwitterLoginEnabled: twitterClientID != "" && twitterClientSecret != "", IsBasicAuthenticationEnabled: !isBasicAuthDisabled, IsEmailVerificationEnabled: !isEmailVerificationDisabled, IsMagicLinkLoginEnabled: !isMagicLinkLoginDisabled, diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index caeea6e..44ad00b 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -31,6 +31,7 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) { isCurrentGoogleLoginEnabled := currentData[constants.EnvKeyGoogleClientID] != nil && currentData[constants.EnvKeyGoogleClientSecret] != nil && currentData[constants.EnvKeyGoogleClientID].(string) != "" && currentData[constants.EnvKeyGoogleClientSecret].(string) != "" isCurrentGithubLoginEnabled := currentData[constants.EnvKeyGithubClientID] != nil && currentData[constants.EnvKeyGithubClientSecret] != nil && currentData[constants.EnvKeyGithubClientID].(string) != "" && currentData[constants.EnvKeyGithubClientSecret].(string) != "" isCurrentLinkedInLoginEnabled := currentData[constants.EnvKeyLinkedInClientID] != nil && currentData[constants.EnvKeyLinkedInClientSecret] != nil && currentData[constants.EnvKeyLinkedInClientID].(string) != "" && currentData[constants.EnvKeyLinkedInClientSecret].(string) != "" + isCurrentTwitterLoginEnabled := currentData[constants.EnvKeyTwitterClientID] != nil && currentData[constants.EnvKeyTwitterClientSecret] != nil && currentData[constants.EnvKeyTwitterClientID].(string) != "" && currentData[constants.EnvKeyTwitterClientSecret].(string) != "" isUpdatedBasicAuthEnabled := !updatedData[constants.EnvKeyDisableBasicAuthentication].(bool) isUpdatedMagicLinkLoginEnabled := !updatedData[constants.EnvKeyDisableMagicLinkLogin].(bool) @@ -39,6 +40,7 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) { isUpdatedGoogleLoginEnabled := updatedData[constants.EnvKeyGoogleClientID] != nil && updatedData[constants.EnvKeyGoogleClientSecret] != nil && updatedData[constants.EnvKeyGoogleClientID].(string) != "" && updatedData[constants.EnvKeyGoogleClientSecret].(string) != "" isUpdatedGithubLoginEnabled := updatedData[constants.EnvKeyGithubClientID] != nil && updatedData[constants.EnvKeyGithubClientSecret] != nil && updatedData[constants.EnvKeyGithubClientID].(string) != "" && updatedData[constants.EnvKeyGithubClientSecret].(string) != "" isUpdatedLinkedInLoginEnabled := updatedData[constants.EnvKeyLinkedInClientID] != nil && updatedData[constants.EnvKeyLinkedInClientSecret] != nil && updatedData[constants.EnvKeyLinkedInClientID].(string) != "" && updatedData[constants.EnvKeyLinkedInClientSecret].(string) != "" + isUpdatedTwitterLoginEnabled := updatedData[constants.EnvKeyTwitterClientID] != nil && updatedData[constants.EnvKeyTwitterClientSecret] != nil && updatedData[constants.EnvKeyTwitterClientID].(string) != "" && updatedData[constants.EnvKeyTwitterClientSecret].(string) != "" if isCurrentBasicAuthEnabled && !isUpdatedBasicAuthEnabled { memorystore.Provider.DeleteSessionForNamespace(constants.AuthRecipeMethodBasicAuth) @@ -67,6 +69,10 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) { if isCurrentLinkedInLoginEnabled && !isUpdatedLinkedInLoginEnabled { memorystore.Provider.DeleteSessionForNamespace(constants.AuthRecipeMethodLinkedIn) } + + if isCurrentTwitterLoginEnabled && !isUpdatedTwitterLoginEnabled { + memorystore.Provider.DeleteSessionForNamespace(constants.AuthRecipeMethodTwitter) + } } // UpdateEnvResolver is a resolver for update config mutation 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 +}