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 <psvagare@gmail.com>
This commit is contained in:
Lennart Fleischmann
2025-01-15 21:28:23 +01:00
committed by GitHub
parent 5023a53980
commit d66b267646
20 changed files with 387 additions and 5 deletions

View File

@@ -116,6 +116,8 @@ third_party:
enabled: false
microsoft:
enabled: false
facebook:
enabled: false
username:
enabled: false
optional: true

View File

@@ -149,6 +149,11 @@ func DefaultConfig() *Config {
AllowLinking: true,
Name: "google",
},
Facebook: ThirdPartyProvider{
DisplayName: "Facebook",
AllowLinking: true,
Name: "facebook",
},
},
},
Passkey: Passkey{

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

129
backend/thirdparty/provider_facebook.go vendored Normal file
View File

@@ -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
}

View File

@@ -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:

View File

@@ -23,3 +23,6 @@ secretGenerator:
- name: apple
envs:
- apple.env
- name: facebook
envs:
- facebook.env

View File

@@ -0,0 +1,51 @@
import { IconProps } from "./Icon";
import cx from "classnames";
import styles from "./styles.sass";
const Facebook = ({ size, secondary, disabled }: IconProps) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 666.66668 666.66717"
xmlns="http://www.w3.org/2000/svg"
>
<defs id="defs13">
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath25">
<path d="M 0,700 H 700 V 0 H 0 Z" id="path23" />
</clipPath>
</defs>
<g
id="g17"
transform="matrix(1.3333333,0,0,-1.3333333,-133.33333,799.99999)"
>
<g id="g19">
<g id="g21" clipPath="url(#clipPath25)">
<g id="g27" transform="translate(600,350)">
<path
className={cx(
styles.facebookIcon,
disabled ? styles.disabledOutline : styles.outline,
)}
d="m 0,0 c 0,138.071 -111.929,250 -250,250 -138.071,0 -250,-111.929 -250,-250 0,-117.245 80.715,-215.622 189.606,-242.638 v 166.242 h -51.552 V 0 h 51.552 v 32.919 c 0,85.092 38.508,124.532 122.048,124.532 15.838,0 43.167,-3.105 54.347,-6.211 V 81.986 c -5.901,0.621 -16.149,0.932 -28.882,0.932 -40.993,0 -56.832,-15.528 -56.832,-55.9 V 0 h 81.659 l -14.028,-76.396 h -67.631 V -248.169 C -95.927,-233.218 0,-127.818 0,0"
id="path29"
/>
</g>
<g id="g31" transform="translate(447.9175,273.6036)">
<path
className={cx(
styles.facebookIcon,
disabled ? styles.disabledLetter : styles.letter,
)}
d="M 0,0 14.029,76.396 H -67.63 v 27.019 c 0,40.372 15.838,55.899 56.831,55.899 12.733,0 22.981,-0.31 28.882,-0.931 v 69.253 c -11.18,3.106 -38.509,6.212 -54.347,6.212 -83.539,0 -122.048,-39.441 -122.048,-124.533 V 76.396 h -51.552 V 0 h 51.552 v -166.242 c 19.343,-4.798 39.568,-7.362 60.394,-7.362 10.254,0 20.358,0.632 30.288,1.831 L -67.63,0 Z"
id="path33"
/>
</g>
</g>
</g>
</g>
</svg>
);
};
export default Facebook;

View File

@@ -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,

View File

@@ -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