From 5c6e8f992857a3816e7f6b8413fa330055b69af0 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Sun, 7 Jan 2024 14:47:50 -0600 Subject: [PATCH 1/4] add thirdparty Discord provider --- backend/config/config.go | 14 ++-- backend/docs/Config.md | 28 +++++++ backend/thirdparty/provider.go | 9 ++- backend/thirdparty/provider_discord.go | 103 +++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 backend/thirdparty/provider_discord.go diff --git a/backend/config/config.go b/backend/config/config.go index 453b37962..ab10e4154 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" @@ -11,9 +15,6 @@ import ( "github.com/knadh/koanf/providers/file" "github.com/teamhanko/hanko/backend/ee/saml/config" "golang.org/x/exp/slices" - "log" - "strings" - "time" ) // Config is the central configuration type @@ -582,9 +583,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 2de0f9953..9b232c239 100644 --- a/backend/docs/Config.md +++ b/backend/docs/Config.md @@ -562,6 +562,34 @@ third_party: # Required if provider is enabled. # secret: "CHANGE_ME" + ## + # + # 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" 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 16ef365e7407b00b8a0f793974f365e6539394b5 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Sun, 7 Jan 2024 15:05:10 -0600 Subject: [PATCH 2/4] 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 96727b41272ed2b1c324207300439cef651f73a1 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Wed, 10 Jan 2024 18:44:55 +0000 Subject: [PATCH 3/4] 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 aa5cb0d70..b869e03dd 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -695,6 +695,9 @@ }, "apple": { "$ref": "#/$defs/ThirdPartyProvider" + }, + "discord": { + "$ref": "#/$defs/ThirdPartyProvider" } }, "additionalProperties": false, From 6603477d827816eb14cc17efa079479b92786176 Mon Sep 17 00:00:00 2001 From: Scott Plunkett Date: Wed, 10 Jan 2024 19:19:15 +0000 Subject: [PATCH 4/4] add tests --- backend/handler/thirdparty_auth_test.go | 12 +- backend/handler/thirdparty_callback_test.go | 130 +++++++++++++++++++- backend/handler/thirdparty_test.go | 26 ++-- 3 files changed, 156 insertions(+), 12 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 64c1d4559..b47ebb1a6 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() { @@ -395,6 +396,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 09c9977f0..a478f5fb1 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) { @@ -64,11 +65,18 @@ func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirect Enabled: false, ClientID: "fakeClientID", Secret: "fakeClientSecret", - }, GitHub: config.ThirdPartyProvider{ + }, + GitHub: config.ThirdPartyProvider{ Enabled: false, ClientID: "fakeClientID", Secret: "fakeClientSecret", - }}, + }, + Discord: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + }, + }, ErrorRedirectURL: "https://error.test.example", RedirectURL: "https://api.test.example/callback", AllowedRedirectURLS: allowedRedirectURLs, @@ -92,6 +100,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 } }