diff --git a/config/flipt.schema.cue b/config/flipt.schema.cue index d2a1fd5b48..e35d745ef8 100644 --- a/config/flipt.schema.cue +++ b/config/flipt.schema.cue @@ -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 } } diff --git a/config/flipt.schema.json b/config/flipt.schema.json index 0e48df7e9f..6d8ad4c1b5 100644 --- a/config/flipt.schema.json +++ b/config/flipt.schema.json @@ -486,6 +486,10 @@ "type": "boolean", "default": false }, + "allow_front_channel_logout": { + "type": "boolean", + "default": false + }, "authorize_parameters": { "type": "object", "additionalProperties": { diff --git a/internal/cmd/authn.go b/internal/cmd/authn.go index a52b930a2d..baac777bf8 100644 --- a/internal/cmd/authn.go +++ b/internal/cmd/authn.go @@ -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), ) } diff --git a/internal/cmd/protoc-gen-flipt-openapi/main.go b/internal/cmd/protoc-gen-flipt-openapi/main.go index 4d0890318c..31f07adbe8 100644 --- a/internal/cmd/protoc-gen-flipt-openapi/main.go +++ b/internal/cmd/protoc-gen-flipt-openapi/main.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "reflect" + "slices" "strings" "unsafe" @@ -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 @@ -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" { @@ -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 } @@ -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 @@ -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." + } } } diff --git a/internal/config/authentication.go b/internal/config/authentication.go index b9291d72d4..9c99169dbb 100644 --- a/internal/config/authentication.go +++ b/internal/config/authentication.go @@ -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 } @@ -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) { diff --git a/internal/config/authentication_test.go b/internal/config/authentication_test.go index b4e8d8f674..7ad7b56acd 100644 --- a/internal/config/authentication_test.go +++ b/internal/config/authentication_test.go @@ -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) + } + }) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 374c8fe4d9..eacedc8185 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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" @@ -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, + ) + }) +} diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index cdb548d6e9..9b06ecd1b8 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/json" "net/http" + "net/http/httptest" "strings" "testing" @@ -12,6 +13,7 @@ import ( "go.flipt.io/flipt/rpc/flipt/auth" "go.flipt.io/flipt/rpc/v2/evaluation" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" grpcstatus "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) @@ -122,6 +124,28 @@ func TestEventSourceMarshalerUnsupported(t *testing.T) { require.Nil(t, buf) } +func TestNewHTTPMethodForwarder(t *testing.T) { + t.Run("forwards HTTP method as x-http-method metadata", func(t *testing.T) { + mux := runtime.NewServeMux(NewHTTPMethodForwarder()) + + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodPut} { + req := httptest.NewRequestWithContext(t.Context(), method, "/", nil) + annotated, err := runtime.AnnotateContext(req.Context(), mux, req, "test.Method") + require.NoError(t, err) + + md, ok := metadata.FromOutgoingContext(annotated) + require.True(t, ok, "method %q", method) + require.Equal(t, method, md["x-http-method"][0], "method %q", method) + } + }) + + t.Run("option is a valid ServeMuxOption", func(t *testing.T) { + opt := NewHTTPMethodForwarder() + mux := runtime.NewServeMux(opt) + require.NotNil(t, mux) + }) +} + func TestFormURLEncodedMarshaler(t *testing.T) { t.Run("ContentType", func(t *testing.T) { m := &formURLEncodedMarshaler{runtime.JSONBuiltin{}} diff --git a/internal/server/authn/method/oidc/server.go b/internal/server/authn/method/oidc/server.go index d1c869e333..dcccbaeb35 100644 --- a/internal/server/authn/method/oidc/server.go +++ b/internal/server/authn/method/oidc/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "maps" + "net/http" "net/url" "strings" "time" @@ -19,6 +20,7 @@ import ( "golang.org/x/oauth2" "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -282,11 +284,9 @@ func (s *Server) Revoke(ctx context.Context, req *auth.RevokeOIDCRequest) (*auth s.logger.Error("failed to get provider", zap.String("provider", req.Provider), zap.Error(err)) return nil, status.Error(codes.Internal, "revoke: failed get provider") } - logoutToken, err := provider.VerifyLogout(ctx, req.LogoutToken) - if err != nil { - s.logger.Debug("failed to verify logout token", zap.Error(err)) - return nil, status.Error(codes.InvalidArgument, "revoke: invalid logout token") - } + + httpMethod := getHTTPMethod(ctx) + listQuery := storage.ListRequest[storageauth.ListAuthenticationsPredicate]{ Predicate: storageauth.ListAuthenticationsPredicate{ Method: new(auth.Method_METHOD_OIDC), @@ -295,20 +295,54 @@ func (s *Server) Revoke(ctx context.Context, req *auth.RevokeOIDCRequest) (*auth QueryParams: storage.QueryParams{Limit: storage.MaxListLimit}, } - switch { - case logoutToken.SessionID != "" && logoutToken.Subject != "": - listQuery.Predicate.Sid = &logoutToken.SessionID - listQuery.Predicate.Sub = &logoutToken.Subject + switch httpMethod { + case http.MethodPost, "": + logoutToken, err := provider.VerifyLogout(ctx, req.LogoutToken) + if err != nil { + s.logger.Debug("failed to verify logout token", zap.Error(err)) + return nil, status.Error(codes.InvalidArgument, "revoke: invalid logout token") + } + switch { + case logoutToken.SessionID != "" && logoutToken.Subject != "": + listQuery.Predicate.Sid = &logoutToken.SessionID + listQuery.Predicate.Sub = &logoutToken.Subject - case logoutToken.SessionID != "": - listQuery.Predicate.Sid = &logoutToken.SessionID + case logoutToken.SessionID != "": + listQuery.Predicate.Sid = &logoutToken.SessionID - case logoutToken.Subject != "": - listQuery.Predicate.Sub = &logoutToken.Subject + case logoutToken.Subject != "": + listQuery.Predicate.Sub = &logoutToken.Subject + + default: + s.logger.Error("logout token is verified as valid but missing both sid and sub") + } + + case http.MethodGet: + if !provider.cfg.AllowFrontChannelLogout { + return nil, status.Error(codes.InvalidArgument, "revoke: operation disabled") + } + + // Per spec, a present iss MUST match the provider's issuer exactly; + // mismatch indicates a misconfigured or spoofed request, so error + // rather than silently ignoring it. + if req.Iss != nil && *req.Iss != provider.cfg.IssuerURL { + return nil, status.Error(codes.InvalidArgument, "revoke: issuer mismatch") + } + + // Without sid or iss the session cannot be identified, so treat it as a no-op + // success per spec rather than erroring. + if req.Sid == nil || req.Iss == nil { + return &auth.RevokeOIDCResponse{}, nil + } + + listQuery.Predicate.Sid = req.Sid default: - s.logger.Error("logout token is verified as valid but missing both sid and sub") - return nil, status.Error(codes.Internal, "revoke: operation requires sid or sub") + return nil, status.Error(codes.InvalidArgument, "revoke: unsupported http method") + } + + if listQuery.Predicate.Sid == nil && listQuery.Predicate.Sub == nil { + return nil, status.Error(codes.InvalidArgument, "revoke: operation requires sid or sub") } var ids []string @@ -342,6 +376,18 @@ func (s *Server) Revoke(ctx context.Context, req *auth.RevokeOIDCRequest) (*auth return &auth.RevokeOIDCResponse{}, nil } +func getHTTPMethod(ctx context.Context) string { + var httpMethod string + md, ok := metadata.FromIncomingContext(ctx) + if ok { + methods := md.Get("x-http-method") + if len(methods) > 0 { + httpMethod = methods[0] + } + } + return httpMethod +} + type claims struct { Email *string `json:"email"` Verified *bool `json:"email_verified"` diff --git a/internal/server/authn/method/oidc/server_internal_test.go b/internal/server/authn/method/oidc/server_internal_test.go index 027a5455b7..ced9f63152 100644 --- a/internal/server/authn/method/oidc/server_internal_test.go +++ b/internal/server/authn/method/oidc/server_internal_test.go @@ -32,6 +32,32 @@ func Test_Server_SkipsAuthentication(t *testing.T) { assert.True(t, server.SkipsAuthentication(t.Context())) } +func TestGetHTTPMethod(t *testing.T) { + t.Run("no metadata", func(t *testing.T) { + assert.Empty(t, getHTTPMethod(t.Context())) + }) + + t.Run("no x-http-method in metadata", func(t *testing.T) { + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("some-key", "some-val")) + assert.Empty(t, getHTTPMethod(ctx)) + }) + + t.Run("GET method", func(t *testing.T) { + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", "GET")) + assert.Equal(t, "GET", getHTTPMethod(ctx)) + }) + + t.Run("POST method", func(t *testing.T) { + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", "POST")) + assert.Equal(t, "POST", getHTTPMethod(ctx)) + }) + + t.Run("empty method value", func(t *testing.T) { + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", "")) + assert.Empty(t, getHTTPMethod(ctx)) + }) +} + func TestEndSessionURI(t *testing.T) { ctx := t.Context() @@ -625,6 +651,7 @@ func TestServer_Revoke(t *testing.T) { tests := []struct { name string + ctx context.Context server *Server req *auth.RevokeOIDCRequest check func(t *testing.T, resp *auth.RevokeOIDCResponse, err error) @@ -675,6 +702,7 @@ func TestServer_Revoke(t *testing.T) { }, { name: "invalid logout token", + ctx: metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodPost)), server: NewServer(logger, memory.NewStore(logger), NewRegistry(makeCfg()), makeCfg()), @@ -692,6 +720,7 @@ func TestServer_Revoke(t *testing.T) { }, { name: "logout token missing sid and sub", + ctx: metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodPost)), server: NewServer(logger, memory.NewStore(logger), NewRegistry(makeCfg()), makeCfg()), @@ -711,7 +740,11 @@ func TestServer_Revoke(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resp, err := tt.server.Revoke(ctx, tt.req) + c := tt.ctx + if c == nil { + c = ctx + } + resp, err := tt.server.Revoke(c, tt.req) tt.check(t, resp, err) }) } diff --git a/internal/server/authn/method/oidc/server_test.go b/internal/server/authn/method/oidc/server_test.go index 6401e05cb6..9685220968 100644 --- a/internal/server/authn/method/oidc/server_test.go +++ b/internal/server/authn/method/oidc/server_test.go @@ -32,6 +32,7 @@ import ( "go.uber.org/zap/zaptest" "golang.org/x/net/html" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/testing/protocmp" @@ -956,13 +957,13 @@ func Test_Server_Callback_DoesStoreSIDWhenEmpty(t *testing.T) { require.Len(t, storedAuth.Results, 1) } -func Test_Server_Revoke(t *testing.T) { +func Test_Server_RevokeBackChannel(t *testing.T) { var ( id, secret = "client_id", "client_secret" nonce = "static" logger = zaptest.NewLogger(t) - ctx = t.Context() + ctx = metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodPost)) ) priv, err := rsa.GenerateKey(rand.Reader, 4096) @@ -1116,6 +1117,181 @@ func Test_Server_Revoke(t *testing.T) { } } +func Test_Server_RevokeFrontChannel(t *testing.T) { + var ( + id, secret = "client_id", "client_secret" + + logger = zaptest.NewLogger(t) + ctx = metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodGet)) + ) + + priv, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + + tp := oidc.StartTestProvider(t, oidc.WithNoTLS(), oidc.WithTestDefaults(&oidc.TestProviderDefaults{ + SigningKey: &oidc.TestSigningKey{ + PrivKey: priv, + PubKey: priv.Public(), + Alg: oidc.RS256, + }, + ClientID: &id, + ClientSecret: &secret, + })) + t.Cleanup(func() { tp.Stop() }) + + authConfig := config.AuthenticationConfig{ + Session: config.AuthenticationSessionConfig{ + TokenLifetime: 1 * time.Hour, + StateLifetime: 10 * time.Minute, + }, + Methods: config.AuthenticationMethodsConfig{ + OIDC: config.AuthenticationMethod[config.AuthenticationMethodOIDCConfig]{ + Enabled: true, + Method: config.AuthenticationMethodOIDCConfig{ + Providers: map[string]config.AuthenticationMethodOIDCProvider{ + "google": { + IssuerURL: tp.Addr(), + ClientID: id, + ClientSecret: secret, + RedirectAddress: "http://localhost:8080", + Scopes: []string{"openid"}, + AllowFrontChannelLogout: true, + }, + "disabled": { + IssuerURL: tp.Addr(), + ClientID: id, + ClientSecret: secret, + RedirectAddress: "http://localhost:8080", + Scopes: []string{"openid"}, + AllowFrontChannelLogout: false, + }, + }, + }, + }, + }, + } + + grpcServer := oidctesting.StartGRPCServer(t, ctx, logger, authConfig) + t.Cleanup(func() { _ = grpcServer.Stop() }) + + tests := []struct { + name string + provider string + sid *string + iss *string + setupAuth func(t *testing.T) *auth.Authentication + verify func(t *testing.T, created *auth.Authentication) + expectErr bool + checkErr func(t *testing.T, err error) + }{ + { + name: "operation disabled", + provider: "disabled", + checkErr: func(t *testing.T, err error) { + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.InvalidArgument, st.Code()) + assert.Equal(t, "revoke: operation disabled", st.Message()) + }, + expectErr: true, + }, + { + name: "no sid or iss", + provider: "google", + }, + { + name: "nil sid with wrong iss errors", + provider: "google", + iss: new("http://evil.example.com"), + expectErr: true, + checkErr: func(t *testing.T, err error) { + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.InvalidArgument, st.Code()) + assert.Equal(t, "revoke: issuer mismatch", st.Message()) + }, + }, + { + name: "mismatched issuer", + provider: "google", + sid: new("some-session"), + iss: new("http://evil.example.com"), + expectErr: true, + checkErr: func(t *testing.T, err error) { + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.InvalidArgument, st.Code()) + assert.Equal(t, "revoke: issuer mismatch", st.Message()) + }, + }, + { + name: "sid found and auth deleted", + provider: "google", + sid: new("known-session"), + iss: new(tp.Addr()), + setupAuth: func(t *testing.T) *auth.Authentication { + _, created, err := grpcServer.Store.CreateAuthentication(ctx, &storageauth.CreateAuthenticationRequest{ + Method: auth.Method_METHOD_OIDC, + ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)), + Provider: "google", + Sid: "known-session", + Sub: "alice", + IDToken: "id-token-jwt", + }) + require.NoError(t, err) + return created + }, + verify: func(t *testing.T, created *auth.Authentication) { + _, err := grpcServer.Store.GetAuthenticationByID(ctx, created.Id) + require.Error(t, err) + storedAuth, err := grpcServer.Store.ListAuthentications(ctx, &storage.ListRequest[storageauth.ListAuthenticationsPredicate]{ + Predicate: storageauth.ListAuthenticationsPredicate{ + Method: new(auth.Method_METHOD_OIDC), + Provider: new("google"), + Sid: new("known-session"), + }, + }) + require.NoError(t, err) + require.Empty(t, storedAuth.Results) + }, + }, + { + name: "no matching authentications", + provider: "google", + sid: new("unknown-session"), + iss: new(tp.Addr()), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var created *auth.Authentication + if tt.setupAuth != nil { + created = tt.setupAuth(t) + } + + resp, err := grpcServer.Client().Revoke(ctx, &auth.RevokeOIDCRequest{ + Provider: tt.provider, + Sid: tt.sid, + Iss: tt.iss, + }) + if tt.expectErr { + require.Error(t, err) + if tt.checkErr != nil { + tt.checkErr(t, err) + } + return + } + require.NoError(t, err) + require.NotNil(t, resp) + + if tt.verify != nil { + tt.verify(t, created) + } + }) + } +} + type errorStore struct { storageauth.Store } @@ -1128,7 +1304,6 @@ func Test_Server_Revoke_StorageError(t *testing.T) { var ( id, secret = "client_id", "client_secret" logger = zaptest.NewLogger(t) - ctx = t.Context() ) priv, err := rsa.GenerateKey(rand.Reader, 4096) @@ -1182,7 +1357,8 @@ func Test_Server_Revoke_StorageError(t *testing.T) { }).SignedString(priv) require.NoError(t, err) - _, err = oidcServer.Revoke(ctx, &auth.RevokeOIDCRequest{Provider: "google", LogoutToken: logoutToken}) + revokeCtx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodPost)) + _, err = oidcServer.Revoke(revokeCtx, &auth.RevokeOIDCRequest{Provider: "google", LogoutToken: logoutToken}) require.Error(t, err) st, ok := status.FromError(err) diff --git a/internal/server/authn/middleware/http/middleware.go b/internal/server/authn/middleware/http/middleware.go index bc94c02ec9..f1b9f9b820 100644 --- a/internal/server/authn/middleware/http/middleware.go +++ b/internal/server/authn/middleware/http/middleware.go @@ -8,8 +8,11 @@ import ( "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "go.flipt.io/flipt/internal/config" middlewarecommon "go.flipt.io/flipt/internal/server/authn/middleware/common" + "go.flipt.io/flipt/rpc/flipt/auth" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) const stateCookieKey = "flipt_client_state" @@ -34,14 +37,41 @@ func NewHTTPMiddleware(config config.AuthenticationSessionConfig) *Middleware { func (m Middleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && r.URL.Path == "/auth/v1/self/expire" { - m.clearAllCookies(w) + m.clearAllCookies(w, http.SameSiteStrictMode) } else if r.Method == http.MethodDelete && r.URL.Path == "/auth/v1/self/revoke" { - m.clearAllCookies(w) + m.clearAllCookies(w, http.SameSiteStrictMode) } next.ServeHTTP(w, r) }) } +// ForwardRevokeOIDCResponseOption is a grpc-gateway forward response option function. +// When the response is a successful RevokeOIDCResponse for a GET request +// (OIDC front-channel logout), it clears session cookies so the browser +// discards them. +func (m Middleware) ForwardRevokeOIDCResponseOption(ctx context.Context, w http.ResponseWriter, resp proto.Message) error { + if _, ok := resp.(*auth.RevokeOIDCResponse); !ok { + return nil + } + + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md, ok = metadata.FromIncomingContext(ctx) + } + if !ok { + return nil + } + + methods := md.Get("x-http-method") + if len(methods) != 1 || methods[0] != http.MethodGet { + return nil + } + + w.Header().Set("Cache-Control", "no-store") + m.clearAllCookies(w, http.SameSiteNoneMode) + return nil +} + // ErrorHandler ensures cookies are cleared when cookie auth is attempted but leads to // an unauthenticated response. This ensures well behaved user-agents won't attempt to // supply the same token via a cookie again in a subsequent call. @@ -51,14 +81,21 @@ func (m Middleware) ErrorHandler(ctx context.Context, sm *runtime.ServeMux, ms r // again in a subsequent call if _, cerr := r.Cookie(middlewarecommon.TokenCookieKey); status.Code(err) == codes.Unauthenticated && !errors.Is(cerr, http.ErrNoCookie) { - m.clearAllCookies(w) + m.clearAllCookies(w, http.SameSiteStrictMode) } // always delegate to default handler m.defaultErrHandler(ctx, sm, ms, w, r, err) } -func (m Middleware) clearAllCookies(w http.ResponseWriter) { +// clearAllCookies clears both the state and token cookies for the session domain. +// The sameSite parameter controls the cookie SameSite attribute: +// - SameSiteNoneMode is used for OIDC front-channel logout because the request +// arrives as a third-party cross-site load in a hidden iframe/img from the OP; +// Lax/Strict would prevent the cookies from being attached and thus cleared. +// - SameSiteStrictMode is used for direct user-initiated expire/revoke requests +// where the caller is on the same site. +func (m Middleware) clearAllCookies(w http.ResponseWriter, sameSite http.SameSite) { for _, cookieName := range []string{stateCookieKey, middlewarecommon.TokenCookieKey} { cookie := &http.Cookie{ Name: cookieName, @@ -68,6 +105,7 @@ func (m Middleware) clearAllCookies(w http.ResponseWriter) { MaxAge: -1, HttpOnly: true, Secure: m.config.Secure, + SameSite: sameSite, } http.SetCookie(w, cookie) diff --git a/internal/server/authn/middleware/http/middleware_test.go b/internal/server/authn/middleware/http/middleware_test.go index ed4993340f..76794d565b 100644 --- a/internal/server/authn/middleware/http/middleware_test.go +++ b/internal/server/authn/middleware/http/middleware_test.go @@ -12,8 +12,11 @@ import ( "github.com/stretchr/testify/require" "go.flipt.io/flipt/internal/config" middlewarecommon "go.flipt.io/flipt/internal/server/authn/middleware/common" + "go.flipt.io/flipt/rpc/flipt/auth" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) func TestHandler(t *testing.T) { @@ -44,7 +47,7 @@ func TestHandler(t *testing.T) { defer res.Body.Close() cookies := res.Cookies() - assertCookiesCleared(t, cookies) + assertCookiesCleared(t, cookies, http.SameSiteStrictMode) }) } } @@ -81,12 +84,12 @@ func TestErrorHandler(t *testing.T) { assert.Equal(t, []byte(defaultResponseBody), body) cookies := res.Cookies() - assertCookiesCleared(t, cookies) + assertCookiesCleared(t, cookies, http.SameSiteStrictMode) }) } } -func assertCookiesCleared(t *testing.T, cookies []*http.Cookie) { +func assertCookiesCleared(t *testing.T, cookies []*http.Cookie, sameSite http.SameSite) { t.Helper() assert.Len(t, cookies, 2) @@ -103,6 +106,77 @@ func assertCookiesCleared(t *testing.T, cookies []*http.Cookie) { assert.Equal(t, "/", cookiesMap[cookieName].Path) assert.Equal(t, -1, cookiesMap[cookieName].MaxAge) assert.True(t, cookiesMap[cookieName].HttpOnly) - assert.False(t, cookiesMap[cookieName].Secure) + assert.Equal(t, sameSite, cookiesMap[cookieName].SameSite) + + if sameSite == http.SameSiteNoneMode { + assert.True(t, cookiesMap[cookieName].Secure) + } } } + +func TestForwardRevokeOIDCResponseOption(t *testing.T) { + middleware := NewHTTPMiddleware(config.AuthenticationSessionConfig{ + Domain: "localhost", + Secure: true, + }) + + t.Run("revoke OIDC success via GET clears cookies", func(t *testing.T) { + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodGet)) + w := httptest.NewRecorder() + + err := middleware.ForwardRevokeOIDCResponseOption(ctx, w, &auth.RevokeOIDCResponse{}) + require.NoError(t, err) + + res := w.Result() + defer res.Body.Close() + assertCookiesCleared(t, res.Cookies(), http.SameSiteNoneMode) + }) + + t.Run("revoke OIDC success via POST does not clear cookies", func(t *testing.T) { + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodPost)) + w := httptest.NewRecorder() + + err := middleware.ForwardRevokeOIDCResponseOption(ctx, w, &auth.RevokeOIDCResponse{}) + require.NoError(t, err) + + res := w.Result() + defer res.Body.Close() + assert.Empty(t, res.Cookies()) + }) + + t.Run("non-revoke response does not clear cookies", func(t *testing.T) { + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodGet)) + w := httptest.NewRecorder() + + err := middleware.ForwardRevokeOIDCResponseOption(ctx, w, &struct{ proto.Message }{}) + require.NoError(t, err) + + res := w.Result() + defer res.Body.Close() + assert.Empty(t, res.Cookies()) + }) + + t.Run("no x-http-method metadata does not clear cookies", func(t *testing.T) { + ctx := t.Context() + w := httptest.NewRecorder() + + err := middleware.ForwardRevokeOIDCResponseOption(ctx, w, &auth.RevokeOIDCResponse{}) + require.NoError(t, err) + + res := w.Result() + defer res.Body.Close() + assert.Empty(t, res.Cookies()) + }) + + t.Run("revoke OIDC success via GET clears cookies using incoming context", func(t *testing.T) { + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-http-method", http.MethodGet)) + w := httptest.NewRecorder() + + err := middleware.ForwardRevokeOIDCResponseOption(ctx, w, &auth.RevokeOIDCResponse{}) + require.NoError(t, err) + + res := w.Result() + defer res.Body.Close() + assertCookiesCleared(t, res.Cookies(), http.SameSiteNoneMode) + }) +} diff --git a/rpc/flipt/auth/auth.pb.go b/rpc/flipt/auth/auth.pb.go index 0f0373308b..750cda752b 100644 --- a/rpc/flipt/auth/auth.pb.go +++ b/rpc/flipt/auth/auth.pb.go @@ -816,6 +816,8 @@ type RevokeOIDCRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` LogoutToken string `protobuf:"bytes,2,opt,name=logout_token,json=logoutToken,proto3" json:"logout_token,omitempty"` + Iss *string `protobuf:"bytes,3,opt,name=iss,proto3,oneof" json:"iss,omitempty"` + Sid *string `protobuf:"bytes,4,opt,name=sid,proto3,oneof" json:"sid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -864,6 +866,20 @@ func (x *RevokeOIDCRequest) GetLogoutToken() string { return "" } +func (x *RevokeOIDCRequest) GetIss() string { + if x != nil && x.Iss != nil { + return *x.Iss + } + return "" +} + +func (x *RevokeOIDCRequest) GetSid() string { + if x != nil && x.Sid != nil { + return *x.Sid + } + return "" +} + type RevokeOIDCResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1054,10 +1070,14 @@ const file_auth_auth_proto_rawDesc = "" + "\x05state\x18\x03 \x01(\tR\x05state\"y\n" + "\x10CallbackResponse\x12!\n" + "\fclient_token\x18\x01 \x01(\tR\vclientToken\x12B\n" + - "\x0eauthentication\x18\x02 \x01(\v2\x1a.flipt.auth.AuthenticationR\x0eauthentication\"R\n" + + "\x0eauthentication\x18\x02 \x01(\v2\x1a.flipt.auth.AuthenticationR\x0eauthentication\"\x90\x01\n" + "\x11RevokeOIDCRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12!\n" + - "\flogout_token\x18\x02 \x01(\tR\vlogoutToken\"\x14\n" + + "\flogout_token\x18\x02 \x01(\tR\vlogoutToken\x12\x15\n" + + "\x03iss\x18\x03 \x01(\tH\x00R\x03iss\x88\x01\x01\x12\x15\n" + + "\x03sid\x18\x04 \x01(\tH\x01R\x03sid\x88\x01\x01B\x06\n" + + "\x04_issB\x06\n" + + "\x04_sid\"\x14\n" + "\x12RevokeOIDCResponse\"Q\n" + "\x1bVerifyServiceAccountRequest\x122\n" + "\x15service_account_token\x18\x01 \x01(\tR\x13serviceAccountToken\"\x85\x01\n" + @@ -1080,11 +1100,11 @@ const file_auth_auth_proto_rawDesc = "" + "\x13ListAuthentications\x12&.flipt.auth.ListAuthenticationsRequest\x1a'.flipt.auth.ListAuthenticationsResponse\"*\xbaG\x10*\x0elistAuthTokens\x82\xd3\xe4\x93\x02\x11\x12\x0f/auth/v1/tokens\x12\x89\x01\n" + "\x14DeleteAuthentication\x12'.flipt.auth.DeleteAuthenticationRequest\x1a\x16.google.protobuf.Empty\"0\xbaG\x11*\x0fdeleteAuthToken\x82\xd3\xe4\x93\x02\x16*\x14/auth/v1/tokens/{id}\x12\x93\x01\n" + "\x18ExpireAuthenticationSelf\x12+.flipt.auth.ExpireAuthenticationSelfRequest\x1a\x16.google.protobuf.Empty\"2\xbaG\x10*\x0eexpireAuthSelf\x82\xd3\xe4\x93\x02\x16\x1a\x14/auth/v1/self/expire\x88\x02\x01\x12\xa6\x01\n" + - "\x18RevokeAuthenticationSelf\x12+.flipt.auth.RevokeAuthenticationSelfRequest\x1a,.flipt.auth.RevokeAuthenticationSelfResponse\"/\xbaG\x10*\x0erevokeAuthSelf\x82\xd3\xe4\x93\x02\x16*\x14/auth/v1/self/revoke2\xbe\x04\n" + + "\x18RevokeAuthenticationSelf\x12+.flipt.auth.RevokeAuthenticationSelfRequest\x1a,.flipt.auth.RevokeAuthenticationSelfResponse\"/\xbaG\x10*\x0erevokeAuthSelf\x82\xd3\xe4\x93\x02\x16*\x14/auth/v1/self/revoke2\xe8\x04\n" + "\x1fAuthenticationMethodOIDCService\x12\x99\x01\n" + "\fAuthorizeURL\x12\x1f.flipt.auth.AuthorizeURLRequest\x1a .flipt.auth.AuthorizeURLResponse\"F\xbaG\x12*\x10oidcAuthorizeURL\x82\xd3\xe4\x93\x02+\x12)/auth/v1/method/oidc/{provider}/authorize\x12\x88\x01\n" + - "\bCallback\x12\x1b.flipt.auth.CallbackRequest\x1a\x1c.flipt.auth.CallbackResponse\"A\xbaG\x0e*\foidcCallback\x82\xd3\xe4\x93\x02*\x12(/auth/v1/method/oidc/{provider}/callback\x12\xf3\x01\n" + - "\x06Revoke\x12\x1d.flipt.auth.RevokeOIDCRequest\x1a\x1e.flipt.auth.RevokeOIDCResponse\"\xa9\x01\xbaGu*\n" + + "\bCallback\x12\x1b.flipt.auth.CallbackRequest\x1a\x1c.flipt.auth.CallbackResponse\"A\xbaG\x0e*\foidcCallback\x82\xd3\xe4\x93\x02*\x12(/auth/v1/method/oidc/{provider}/callback\x12\x9d\x02\n" + + "\x06Revoke\x12\x1d.flipt.auth.RevokeOIDCRequest\x1a\x1e.flipt.auth.RevokeOIDCResponse\"\xd3\x01\xbaGu*\n" + "oidcRevoke:g\n" + "e\x12c\n" + "a\n" + @@ -1093,7 +1113,7 @@ const file_auth_auth_proto_rawDesc = "" + "8\xba\x01\flogout_token\xca\x01\x06object\xfa\x01\x1d\n" + "\x1b\n" + "\flogout_token\x12\v\n" + - "\t\xca\x01\x06string\x82\xd3\xe4\x93\x02+:\x01*\"&/auth/v1/method/oidc/{provider}/revoke2\xec\x01\n" + + "\t\xca\x01\x06string\x82\xd3\xe4\x93\x02U:\x01*Z(\x12&/auth/v1/method/oidc/{provider}/revoke\"&/auth/v1/method/oidc/{provider}/revoke2\xec\x01\n" + "%AuthenticationMethodKubernetesService\x12\xc2\x01\n" + "\x14VerifyServiceAccount\x12'.flipt.auth.VerifyServiceAccountRequest\x1a(.flipt.auth.VerifyServiceAccountResponse\"W\xbaG *\x1ekubernetesVerifyServiceAccount\x82\xd3\xe4\x93\x02.:\x01*\")/auth/v1/method/kubernetes/serviceaccount2\xc1\x01\n" + "!AuthenticationMethodGithubService\x12S\n" + @@ -1206,6 +1226,7 @@ func file_auth_auth_proto_init() { } file_auth_auth_proto_msgTypes[7].OneofWrappers = []any{} file_auth_auth_proto_msgTypes[9].OneofWrappers = []any{} + file_auth_auth_proto_msgTypes[14].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/rpc/flipt/auth/auth.pb.gw.go b/rpc/flipt/auth/auth.pb.gw.go index 516b74a06f..71bad063e4 100644 --- a/rpc/flipt/auth/auth.pb.gw.go +++ b/rpc/flipt/auth/auth.pb.gw.go @@ -398,6 +398,59 @@ func local_request_AuthenticationMethodOIDCService_Revoke_0(ctx context.Context, return msg, metadata, err } +var filter_AuthenticationMethodOIDCService_Revoke_1 = &utilities.DoubleArray{Encoding: map[string]int{"provider": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} + +func request_AuthenticationMethodOIDCService_Revoke_1(ctx context.Context, marshaler runtime.Marshaler, client AuthenticationMethodOIDCServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RevokeOIDCRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["provider"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "provider") + } + protoReq.Provider, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "provider", err) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AuthenticationMethodOIDCService_Revoke_1); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Revoke(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthenticationMethodOIDCService_Revoke_1(ctx context.Context, marshaler runtime.Marshaler, server AuthenticationMethodOIDCServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RevokeOIDCRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["provider"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "provider") + } + protoReq.Provider, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "provider", err) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AuthenticationMethodOIDCService_Revoke_1); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Revoke(ctx, &protoReq) + return msg, metadata, err +} + func request_AuthenticationMethodKubernetesService_VerifyServiceAccount_0(ctx context.Context, marshaler runtime.Marshaler, client AuthenticationMethodKubernetesServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq VerifyServiceAccountRequest @@ -721,6 +774,26 @@ func RegisterAuthenticationMethodOIDCServiceHandlerServer(ctx context.Context, m } forward_AuthenticationMethodOIDCService_Revoke_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_AuthenticationMethodOIDCService_Revoke_1, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/flipt.auth.AuthenticationMethodOIDCService/Revoke", runtime.WithHTTPPathPattern("/auth/v1/method/oidc/{provider}/revoke")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthenticationMethodOIDCService_Revoke_1(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthenticationMethodOIDCService_Revoke_1(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1115,6 +1188,23 @@ func RegisterAuthenticationMethodOIDCServiceHandlerClient(ctx context.Context, m } forward_AuthenticationMethodOIDCService_Revoke_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_AuthenticationMethodOIDCService_Revoke_1, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/flipt.auth.AuthenticationMethodOIDCService/Revoke", runtime.WithHTTPPathPattern("/auth/v1/method/oidc/{provider}/revoke")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthenticationMethodOIDCService_Revoke_1(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthenticationMethodOIDCService_Revoke_1(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1122,12 +1212,14 @@ var ( pattern_AuthenticationMethodOIDCService_AuthorizeURL_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"auth", "v1", "method", "oidc", "provider", "authorize"}, "")) pattern_AuthenticationMethodOIDCService_Callback_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"auth", "v1", "method", "oidc", "provider", "callback"}, "")) pattern_AuthenticationMethodOIDCService_Revoke_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"auth", "v1", "method", "oidc", "provider", "revoke"}, "")) + pattern_AuthenticationMethodOIDCService_Revoke_1 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"auth", "v1", "method", "oidc", "provider", "revoke"}, "")) ) var ( forward_AuthenticationMethodOIDCService_AuthorizeURL_0 = runtime.ForwardResponseMessage forward_AuthenticationMethodOIDCService_Callback_0 = runtime.ForwardResponseMessage forward_AuthenticationMethodOIDCService_Revoke_0 = runtime.ForwardResponseMessage + forward_AuthenticationMethodOIDCService_Revoke_1 = runtime.ForwardResponseMessage ) // RegisterAuthenticationMethodKubernetesServiceHandlerFromEndpoint is same as RegisterAuthenticationMethodKubernetesServiceHandler but diff --git a/rpc/flipt/auth/auth.proto b/rpc/flipt/auth/auth.proto index e10e7b8873..06d43776ab 100644 --- a/rpc/flipt/auth/auth.proto +++ b/rpc/flipt/auth/auth.proto @@ -160,6 +160,8 @@ message CallbackResponse { message RevokeOIDCRequest { string provider = 1; string logout_token = 2; + optional string iss = 3; + optional string sid = 4; } message RevokeOIDCResponse {} @@ -173,10 +175,12 @@ service AuthenticationMethodOIDCService { option (google.api.http) = {get: "/auth/v1/method/oidc/{provider}/callback"}; option (gnostic.openapi.v3.operation) = {operation_id: "oidcCallback"}; } + // Handles OpenID Connect Back-Channel (POST) and Front-Channel (GET) Logout requests initiated by the identity provider. rpc Revoke(RevokeOIDCRequest) returns (RevokeOIDCResponse) { option (google.api.http) = { post: "/auth/v1/method/oidc/{provider}/revoke" body: "*" + additional_bindings: {get: "/auth/v1/method/oidc/{provider}/revoke"} }; option (gnostic.openapi.v3.operation) = { operation_id: "oidcRevoke" diff --git a/rpc/flipt/auth/auth_grpc.pb.go b/rpc/flipt/auth/auth_grpc.pb.go index d68cea7d99..006092edd6 100644 --- a/rpc/flipt/auth/auth_grpc.pb.go +++ b/rpc/flipt/auth/auth_grpc.pb.go @@ -431,6 +431,7 @@ const ( type AuthenticationMethodOIDCServiceClient interface { AuthorizeURL(ctx context.Context, in *AuthorizeURLRequest, opts ...grpc.CallOption) (*AuthorizeURLResponse, error) Callback(ctx context.Context, in *CallbackRequest, opts ...grpc.CallOption) (*CallbackResponse, error) + // Handles OpenID Connect Back-Channel (POST) and Front-Channel (GET) Logout requests initiated by the identity provider. Revoke(ctx context.Context, in *RevokeOIDCRequest, opts ...grpc.CallOption) (*RevokeOIDCResponse, error) } @@ -478,6 +479,7 @@ func (c *authenticationMethodOIDCServiceClient) Revoke(ctx context.Context, in * type AuthenticationMethodOIDCServiceServer interface { AuthorizeURL(context.Context, *AuthorizeURLRequest) (*AuthorizeURLResponse, error) Callback(context.Context, *CallbackRequest) (*CallbackResponse, error) + // Handles OpenID Connect Back-Channel (POST) and Front-Channel (GET) Logout requests initiated by the identity provider. Revoke(context.Context, *RevokeOIDCRequest) (*RevokeOIDCResponse, error) mustEmbedUnimplementedAuthenticationMethodOIDCServiceServer() } diff --git a/rpc/flipt/buf.gen.yaml b/rpc/flipt/buf.gen.yaml index 3b6e0d240a..7e49285570 100644 --- a/rpc/flipt/buf.gen.yaml +++ b/rpc/flipt/buf.gen.yaml @@ -21,11 +21,13 @@ plugins: - paths=source_relative - grpc_api_configuration=flipt.yaml strategy: all - - remote: buf.build/community/google-gnostic-openapi:v0.7.0 + - local: protoc-gen-flipt-openapi out: . opt: - paths=source_relative - fq_schema_naming=false - default_response=false - enum_type=string - - description= \ No newline at end of file + - description= + - title=Common API + strategy: all diff --git a/rpc/flipt/openapi.yaml b/rpc/flipt/openapi.yaml index 8be09c970c..805dfe9a83 100644 --- a/rpc/flipt/openapi.yaml +++ b/rpc/flipt/openapi.yaml @@ -1,9 +1,8 @@ -# Generated with protoc-gen-openapi -# https://github.com/google/gnostic/tree/master/cmd/protoc-gen-openapi +# Generated with protoc-gen-flipt-openapi openapi: 3.0.3 info: - title: "" + title: Common API version: 0.0.1 servers: - url: http://localhost:8080 @@ -116,9 +115,36 @@ paths: schema: $ref: '#/components/schemas/CallbackResponse' /auth/v1/method/oidc/{provider}/revoke: + get: + tags: + - AuthenticationMethodOIDCService + 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. + operationId: oidcFrontChannelLogout + parameters: + - name: provider + in: path + required: true + schema: + type: string + - name: iss + in: query + schema: + type: string + - name: sid + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RevokeOIDCResponse' post: tags: - AuthenticationMethodOIDCService + description: Handles OpenID Connect Back-Channel Logout requests initiated by the identity provider. operationId: oidcRevoke parameters: - name: provider diff --git a/rpc/v2/environments/buf.gen.yaml b/rpc/v2/environments/buf.gen.yaml index 59c300583d..e07694da28 100644 --- a/rpc/v2/environments/buf.gen.yaml +++ b/rpc/v2/environments/buf.gen.yaml @@ -10,3 +10,4 @@ plugins: - enum_type=string - description=Flipt Management REST API - version=2.9.0 + - title=Management API diff --git a/rpc/v2/environments/openapi.yaml b/rpc/v2/environments/openapi.yaml index 8f40ec7ed7..d55598dda4 100644 --- a/rpc/v2/environments/openapi.yaml +++ b/rpc/v2/environments/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: - title: EnvironmentsService API + title: Management API description: Flipt Management REST API version: 2.9.0 paths: