diff --git a/backend/config/config.yaml b/backend/config/config.yaml index 255e24a35..db17b6283 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -87,6 +87,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 2f16652d1..d224b6dd6 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -129,6 +129,10 @@ func DefaultConfig() *Config { DisplayName: "Discord", AllowLinking: true, }, + 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 789825124..857ca101c 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 { @@ -113,6 +114,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 f625847a9..b38e05982 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 9261bc6af..4ddc164b5 100644 --- a/backend/handler/thirdparty_test.go +++ b/backend/handler/thirdparty_test.go @@ -89,6 +89,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", @@ -112,6 +118,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 0a8345932..1949ce3da 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -1073,6 +1073,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