Skip to content
Merged
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
3 changes: 2 additions & 1 deletion config/flipt.schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ JsonPath: string
use_end_session_endpoint?: bool
algorithms?: [...("RS256" | "RS384" | "RS512" | "ES256" | "ES384" | "ES512" | "PS256" | "PS384" | "PS512")] | *["RS256"]
fetch_extra_user_info?: bool
authorize_parameters?: [string]: string
allow_front_channel_logout?: bool
authorize_parameters?: [string]: string
}

}
Expand Down
4 changes: 4 additions & 0 deletions config/flipt.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@
"type": "boolean",
"default": false
},
"allow_front_channel_logout": {
"type": "boolean",
"default": false
},
"authorize_parameters": {
"type": "object",
"additionalProperties": {
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,12 +268,13 @@ func authenticationHTTPMount(

methodMiddleware := method.NewHTTPMiddleware(cfg.Session)
muxOpts = append(muxOpts, runtime.WithForwardResponseOption(methodMiddleware.ForwardResponseOption))

if cfg.Methods.OIDC.Enabled {
muxOpts = append(
muxOpts,
register(ctx, rpcauth.NewAuthenticationMethodOIDCServiceClient(conn), rpcauth.RegisterAuthenticationMethodOIDCServiceHandlerClient),
gateway.NewFormURLEncodedMarshaler(),
gateway.NewHTTPMethodForwarder(),
runtime.WithForwardResponseOption(authmiddleware.ForwardRevokeOIDCResponseOption),
)
}

Expand Down
99 changes: 70 additions & 29 deletions internal/cmd/protoc-gen-flipt-openapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"path/filepath"
"reflect"
"slices"
"strings"
"unsafe"

Expand All @@ -14,6 +15,13 @@ import (
"google.golang.org/protobuf/types/pluginpb"
)

type apiKind string

const (
commonAPI apiKind = "Common API"
managementAPI apiKind = "Management API"
)

var flags flag.FlagSet

// The code of this function is copied from https://github.com/google/gnostic/blob/main/cmd/protoc-gen-openapi/main.go
Expand All @@ -37,6 +45,10 @@ func main() {
}

opts.Run(func(plugin *protogen.Plugin) error {
if conf.Title == nil {
return fmt.Errorf("title option is required")
}
kind := apiKind(*conf.Title)
// Enable "optional" keyword in front of type (e.g. optional string label = 1;)
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
if *conf.OutputMode == "source_relative" {
Expand All @@ -47,8 +59,10 @@ func main() {
outfileName := strings.TrimSuffix(file.Desc.Path(), filepath.Ext(file.Desc.Path())) + ".openapi.gen.out"
outputFile := plugin.NewGeneratedFile(outfileName, "")
gen := generator.NewOpenAPIv3Generator(plugin, conf, []*protogen.File{file})
// force injecting extra well-known types so they will be included in the openapi.yaml
appendRequiredSchemaWithReflectDangerous(gen, "Flag", "Segment")
if kind == managementAPI {
// force injecting extra well-known types so they will be included in the openapi.yaml
appendRequiredSchemaWithReflectDangerous(gen, "Flag", "Segment")
}
if err := gen.Run(outputFile); err != nil {
return err
}
Expand All @@ -60,15 +74,17 @@ func main() {
outputFile.Skip() // don't store temporary output
outfileName = strings.TrimSuffix(file.Desc.Path(), filepath.Ext(file.Desc.Path())) + ".openapi.yaml"
outputFile = plugin.NewGeneratedFile(outfileName, "")
return run(data, outputFile)
return run(kind, data, outputFile)
// end of changes
}
} else {
outputFile := plugin.NewGeneratedFile("openapi.gen.out", "")

gen := generator.NewOpenAPIv3Generator(plugin, conf, plugin.Files)
// force injecting extra well-known types so they will be included in the openapi.yaml
appendRequiredSchemaWithReflectDangerous(gen, "Flag", "Segment")
if kind == managementAPI {
// force injecting extra well-known types so they will be included in the openapi.yaml
appendRequiredSchemaWithReflectDangerous(gen, "Flag", "Segment")
}
err := gen.Run(outputFile)
if err != nil {
return err
Expand All @@ -80,51 +96,76 @@ func main() {
}
outputFile.Skip() // don't store temporary output
outputFile = plugin.NewGeneratedFile("openapi.yaml", "")
return run(data, outputFile)
return run(kind, data, outputFile)
// end of changes
}
return nil
})
}

func run(data []byte, outputFile *protogen.GeneratedFile) error {
func run(kind apiKind, data []byte, outputFile *protogen.GeneratedFile) error {
doc, err := v3.ParseDocument(data)
if err != nil {
return err
}

// Add Resource-specific OpenAPI schemas.
doc.Components.Schemas.AdditionalProperties = append(
doc.Components.Schemas.AdditionalProperties,
&v3.NamedSchemaOrReference{Name: "AtType", Value: buildPayloadType()},
&v3.NamedSchemaOrReference{Name: "FlagResourcePayload", Value: buildAnyVariant("flipt.core.Flag", "Flag")},
&v3.NamedSchemaOrReference{Name: "SegmentResourcePayload", Value: buildAnyVariant("flipt.core.Segment", "Segment")},
&v3.NamedSchemaOrReference{
Name: "ResourcePayload", Value: &v3.SchemaOrReference{
Oneof: &v3.SchemaOrReference_Schema{
Schema: &v3.Schema{
Type: "object",
OneOf: []*v3.SchemaOrReference{
{Oneof: &v3.SchemaOrReference_Reference{Reference: &v3.Reference{XRef: "#/components/schemas/FlagResourcePayload"}}},
{Oneof: &v3.SchemaOrReference_Reference{Reference: &v3.Reference{XRef: "#/components/schemas/SegmentResourcePayload"}}},
if kind == managementAPI {
// Add Resource-specific OpenAPI schemas.
doc.Components.Schemas.AdditionalProperties = append(
doc.Components.Schemas.AdditionalProperties,
&v3.NamedSchemaOrReference{Name: "AtType", Value: buildPayloadType()},
&v3.NamedSchemaOrReference{Name: "FlagResourcePayload", Value: buildAnyVariant("flipt.core.Flag", "Flag")},
&v3.NamedSchemaOrReference{Name: "SegmentResourcePayload", Value: buildAnyVariant("flipt.core.Segment", "Segment")},
&v3.NamedSchemaOrReference{
Name: "ResourcePayload", Value: &v3.SchemaOrReference{
Oneof: &v3.SchemaOrReference_Schema{
Schema: &v3.Schema{
Type: "object",
OneOf: []*v3.SchemaOrReference{
{Oneof: &v3.SchemaOrReference_Reference{Reference: &v3.Reference{XRef: "#/components/schemas/FlagResourcePayload"}}},
{Oneof: &v3.SchemaOrReference_Reference{Reference: &v3.Reference{XRef: "#/components/schemas/SegmentResourcePayload"}}},
},
Description: "Contains a Flag or Segment serialized message along with an @type that describes the type of the serialized message.",
},
Description: "Contains a Flag or Segment serialized message along with an @type that describes the type of the serialized message.",
},
},
},
},
)
)
}

// Point Resource payload fields at the Flipt-specific ResourcePayload schema
// instead of GoogleProtobufAny.
for _, s := range doc.Components.Schemas.AdditionalProperties {
if s.Name == "Resource" || s.Name == "UpdateResourceRequest" {
schema := s.Value.GetSchema()
for _, item := range schema.Properties.AdditionalProperties {
if item.Name == "payload" {
item.Value.GetSchema().AllOf[0].GetReference().XRef = "#/components/schemas/ResourcePayload"
switch {
case kind == managementAPI && slices.Contains([]string{"Resource", "UpdateResourceRequest"}, s.Name):
{
schema := s.Value.GetSchema()
for _, item := range schema.Properties.AdditionalProperties {
if item.Name == "payload" {
item.Value.GetSchema().AllOf[0].GetReference().XRef = "#/components/schemas/ResourcePayload"
}
}
}
case kind == commonAPI && s.Name == "RevokeOIDCRequest":
schema := s.Value.GetSchema()
schema.Properties.AdditionalProperties = slices.DeleteFunc(schema.Properties.AdditionalProperties, func(e *v3.NamedSchemaOrReference) bool {
return e.Name == "iss" || e.Name == "sid"
})

}
}

if kind == commonAPI {
for _, s := range doc.Paths.Path {
if s.Name == "/auth/v1/method/oidc/{provider}/revoke" {
s.Value.Get.Parameters = slices.DeleteFunc(s.Value.Get.Parameters, func(p *v3.ParameterOrReference) bool {
return p.GetParameter().Name == "logoutToken"
})
s.Value.Get.RequestBody = nil
s.Value.Get.OperationId = "oidcFrontChannelLogout"
s.Value.Get.Description = "Handles OpenID Connect Front-Channel Logout requests initiated by the identity provider. The optional `iss` and `sid` query parameters are used to identify the user session to be terminated."
s.Value.Post.Description = "Handles OpenID Connect Back-Channel Logout requests initiated by the identity provider."
}
}
}

Expand Down
26 changes: 19 additions & 7 deletions internal/config/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ func (c *AuthenticationConfig) validate() error {
return errFieldWrap("authentication", "methods", fmt.Errorf("kubernetes and jwt methods cannot currently both be enabled at the same time"))
}

if c.SessionEnabled() && !c.Session.Secure && c.Methods.OIDC.Enabled {
for name, provider := range c.Methods.OIDC.Method.Providers {
if provider.AllowFrontChannelLogout {
return errFieldWrap("authentication", "session.secure", fmt.Errorf(
"session secure must be true for provider %q: front-channel logout requires SameSite=None cookies which are rejected by browsers unless the Secure flag is set",
name,
))
}
}
}

return nil
}

Expand Down Expand Up @@ -532,13 +543,14 @@ type AuthenticationMethodOIDCProvider struct {
ClientSecret string `json:"-" mapstructure:"client_secret" yaml:"-"`
RedirectAddress string `json:"redirectAddress,omitempty" mapstructure:"redirect_address" yaml:"redirect_address,omitempty"`
// Deprecated: Nonce is no longer used. A random nonce is always generated per auth flow.
Nonce string `json:"nonce,omitempty" mapstructure:"nonce" yaml:"nonce,omitempty"`
Scopes []string `json:"scopes,omitempty" mapstructure:"scopes" yaml:"scopes,omitempty"`
UsePKCE bool `json:"usePKCE,omitempty" mapstructure:"use_pkce" yaml:"use_pkce,omitempty"`
Algorithms []string `json:"algorithms,omitempty" mapstructure:"algorithms" yaml:"algorithms,omitempty"`
FetchExtraUserInfo bool `json:"fetchExtraUserInfo,omitempty" mapstructure:"fetch_extra_user_info" yaml:"fetch_extra_user_info,omitempty"`
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"`
Nonce string `json:"nonce,omitempty" mapstructure:"nonce" yaml:"nonce,omitempty"`
Scopes []string `json:"scopes,omitempty" mapstructure:"scopes" yaml:"scopes,omitempty"`
UsePKCE bool `json:"usePKCE,omitempty" mapstructure:"use_pkce" yaml:"use_pkce,omitempty"`
Algorithms []string `json:"algorithms,omitempty" mapstructure:"algorithms" yaml:"algorithms,omitempty"`
FetchExtraUserInfo bool `json:"fetchExtraUserInfo,omitempty" mapstructure:"fetch_extra_user_info" yaml:"fetch_extra_user_info,omitempty"`
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"`
}

func (a AuthenticationMethodOIDCProvider) setDefaults(defaults map[string]any) {
Expand Down
124 changes: 124 additions & 0 deletions internal/config/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,127 @@ func TestAuthenticationMethodJWTConfig_validate_ClaimsMapping(t *testing.T) {
})
}
}

func TestAuthenticationConfig_validate_FrontChannelLogoutRequiresSecureSession(t *testing.T) {
tests := []struct {
name string
config AuthenticationConfig
expectError bool
errorContains string
}{
{
name: "allow_front_channel_logout with secure false should fail",
config: AuthenticationConfig{
Session: AuthenticationSessionConfig{
Domain: "localhost",
Secure: false,
},
Methods: AuthenticationMethodsConfig{
OIDC: AuthenticationMethod[AuthenticationMethodOIDCConfig]{
Enabled: true,
Method: AuthenticationMethodOIDCConfig{
Providers: map[string]AuthenticationMethodOIDCProvider{
"google": {
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectAddress: "http://localhost:8080",
AllowFrontChannelLogout: true,
},
},
},
},
},
},
expectError: true,
errorContains: "session secure must be true",
},
{
name: "allow_front_channel_logout with secure true should pass",
config: AuthenticationConfig{
Session: AuthenticationSessionConfig{
Domain: "localhost",
Secure: true,
},
Methods: AuthenticationMethodsConfig{
OIDC: AuthenticationMethod[AuthenticationMethodOIDCConfig]{
Enabled: true,
Method: AuthenticationMethodOIDCConfig{
Providers: map[string]AuthenticationMethodOIDCProvider{
"google": {
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectAddress: "http://localhost:8080",
AllowFrontChannelLogout: true,
},
},
},
},
},
},
expectError: false,
},
{
name: "allow_front_channel_logout false with secure false should pass",
config: AuthenticationConfig{
Session: AuthenticationSessionConfig{
Domain: "localhost",
Secure: false,
},
Methods: AuthenticationMethodsConfig{
OIDC: AuthenticationMethod[AuthenticationMethodOIDCConfig]{
Enabled: true,
Method: AuthenticationMethodOIDCConfig{
Providers: map[string]AuthenticationMethodOIDCProvider{
"google": {
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectAddress: "http://localhost:8080",
AllowFrontChannelLogout: false,
},
},
},
},
},
},
expectError: false,
},
{
name: "oidc disabled should skip validation",
config: AuthenticationConfig{
Session: AuthenticationSessionConfig{
Domain: "localhost",
Secure: false,
},
Methods: AuthenticationMethodsConfig{
OIDC: AuthenticationMethod[AuthenticationMethodOIDCConfig]{
Enabled: false,
Method: AuthenticationMethodOIDCConfig{
Providers: map[string]AuthenticationMethodOIDCProvider{
"google": {
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectAddress: "http://localhost:8080",
AllowFrontChannelLogout: true,
},
},
},
},
},
},
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.validate()

if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorContains)
} else {
assert.NoError(t, err)
}
})
}
}
13 changes: 13 additions & 0 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"go.uber.org/zap"
"google.golang.org/genproto/googleapis/rpc/status"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
grpcstatus "google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
Expand Down Expand Up @@ -223,3 +224,15 @@ func (u formURLEncodedMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
func NewFormURLEncodedMarshaler() runtime.ServeMuxOption {
return runtime.WithMarshalerOption(MIMEFormURLEncoded, &formURLEncodedMarshaler{runtime.JSONBuiltin{}})
}

// NewHTTPMethodForwarder returns a ServeMuxOption that forwards the original HTTP
// method as the x-http-method gRPC metadata header. This allows downstream gRPC
// handlers to distinguish between request methods (e.g. GET vs POST) when routing
// on a single gRPC method, such as for OIDC front-channel logout path matching.
func NewHTTPMethodForwarder() runtime.ServeMuxOption {
return runtime.WithMetadata(func(ctx context.Context, r *http.Request) metadata.MD {
return metadata.Pairs(
"x-http-method", r.Method,
)
})
}
Loading
Loading