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

feat: third party custom providers #1984

Merged
merged 8 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,26 +120,32 @@ func DefaultConfig() *Config {
Apple: ThirdPartyProvider{
DisplayName: "Apple",
AllowLinking: true,
Name: "apple",
},
Discord: ThirdPartyProvider{
DisplayName: "Discord",
AllowLinking: true,
Name: "discord",
},
LinkedIn: ThirdPartyProvider{
DisplayName: "LinkedIn",
AllowLinking: true,
Name: "linkedin",
},
Microsoft: ThirdPartyProvider{
DisplayName: "Microsoft",
AllowLinking: true,
Name: "microsoft",
},
GitHub: ThirdPartyProvider{
DisplayName: "GitHub",
AllowLinking: true,
Name: "github",
},
Google: ThirdPartyProvider{
DisplayName: "Google",
AllowLinking: true,
Name: "google",
},
},
},
Expand Down
210 changes: 206 additions & 4 deletions backend/config/config_third_party.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
type ThirdParty struct {
// `providers` contains the configurations for the available OAuth/OIDC identity providers.
Providers ThirdPartyProviders `yaml:"providers" json:"providers,omitempty" koanf:"providers" jsonschema:"title=providers,uniqueItems=true"`
// `custom_providers contains the configurations for custom OAuth/OIDC identity providers.
CustomProviders CustomThirdPartyProviders `yaml:"custom_providers" json:"custom_providers,omitempty" koanf:"custom_providers" jsonschema:"title=custom_providers"`
// `redirect_url` is the URL the third party provider redirects to with an authorization code. Must consist of the base URL
// of your running Hanko backend instance and the `callback` endpoint of the API,
// i.e. `{YOUR_BACKEND_INSTANCE}/thirdparty/callback.`
Expand Down Expand Up @@ -54,7 +56,10 @@ type ThirdParty struct {
}

func (t *ThirdParty) Validate() error {
if t.Providers.HasEnabled() {
hasEnabledProviders := t.Providers.HasEnabled()
hasEnabledCustomProviders := t.CustomProviders.HasEnabled()

if hasEnabledProviders || hasEnabledCustomProviders {
if t.RedirectURL == "" {
return errors.New("redirect_url must be set")
}
Expand All @@ -78,9 +83,18 @@ func (t *ThirdParty) Validate() error {
}
}

err := t.Providers.Validate()
if err != nil {
return fmt.Errorf("failed to validate third party providers: %w", err)
if hasEnabledProviders {
err := t.Providers.Validate()
if err != nil {
return fmt.Errorf("failed to validate third party providers: %w", err)
}
}

if hasEnabledCustomProviders {
err := t.CustomProviders.Validate()
if err != nil {
return fmt.Errorf("failed to validate custom third party providers: %w", err)
}
}

return nil
Expand All @@ -97,9 +111,194 @@ func (t *ThirdParty) PostProcess() error {
t.AllowedRedirectURLMap[redirectUrl] = g
}

if t.CustomProviders != nil {
providers := make(map[string]CustomThirdPartyProvider)
for key, provider := range t.CustomProviders {
// add prefix per default to ensure built-in and custom providers can be distinguished
keyLower := strings.ToLower(key)
provider.Name = "custom_" + keyLower
providers[keyLower] = provider
}
t.CustomProviders = providers
}

return nil
}

type CustomThirdPartyProviders map[string]CustomThirdPartyProvider

func (p *CustomThirdPartyProviders) GetEnabled() []CustomThirdPartyProvider {
var enabledProviders []CustomThirdPartyProvider
for _, provider := range *p {
if provider.Enabled {
enabledProviders = append(enabledProviders, provider)
}
}

return enabledProviders
}

func (p *CustomThirdPartyProviders) HasEnabled() bool {
for _, provider := range *p {
if provider.Enabled {
return true
}
}

return false
}

func (p *CustomThirdPartyProviders) Validate() error {
for _, v := range p.GetEnabled() {
err := v.Validate()
if err != nil {
return fmt.Errorf(
"failed to validate third party provider %s: %w",
strings.TrimPrefix(v.Name, "custom_"),
err,
)
}
}
return nil
}

type CustomThirdPartyProvider struct {
// `allow_linking` indicates whether existing accounts can be automatically linked with this provider.
//
// Linking is based on matching one of the email addresses of an existing user account with the (primary)
// email address of the third party provider account.
AllowLinking bool `yaml:"allow_linking" json:"allow_linking,omitempty" koanf:"allow_linking" jsonschema:"default=false"`
// `attributeMapping` defines a map that associates a set of known standard OIDC conformant end-user claims
lfleischmann marked this conversation as resolved.
Show resolved Hide resolved
// (the key of a map entry) at the Hanko backend to claims retrieved from a third party provider (the value of the
// map entry). This is primarily necessary if a non-OIDC provider is configured/used in which case it is probable
// that user data returned from the userinfo endpoint does not already conform to OIDC standard claims.
//
// Example: You configure an OAuth Provider (i.e. non-OIDC) and the provider's configured userinfo endpoint returns
// an end-user's user ID at the provider not under a `sub` key in its JSON response but rather under a `user_id`
// key. You would then configure an attribute mapping as follows:
//
// ```yaml
//attribute_mapping:
// sub: user_id
// ```
//
// See https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims for a list of known standard claims
// that provider claims can be mapped into. Any other claims received from a provider are not discarded but are
// retained internally in a `custom_claims` claim.
//
// Mappings are one-to-one mappings, complex mappings (e.g. mapping concatenations of two claims) are not possible.
AttributeMapping map[string]string `yaml:"attribute_mapping" json:"attribute_mapping,omitempty" koanf:"attribute_mapping"`
// URL of the provider's authorization endpoint where the end-user is redirected to authenticate and grant consent for
// an application to access their resources.
//
// Required if `use_discovery` is false or omitted.
AuthorizationEndpoint string `yaml:"authorization_endpoint" json:"authorization_endpoint,omitempty" koanf:"authorization_endpoint"`
// `name` is a unique name/slug/identifier for the provider derived from the key used in the `custom_providers` map
// to configure the respective provider. It is derived in the sense that it is a concatenation the prefix "custom_"
// and the key used in the configuration. This allows distinguishing between built-in and custom providers at
// runtime.
lfleischmann marked this conversation as resolved.
Show resolved Hide resolved
Name string `jsonschema:"-"`
// `issuer` is the provider's issuer identifier. It should be a URL that uses the "https"
// scheme and has no query or fragment components.
//
// Required if `use_discovery` is true.
Issuer string `yaml:"issuer" json:"issuer,omitempty" koanf:"issuer"`
// `client_id` is the ID of the OAuth/OIDC client. Must be obtained from the provider.
//
// Required if the provider is `enabled`.
ClientID string `yaml:"client_id" json:"client_id" koanf:"client_id" split_words:"true"`
// `display_name` is the name of the provider that is intended to be shown to an end-user.
DisplayName string `yaml:"display_name" json:"display_name" koanf:"display_name" jsonschema:"required"`
// `enabled` indicates if the provider is enabled or disabled.
Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"`
// `scopes` is a list of scopes requested from the provider that specify the level of access an application has to
// a user's resources on a server, defining what actions the app can perform on behalf of the user.
Scopes []string `yaml:"scopes" json:"scopes" koanf:"scopes" jsonschema:"required"`
// `secret` is the client secret for the OAuth/OIDC client. Must be obtained from the provider.
//
// Required if the provider is `enabled`.
Secret string `yaml:"secret" json:"secret" koanf:"secret"`
// URL of the provider's token endpoint URL where an application exchanges an authorization code for an access
// token, which is used to authenticate API requests on behalf of the end-user.
//
// Required if `use_discovery` is false or omitted.
TokenEndpoint string `yaml:"token_endpoint" json:"token_endpoint,omitempty" koanf:"token_endpoint"`
// `use_discovery` determines if configuration information about an OpenID Connect (OIDC) provider, such as
// endpoint URLs and supported features,should be automatically retrieved, from a well-known
// URL (typically /.well-known/openid-configuration).
UseDiscovery bool `yaml:"use_discovery" json:"use_discovery,omitempty" koanf:"use_discovery" jsonschema:"default=true"`
// URL of the provider's endpoint that returns claims about an authenticated end-user.
//
// Required if `use_discovery` is false or omitted.
UserinfoEndpoint string `yaml:"userinfo_endpoint" json:"userinfo_endpoint,omitempty" koanf:"userinfo_endpoint"`
}

func (p *CustomThirdPartyProvider) Validate() error {
if p.Enabled {
if p.DisplayName == "" {
return errors.New("missing display_name")
}
if p.ClientID == "" {
return errors.New("missing client_id")
}
if p.Secret == "" {
return errors.New("missing client secret")
}
if len(p.Scopes) == 0 {
return errors.New("missing scopes")
}
if p.UseDiscovery == true {
if p.Issuer == "" {
return errors.New("issuer must be set when use_discovery is set to true")
}
} else {
authorizationEndpointSet := p.AuthorizationEndpoint != ""
tokenEndpointSet := p.TokenEndpoint != ""
userinfoEndpointSet := p.UserinfoEndpoint != ""

if !authorizationEndpointSet || !tokenEndpointSet || !userinfoEndpointSet {
return errors.New("authorization_endpoint, token_endpoint and userinfo_endpoint must be set when use_discovery is set to false or unset")
}
}
}

return nil
}

func (CustomThirdPartyProvider) JSONSchemaExtend(schema *jsonschema.Schema) {
schema.Title = "custom_provider"

enabledTrue := &jsonschema.Schema{Properties: orderedmap.New[string, *jsonschema.Schema]()}
enabledTrue.Properties.Set("enabled", &jsonschema.Schema{Const: true})

useDiscoveryFalse := &jsonschema.Schema{Properties: orderedmap.New[string, *jsonschema.Schema]()}
useDiscoveryFalse.Properties.Set("use_discovery", &jsonschema.Schema{Const: false})

useDiscoveryTrue := &jsonschema.Schema{Properties: orderedmap.New[string, *jsonschema.Schema]()}
useDiscoveryTrue.Properties.Set("use_discovery", &jsonschema.Schema{Const: true})

useDiscoveryNull := &jsonschema.Schema{Properties: orderedmap.New[string, *jsonschema.Schema]()}
useDiscoveryNull.Properties.Set("use_discovery", &jsonschema.Schema{Type: "null"})

useDiscoveryFalseOrNull := &jsonschema.Schema{AnyOf: []*jsonschema.Schema{useDiscoveryFalse, useDiscoveryNull}}

endpointsRequired := &jsonschema.Schema{
Required: []string{"authorization_endpoint", "token_endpoint", "userinfo_endpoint"},
}

issuerRequired := &jsonschema.Schema{
Required: []string{"issuer"},
}

schema.If = enabledTrue
schema.Then = &jsonschema.Schema{
Required: []string{"client_id", "secret"},
If: useDiscoveryFalseOrNull,
Then: endpointsRequired,
Else: issuerRequired,
}
}

type ThirdPartyProviders struct {
// `apple` contains the provider configuration for Apple.
Apple ThirdPartyProvider `yaml:"apple" json:"apple,omitempty" koanf:"apple"`
Expand Down Expand Up @@ -181,6 +380,9 @@ type ThirdPartyProvider struct {
//
// Required if the provider is `enabled`.
Secret string `yaml:"secret" json:"secret,omitempty" koanf:"secret"`
// `name` is a unique name/slug/identifier for the provider. It is the lowercased key of the corresponding field
// in ThirdPartyProviders. See also: CustomThirdPartyProvider.Name.
Name string `jsonschema:"-"`
}

func (ThirdPartyProvider) JSONSchemaExtend(schema *jsonschema.Schema) {
Expand Down
7 changes: 4 additions & 3 deletions backend/dto/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dto

import (
"github.com/gofrs/uuid"
"github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/persistence/models"
)

Expand All @@ -23,17 +24,17 @@ type EmailUpdateRequest struct {
}

// FromEmailModel Converts the DB model to a DTO object
func FromEmailModel(email *models.Email) *EmailResponse {
func FromEmailModel(email *models.Email, cfg *config.Config) *EmailResponse {
emailResponse := &EmailResponse{
ID: email.ID,
Address: email.Address,
IsVerified: email.Verified,
IsPrimary: email.IsPrimary(),
Identities: FromIdentitiesModel(email.Identities),
Identities: FromIdentitiesModel(email.Identities, cfg),
}

if len(email.Identities) > 0 {
identity := FromIdentityModel(&email.Identities[0])
identity := FromIdentityModel(&email.Identities[0], cfg)
emailResponse.Identity = identity
}

Expand Down
2 changes: 1 addition & 1 deletion backend/dto/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func ProfileDataFromUserModel(user *models.User, cfg *config.Config) *ProfileDat

var emails []EmailResponse
for _, emailModel := range user.Emails {
email := FromEmailModel(&emailModel)
email := FromEmailModel(&emailModel, cfg)
emails = append(emails, *email)
}

Expand Down
23 changes: 14 additions & 9 deletions backend/dto/thirdparty.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,36 @@ type Identity struct {

type Identities []Identity

func FromIdentitiesModel(identities models.Identities) Identities {
func FromIdentitiesModel(identities models.Identities, cfg *config.Config) Identities {
var result Identities
for _, i := range identities {
identity := FromIdentityModel(&i)
identity := FromIdentityModel(&i, cfg)
result = append(result, *identity)
}
return result
}

func FromIdentityModel(identity *models.Identity) *Identity {
func FromIdentityModel(identity *models.Identity, cfg *config.Config) *Identity {
if identity == nil {
return nil
}

return &Identity{
ID: identity.ProviderID,
Provider: getProviderDisplayName(identity),
Provider: getProviderDisplayName(identity, cfg),
}
}

func getProviderDisplayName(identity *models.Identity) string {
s := structs.New(config.ThirdPartyProviders{})
for _, field := range s.Fields() {
if strings.ToLower(field.Name()) == strings.ToLower(identity.ProviderName) {
return field.Name()
func getProviderDisplayName(identity *models.Identity, cfg *config.Config) string {
if strings.HasPrefix(identity.ProviderName, "custom_") {
providerNameWithoutPrefix := strings.TrimPrefix(identity.ProviderName, "custom_")
return cfg.ThirdParty.CustomProviders[providerNameWithoutPrefix].DisplayName
} else {
s := structs.New(config.ThirdPartyProviders{})
for _, field := range s.Fields() {
if strings.ToLower(field.Name()) == strings.ToLower(identity.ProviderName) {
return field.Name()
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions backend/flow_api/flow/shared/action_exchange_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ func (a ExchangeToken) Execute(c flowpilot.ExecutionContext) error {
return fmt.Errorf("failed to set third_party_provider to the stash: %w", err)
}

c.PreventRevert()

return c.Continue(onboardingStates...)
}

Expand Down
Loading
Loading