From e7a41b4cd948bed642fabecf52138a20ae1d7883 Mon Sep 17 00:00:00 2001 From: Javier Adriel Date: Mon, 5 Dec 2022 18:14:41 -0600 Subject: [PATCH] Call end_session_endpoint in IDP provider when login out from Console (#2476) --- integration/login_test.go | 5 +- models/login_response.go | 3 + models/logout_request.go | 67 +++++++++++++++++++ pkg/auth/idp/oauth2/config.go | 1 + pkg/auth/idp/oauth2/provider.go | 2 + portal-ui/src/common/utils.ts | 2 + .../src/screens/LoginPage/LoginCallback.tsx | 3 + .../src/screens/LogoutPage/LogoutPage.tsx | 3 +- restapi/embedded_spec.go | 42 ++++++++++++ restapi/operations/auth/logout_parameters.go | 38 +++++++++++ restapi/user_login.go | 11 ++- restapi/user_logout.go | 60 ++++++++++++++++- swagger-console.yml | 13 ++++ 13 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 models/logout_request.go diff --git a/integration/login_test.go b/integration/login_test.go index 9c0d80403..8ebdc19e7 100644 --- a/integration/login_test.go +++ b/integration/login_test.go @@ -116,8 +116,8 @@ func TestLogout(t *testing.T) { log.Println("authentication token not found in cookies response") return } - - request, err = http.NewRequest("POST", "http://localhost:9090/api/v1/logout", requestDataBody) + logoutRequest := bytes.NewReader([]byte("{}")) + request, err = http.NewRequest("POST", "http://localhost:9090/api/v1/logout", logoutRequest) if err != nil { log.Println(err) return @@ -126,7 +126,6 @@ func TestLogout(t *testing.T) { request.Header.Add("Content-Type", "application/json") response, err = client.Do(request) - assert.NotNil(response, "Logout response is nil") assert.Nil(err, "Logout errored out") assert.Equal(response.StatusCode, 200) diff --git a/models/login_response.go b/models/login_response.go index b1f2a3830..5513a7f5d 100644 --- a/models/login_response.go +++ b/models/login_response.go @@ -34,6 +34,9 @@ import ( // swagger:model loginResponse type LoginResponse struct { + // ID p refresh token + IDPRefreshToken string `json:"IDPRefreshToken,omitempty"` + // session Id SessionID string `json:"sessionId,omitempty"` } diff --git a/models/logout_request.go b/models/logout_request.go new file mode 100644 index 000000000..6d801f328 --- /dev/null +++ b/models/logout_request.go @@ -0,0 +1,67 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// LogoutRequest logout request +// +// swagger:model logoutRequest +type LogoutRequest struct { + + // state + State string `json:"state,omitempty"` +} + +// Validate validates this logout request +func (m *LogoutRequest) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this logout request based on context it is used +func (m *LogoutRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *LogoutRequest) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *LogoutRequest) UnmarshalBinary(b []byte) error { + var res LogoutRequest + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/pkg/auth/idp/oauth2/config.go b/pkg/auth/idp/oauth2/config.go index 5409a88ba..bc46bd4f6 100644 --- a/pkg/auth/idp/oauth2/config.go +++ b/pkg/auth/idp/oauth2/config.go @@ -37,6 +37,7 @@ type ProviderConfig struct { Userinfo bool RedirectCallbackDynamic bool RedirectCallback string + EndSessionEndpoint string RoleArn string // can be empty } diff --git a/pkg/auth/idp/oauth2/provider.go b/pkg/auth/idp/oauth2/provider.go index f684ed3a6..98891cc88 100644 --- a/pkg/auth/idp/oauth2/provider.go +++ b/pkg/auth/idp/oauth2/provider.go @@ -110,6 +110,7 @@ type Provider struct { IDPName string // if enabled means that we need extrace access_token as well UserInfo bool + RefreshToken string oauth2Config Configuration provHTTPClient *http.Client } @@ -319,6 +320,7 @@ func (client *Provider) VerifyIdentity(ctx context.Context, code, state, roleARN getWebTokenExpiry := func() (*credentials.WebIdentityToken, error) { customCtx := context.WithValue(ctx, oauth2.HTTPClient, client.provHTTPClient) oauth2Token, err := client.oauth2Config.Exchange(customCtx, code) + client.RefreshToken = oauth2Token.RefreshToken if err != nil { return nil, err } diff --git a/portal-ui/src/common/utils.ts b/portal-ui/src/common/utils.ts index 76e1e3166..183d8628c 100644 --- a/portal-ui/src/common/utils.ts +++ b/portal-ui/src/common/utils.ts @@ -84,7 +84,9 @@ export const deleteCookie = (name: string) => { export const clearSession = () => { storage.removeItem("token"); + storage.removeItem("auth-state"); deleteCookie("token"); + deleteCookie("idp-refresh-token"); }; // timeFromDate gets time string from date input diff --git a/portal-ui/src/screens/LoginPage/LoginCallback.tsx b/portal-ui/src/screens/LoginPage/LoginCallback.tsx index 77c418972..f17ff4813 100644 --- a/portal-ui/src/screens/LoginPage/LoginCallback.tsx +++ b/portal-ui/src/screens/LoginPage/LoginCallback.tsx @@ -142,6 +142,9 @@ const LoginCallback = ({ classes }: ILoginCallBackProps) => { targetPath = `${localStorage.getItem("redirect-path")}`; localStorage.setItem("redirect-path", ""); } + if (state) { + localStorage.setItem("auth-state", state); + } setLoading(false); navigate(targetPath); }) diff --git a/portal-ui/src/screens/LogoutPage/LogoutPage.tsx b/portal-ui/src/screens/LogoutPage/LogoutPage.tsx index ea2cd2963..ca4d768d1 100644 --- a/portal-ui/src/screens/LogoutPage/LogoutPage.tsx +++ b/portal-ui/src/screens/LogoutPage/LogoutPage.tsx @@ -35,8 +35,9 @@ const LogoutPage = () => { dispatch(resetSession()); navigate(`login`); }; + const state = localStorage.getItem("auth-state"); api - .invoke("POST", `/api/v1/logout`) + .invoke("POST", `/api/v1/logout`, { state }) .then(() => { deleteSession(); }) diff --git a/restapi/embedded_spec.go b/restapi/embedded_spec.go index 7b2d8db57..649a29b02 100644 --- a/restapi/embedded_spec.go +++ b/restapi/embedded_spec.go @@ -3460,6 +3460,16 @@ func init() { ], "summary": "Logout from Console.", "operationId": "Logout", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/logoutRequest" + } + } + ], "responses": { "200": { "description": "A successful response." @@ -6426,11 +6436,22 @@ func init() { "loginResponse": { "type": "object", "properties": { + "IDPRefreshToken": { + "type": "string" + }, "sessionId": { "type": "string" } } }, + "logoutRequest": { + "type": "object", + "properties": { + "state": { + "type": "string" + } + } + }, "makeBucketRequest": { "type": "object", "required": [ @@ -11615,6 +11636,16 @@ func init() { ], "summary": "Logout from Console.", "operationId": "Logout", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/logoutRequest" + } + } + ], "responses": { "200": { "description": "A successful response." @@ -14707,11 +14738,22 @@ func init() { "loginResponse": { "type": "object", "properties": { + "IDPRefreshToken": { + "type": "string" + }, "sessionId": { "type": "string" } } }, + "logoutRequest": { + "type": "object", + "properties": { + "state": { + "type": "string" + } + } + }, "makeBucketRequest": { "type": "object", "required": [ diff --git a/restapi/operations/auth/logout_parameters.go b/restapi/operations/auth/logout_parameters.go index 3c8e1840f..4d4d75c08 100644 --- a/restapi/operations/auth/logout_parameters.go +++ b/restapi/operations/auth/logout_parameters.go @@ -23,10 +23,15 @@ package auth // Editing this file might prove futile when you re-run the swagger generate command import ( + "io" "net/http" "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/validate" + + "github.com/minio/console/models" ) // NewLogoutParams creates a new LogoutParams object @@ -45,6 +50,12 @@ type LogoutParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` + + /* + Required: true + In: body + */ + Body *models.LogoutRequest } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface @@ -56,6 +67,33 @@ func (o *LogoutParams) BindRequest(r *http.Request, route *middleware.MatchedRou o.HTTPRequest = r + if runtime.HasBody(r) { + defer r.Body.Close() + var body models.LogoutRequest + if err := route.Consumer.Consume(r.Body, &body); err != nil { + if err == io.EOF { + res = append(res, errors.Required("body", "body", "")) + } else { + res = append(res, errors.NewParseError("body", "body", "", err)) + } + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + ctx := validate.WithOperationRequest(r.Context()) + if err := body.ContextValidate(ctx, route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Body = &body + } + } + } else { + res = append(res, errors.Required("body", "body", "")) + } if len(res) > 0 { return errors.CompositeValidationError(res...) } diff --git a/restapi/user_login.go b/restapi/user_login.go index 90d1678d2..bd44a9d28 100644 --- a/restapi/user_login.go +++ b/restapi/user_login.go @@ -65,6 +65,14 @@ func registerLoginHandlers(api *operations.ConsoleAPI) { return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) { cookie := NewSessionCookieForConsole(loginResponse.SessionID) http.SetCookie(w, &cookie) + http.SetCookie(w, &http.Cookie{ + Path: "/", + Name: "idp-refresh-token", + Value: loginResponse.IDPRefreshToken, + HttpOnly: true, + Secure: len(GlobalPublicCerts) > 0, + SameSite: http.SameSiteLaxMode, + }) authApi.NewLoginOauth2AuthNoContent().WriteResponse(w, p) }) }) @@ -252,7 +260,8 @@ func getLoginOauth2AuthResponse(params authApi.LoginOauth2AuthParams, openIDProv } // serialize output loginResponse := &models.LoginResponse{ - SessionID: *token, + SessionID: *token, + IDPRefreshToken: identityProvider.Client.RefreshToken, } return loginResponse, nil } diff --git a/restapi/user_logout.go b/restapi/user_logout.go index d5a9fd043..ee0d06bd7 100644 --- a/restapi/user_logout.go +++ b/restapi/user_logout.go @@ -17,11 +17,17 @@ package restapi import ( + "context" + "encoding/base64" + "encoding/json" "net/http" + "net/url" + "time" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/minio/console/models" + "github.com/minio/console/pkg/auth/idp/oauth2" "github.com/minio/console/restapi/operations" authApi "github.com/minio/console/restapi/operations/auth" ) @@ -29,13 +35,26 @@ import ( func registerLogoutHandlers(api *operations.ConsoleAPI) { // logout from console api.AuthLogoutHandler = authApi.LogoutHandlerFunc(func(params authApi.LogoutParams, session *models.Principal) middleware.Responder { - getLogoutResponse(session) + err := getLogoutResponse(session, params) + if err != nil { + return authApi.NewLogoutDefault(int(err.Code)).WithPayload(err) + } // Custom response writer to expire the session cookies return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) { expiredCookie := ExpireSessionCookie() // this will tell the browser to clear the cookie and invalidate user session // additionally we are deleting the cookie from the client side http.SetCookie(w, &expiredCookie) + http.SetCookie(w, &http.Cookie{ + Path: "/", + Name: "idp-refresh-token", + Value: "", + MaxAge: -1, + Expires: time.Now().Add(-100 * time.Hour), + HttpOnly: true, + Secure: len(GlobalPublicCerts) > 0, + SameSite: http.SameSiteLaxMode, + }) authApi.NewLogoutOK().WriteResponse(w, p) }) }) @@ -47,8 +66,45 @@ func logout(credentials ConsoleCredentialsI) { } // getLogoutResponse performs logout() and returns nil or errors -func getLogoutResponse(session *models.Principal) { +func getLogoutResponse(session *models.Principal, params authApi.LogoutParams) *models.Error { + ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) + defer cancel() + state := params.Body.State + if state != "" { + if err := logoutFromIDPProvider(params.HTTPRequest, state); err != nil { + return ErrorWithContext(ctx, err) + } + } creds := getConsoleCredentialsFromSession(session) credentials := ConsoleCredentials{ConsoleCredentials: creds} logout(credentials) + return nil +} + +func logoutFromIDPProvider(r *http.Request, state string) error { + decodedRState, err := base64.StdEncoding.DecodeString(state) + if err != nil { + return err + } + var requestItems oauth2.LoginURLParams + err = json.Unmarshal(decodedRState, &requestItems) + if err != nil { + return err + } + providerCfg := GlobalMinIOConfig.OpenIDProviders[requestItems.IDPName] + refreshToken, err := r.Cookie("idp-refresh-token") + if err != nil { + return err + } + if providerCfg.EndSessionEndpoint != "" { + params := url.Values{} + params.Add("client_id", providerCfg.ClientID) + params.Add("client_secret", providerCfg.ClientSecret) + params.Add("refresh_token", refreshToken.Value) + _, err := http.PostForm(providerCfg.EndSessionEndpoint, params) + if err != nil { + return err + } + } + return nil } diff --git a/swagger-console.yml b/swagger-console.yml index d472f7fc5..9a67a87b0 100644 --- a/swagger-console.yml +++ b/swagger-console.yml @@ -83,6 +83,12 @@ paths: post: summary: Logout from Console. operationId: Logout + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/logoutRequest" responses: 200: description: A successful response. @@ -4080,6 +4086,13 @@ definitions: properties: sessionId: type: string + IDPRefreshToken: + type: string + logoutRequest: + type: object + properties: + state: + type: string # Structure that holds the `Bearer {TOKEN}` present on authenticated requests principal: type: object