mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-26 21:57:14 +08:00
* feat: third party custom providers
- New configuration option `third_party.custom_providers`. `custom_providers`
is a map of arbitrarily chosen keys to a `CustomThirdPartyProvider` - this is
implemented as a new type differing from the existing configuration type
`ThirdPartyProvider` used for built-in providers because they have different
configuration requirements.
- Both `ThirdPartyProvider` and `CustomThirdPartyProvider` types get a non-
configurable, automatically populated `Name` (during the config's `PostProcess`)
that sort of serves as an identifier/slug for the provider in order to
distinguish provider types at runtime.
- A `CustomThirdPartyProvider`s `Name` is automatically prefixed during
`PostProcess` with a "custom_" prefix to ensure that providers can be
distinguished at runtime.
- A (built-in) `ThirdPartyProvider`s `Name` is "hard-coded" through the
`DefaultConfig`.
- Built-in OAuth/OIDC provider implementations are currently instantiated
on-demand instead of once at appliation startup (i.e. unlike SAML
providers) - i.e. when a user requests auth/authz with a third party
provider, only then a provider is instantiated and created via factory
function (`thirdparty.GetProvider`). Custom providers follow this
pattern, hence the factory function had to be adjusted to take into account
providers with the aforementioned "custom_" prefix (i.e.: if it is a
"custom_" provider, instantiate a `customProvider` implementation).
- The `customProvider` implementation uses the `go-oidc` library. Instances
of providers of the type this library offers can be instantiated by passing
in an `issuer` URL. Such an instantiation automatically attempts to retrieve
an OIDC discovery document from a `.well-known` endpoint. This also performs
an issuer validation. Providers configured to not use OIDC discovery (i.e.
`use_discovery` in the `CustomThirdPartyProvider` is `false` or omitted) do
not do this issuer check.
- The `customProvider` implementation is further based on the assumption that
provider user data is only extracted from a userinfo endpoint response, i.e.
in case of an OIDC provider, the implementation does not make use of the ID
token - no validation is performed on the ID token.
- The `customProvider` implementation requires configuring a list of `scopes`:
because the custom providers allow configuring both OAuth as well as OIDC
providers, we cannot simply set a default set of scopes, e.g. `openid`, which
is a required claim for OIDC - some providers return errors on unknown claims
so setting this would make the third party auth process prone to errors.
- The `customProvider` implementation allows for a simple mapping of claims
contained in the userinfo response from the provider to "known" standard OIDC
conformant claims at the Hanko backend (defined in the `thirdparty.Claims`
struct) through an `attribute_mapping` configuration option. The mapping is a
simple one-to-one mapping, i.e. no complex mapping instructions are possible,
e.g. mapping concatenations of multiple claims in the provider data source or
similar. Any other non-standard claims returned by the provider are placed in
a `custom_claims` attribute. Except for the user ID (`sub`), `email` and
`email_verified` claims the third party functionality currently does not allow
accessing this user data but there's a good chance this will change in the future,
so I tried to make sure that any info retrieved from the provider is somehow
preserved (it is persisted in the `data` column for an `identity` btw. and updated
on every login with the provider).
- I also noticed that the `thirdparty.Claims` were missing the `address` claim,
so I added that for completeness' sake.
- The changes also fix a "bug" in the account `linking` logic whereby third party
connections were established by simply assuming that the email retrieved from the
provider was verified. So, even if the email address at the provider was not
verified (or the provider simply did/does not provide info about the verification
status) an account was created and/or linked and the flow API capabilities of
automatically triggering a passcode if the backend was configured to require email
verification would not take effect. This was a wrong assumption and the verification
status is now based on the actual value retrieved from the provider.
- In case of a triggered passcode, the changes also modify the token exchange
action to prevent showing a `back` button/link, since it does not make sense to
go `back` to anything right after the token exchange - there is nothing to go
"back" to.
192 lines
5.3 KiB
Go
192 lines
5.3 KiB
Go
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"
|
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
"github.com/stretchr/testify/suite"
|
|
auditlog "github.com/teamhanko/hanko/backend/audit_log"
|
|
"github.com/teamhanko/hanko/backend/config"
|
|
"github.com/teamhanko/hanko/backend/crypto/jwk"
|
|
"github.com/teamhanko/hanko/backend/dto"
|
|
"github.com/teamhanko/hanko/backend/session"
|
|
"github.com/teamhanko/hanko/backend/test"
|
|
"github.com/teamhanko/hanko/backend/utils"
|
|
)
|
|
|
|
func TestThirdPartySuite(t *testing.T) {
|
|
t.Parallel()
|
|
suite.Run(t, new(thirdPartySuite))
|
|
}
|
|
|
|
type thirdPartySuite struct {
|
|
test.Suite
|
|
}
|
|
|
|
func (s *thirdPartySuite) setUpContext(request *http.Request) (echo.Context, *httptest.ResponseRecorder) {
|
|
s.T().Helper()
|
|
e := echo.New()
|
|
e.Validator = dto.NewCustomValidator()
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(request, rec)
|
|
return c, rec
|
|
}
|
|
|
|
func (s *thirdPartySuite) setUpHandler(cfg *config.Config) *ThirdPartyHandler {
|
|
s.T().Helper()
|
|
auditLogger := auditlog.NewLogger(s.Storage, cfg.AuditLog)
|
|
|
|
jwkMngr, err := jwk.NewDefaultManager(cfg.Secrets.Keys, s.Storage.GetJwkPersister())
|
|
s.Require().NoError(err)
|
|
|
|
sessionMngr, err := session.NewManager(jwkMngr, *cfg)
|
|
s.Require().NoError(err)
|
|
|
|
handler := NewThirdPartyHandler(cfg, s.Storage, sessionMngr, auditLogger)
|
|
return handler
|
|
}
|
|
|
|
func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirectURLs []string) *config.Config {
|
|
s.T().Helper()
|
|
cfg := config.DefaultConfig()
|
|
cfg.ThirdParty = config.ThirdParty{
|
|
Providers: config.ThirdPartyProviders{
|
|
Apple: config.ThirdPartyProvider{
|
|
Name: "apple",
|
|
Enabled: false,
|
|
ClientID: "fakeClientID",
|
|
Secret: "fakeClientSecret",
|
|
AllowLinking: true,
|
|
},
|
|
Google: config.ThirdPartyProvider{
|
|
Name: "google",
|
|
Enabled: false,
|
|
ClientID: "fakeClientID",
|
|
Secret: "fakeClientSecret",
|
|
AllowLinking: true,
|
|
},
|
|
GitHub: config.ThirdPartyProvider{
|
|
Name: "github",
|
|
Enabled: false,
|
|
ClientID: "fakeClientID",
|
|
Secret: "fakeClientSecret",
|
|
AllowLinking: true,
|
|
},
|
|
Discord: config.ThirdPartyProvider{
|
|
Name: "discord",
|
|
Enabled: false,
|
|
ClientID: "fakeClientID",
|
|
Secret: "fakeClientSecret",
|
|
AllowLinking: true,
|
|
},
|
|
Microsoft: config.ThirdPartyProvider{
|
|
Name: "microsoft",
|
|
Enabled: false,
|
|
ClientID: "fakeClientID",
|
|
Secret: "fakeClientSecret",
|
|
AllowLinking: false,
|
|
},
|
|
},
|
|
ErrorRedirectURL: "https://error.test.example",
|
|
RedirectURL: "https://api.test.example/callback",
|
|
AllowedRedirectURLS: allowedRedirectURLs,
|
|
}
|
|
|
|
cfg.AuditLog.Storage.Enabled = true
|
|
cfg.AuditLog.Mask = false
|
|
cfg.Email.Limit = 5
|
|
cfg.Account.AllowSignup = true
|
|
|
|
for _, provider := range enabledProviders {
|
|
switch provider {
|
|
case "apple":
|
|
cfg.ThirdParty.Providers.Apple.Enabled = true
|
|
case "google":
|
|
cfg.ThirdParty.Providers.Google.Enabled = true
|
|
case "github":
|
|
cfg.ThirdParty.Providers.GitHub.Enabled = true
|
|
case "discord":
|
|
cfg.ThirdParty.Providers.Discord.Enabled = true
|
|
case "microsoft":
|
|
cfg.ThirdParty.Providers.Microsoft.Enabled = true
|
|
}
|
|
}
|
|
|
|
err := cfg.PostProcess()
|
|
s.Require().NoError(err)
|
|
|
|
return cfg
|
|
}
|
|
|
|
func (s *thirdPartySuite) setUpFakeJwkSet() jwk2.Set {
|
|
s.T().Helper()
|
|
generator := test.JwkManager{}
|
|
keySet, err := generator.GetPublicKeys()
|
|
s.Require().NoError(err)
|
|
return keySet
|
|
}
|
|
|
|
func (s *thirdPartySuite) setUpAppleIdToken(sub, aud, email string, emailVerified bool) string {
|
|
s.T().Helper()
|
|
token := jwt.New()
|
|
_ = token.Set(jwt.SubjectKey, sub)
|
|
_ = token.Set(jwt.IssuedAtKey, time.Now().UTC())
|
|
_ = token.Set(jwt.IssuerKey, "https://appleid.apple.com")
|
|
_ = token.Set(jwt.AudienceKey, aud)
|
|
_ = token.Set("email_verified", strconv.FormatBool(emailVerified))
|
|
_ = token.Set("email", email)
|
|
|
|
generator := test.JwkManager{}
|
|
signingKey, err := generator.GetSigningKey()
|
|
s.Require().NoError(err)
|
|
|
|
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKey))
|
|
s.Require().NoError(err)
|
|
|
|
return string(signedToken)
|
|
}
|
|
|
|
func (s *thirdPartySuite) setUpMicrosoftIdToken(sub, aud, email string, edov bool) string {
|
|
s.T().Helper()
|
|
token := jwt.New()
|
|
_ = token.Set(jwt.SubjectKey, sub)
|
|
_ = token.Set(jwt.IssuedAtKey, time.Now().UTC())
|
|
_ = token.Set(jwt.IssuerKey, "https://login.microsoftonline.com/0ec22c9c-397e-484d-8edc-6212147ebe5b/v2.0")
|
|
_ = token.Set(jwt.AudienceKey, aud)
|
|
_ = token.Set("email", email)
|
|
_ = token.Set("xms_edov", edov)
|
|
|
|
generator := test.JwkManager{}
|
|
signingKey, err := generator.GetSigningKey()
|
|
s.Require().NoError(err)
|
|
|
|
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKey))
|
|
s.Require().NoError(err)
|
|
|
|
return string(signedToken)
|
|
}
|
|
|
|
func (s *thirdPartySuite) assertLocationHeaderHasToken(rec *httptest.ResponseRecorder) {
|
|
s.T().Helper()
|
|
location, err := url.Parse(rec.Header().Get("Location"))
|
|
s.NoError(err)
|
|
s.True(location.Query().Has(utils.HankoTokenQuery))
|
|
s.NotEmpty(location.Query().Get(utils.HankoTokenQuery))
|
|
}
|
|
|
|
func (s *thirdPartySuite) assertStateCookieRemoved(rec *httptest.ResponseRecorder) {
|
|
s.T().Helper()
|
|
cookies := rec.Result().Cookies()
|
|
s.Len(cookies, 1)
|
|
s.Equal(utils.HankoThirdpartyStateCookie, cookies[0].Name)
|
|
s.Equal(-1, cookies[0].MaxAge)
|
|
}
|