Skip to content
Draft
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
2 changes: 2 additions & 0 deletions config/flipt.schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ JsonPath: string
#authentication_oidc_provider: {
@jsonschema(id="authentication_oidc_provider")
issuer_url?: string
discovery_url?: string
client_id?: string
client_secret?: string
redirect_address?: string
Expand All @@ -151,6 +152,7 @@ JsonPath: string
fetch_extra_user_info?: bool
allow_front_channel_logout?: bool
authorize_parameters?: [string]: string
claims_mapping?: [string]: string
}

}
Expand Down
10 changes: 10 additions & 0 deletions config/flipt.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,9 @@
"issuer_url": {
"type": "string"
},
"discovery_url": {
"type": "string"
},
"client_id": {
"type": "string"
},
Expand Down Expand Up @@ -496,6 +499,13 @@
"type": "string"
},
"default": {}
},
"claims_mapping": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"default": {}
}
}
}
Expand Down
40 changes: 39 additions & 1 deletion internal/config/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,14 @@ func (a AuthenticationMethodOIDCConfig) validate() error {

// AuthenticationMethodOIDCProvider configures provider credentials
type AuthenticationMethodOIDCProvider struct {
IssuerURL string `json:"issuerURL,omitempty" mapstructure:"issuer_url" yaml:"issuer_url,omitempty"`
IssuerURL string `json:"issuerURL,omitempty" mapstructure:"issuer_url" yaml:"issuer_url,omitempty"`
// DiscoveryURL optionally overrides the URL used to fetch the OIDC discovery
// document (.well-known/openid-configuration). When empty, IssuerURL is used.
// Set this for providers (e.g. Azure AD B2C) that serve discovery from a URL
// that differs from the issuer reported in that document and in issued tokens.
// When set, the discovery document is fetched from DiscoveryURL while tokens
// are still verified against IssuerURL.
DiscoveryURL string `json:"discoveryURL,omitempty" mapstructure:"discovery_url" yaml:"discovery_url,omitempty"`
ClientID string `json:"-" mapstructure:"client_id" yaml:"-"`
ClientSecret string `json:"-" mapstructure:"client_secret" yaml:"-"`
RedirectAddress string `json:"redirectAddress,omitempty" mapstructure:"redirect_address" yaml:"redirect_address,omitempty"`
Expand All @@ -551,8 +558,19 @@ type AuthenticationMethodOIDCProvider struct {
AuthorizeParameters map[string]string `json:"authorizeParameters,omitempty" mapstructure:"authorize_parameters" yaml:"authorize_parameters,omitempty"`
UseEndSessionEndpoint bool `json:"useEndSessionEndpoint,omitempty" mapstructure:"use_end_session_endpoint" yaml:"use_end_session_endpoint,omitempty"`
AllowFrontChannelLogout bool `json:"allowFrontChannelLogout,omitempty" mapstructure:"allow_front_channel_logout" yaml:"allow_front_channel_logout,omitempty"`
// ClaimsMapping maps user attribute names to JSON Pointer expressions that
// specify how to extract those attributes from the ID token (or user info)
// claims. This is useful for providers whose claims don't match the standard
// OIDC names, e.g. Azure AD B2C, which returns the email in an "emails" array:
// {"email": "/emails/0"}. Only the keys "email", "name", "picture" and "sub"
// are supported.
ClaimsMapping map[string]string `json:"claimsMapping,omitempty" mapstructure:"claims_mapping" yaml:"claims_mapping,omitempty"`
}

// validOIDCClaimKeys enumerates the user attributes that can be overridden via
// AuthenticationMethodOIDCProvider.ClaimsMapping.
var validOIDCClaimKeys = []string{"email", "name", "picture", "sub"}

func (a AuthenticationMethodOIDCProvider) setDefaults(defaults map[string]any) {
defaults["algorithms"] = []string{"RS256"}
}
Expand All @@ -570,6 +588,26 @@ func (a AuthenticationMethodOIDCProvider) validate() error {
return errFieldRequired("authentication", "redirect_address")
}

// When overriding the discovery URL we skip go-oidc's check that the
// discovered issuer matches the fetch URL, and instead pin token
// verification to issuer_url. That pin is only meaningful when issuer_url
// is set, so require it here.
if a.DiscoveryURL != "" && a.IssuerURL == "" {
return errFieldRequired("authentication", "issuer_url")
}

for key, expr := range a.ClaimsMapping {
if !slices.Contains(validOIDCClaimKeys, key) {
return errFieldWrap("authentication", "claims_mapping", fmt.Errorf("invalid claim key %q", key))
}

if len(expr) < 2 || !strings.HasPrefix(expr, "/") {
// jsonpointer expressions must start with `/`, so a valid expression
// must contain `/` followed by at least one character.
return errFieldWrap("authentication", "claims_mapping", fmt.Errorf("invalid expression for key %q", key))
}
}

return nil
}

Expand Down
64 changes: 64 additions & 0 deletions internal/config/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,70 @@ func TestWithForwardPrefix(t *testing.T) {
assert.Equal(t, "/some/prefix", getForwardPrefix(ctx))
}

func TestAuthenticationMethodOIDCProvider_validate(t *testing.T) {
base := func() AuthenticationMethodOIDCProvider {
return AuthenticationMethodOIDCProvider{
IssuerURL: "https://issuer.example.com",
ClientID: "id",
ClientSecret: "secret",
RedirectAddress: "https://flipt.example.com",
}
}

tests := []struct {
name string
mutate func(*AuthenticationMethodOIDCProvider)
errorContains string
}{
{
name: "valid without discovery_url",
mutate: func(*AuthenticationMethodOIDCProvider) {},
},
{
name: "valid with discovery_url and issuer_url",
mutate: func(p *AuthenticationMethodOIDCProvider) {
p.DiscoveryURL = "https://tenant.b2clogin.com/tenant.onmicrosoft.com/policy/v2.0"
},
},
{
name: "discovery_url without issuer_url fails",
mutate: func(p *AuthenticationMethodOIDCProvider) {
p.IssuerURL = ""
p.DiscoveryURL = "https://tenant.b2clogin.com/tenant.onmicrosoft.com/policy/v2.0"
},
errorContains: "issuer_url",
},
{
name: "valid claims_mapping",
mutate: func(p *AuthenticationMethodOIDCProvider) {
p.ClaimsMapping = map[string]string{"email": "/emails/0", "name": "/given_name"}
},
},
{
name: "invalid claims_mapping key fails",
mutate: func(p *AuthenticationMethodOIDCProvider) {
p.ClaimsMapping = map[string]string{"role": "/roles/0"}
},
errorContains: `invalid claim key "role"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := base()
tt.mutate(&cfg)

err := cfg.validate()
if tt.errorContains != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorContains)
return
}
assert.NoError(t, err)
})
}
}

func TestAuthenticationMethodJWTConfig_validate_ClaimsMapping(t *testing.T) {
tests := []struct {
name string
Expand Down
48 changes: 41 additions & 7 deletions internal/server/authn/method/oidc/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-openapi/jsonpointer"
"go.flipt.io/flipt/errors"
"go.flipt.io/flipt/internal/config"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -110,14 +111,27 @@ type client struct {
oidcCfg *oidc.Config
endSessionEndpoint string
createdAt time.Time
claimsMapping map[string]jsonpointer.Pointer
}

func callbackURL(host, provider string) string {
return fmt.Sprintf("%s/auth/v1/method/oidc/%s/callback", strings.TrimSuffix(host, "/"), provider)
}

func newOIDCClient(ctx context.Context, cfg config.AuthenticationMethodOIDCProvider, provider string) (*client, error) {
p, err := oidc.NewProvider(ctx, cfg.IssuerURL)
// Determine where to fetch the discovery document from. Some providers
// (e.g. Azure AD B2C) serve the discovery document from a URL that differs
// from the issuer reported inside that document and in issued tokens. When
// discovery_url is set we fetch from it and pin token verification to
// issuer_url via InsecureIssuerURLContext, which skips go-oidc's default
// requirement that the discovered issuer match the fetch URL.
discoveryURL := cfg.IssuerURL
if cfg.DiscoveryURL != "" {
discoveryURL = cfg.DiscoveryURL
ctx = oidc.InsecureIssuerURLContext(ctx, cfg.IssuerURL)
}

p, err := oidc.NewProvider(ctx, discoveryURL)
if err != nil {
return nil, fmt.Errorf("creating OIDC provider: %w", err)
}
Expand All @@ -143,16 +157,36 @@ func newOIDCClient(ctx context.Context, cfg config.AuthenticationMethodOIDCProvi
SupportedSigningAlgs: supportedAlgs,
}

claimsMapping, err := newClaimsMapping(cfg.ClaimsMapping)
if err != nil {
return nil, err
}

return &client{
key: provider,
cfg: cfg,
provider: p,
oauth2Cfg: oauth2Config,
oidcCfg: oidcConfig,
createdAt: time.Now(),
key: provider,
cfg: cfg,
provider: p,
oauth2Cfg: oauth2Config,
oidcCfg: oidcConfig,
createdAt: time.Now(),
claimsMapping: claimsMapping,
}, nil
}

// newClaimsMapping parses and validates the configured OIDC claim mapping
// expressions, returning a map of claim names to JSON Pointers.
func newClaimsMapping(cfgClaimsMapping map[string]string) (map[string]jsonpointer.Pointer, error) {
claimsMapping := make(map[string]jsonpointer.Pointer, len(cfgClaimsMapping))
for name, expr := range cfgClaimsMapping {
jsonptr, err := jsonpointer.New(expr)
if err != nil {
return nil, fmt.Errorf("invalid claim mapping expression for %q: %w", name, err)
}
claimsMapping[name] = jsonptr
}
return claimsMapping, nil
}

// UsePKCE returns whether PKCE is enabled for this provider.
func (c *client) UsePKCE() bool {
return c.cfg.UsePKCE
Expand Down
Loading
Loading