Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add thirdparty Discord provider (OAuth 2.0) #1279

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ package config
import (
"errors"
"fmt"
"log"
"strings"
"time"

"github.com/fatih/structs"
"github.com/gobwas/glob"
"github.com/kelseyhightower/envconfig"
Expand All @@ -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
Expand Down Expand Up @@ -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"`
plunkettscott marked this conversation as resolved.
Show resolved Hide resolved
}

func (p *ThirdPartyProviders) Validate() error {
Expand Down
28 changes: 28 additions & 0 deletions backend/docs/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
12 changes: 11 additions & 1 deletion backend/handler/thirdparty_auth_test.go
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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",
Expand Down
130 changes: 127 additions & 3 deletions backend/handler/thirdparty_callback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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: "[email protected]",
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("[email protected]")
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: "[email protected]",
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("[email protected]")
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(), "", "", "")
Copy link
Member

@lfleischmann lfleischmann Jan 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one still counts as a sign up because no user with an appropriate identity exists. Hence the logs are empty (because it looks for an event with a different key, i.e. signin instead of signup) and the test fails. To accomplish this you would need an existing user.

The test suite runs a postgres container and uses fixtures to populate the database with data for the test. So, in order for this to work you'd have to add an existing user with the required data (see the link above for examples).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @plunkettscott, what's the status on this? We're about to make some slight changes to the third party stuff, which most likely also affects some of the tests. Would be great if we could merge this PR before merging the third party changes so that you don't have to bother with any more changes.

s.NoError(lerr)
s.Len(logs, 1)
}
}

func (s *thirdPartySuite) TestThirdPartyHandler_Callback_SignUp_WithUnclaimedEmail() {
defer gock.Off()
if testing.Short() {
Expand Down
26 changes: 18 additions & 8 deletions backend/handler/thirdparty_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}

Expand Down
3 changes: 3 additions & 0 deletions backend/json_schema/hanko.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,9 @@
},
"apple": {
"$ref": "#/$defs/ThirdPartyProvider"
},
"discord": {
"$ref": "#/$defs/ThirdPartyProvider"
}
},
"additionalProperties": false,
Expand Down
9 changes: 6 additions & 3 deletions backend/thirdparty/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading