From d66b267646da8592de81cbb8a8655de56307c3bc Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann <67686424+lfleischmann@users.noreply.github.com> Date: Wed, 15 Jan 2025 21:28:23 +0100 Subject: [PATCH] feat: add facebook provider (#2007) * add sign in with facebook * feat: add facebook provider to factory function * feat: add facebook config defaults * feat: use newest facebook api version * feat: make facebook provider consistent with other providers * 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/ * docs: elaborate comment * fix: fix third party tests * feat: add facebook icon * feat: add appsecret_proof to requests w. access token * refactor: build userinfo url programmatically * feat: map all available name claims --------- Co-authored-by: Prathamesh --- backend/config/config.yaml | 2 + backend/config/config_default.go | 5 + 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 | 7 +- backend/handler/thirdparty_callback_test.go | 117 ++++++++++++++++ backend/handler/thirdparty_test.go | 9 ++ backend/json_schema/hanko.config.json | 4 + 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 + backend/thirdparty/provider.go | 2 + backend/thirdparty/provider_facebook.go | 129 ++++++++++++++++++ .../thirdparty-x-domain/env-patch.yaml | 10 ++ .../thirdparty-x-domain/kustomization.yaml | 3 + .../src/components/icons/Facebook.tsx | 51 +++++++ .../elements/src/components/icons/icons.ts | 2 + .../elements/src/components/icons/styles.sass | 9 ++ 20 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 backend/thirdparty/provider_facebook.go create mode 100644 frontend/elements/src/components/icons/Facebook.tsx diff --git a/backend/config/config.yaml b/backend/config/config.yaml index ae7851d6c..c00026fc3 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -116,6 +116,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 2b2c99618..932c44d6f 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -149,6 +149,11 @@ func DefaultConfig() *Config { AllowLinking: true, Name: "google", }, + Facebook: ThirdPartyProvider{ + DisplayName: "Facebook", + AllowLinking: true, + Name: "facebook", + }, }, }, 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 0cfb63c1d..df29b097f 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..5530bf225 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() { 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..cea8e196a 100644 --- a/backend/handler/thirdparty_test.go +++ b/backend/handler/thirdparty_test.go @@ -94,6 +94,13 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect Secret: "fakeClientSecret", AllowLinking: false, }, + Facebook: config.ThirdPartyProvider{ + Name: "facebook", + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: false, + }, }, ErrorRedirectURL: "https://error.test.example", RedirectURL: "https://api.test.example/callback", @@ -117,6 +124,8 @@ 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 644996896..b2bf1f0c2 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -1408,6 +1408,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/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 diff --git a/backend/thirdparty/provider.go b/backend/thirdparty/provider.go index defd55011..02a9b516f 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) } diff --git a/backend/thirdparty/provider_facebook.go b/backend/thirdparty/provider_facebook.go new file mode 100644 index 000000000..6c3c9f637 --- /dev/null +++ b/backend/thirdparty/provider_facebook.go @@ -0,0 +1,129 @@ +package thirdparty + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "github.com/teamhanko/hanko/backend/config" + "golang.org/x/oauth2" + "net/url" +) + +const ( + FacebookAuthBase = "https://www.facebook.com" + FacebookAPIBase = "https://graph.facebook.com" + FacebookOauthAuthEndpoint = FacebookAuthBase + "/v21.0/dialog/oauth" + FacebookOauthTokenEndpoint = FacebookAPIBase + "/v21.0/oauth/access_token" + FacebookUserInfoEndpoint = FacebookAPIBase + "/me" +) + +var DefaultFacebookScopes = []string{ + "email", "public_profile", +} + +type facebookProvider struct { + config config.ThirdPartyProvider + oauthConfig *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"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name"` + LastName string `json:"last_name"` +} + +// 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: config, + oauthConfig: &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.Secret, + 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.oauthConfig.Exchange(context.Background(), code) +} + +func (f facebookProvider) GetUserData(token *oauth2.Token) (*UserData, error) { + endpointURL, err := url.Parse(FacebookUserInfoEndpoint) + if err != nil { + return nil, err + } + + endpointURLQuery := endpointURL.Query() + 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 + hash := hmac.New(sha256.New, []byte(f.config.Secret)) + hash.Write([]byte(token.AccessToken)) + appsecretProof := hex.EncodeToString(hash.Sum(nil)) + + endpointURLQuery.Add("appsecret_proof", appsecretProof) + endpointURL.RawQuery = endpointURLQuery.Encode() + + var fbUser FacebookUser + if err = makeRequest(token, f.oauthConfig, endpointURL.String(), &fbUser); err != nil { + return nil, err + } + + if fbUser.Email == "" { + return nil, errors.New("unable to find email with Facebook provider") + } + + data := &UserData{ + Emails: []Email{ + { + 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, + }, + }, + Metadata: &Claims{ + Issuer: FacebookAuthBase, + Subject: fbUser.ID, + Name: fbUser.Name, + Picture: fbUser.Picture.Data.URL, + Email: fbUser.Email, + EmailVerified: true, + GivenName: fbUser.FirstName, + MiddleName: fbUser.MiddleName, + FamilyName: fbUser.LastName, + }, + } + + return data, nil +} + +func (f facebookProvider) Name() string { + return f.config.Name +} 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 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