From ac42358d9fb10a477f50c7ac98b61c3348f03691 Mon Sep 17 00:00:00 2001 From: Prathamesh Date: Wed, 23 Oct 2024 17:54:38 +0530 Subject: [PATCH 01/12] added sign in with facebook --- backend/config/config.yaml | 2 + backend/config/config_default.go | 4 + backend/config/config_third_party.go | 5 +- .../flow/shared/hook_generate_oauth_links.go | 6 +- backend/handler/thirdparty_auth_test.go | 9 ++ .../handler/thirdparty_callback_error_test.go | 58 ++++++++- backend/handler/thirdparty_callback_test.go | 117 ++++++++++++++++++ backend/handler/thirdparty_test.go | 9 ++ backend/json_schema/hanko.config.json | 4 + backend/thirdparty/provider_facebook.go | 97 +++++++++++++++ .../thirdparty-x-domain/env-patch.yaml | 10 ++ .../thirdparty-x-domain/kustomization.yaml | 3 + 12 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 backend/thirdparty/provider_facebook.go diff --git a/backend/config/config.yaml b/backend/config/config.yaml index 9c824a493..6ba210443 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -114,6 +114,8 @@ third_party: enabled: false microsoft: enabled: false + facebook: + enabled: false username: enabled: false optional: true diff --git a/backend/config/config_default.go b/backend/config/config_default.go index 32aac92ab..4ad0951df 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -148,6 +148,10 @@ func DefaultConfig() *Config { AllowLinking: true, Name: "google", }, + Facebook: ThirdPartyProvider{ + DisplayName: "Facebook", + AllowLinking: true, + }, }, }, Passkey: Passkey{ diff --git a/backend/config/config_third_party.go b/backend/config/config_third_party.go index 232dd4253..71ae125fd 100644 --- a/backend/config/config_third_party.go +++ b/backend/config/config_third_party.go @@ -3,11 +3,12 @@ package config import ( "errors" "fmt" + "strings" + "github.com/fatih/structs" "github.com/gobwas/glob" "github.com/invopop/jsonschema" orderedmap "github.com/wk8/go-ordered-map/v2" - "strings" ) type ThirdParty struct { @@ -375,6 +376,8 @@ type ThirdPartyProviders struct { LinkedIn ThirdPartyProvider `yaml:"linkedin" json:"linkedin,omitempty" koanf:"linkedin"` // `microsoft` contains the provider configuration for Microsoft. Microsoft ThirdPartyProvider `yaml:"microsoft" json:"microsoft,omitempty" koanf:"microsoft"` + //`facebook` contains the provider configuration for Facebook. + Facebook ThirdPartyProvider `yaml:"facebook" json:"facebook,omitempty" koanf:"facebook"` } func (p *ThirdPartyProviders) Validate() error { diff --git a/backend/flow_api/flow/shared/hook_generate_oauth_links.go b/backend/flow_api/flow/shared/hook_generate_oauth_links.go index 612c36809..603e887a3 100644 --- a/backend/flow_api/flow/shared/hook_generate_oauth_links.go +++ b/backend/flow_api/flow/shared/hook_generate_oauth_links.go @@ -2,9 +2,10 @@ package shared import ( "fmt" + "net/url" + "github.com/labstack/echo/v4" "github.com/teamhanko/hanko/backend/flowpilot" - "net/url" ) type GenerateOAuthLinks struct { @@ -38,6 +39,9 @@ func (h GenerateOAuthLinks) Execute(c flowpilot.HookExecutionContext) error { if deps.Cfg.ThirdParty.Providers.Apple.Enabled { c.AddLink(OAuthLink("apple", h.generateHref(deps.HttpContext, "apple", returnToUrl))) } + if deps.Cfg.ThirdParty.Providers.Facebook.Enabled { + c.AddLink(OAuthLink("facebook", h.generateHref(deps.HttpContext, "facebook", returnToUrl))) + } return nil } diff --git a/backend/handler/thirdparty_auth_test.go b/backend/handler/thirdparty_auth_test.go index e1a54cdf1..f1e7dbf96 100644 --- a/backend/handler/thirdparty_auth_test.go +++ b/backend/handler/thirdparty_auth_test.go @@ -66,6 +66,15 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Auth() { requestedRedirectTo: "https://app.test.example", expectedBaseURL: thirdparty.MicrosoftOAuthAuthEndpoint, }, + { + name: "successful redirect to facebook", + referer: "https://login.test.example", + enabledProviders: []string{"facebook"}, + allowedRedirectURLs: []string{"https://*.test.example"}, + requestedProvider: "facebook", + requestedRedirectTo: "https://app.test.example", + expectedBaseURL: thirdparty.FacebookOauthAuthEndpoint, + }, { name: "error redirect on missing provider", referer: "https://login.test.example", diff --git a/backend/handler/thirdparty_callback_error_test.go b/backend/handler/thirdparty_callback_error_test.go index cc2af7c84..1ae96e592 100644 --- a/backend/handler/thirdparty_callback_error_test.go +++ b/backend/handler/thirdparty_callback_error_test.go @@ -2,12 +2,13 @@ package handler import ( "fmt" - "github.com/h2non/gock" - "github.com/teamhanko/hanko/backend/thirdparty" - "github.com/teamhanko/hanko/backend/utils" "net/http" "net/http/httptest" "testing" + + "github.com/h2non/gock" + "github.com/teamhanko/hanko/backend/thirdparty" + "github.com/teamhanko/hanko/backend/utils" ) func (s *thirdPartySuite) TestThirdPartyHandler_Callback_Error_LinkingNotAllowedForProvider() { @@ -438,3 +439,54 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_Error_MicrosoftUnverifi s.Len(logs, 1) } } + +func (s *thirdPartySuite) TestThirdPartyHandler_Callback_Error_FacebookUnverifiedEmail() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/thirdparty") + s.NoError(err) + + gock.New(thirdparty.FacebookOauthTokenEndpoint). + Post("/"). + Reply(200). + JSON(map[string]string{"access_token": "fakeAccessToken"}) + + gock.New(thirdparty.FacebookUserInfoEndpoint). + Get("/me"). + Reply(200). + JSON(map[string]interface{}{ + "id": "facebook_abcde", + "email": "test-facebook@example.com", + "verified": false, + }) + + cfg := s.setUpConfig([]string{"facebook"}, []string{"https://example.com"}) + cfg.Email.RequireVerification = true + + state, err := thirdparty.GenerateState(cfg, "facebook", "https://example.com") + s.NoError(err) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/thirdparty/callback?code=abcde&state=%s", state), nil) + req.AddCookie(&http.Cookie{ + Name: utils.HankoThirdpartyStateCookie, + Value: string(state), + }) + + c, rec := s.setUpContext(req) + handler := s.setUpHandler(cfg) + + if s.NoError(handler.Callback(c)) { + s.Equal(http.StatusTemporaryRedirect, rec.Code) + location, err := rec.Result().Location() + s.NoError(err) + + s.Equal(thirdparty.ErrorCodeUnverifiedProviderEmail, location.Query().Get("error")) + s.Equal("third party provider email must be verified", location.Query().Get("error_description")) + + logs, lerr := s.Storage.GetAuditLogPersister().List(0, 0, nil, nil, []string{"thirdparty_signin_signup_failed"}, "", "", "", "") + s.NoError(lerr) + s.Len(logs, 1) + } +} diff --git a/backend/handler/thirdparty_callback_test.go b/backend/handler/thirdparty_callback_test.go index ca8ce9170..556ca5970 100644 --- a/backend/handler/thirdparty_callback_test.go +++ b/backend/handler/thirdparty_callback_test.go @@ -618,6 +618,123 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignIn_Microsoft() { } } +func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignUp_Facebook() { + defer gock.Off() + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + gock.New(thirdparty.FacebookOauthTokenEndpoint). + Post("/"). + Reply(200). + JSON(map[string]string{"access_token": "fakeAccessToken"}) + + gock.New(thirdparty.FacebookUserInfoEndpoint). + Get("/me"). + Reply(200). + JSON(&thirdparty.FacebookUser{ + ID: "facebook_abcde", + Email: "test-facebook-signup@example.com", + }) + + cfg := s.setUpConfig([]string{"facebook"}, []string{"https://example.com"}) + + state, err := thirdparty.GenerateState(cfg, "facebook", "https://example.com") + s.NoError(err) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/thirdparty/callback?code=abcde&state=%s", state), nil) + req.AddCookie(&http.Cookie{ + Name: utils.HankoThirdpartyStateCookie, + Value: string(state), + }) + + c, rec := s.setUpContext(req) + handler := s.setUpHandler(cfg) + + if s.NoError(handler.Callback(c)) { + s.Equal(http.StatusTemporaryRedirect, rec.Code) + + s.assertLocationHeaderHasToken(rec) + s.assertStateCookieRemoved(rec) + + email, err := s.Storage.GetEmailPersister().FindByAddress("test-facebook-signup@example.com") + s.NoError(err) + s.NotNil(email) + s.True(email.IsPrimary()) + + user, err := s.Storage.GetUserPersister().Get(*email.UserID) + s.NoError(err) + s.NotNil(user) + + identity := email.Identities.GetIdentity("facebook", "facebook_abcde") + s.NotNil(identity) + + logs, lerr := s.Storage.GetAuditLogPersister().List(0, 0, nil, nil, []string{"thirdparty_signup_succeeded"}, user.ID.String(), email.Address, "", "") + s.NoError(lerr) + s.Len(logs, 1) + } +} + +func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignIn_Facebook() { + defer gock.Off() + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/thirdparty") + s.NoError(err) + + gock.New(thirdparty.FacebookOauthTokenEndpoint). + Post("/"). + Reply(200). + JSON(map[string]string{"access_token": "fakeAccessToken"}) + + gock.New(thirdparty.FacebookUserInfoEndpoint). + Get("/me"). + Reply(200). + JSON(&thirdparty.FacebookUser{ + ID: "facebook_abcde", + Email: "test-with-facebook-identity@example.com", + }) + + cfg := s.setUpConfig([]string{"facebook"}, []string{"https://example.com"}) + + state, err := thirdparty.GenerateState(cfg, "facebook", "https://example.com") + s.NoError(err) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/thirdparty/callback?code=abcde&state=%s", state), nil) + req.AddCookie(&http.Cookie{ + Name: utils.HankoThirdpartyStateCookie, + Value: string(state), + }) + + c, rec := s.setUpContext(req) + handler := s.setUpHandler(cfg) + + if s.NoError(handler.Callback(c)) { + s.Equal(http.StatusTemporaryRedirect, rec.Code) + + s.assertLocationHeaderHasToken(rec) + s.assertStateCookieRemoved(rec) + + email, err := s.Storage.GetEmailPersister().FindByAddress("test-with-facebook-identity@example.com") + s.NoError(err) + s.NotNil(email) + s.True(email.IsPrimary()) + + user, err := s.Storage.GetUserPersister().Get(*email.UserID) + s.NoError(err) + s.NotNil(user) + + identity := email.Identities.GetIdentity("facebook", "facebook_abcde") + s.NotNil(identity) + + logs, lerr := s.Storage.GetAuditLogPersister().List(0, 0, nil, nil, []string{"thirdparty_signin_succeeded"}, user.ID.String(), "", "", "") + s.NoError(lerr) + s.Len(logs, 1) + } +} + func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignUp_WithUnclaimedEmail() { defer gock.Off() if testing.Short() { diff --git a/backend/handler/thirdparty_test.go b/backend/handler/thirdparty_test.go index f552527f3..3d9035cae 100644 --- a/backend/handler/thirdparty_test.go +++ b/backend/handler/thirdparty_test.go @@ -94,6 +94,12 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect Secret: "fakeClientSecret", AllowLinking: false, }, + Facebook: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: false, + }, }, ErrorRedirectURL: "https://error.test.example", RedirectURL: "https://api.test.example/callback", @@ -117,6 +123,9 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect cfg.ThirdParty.Providers.Discord.Enabled = true case "microsoft": cfg.ThirdParty.Providers.Microsoft.Enabled = true + case "facebook": + cfg.ThirdParty.Providers.Facebook.Enabled = true + } } } diff --git a/backend/json_schema/hanko.config.json b/backend/json_schema/hanko.config.json index aae4b9b20..46f606ba1 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -1406,6 +1406,10 @@ "microsoft": { "$ref": "#/$defs/ThirdPartyProvider", "description": "`microsoft` contains the provider configuration for Microsoft." + }, + "facebook": { + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`facebook` contains the provider configuration for Facebook." } }, "additionalProperties": false, diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go new file mode 100644 index 000000000..f530e7534 --- /dev/null +++ b/backend/thirdparty/provider_facebook.go @@ -0,0 +1,97 @@ +package thirdparty + +import ( + "context" + "encoding/json" + "errors" + + "github.com/teamhanko/hanko/backend/config" + "golang.org/x/oauth2" + "golang.org/x/oauth2/facebook" +) + +const ( + FacebookAuthBase = "https://www.facebook.com" + FacebookAPIBase = "https://graph.facebook.com" + FacebookOauthAuthEndpoint = FacebookAuthBase + "/v9.0/dialog/oauth" + FacebookOauthTokenEndpoint = FacebookAPIBase + "/v9.0/oauth/access_token" + FacebookUserInfoEndpoint = FacebookAPIBase + "/me?fields=id,name,email,picture" +) + +var DefaultFacebookScopes = []string{ + "email", "public_profile", +} + +type facebookProvider struct { + *oauth2.Config +} + +type FacebookUser struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Picture struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } `json:"picture"` +} + +// NewFacebookProvider creates a Facebook third-party OAuth provider. +func NewFacebookProvider(config config.ThirdPartyProvider, redirectURL string) (OAuthProvider, error) { + if !config.Enabled { + return nil, errors.New("facebook provider is disabled") + } + + return &facebookProvider{ + Config: &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.Secret, + Endpoint: facebook.Endpoint, + Scopes: DefaultFacebookScopes, + RedirectURL: redirectURL, + }, + }, nil +} + +func (f facebookProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return f.Exchange(context.Background(), code) +} + +func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { + var fbUser FacebookUser + client := f.Client(context.Background(), token) + resp, err := client.Get(FacebookUserInfoEndpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&fbUser); err != nil { + return nil, err + } + + data := &UserData{ + Emails: []Email{ + { + Email: fbUser.Email, + Verified: true, // Facebook email is considered verified + Primary: true, + }, + }, + Metadata: &Claims{ + Issuer: FacebookAuthBase, + Subject: fbUser.ID, + Name: fbUser.Name, + Picture: fbUser.Picture.Data.URL, + Email: fbUser.Email, + EmailVerified: true, + }, + } + + return data, nil +} + +func (f facebookProvider) Name() string { + return "facebook" +} diff --git a/deploy/k8s/overlays/thirdparty-x-domain/env-patch.yaml b/deploy/k8s/overlays/thirdparty-x-domain/env-patch.yaml index 08f5ae1e2..bea6da6be 100644 --- a/deploy/k8s/overlays/thirdparty-x-domain/env-patch.yaml +++ b/deploy/k8s/overlays/thirdparty-x-domain/env-patch.yaml @@ -59,6 +59,16 @@ spec: secretKeyRef: key: client_secret name: apple + - name: THIRD_PARTY_PROVIDERS_FACEBOOK_CLIENT_ID + valueFrom: + secretKeyRef: + key: client_id + name: facebook + - name: THIRD_PARTY_PROVIDERS_FACEBOOK_SECRET + valueFrom: + secretKeyRef: + key: client_secret + name: facebook initContainers: - name: hanko-migrate env: diff --git a/deploy/k8s/overlays/thirdparty-x-domain/kustomization.yaml b/deploy/k8s/overlays/thirdparty-x-domain/kustomization.yaml index 781106a71..999d976ab 100644 --- a/deploy/k8s/overlays/thirdparty-x-domain/kustomization.yaml +++ b/deploy/k8s/overlays/thirdparty-x-domain/kustomization.yaml @@ -23,3 +23,6 @@ secretGenerator: - name: apple envs: - apple.env + - name: facebook + envs: + - facebook.env From 2c8d43f858103e41a04a7b09e0b8489bd3b2bfba Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Wed, 11 Dec 2024 18:58:04 +0100 Subject: [PATCH 02/12] feat: add facebook provider to factory function --- backend/thirdparty/provider.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/thirdparty/provider.go b/backend/thirdparty/provider.go index adab1bbee..7da26056b 100644 --- a/backend/thirdparty/provider.go +++ b/backend/thirdparty/provider.go @@ -125,6 +125,8 @@ func getThirdPartyProvider(config config.ThirdParty, id string) (OAuthProvider, return NewMicrosoftProvider(config.Providers.Microsoft, config.RedirectURL) case "linkedin": return NewLinkedInProvider(config.Providers.LinkedIn, config.RedirectURL) + case "facebook": + return NewFacebookProvider(config.Providers.Facebook, config.RedirectURL) default: return nil, fmt.Errorf("unknown provider: %s", id) } From 9af2d246d918c2b87be3b5096fc3603e0e3470b2 Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Wed, 11 Dec 2024 18:58:29 +0100 Subject: [PATCH 03/12] feat: add facebook config defaults --- backend/config/config_default.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/config/config_default.go b/backend/config/config_default.go index 4ad0951df..c8968d534 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -151,6 +151,7 @@ func DefaultConfig() *Config { Facebook: ThirdPartyProvider{ DisplayName: "Facebook", AllowLinking: true, + Name: "facebook", }, }, }, From a101c4b44dbe01830fea1b0d575a7ab3bccab722 Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Wed, 11 Dec 2024 21:21:49 +0100 Subject: [PATCH 04/12] feat: use newest facebook api version --- backend/thirdparty/provider_facebook.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index f530e7534..0d3836013 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -13,8 +13,8 @@ import ( const ( FacebookAuthBase = "https://www.facebook.com" FacebookAPIBase = "https://graph.facebook.com" - FacebookOauthAuthEndpoint = FacebookAuthBase + "/v9.0/dialog/oauth" - FacebookOauthTokenEndpoint = FacebookAPIBase + "/v9.0/oauth/access_token" + FacebookOauthAuthEndpoint = FacebookAuthBase + "/v21.0/dialog/oauth" + FacebookOauthTokenEndpoint = FacebookAPIBase + "/v21.0/oauth/access_token" FacebookUserInfoEndpoint = FacebookAPIBase + "/me?fields=id,name,email,picture" ) From e32c11420b8c09d450b0ce816f25d4f8be83d53e Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Thu, 12 Dec 2024 11:36:00 +0100 Subject: [PATCH 05/12] feat: make facebook provider consistent with other providers --- backend/thirdparty/provider_facebook.go | 35 ++++++++++++------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index 0d3836013..920307623 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -2,12 +2,9 @@ package thirdparty import ( "context" - "encoding/json" "errors" - "github.com/teamhanko/hanko/backend/config" "golang.org/x/oauth2" - "golang.org/x/oauth2/facebook" ) const ( @@ -23,7 +20,8 @@ var DefaultFacebookScopes = []string{ } type facebookProvider struct { - *oauth2.Config + config config.ThirdPartyProvider + oauthConfig *oauth2.Config } type FacebookUser struct { @@ -44,30 +42,31 @@ func NewFacebookProvider(config config.ThirdPartyProvider, redirectURL string) ( } return &facebookProvider{ - Config: &oauth2.Config{ + config: config, + oauthConfig: &oauth2.Config{ ClientID: config.ClientID, ClientSecret: config.Secret, - Endpoint: facebook.Endpoint, - Scopes: DefaultFacebookScopes, - RedirectURL: redirectURL, + Endpoint: oauth2.Endpoint{ + AuthURL: FacebookOauthAuthEndpoint, + TokenURL: FacebookOauthTokenEndpoint, + }, + Scopes: DefaultFacebookScopes, + RedirectURL: redirectURL, }, }, nil } +func (f facebookProvider) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return f.oauthConfig.AuthCodeURL(state, opts...) +} + func (f facebookProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return f.Exchange(context.Background(), code) + return f.oauthConfig.Exchange(context.Background(), code) } func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { var fbUser FacebookUser - client := f.Client(context.Background(), token) - resp, err := client.Get(FacebookUserInfoEndpoint) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if err := json.NewDecoder(resp.Body).Decode(&fbUser); err != nil { + if err := makeRequest(token, f.oauthConfig, FacebookUserInfoEndpoint, &fbUser); err != nil { return nil, err } @@ -93,5 +92,5 @@ func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { } func (f facebookProvider) Name() string { - return "facebook" + return f.config.Name } From db0de5dbcda294f6e35d3e090a38c5cb32d35eaa Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Thu, 12 Dec 2024 11:37:34 +0100 Subject: [PATCH 06/12] feat: add check for email We cannot assume a user always has a valid email. Even though it is not the used "me" endpoint, see: https://developers.facebook.com/docs/graph-api/reference/user/ --- backend/thirdparty/provider_facebook.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index 920307623..67d83d9c2 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -70,6 +70,10 @@ func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { return nil, err } + if fbUser.Email == "" { + return nil, errors.New("unable to find email with Facebook provider") + } + data := &UserData{ Emails: []Email{ { From 84d4985b6d4a9a6680139cdd7dc394cbb8d8b3c7 Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Thu, 12 Dec 2024 13:35:35 +0100 Subject: [PATCH 07/12] docs: elaborate comment --- backend/thirdparty/provider_facebook.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index 67d83d9c2..abdfde29a 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -77,8 +77,10 @@ func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { data := &UserData{ Emails: []Email{ { - Email: fbUser.Email, - Verified: true, // Facebook email is considered verified + Email: fbUser.Email, + // Consider the email as verified because a User node only returns an email if a valid + // email address is available. See: https://developers.facebook.com/docs/graph-api/reference/user/ + Verified: true, Primary: true, }, }, From fc9b44bcdeb4291795093e5748241bcbd9ee664e Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Thu, 12 Dec 2024 14:10:58 +0100 Subject: [PATCH 08/12] fix: fix third party tests --- .../handler/thirdparty_callback_error_test.go | 51 ------------------- backend/handler/thirdparty_test.go | 2 +- backend/test/fixtures/thirdparty/emails.yaml | 6 +++ .../test/fixtures/thirdparty/identities.yaml | 7 +++ .../fixtures/thirdparty/primary_emails.yaml | 5 ++ backend/test/fixtures/thirdparty/users.yaml | 4 ++ 6 files changed, 23 insertions(+), 52 deletions(-) diff --git a/backend/handler/thirdparty_callback_error_test.go b/backend/handler/thirdparty_callback_error_test.go index 1ae96e592..5530bf225 100644 --- a/backend/handler/thirdparty_callback_error_test.go +++ b/backend/handler/thirdparty_callback_error_test.go @@ -439,54 +439,3 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_Error_MicrosoftUnverifi s.Len(logs, 1) } } - -func (s *thirdPartySuite) TestThirdPartyHandler_Callback_Error_FacebookUnverifiedEmail() { - if testing.Short() { - s.T().Skip("skipping test in short mode.") - } - - err := s.LoadFixtures("../test/fixtures/thirdparty") - s.NoError(err) - - gock.New(thirdparty.FacebookOauthTokenEndpoint). - Post("/"). - Reply(200). - JSON(map[string]string{"access_token": "fakeAccessToken"}) - - gock.New(thirdparty.FacebookUserInfoEndpoint). - Get("/me"). - Reply(200). - JSON(map[string]interface{}{ - "id": "facebook_abcde", - "email": "test-facebook@example.com", - "verified": false, - }) - - cfg := s.setUpConfig([]string{"facebook"}, []string{"https://example.com"}) - cfg.Email.RequireVerification = true - - state, err := thirdparty.GenerateState(cfg, "facebook", "https://example.com") - s.NoError(err) - - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/thirdparty/callback?code=abcde&state=%s", state), nil) - req.AddCookie(&http.Cookie{ - Name: utils.HankoThirdpartyStateCookie, - Value: string(state), - }) - - c, rec := s.setUpContext(req) - handler := s.setUpHandler(cfg) - - if s.NoError(handler.Callback(c)) { - s.Equal(http.StatusTemporaryRedirect, rec.Code) - location, err := rec.Result().Location() - s.NoError(err) - - s.Equal(thirdparty.ErrorCodeUnverifiedProviderEmail, location.Query().Get("error")) - s.Equal("third party provider email must be verified", location.Query().Get("error_description")) - - logs, lerr := s.Storage.GetAuditLogPersister().List(0, 0, nil, nil, []string{"thirdparty_signin_signup_failed"}, "", "", "", "") - s.NoError(lerr) - s.Len(logs, 1) - } -} diff --git a/backend/handler/thirdparty_test.go b/backend/handler/thirdparty_test.go index 3d9035cae..cea8e196a 100644 --- a/backend/handler/thirdparty_test.go +++ b/backend/handler/thirdparty_test.go @@ -95,6 +95,7 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect AllowLinking: false, }, Facebook: config.ThirdPartyProvider{ + Name: "facebook", Enabled: false, ClientID: "fakeClientID", Secret: "fakeClientSecret", @@ -126,7 +127,6 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect case "facebook": cfg.ThirdParty.Providers.Facebook.Enabled = true } - } } err := cfg.PostProcess() diff --git a/backend/test/fixtures/thirdparty/emails.yaml b/backend/test/fixtures/thirdparty/emails.yaml index 70cddf2f3..2d632be59 100644 --- a/backend/test/fixtures/thirdparty/emails.yaml +++ b/backend/test/fixtures/thirdparty/emails.yaml @@ -36,6 +36,12 @@ verified: false created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +- id: 967ce4a0-677d-4dc3-bacf-53d54471369c + user_id: + address: test-with-facebook-identity@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 - id: 527afce8-3b7b-41b6-b1ed-33d408c5a7bb user_id: 43fb7e88-4d5d-4b2b-9335-391e78d7e472 address: test-no-identity@example.com diff --git a/backend/test/fixtures/thirdparty/identities.yaml b/backend/test/fixtures/thirdparty/identities.yaml index 71b7f98d2..d0feca72b 100644 --- a/backend/test/fixtures/thirdparty/identities.yaml +++ b/backend/test/fixtures/thirdparty/identities.yaml @@ -33,3 +33,10 @@ email_id: d781006b-4f55-4327-bad6-55bc34b88585 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +- id: b6b1309d-61de-4a82-b8b8-d54db0be679b + provider_id: "facebook_abcde" + provider_name: "facebook" + data: '{"email":"test-with-facebook-identity@example.com","sub":"facebook_abcde"}' + email_id: d781006b-4f55-4327-bad6-55bc34b88585 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/thirdparty/primary_emails.yaml b/backend/test/fixtures/thirdparty/primary_emails.yaml index e3cd5d68c..2f1a300a8 100644 --- a/backend/test/fixtures/thirdparty/primary_emails.yaml +++ b/backend/test/fixtures/thirdparty/primary_emails.yaml @@ -28,3 +28,8 @@ user_id: 43fb7e88-4d5d-4b2b-9335-391e78d7e472 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +- id: e2beaaa9-1275-4eb5-aa28-9970b36d249e + email_id: 967ce4a0-677d-4dc3-bacf-53d54471369c + user_id: ef0a05a7-98d1-4e5a-a60f-2c5f740cd26d + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/thirdparty/users.yaml b/backend/test/fixtures/thirdparty/users.yaml index e8f0ad4c4..456c52d61 100644 --- a/backend/test/fixtures/thirdparty/users.yaml +++ b/backend/test/fixtures/thirdparty/users.yaml @@ -18,6 +18,10 @@ - id: 48df412f-a7b1-4fbc-ad2d-56bd3e103fd7 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +# user with email and facebook identity +- id: ef0a05a7-98d1-4e5a-a60f-2c5f740cd26d + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 # user with email, no identity - id: 43fb7e88-4d5d-4b2b-9335-391e78d7e472 created_at: 2020-12-31 23:59:59 From b3935e3819a81b71b4e7b8e4262ee3a19b7adb9c Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Thu, 12 Dec 2024 14:11:53 +0100 Subject: [PATCH 09/12] feat: add facebook icon --- .../src/components/icons/Facebook.tsx | 51 +++++++++++++++++++ .../elements/src/components/icons/icons.ts | 2 + .../elements/src/components/icons/styles.sass | 9 ++++ 3 files changed, 62 insertions(+) create mode 100644 frontend/elements/src/components/icons/Facebook.tsx diff --git a/frontend/elements/src/components/icons/Facebook.tsx b/frontend/elements/src/components/icons/Facebook.tsx new file mode 100644 index 000000000..2f8b30e69 --- /dev/null +++ b/frontend/elements/src/components/icons/Facebook.tsx @@ -0,0 +1,51 @@ +import { IconProps } from "./Icon"; +import cx from "classnames"; +import styles from "./styles.sass"; + +const Facebook = ({ size, secondary, disabled }: IconProps) => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Facebook; diff --git a/frontend/elements/src/components/icons/icons.ts b/frontend/elements/src/components/icons/icons.ts index cc59325a6..152e68348 100644 --- a/frontend/elements/src/components/icons/icons.ts +++ b/frontend/elements/src/components/icons/icons.ts @@ -4,6 +4,7 @@ import { default as copy } from "./Copy"; import { default as customProvider } from "./CustomProvider"; import { default as discord } from "./Discord"; import { default as exclamation } from "./ExclamationMark"; +import { default as facebook } from "./Facebook"; import { default as github } from "./GitHub"; import { default as google } from "./Google"; import { default as linkedin } from "./LinkedIn"; @@ -22,6 +23,7 @@ export { customProvider, discord, exclamation, + facebook, github, google, linkedin, diff --git a/frontend/elements/src/components/icons/styles.sass b/frontend/elements/src/components/icons/styles.sass index c7f8e3357..28b3782b4 100644 --- a/frontend/elements/src/components/icons/styles.sass +++ b/frontend/elements/src/components/icons/styles.sass @@ -101,3 +101,12 @@ &.red fill: #F25022 +.facebookIcon + &.outline + fill: #0866FF + &.disabledOutline + fill: variables.$color-shade-1 + &.letter + fill: #FFFFFF + &.disabledLetter + fill: variables.$color-shade-2 From aaae15383f063520df3733a0dc5577432f9501e6 Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Thu, 12 Dec 2024 18:25:03 +0100 Subject: [PATCH 10/12] feat: add appsecret_proof to requests w. access token --- backend/thirdparty/provider_facebook.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index abdfde29a..abb1dfd39 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -2,6 +2,9 @@ package thirdparty import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "errors" "github.com/teamhanko/hanko/backend/config" "golang.org/x/oauth2" @@ -65,8 +68,16 @@ func (f facebookProvider) GetOAuthToken(code string) (*oauth2.Token, error) { } func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { + // Calculate appsecret_proof + // See: https://developers.facebook.com/docs/graph-api/guides/secure-requests/#appsecret_proof + hash := hmac.New(sha256.New, []byte(f.config.Secret)) + hash.Write([]byte(token.AccessToken)) + appsecretProof := hex.EncodeToString(hash.Sum(nil)) + + url := FacebookUserInfoEndpoint + "&appsecret_proof=" + appsecretProof + var fbUser FacebookUser - if err := makeRequest(token, f.oauthConfig, FacebookUserInfoEndpoint, &fbUser); err != nil { + if err := makeRequest(token, f.oauthConfig, url, &fbUser); err != nil { return nil, err } From db96df8524f17ccfdafdff71135e7a42dba2e91e Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Fri, 13 Dec 2024 17:30:42 +0100 Subject: [PATCH 11/12] refactor: build userinfo url programmatically --- backend/thirdparty/provider_facebook.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index abb1dfd39..29404e47d 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -8,6 +8,7 @@ import ( "errors" "github.com/teamhanko/hanko/backend/config" "golang.org/x/oauth2" + "net/url" ) const ( @@ -15,7 +16,7 @@ const ( FacebookAPIBase = "https://graph.facebook.com" FacebookOauthAuthEndpoint = FacebookAuthBase + "/v21.0/dialog/oauth" FacebookOauthTokenEndpoint = FacebookAPIBase + "/v21.0/oauth/access_token" - FacebookUserInfoEndpoint = FacebookAPIBase + "/me?fields=id,name,email,picture" + FacebookUserInfoEndpoint = FacebookAPIBase + "/me" ) var DefaultFacebookScopes = []string{ @@ -68,16 +69,25 @@ func (f facebookProvider) GetOAuthToken(code string) (*oauth2.Token, error) { } func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { - // Calculate appsecret_proof - // See: https://developers.facebook.com/docs/graph-api/guides/secure-requests/#appsecret_proof + endpointURL, err := url.Parse(FacebookUserInfoEndpoint) + if err != nil { + return nil, err + } + + endpointURLQuery := endpointURL.Query() + endpointURLQuery.Add("fields", "id,name,email,picture") + + // Calculate appsecret_proof, see: + // https://developers.facebook.com/docs/graph-api/guides/secure-requests/#appsecret_proof hash := hmac.New(sha256.New, []byte(f.config.Secret)) hash.Write([]byte(token.AccessToken)) appsecretProof := hex.EncodeToString(hash.Sum(nil)) - url := FacebookUserInfoEndpoint + "&appsecret_proof=" + appsecretProof + endpointURLQuery.Add("appsecret_proof", appsecretProof) + endpointURL.RawQuery = endpointURLQuery.Encode() var fbUser FacebookUser - if err := makeRequest(token, f.oauthConfig, url, &fbUser); err != nil { + if err = makeRequest(token, f.oauthConfig, endpointURL.String(), &fbUser); err != nil { return nil, err } From af6a8567576422722c557df0d50072e2d659d684 Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Fri, 13 Dec 2024 17:33:21 +0100 Subject: [PATCH 12/12] feat: map all available name claims --- backend/thirdparty/provider_facebook.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go index 29404e47d..6c3c9f637 100644 --- a/backend/thirdparty/provider_facebook.go +++ b/backend/thirdparty/provider_facebook.go @@ -37,6 +37,9 @@ type FacebookUser struct { URL string `json:"url"` } `json:"data"` } `json:"picture"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name"` + LastName string `json:"last_name"` } // NewFacebookProvider creates a Facebook third-party OAuth provider. @@ -75,7 +78,7 @@ func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { } endpointURLQuery := endpointURL.Query() - endpointURLQuery.Add("fields", "id,name,email,picture") + endpointURLQuery.Add("fields", "id,name,email,picture,first_name,middle_name,last_name") // Calculate appsecret_proof, see: // https://developers.facebook.com/docs/graph-api/guides/secure-requests/#appsecret_proof @@ -112,6 +115,9 @@ func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { Picture: fbUser.Picture.Data.URL, Email: fbUser.Email, EmailVerified: true, + GivenName: fbUser.FirstName, + MiddleName: fbUser.MiddleName, + FamilyName: fbUser.LastName, }, }