From 63a835d4fadcf61d37fa04190c24a2987fade451 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Sun, 7 Jan 2024 14:47:50 -0600 Subject: [PATCH 1/6] add thirdparty Discord provider --- backend/config/config.go | 14 ++-- backend/docs/Config.md | 38 +++++++++ backend/thirdparty/provider.go | 9 ++- backend/thirdparty/provider_discord.go | 103 +++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 backend/thirdparty/provider_discord.go diff --git a/backend/config/config.go b/backend/config/config.go index 3302c8c2b..d069822f9 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -3,6 +3,10 @@ package config import ( "errors" "fmt" + "log" + "strings" + "time" + "github.com/fatih/structs" "github.com/gobwas/glob" "github.com/kelseyhightower/envconfig" @@ -12,9 +16,6 @@ import ( zeroLogger "github.com/rs/zerolog/log" "github.com/teamhanko/hanko/backend/ee/saml/config" "golang.org/x/exp/slices" - "log" - "strings" - "time" ) // Config is the central configuration type @@ -622,9 +623,10 @@ func (p *ThirdPartyProvider) Validate() error { } type ThirdPartyProviders struct { - Google ThirdPartyProvider `yaml:"google" json:"google,omitempty" koanf:"google"` - GitHub ThirdPartyProvider `yaml:"github" json:"github,omitempty" koanf:"github"` - Apple ThirdPartyProvider `yaml:"apple" json:"apple,omitempty" koanf:"apple"` + Google ThirdPartyProvider `yaml:"google" json:"google,omitempty" koanf:"google"` + GitHub ThirdPartyProvider `yaml:"github" json:"github,omitempty" koanf:"github"` + Apple ThirdPartyProvider `yaml:"apple" json:"apple,omitempty" koanf:"apple"` + Discord ThirdPartyProvider `yaml:"discord" json:"discord,omitempty" koanf:"discord"` } func (p *ThirdPartyProviders) Validate() error { diff --git a/backend/docs/Config.md b/backend/docs/Config.md index 75870c64d..ad5fdd704 100644 --- a/backend/docs/Config.md +++ b/backend/docs/Config.md @@ -592,6 +592,44 @@ third_party: # Default: true # allow_linking: true + ## + # + # The Discord provider configuration + # + discord: + ## + # + # Enable or disable the Discord provider. + # + # Default: false + # + enabled: false + ## + # + # The client ID of your Discord OAuth credentials. + # See: https://docs.hanko.io/guides/authentication-methods/oauth/discord + # + # Required if provider is enabled. + # + client_id: "CHANGE_ME" + ## + # + # The secret of your Discord OAuth credentials. + # See: https://docs.hanko.io/guides/authentication-methods/oauth/discord + # + # Required if provider is enabled. + # + secret: "CHANGE_ME" + ## + # + # Indicates whether accounts can be linked with this provider. + # This option only controls linking for existing accounts. Account registrations + # are not affected (see the 'accounts.allow_signup' option for controlling + # account registration). + # + # Default: true + # + allow_linking: true log: ## log_health_and_metrics # diff --git a/backend/thirdparty/provider.go b/backend/thirdparty/provider.go index b444a65a3..78614e6a5 100644 --- a/backend/thirdparty/provider.go +++ b/backend/thirdparty/provider.go @@ -6,13 +6,14 @@ import ( "encoding/json" "errors" "fmt" - "github.com/fatih/structs" - "github.com/teamhanko/hanko/backend/config" - "golang.org/x/oauth2" "io" "net/http" "strings" "time" + + "github.com/fatih/structs" + "github.com/teamhanko/hanko/backend/config" + "golang.org/x/oauth2" ) type UserData struct { @@ -85,6 +86,8 @@ func GetProvider(config config.ThirdParty, name string) (OAuthProvider, error) { return NewGithubProvider(config.Providers.GitHub, config.RedirectURL) case "apple": return NewAppleProvider(config.Providers.Apple, config.RedirectURL) + case "discord": + return NewDiscordProvider(config.Providers.Discord, config.RedirectURL) default: return nil, fmt.Errorf("provider '%s' is not supported", name) } diff --git a/backend/thirdparty/provider_discord.go b/backend/thirdparty/provider_discord.go new file mode 100644 index 000000000..056648865 --- /dev/null +++ b/backend/thirdparty/provider_discord.go @@ -0,0 +1,103 @@ +package thirdparty + +import ( + "context" + "errors" + "fmt" + + "github.com/teamhanko/hanko/backend/config" + "golang.org/x/oauth2" +) + +const ( + DiscordAPIBase = "https://discord.com/api" + DiscordOauthAuthEndpoint = "https://discord.com/oauth2/authorize" + DiscordOauthTokenEndpoint = DiscordAPIBase + "/oauth2/token" + DiscordUserInfoEndpoint = DiscordAPIBase + "/users/@me" +) + +var DefaultDiscordScopes = []string{ + "identify", + "email", +} + +type discordProvider struct { + *oauth2.Config +} + +type DiscordUser struct { + ID string `json:"id"` + Username string `json:"username"` + GlobalName string `json:"global_name"` + Avatar string `json:"avatar"` + Email string `json:"email"` + Verified bool `json:"verified"` +} + +// NewDiscordProvider creates a Discord third party provider. +func NewDiscordProvider(config config.ThirdPartyProvider, redirectURL string) (OAuthProvider, error) { + if !config.Enabled { + return nil, errors.New("discord provider is disabled") + } + + return &discordProvider{ + Config: &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: DiscordOauthAuthEndpoint, + TokenURL: DiscordOauthTokenEndpoint, + }, + Scopes: DefaultDiscordScopes, + RedirectURL: redirectURL, + }, + }, nil +} + +func (g discordProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g discordProvider) GetUserData(token *oauth2.Token) (*UserData, error) { + var user DiscordUser + if err := makeRequest(token, g.Config, DiscordUserInfoEndpoint, &user); err != nil { + return nil, err + } + + data := &UserData{} + + if user.Email != "" { + data.Emails = append(data.Emails, Email{ + Email: user.Email, + Verified: user.Verified, + Primary: true, + }) + } + + if len(data.Emails) <= 0 { + return nil, errors.New("unable to find email with Discord provider") + } + + data.Metadata = &Claims{ + Issuer: DiscordAPIBase, + Subject: user.ID, + Name: user.GlobalName, + Picture: g.buildAvatarURL(user.ID, user.Avatar), + Email: user.Email, + EmailVerified: user.Verified, + } + + return data, nil +} + +func (g discordProvider) buildAvatarURL(userID string, avatarHash string) string { + if avatarHash == "" { + return "" // No image + } + + return fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", userID, avatarHash) +} + +func (g discordProvider) Name() string { + return "discord" +} From b2690e01a86fddadaec5d99d07b6a604c171ef53 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Sun, 7 Jan 2024 15:05:10 -0600 Subject: [PATCH 2/6] add Discord icon --- .../elements/src/components/icons/Discord.tsx | 25 +++++++++++++++++++ .../elements/src/components/icons/icons.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 frontend/elements/src/components/icons/Discord.tsx diff --git a/frontend/elements/src/components/icons/Discord.tsx b/frontend/elements/src/components/icons/Discord.tsx new file mode 100644 index 000000000..6108cffb4 --- /dev/null +++ b/frontend/elements/src/components/icons/Discord.tsx @@ -0,0 +1,25 @@ +import { IconProps } from "./Icon"; +import cx from "classnames"; +import styles from "./styles.sass"; + +const Discord = ({ size, secondary, disabled }: IconProps) => { + return ( + + + + ); +}; + +export default Discord; diff --git a/frontend/elements/src/components/icons/icons.ts b/frontend/elements/src/components/icons/icons.ts index c5c8f7dd7..0b9418d44 100644 --- a/frontend/elements/src/components/icons/icons.ts +++ b/frontend/elements/src/components/icons/icons.ts @@ -5,5 +5,6 @@ import { default as exclamation } from "./ExclamationMark"; import { default as google } from "./Google"; import { default as github } from "./GitHub"; import { default as apple } from "./Apple"; +import { default as discord } from "./Discord"; -export { passkey, spinner, checkmark, exclamation, google, github, apple }; +export { passkey, spinner, checkmark, exclamation, google, github, apple, discord }; From 26d753613ff236bb57ceeade27b5ae4f27e02c9e Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Wed, 10 Jan 2024 18:44:55 +0000 Subject: [PATCH 3/6] add json schema --- backend/json_schema/hanko.config.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/json_schema/hanko.config.json b/backend/json_schema/hanko.config.json index aed199014..fa82843dd 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -709,6 +709,9 @@ }, "apple": { "$ref": "#/$defs/ThirdPartyProvider" + }, + "discord": { + "$ref": "#/$defs/ThirdPartyProvider" } }, "additionalProperties": false, From 2cde79c996ea3e728abe30d117d37723cb143a85 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Wed, 10 Jan 2024 19:19:15 +0000 Subject: [PATCH 4/6] add tests --- backend/handler/thirdparty_auth_test.go | 12 +- backend/handler/thirdparty_callback_test.go | 130 +++++++++++++++++++- backend/handler/thirdparty_test.go | 24 ++-- 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/backend/handler/thirdparty_auth_test.go b/backend/handler/thirdparty_auth_test.go index 5d43965e6..c2844bd54 100644 --- a/backend/handler/thirdparty_auth_test.go +++ b/backend/handler/thirdparty_auth_test.go @@ -1,11 +1,12 @@ package handler import ( - "github.com/teamhanko/hanko/backend/thirdparty" "net/http" "net/http/httptest" "net/url" "strings" + + "github.com/teamhanko/hanko/backend/thirdparty" ) func (s *thirdPartySuite) TestThirdPartyHandler_Auth() { @@ -47,6 +48,15 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Auth() { requestedRedirectTo: "https://app.test.example", expectedBaseURL: thirdparty.AppleAuthEndpoint, }, + { + name: "successful redirect to discord", + referer: "https://login.test.example", + enabledProviders: []string{"discord"}, + allowedRedirectURLs: []string{"https://*.test.example"}, + requestedProvider: "discord", + requestedRedirectTo: "https://app.test.example", + expectedBaseURL: thirdparty.DiscordOauthAuthEndpoint, + }, { name: "error redirect on missing provider", referer: "https://login.test.example", diff --git a/backend/handler/thirdparty_callback_test.go b/backend/handler/thirdparty_callback_test.go index 4c1d9a625..b7ebb6e93 100644 --- a/backend/handler/thirdparty_callback_test.go +++ b/backend/handler/thirdparty_callback_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_SignUp_Google() { @@ -383,6 +384,129 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignIn_Apple() { } } +func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignUp_Discord() { + defer gock.Off() + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + gock.New(thirdparty.DiscordOauthTokenEndpoint). + Post("/"). + Reply(200). + JSON(map[string]string{"access_token": "fakeAccessToken"}) + + gock.New(thirdparty.DiscordUserInfoEndpoint). + Get("/"). + Reply(200). + JSON(&thirdparty.DiscordUser{ + ID: "discord_abcde", + Email: "test-discord-signup@example.com", + Verified: true, + }) + + cfg := s.setUpConfig([]string{"discord"}, []string{"https://example.com"}) + + state, err := thirdparty.GenerateState(cfg, "discord", "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-discord-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.Identity + s.NotNil(identity) + s.Equal("discord", identity.ProviderName) + s.Equal("discord_abcde", identity.ProviderID) + + 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_Discord() { + 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.DiscordOauthTokenEndpoint). + Post("/"). + Reply(200). + JSON(map[string]string{"access_token": "fakeAccessToken"}) + + gock.New(thirdparty.DiscordUserInfoEndpoint). + Get("/"). + Reply(200). + JSON(&thirdparty.DiscordUser{ + ID: "discord_abcde", + Email: "test-with-discord-identity@example.com", + Verified: true, + }) + + cfg := s.setUpConfig([]string{"discord"}, []string{"https://example.com"}) + + state, err := thirdparty.GenerateState(cfg, "discord", "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-discord-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.Identity + s.NotNil(identity) + s.Equal("discord", identity.ProviderName) + s.Equal("discord_abcde", identity.ProviderID) + + 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 7edde67ff..58b03d2b8 100644 --- a/backend/handler/thirdparty_test.go +++ b/backend/handler/thirdparty_test.go @@ -1,6 +1,13 @@ package handler import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwa" jwk2 "github.com/lestrrat-go/jwx/v2/jwk" @@ -13,12 +20,6 @@ import ( "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/test" "github.com/teamhanko/hanko/backend/utils" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "testing" - "time" ) func TestThirdPartySuite(t *testing.T) { @@ -72,7 +73,14 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect ClientID: "fakeClientID", Secret: "fakeClientSecret", AllowLinking: true, - }}, + }, + Discord: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: true, + }, + }, ErrorRedirectURL: "https://error.test.example", RedirectURL: "https://api.test.example/callback", AllowedRedirectURLS: allowedRedirectURLs, @@ -99,6 +107,8 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect cfg.ThirdParty.Providers.Google.Enabled = true case "github": cfg.ThirdParty.Providers.GitHub.Enabled = true + case "discord": + cfg.ThirdParty.Providers.Discord.Enabled = true } } From 7e449514e9683acce0ac9c62418e21e05da4264f Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Fri, 16 Feb 2024 14:52:48 +0100 Subject: [PATCH 5/6] fix: make tests work again --- backend/handler/thirdparty_callback_test.go | 8 ++------ backend/test/fixtures/thirdparty/emails.yaml | 6 ++++++ backend/test/fixtures/thirdparty/identities.yaml | 7 +++++++ backend/test/fixtures/thirdparty/primary_emails.yaml | 5 +++++ backend/test/fixtures/thirdparty/users.yaml | 4 ++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/backend/handler/thirdparty_callback_test.go b/backend/handler/thirdparty_callback_test.go index b7ebb6e93..4f0e5edec 100644 --- a/backend/handler/thirdparty_callback_test.go +++ b/backend/handler/thirdparty_callback_test.go @@ -433,10 +433,8 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignUp_Discord() { s.NoError(err) s.NotNil(user) - identity := email.Identity + identity := email.Identities.GetIdentity("discord", "discord_abcde") s.NotNil(identity) - s.Equal("discord", identity.ProviderName) - s.Equal("discord_abcde", identity.ProviderID) logs, lerr := s.Storage.GetAuditLogPersister().List(0, 0, nil, nil, []string{"thirdparty_signup_succeeded"}, user.ID.String(), email.Address, "", "") s.NoError(lerr) @@ -496,10 +494,8 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignIn_Discord() { s.NoError(err) s.NotNil(user) - identity := email.Identity + identity := email.Identities.GetIdentity("discord", "discord_abcde") s.NotNil(identity) - s.Equal("discord", identity.ProviderName) - s.Equal("discord_abcde", identity.ProviderID) logs, lerr := s.Storage.GetAuditLogPersister().List(0, 0, nil, nil, []string{"thirdparty_signin_succeeded"}, user.ID.String(), "", "", "") s.NoError(lerr) diff --git a/backend/test/fixtures/thirdparty/emails.yaml b/backend/test/fixtures/thirdparty/emails.yaml index 06011ae4e..45e2f0f6b 100644 --- a/backend/test/fixtures/thirdparty/emails.yaml +++ b/backend/test/fixtures/thirdparty/emails.yaml @@ -24,6 +24,12 @@ verified: true created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +- id: 09f35529-cca6-44a7-ab1d-b07e95a04e3b + user_id: d69bffda-4e4a-4424-a238-fbecc1651d81 + address: test-with-discord-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 4bdaf4fbc..6d8fbb2d2 100644 --- a/backend/test/fixtures/thirdparty/identities.yaml +++ b/backend/test/fixtures/thirdparty/identities.yaml @@ -19,3 +19,10 @@ email_id: 05ab6e1f-8dfb-4329-ae04-22571a68d96b created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +- id: 18d61f13-2789-467a-a3a6-4292c0621580 + provider_id: "discord_abcde" + provider_name: "discord" + data: '{"email":"test-with-discord-identity@example.com","email_verified":true,"iss":"https://discord.com/api","sub":"discord_abcde"}' + email_id: 09f35529-cca6-44a7-ab1d-b07e95a04e3b + 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 ce0d3bbec..8fe377d80 100644 --- a/backend/test/fixtures/thirdparty/primary_emails.yaml +++ b/backend/test/fixtures/thirdparty/primary_emails.yaml @@ -13,6 +13,11 @@ user_id: b3537e49-de92-4e16-8981-ae4beb44c447 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +- id: f2eb0190-77f8-42a4-b45f-6f932a98995c + email_id: 09f35529-cca6-44a7-ab1d-b07e95a04e3b + user_id: d69bffda-4e4a-4424-a238-fbecc1651d81 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 - id: c57715eb-0c63-4910-b429-9b6165c50fab email_id: 527afce8-3b7b-41b6-b1ed-33d408c5a7bb user_id: 43fb7e88-4d5d-4b2b-9335-391e78d7e472 diff --git a/backend/test/fixtures/thirdparty/users.yaml b/backend/test/fixtures/thirdparty/users.yaml index 79fd313a5..9e67b2402 100644 --- a/backend/test/fixtures/thirdparty/users.yaml +++ b/backend/test/fixtures/thirdparty/users.yaml @@ -10,6 +10,10 @@ - id: b3537e49-de92-4e16-8981-ae4beb44c447 created_at: 2020-12-31 23:59:59 updated_at: 2020-12-31 23:59:59 +# user with email and discord identity +- id: d69bffda-4e4a-4424-a238-fbecc1651d81 + 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 9225e9051590b556ef3410145857fd2b9fff8ffe Mon Sep 17 00:00:00 2001 From: Lennart Fleischmann Date: Fri, 16 Feb 2024 18:08:59 +0100 Subject: [PATCH 6/6] feat: allow linking for discord provider per default --- backend/config/config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/config/config.go b/backend/config/config.go index d069822f9..54a38961d 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -183,6 +183,9 @@ func DefaultConfig() *Config { Apple: ThirdPartyProvider{ AllowLinking: true, }, + Discord: ThirdPartyProvider{ + AllowLinking: true, + }, }, }, }