diff --git a/e2e/common/testclient.go b/e2e/common/testclient.go index 48ee7ee..d2fb858 100644 --- a/e2e/common/testclient.go +++ b/e2e/common/testclient.go @@ -42,6 +42,9 @@ func (l LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error } res, err := l.Delegate.RoundTrip(req) + if err != nil { + return res, err + } if dump, derr := httputil.DumpResponse(res, l.LogBody); derr == nil { l.LogFunc(string(dump)) @@ -70,11 +73,15 @@ func (c CookieTracker) RoundTrip(req *http.Request) (*http.Response, error) { // OIDCTestClient encapsulates a http.Client and keeps track of the state of the OIDC login process. type OIDCTestClient struct { - http *http.Client // Delegate HTTP client - cookies map[string]*http.Cookie // Cookies received from the server - loginURL string // URL of the IdP where users need to authenticate - loginMethod string // Method (GET/POST) to use when posting the credentials to the IdP - tlsConfig *tls.Config // Custom TLS configuration, if needed + http *http.Client // Delegate HTTP client + cookies map[string]*http.Cookie // Cookies received from the server + loginURL string // URL of the IdP where users need to authenticate + loginMethod string // Method (GET/POST) to use when posting the credentials to the IdP + idpBaseURL string // Base URL of the IdP + logoutURL string // URL of the IdP where users need to log out + logoutMethod string // Method (GET/POST) to use when posting the logout request to the IdP + logoutForm url.Values // Form data to use when posting the logout request to the IdP + tlsConfig *tls.Config // Custom TLS configuration, if needed } // Option is a functional option for configuring the OIDCTestClient. @@ -107,6 +114,15 @@ func WithLoggingOptions(logFunc func(...any), logBody bool) Option { } } +// WithBaseURL configures the OIDCTestClient to use the specified IdP base url. +// Required when the form action URL is relative. For example the logout one. +func WithBaseURL(idpBaseURL string) Option { + return func(o *OIDCTestClient) error { + o.idpBaseURL = idpBaseURL + return nil + } +} + // NewOIDCTestClient creates a new OIDCTestClient. func NewOIDCTestClient(opts ...Option) (*OIDCTestClient, error) { var ( @@ -168,60 +184,168 @@ func (o *OIDCTestClient) Login(formData map[string]string) (*http.Response, erro return o.Send(req) } +// Logout logs out from the IdP. +func (o *OIDCTestClient) Logout() (*http.Response, error) { + if o.logoutURL == "" { + return nil, fmt.Errorf("logout URL is not set") + } + req, err := http.NewRequest(o.logoutMethod, o.logoutURL, strings.NewReader(o.logoutForm.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return o.Send(req) +} + // ParseLoginForm parses the HTML response body to get the URL where the login page would post the user-entered credentials. func (o *OIDCTestClient) ParseLoginForm(responseBody io.ReadCloser, formID string) error { body, err := io.ReadAll(responseBody) if err != nil { return err } - o.loginURL, o.loginMethod, err = getFormAction(string(body), formID) + o.loginURL, o.loginMethod, _, err = extractFromData(string(body), idFormMatcher{formID}, false) return err } -// getFormAction returns the action attribute of the form with the specified ID in the given HTML response body. -func getFormAction(responseBody string, formID string) (string, string, error) { +// ParseLogoutForm parses the HTML response body to get the URL where the logout page would post the session logout. +func (o *OIDCTestClient) ParseLogoutForm(responseBody io.ReadCloser) error { + body, err := io.ReadAll(responseBody) + if err != nil { + return err + } + var logoutURL string + logoutURL, o.logoutMethod, o.logoutForm, err = extractFromData(string(body), firstFormMatcher{}, true) + if err != nil { + return err + } + + // If the logout URL is relative, use the host from the OIDCTestClient configuration + if !strings.HasPrefix(logoutURL, "http") { + logoutURL = o.idpBaseURL + logoutURL + } + o.logoutURL = logoutURL + return nil +} + +// extractFromData extracts the form action, method and values from the HTML response body. +func extractFromData(responseBody string, match formMatch, includeFromInputs bool) (string, string, url.Values, error) { // Parse HTML response doc, err := html.Parse(strings.NewReader(responseBody)) if err != nil { - return "", "", err - } - - // Find the form with the specified ID - var findForm func(*html.Node) (string, string) - findForm = func(n *html.Node) (string, string) { - var ( - action string - method = "POST" - ) - if n.Type == html.ElementNode && n.Data == "form" { - for _, attr := range n.Attr { - if attr.Key == "id" && attr.Val == formID { - for _, a := range n.Attr { - if a.Key == "action" { - action = a.Val - } else if a.Key == "method" { - method = strings.ToUpper(a.Val) - } - } - return action, method - } - } + return "", "", nil, err + } + + // Find the form with the specified ID or match criteria + form := findForm(doc, match) + if form == nil { + return "", "", nil, fmt.Errorf("%s not found", match) + } + + var ( + action, method string + formValues = make(url.Values) + ) + + // Get the action and method of the form + for _, a := range form.Attr { + switch a.Key { + case "action": + action = a.Val + case "method": + method = strings.ToUpper(a.Val) + } + } + + // If we want to include inputs, recursively iterate the children + if includeFromInputs { + formValues = findFormInputs(form) + } + + return action, method, formValues, nil +} + +// findForm recursively searches for a form in the HTML response body that matches the specified criteria. +func findForm(n *html.Node, match formMatch) *html.Node { + // Check if the current node is a form and matches the specified criteria + if match.matches(n) { + return n + } + + // Else, recursively search for the form in child nodes + for c := n.FirstChild; c != nil; c = c.NextSibling { + if form := findForm(c, match); form != nil { + return form } + } + return nil +} - // Recursively search for the form in child nodes - for c := n.FirstChild; c != nil; c = c.NextSibling { - if ra, rm := findForm(c); ra != "" { - return ra, rm +// findFormInputs recursively searches for input fields in the HTML form node. +func findFormInputs(formNode *html.Node) url.Values { + form := make(url.Values) + for c := formNode.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.Data == "input" { + var name, value string + for _, a := range c.Attr { + switch a.Key { + case "name": + name = a.Val + case "value": + value = a.Val + } + } + form.Add(name, value) + } else { + for k, v := range findFormInputs(c) { + form[k] = append(form[k], v...) } } + } + return form +} + +var ( + _ formMatch = idFormMatcher{} + _ formMatch = firstFormMatcher{} +) + +type ( + // formMatch is an interface that defines the criteria to match a form in the HTML response body. + formMatch interface { + matches(*html.Node) bool + String() string + } + + // idFormMatcher matches a form with the specified ID. + idFormMatcher struct { + id string + } - return "", "" + // firstFormMatcher matches the first form in the HTML response body. + firstFormMatcher struct{} +) + +func (m idFormMatcher) matches(n *html.Node) bool { + if n.Type != html.ElementNode || n.Data != "form" { + return false } - action, method := findForm(doc) - if action == "" { - return "", "", fmt.Errorf("form with ID '%s' not found", formID) + for _, a := range n.Attr { + if a.Key == "id" && a.Val == m.id { + return true + } } + return false +} + +func (m idFormMatcher) String() string { + return fmt.Sprintf("form with ID '%s'", m.id) +} + +func (m firstFormMatcher) matches(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "form" +} - return action, method, nil +func (m firstFormMatcher) String() string { + return "first form" } diff --git a/e2e/keycloak/authz-config.json b/e2e/keycloak/authz-config.json index eb8710d..d593561 100644 --- a/e2e/keycloak/authz-config.json +++ b/e2e/keycloak/authz-config.json @@ -17,6 +17,10 @@ "preamble": "Bearer", "header": "authorization" }, + "logout": { + "path": "/logout", + "redirect_uri": "http://host.docker.internal:8080/realms/master/protocol/openid-connect/logout" + }, "redis_session_store_config": { "server_uri": "redis://redis:6379" } diff --git a/e2e/keycloak/keycloak_test.go b/e2e/keycloak/keycloak_test.go index 697ec1d..ed0bf6d 100644 --- a/e2e/keycloak/keycloak_test.go +++ b/e2e/keycloak/keycloak_test.go @@ -29,6 +29,7 @@ import ( const ( dockerLocalHost = "host.docker.internal" + idpBaseURLHost = "http://host.docker.internal:8080" keyCloakLoginFormID = "kc-form-login" testCAFile = "certs/ca.crt" username = "authservice" @@ -129,3 +130,83 @@ func TestOIDCRefreshTokens(t *testing.T) { require.Contains(t, string(body), "Access allowed") }) } + +func TestOIDCLogout(t *testing.T) { + skipIfDockerHostNonResolvable(t) + + // Initialize the test OIDC client that will keep track of the state of the OIDC login process + client, err := common.NewOIDCTestClient( + common.WithCustomCA(testCAFile), + common.WithLoggingOptions(t.Log, true), + common.WithBaseURL(idpBaseURLHost), + ) + require.NoError(t, err) + + t.Run("first request requires login", func(t *testing.T) { + // Send a request to the test server. It will be redirected to the IdP login page + res, err := client.Get(testURL) + require.NoError(t, err) + + // Parse the response body to get the URL where the login page would post the user-entered credentials + require.NoError(t, client.ParseLoginForm(res.Body, keyCloakLoginFormID)) + + // Submit the login form to the IdP. This will authenticate and redirect back to the application + res, err = client.Login(map[string]string{"username": username, "password": password, "credentialId": ""}) + require.NoError(t, err) + + // Verify that we get the expected response from the application + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Contains(t, string(body), "Access allowed") + }) + + t.Run("second request works without login redirect", func(t *testing.T) { + res, err := client.Get(testURL) + require.NoError(t, err) + + // Verify that we get the expected response from the application + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Contains(t, string(body), "Access allowed") + }) + + t.Run("logout", func(t *testing.T) { + // Logout + res, err := client.Get(testURL + "/logout") + require.NoError(t, err) + + // Parse the response body to get the URL where the login page would post the session logout + require.NoError(t, client.ParseLogoutForm(res.Body)) + + // Submit the logout form to the IdP. This will log out the user and redirect back to the application + res, err = client.Logout() + require.NoError(t, err) + + // Verify that we get the logout confirmation from the IDP + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Contains(t, string(body), "You are logged out") + }) + + t.Run("request after logout requires login again", func(t *testing.T) { + // Send a request to the test server. It will be redirected to the IdP login page + res, err := client.Get(testURL) + require.NoError(t, err) + + // Parse the response body to get the URL where the login page would post the user-entered credentials + require.NoError(t, client.ParseLoginForm(res.Body, keyCloakLoginFormID)) + + // Submit the login form to the IdP. This will authenticate and redirect back to the application + res, err = client.Login(map[string]string{"username": username, "password": password, "credentialId": ""}) + require.NoError(t, err) + + // Verify that we get the expected response from the application + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Contains(t, string(body), "Access allowed") + }) +} diff --git a/internal/authz/oidc.go b/internal/authz/oidc.go index 96b46cf..c8e8889 100644 --- a/internal/authz/oidc.go +++ b/internal/authz/oidc.go @@ -137,7 +137,26 @@ func (o *oidcHandler) Process(ctx context.Context, req *envoy.CheckRequest, resp // If the request is for the configured logout path, // then logout and redirect to the configured logout redirect uri. - // TODO (sergicastro): Handle logout request + if matchesLogoutPath(log, o.config, req.GetAttributes().GetRequest().GetHttp()) { + log.Info("handling logout request") + if sessionID != "" { + log.Info("removing session from session store during logout", "session-id", sessionID) + store := o.sessions.Get(o.config) + if err := store.RemoveSession(ctx, sessionID); err != nil { + log.Error("error removing session", err) + setDenyResponse(resp, newSessionErrorResponse(), codes.Unauthenticated) + return nil + } + } + log.Info("Logout complete. Redirecting to logout redirect uri") + deny := newDenyResponse() + // add IDP logout location + setRedirect(deny, o.config.GetLogout().GetRedirectUri()) + // add the set-cookie header to delete the session_id cookie + setSetCookieHeader(deny, generateSetCookieHeader(getCookieName(o.config), "deleted", 0)) + setDenyResponse(resp, deny, codes.Unauthenticated) + return nil + } // If the request does not have a session_id cookie, // then generate a session id, put it in a header, and redirect for login. @@ -152,7 +171,7 @@ func (o *oidcHandler) Process(ctx context.Context, req *envoy.CheckRequest, resp // If the request path is the callback for receiving the authorization code, // has a session id then exchange it for tokens and redirects end-user back to // their originally requested URL. - if o.matchesCallbackPath(log, req.GetAttributes().GetRequest().GetHttp()) { + if matchesCallbackPath(log, o.config, req.GetAttributes().GetRequest().GetHttp()) { log.Debug("handling callback request") o.retrieveTokens(ctx, log, req, resp, sessionID) return nil @@ -272,18 +291,11 @@ func (o *oidcHandler) redirectToIDP(ctx context.Context, log telemetry.Logger, // Generate denied response with redirect headers deny := newDenyResponse() - deny.Status = &typev3.HttpStatus{Code: typev3.StatusCode_Found} - deny.Headers = append(deny.Headers, &corev3.HeaderValueOption{ - Header: &corev3.HeaderValue{Key: inthttp.HeaderLocation, Value: redirectURL}, - }) + setRedirect(deny, redirectURL) // add the set-cookie header cookieName := getCookieName(o.config) - cookie := generateSetCookieHeader(cookieName, sessionID, 0) - deny.Headers = append(deny.Headers, &corev3.HeaderValueOption{ - Header: &corev3.HeaderValue{Key: inthttp.HeaderSetCookie, Value: cookie}, - }) - + setSetCookieHeader(deny, generateSetCookieHeader(cookieName, sessionID, -1)) setDenyResponse(resp, deny, codes.Unauthenticated) } @@ -647,6 +659,21 @@ func setDenyResponse(resp *envoy.CheckResponse, deny *envoy.DeniedHttpResponse, resp.Status = &status.Status{Code: int32(code)} } +// setRedirect populates the DeniedHttpResponse with the given location and a 302 status code. +func setRedirect(deny *envoy.DeniedHttpResponse, location string) { + deny.Status = &typev3.HttpStatus{Code: typev3.StatusCode_Found} + deny.Headers = append(deny.Headers, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{Key: inthttp.HeaderLocation, Value: location}, + }) +} + +// setSetCookieHeader populates the DeniedHttpResponse with the given cookie. +func setSetCookieHeader(deny *envoy.DeniedHttpResponse, cookie string) { + deny.Headers = append(deny.Headers, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{Key: inthttp.HeaderSetCookie, Value: cookie}, + }) +} + // allowResponse populates the CheckResponse as an OK response with the required tokens. func (o *oidcHandler) allowResponse(resp *envoy.CheckResponse, tokens *oidc.TokenResponse) { ok := resp.GetOkResponse() @@ -663,13 +690,14 @@ func (o *oidcHandler) allowResponse(resp *envoy.CheckResponse, tokens *oidc.Toke } // matchesCallbackPath checks if the request matches the configured callback uri. -func (o *oidcHandler) matchesCallbackPath(log telemetry.Logger, httpReq *envoy.AttributeContext_HttpRequest) bool { +// Request done by the IDP directly to the authservice to exchange the authorization code for tokens. +func matchesCallbackPath(log telemetry.Logger, config *oidcv1.OIDCConfig, httpReq *envoy.AttributeContext_HttpRequest) bool { reqFullPath := httpReq.GetPath() reqHost := httpReq.GetHost() reqPath, _, _ := inthttp.GetPathQueryFragment(reqFullPath) // no need to handle the error since config validation already checks for this - confURI, _ := url.Parse(o.config.GetCallbackUri()) + confURI, _ := url.Parse(config.GetCallbackUri()) confPort := confURI.Port() confHost := confURI.Hostname() confScheme := confURI.Scheme @@ -692,6 +720,23 @@ func (o *oidcHandler) matchesCallbackPath(log telemetry.Logger, httpReq *envoy.A return false } +// matchesLogoutPath checks if the request matches the configured logout uri. +// Request done by the end-user to log out. +func matchesLogoutPath(log telemetry.Logger, config *oidcv1.OIDCConfig, httpReq *envoy.AttributeContext_HttpRequest) bool { + if config.GetLogout() == nil { + return false + } + + reqPath, _, _ := inthttp.GetPathQueryFragment(httpReq.GetPath()) + confPath := config.GetLogout().GetPath() + + if reqPath == confPath { + log.Debug("request matches configured logout uri") + return true + } + return false +} + // encodeTokensToHeaders encodes the tokens to the headers according to the configuration. func (o *oidcHandler) encodeTokensToHeaders(tokens *oidc.TokenResponse) map[string]string { headers := make(map[string]string) @@ -734,7 +779,7 @@ func generateSetCookieHeader(cookieName, cookieValue string, timeout time.Durati // getCookieDirectives returns the directives to use in the Set-Cookie header depending on the timeout. func getCookieDirectives(timeout time.Duration) []string { directives := []string{inthttp.HeaderSetCookieHTTPOnly, inthttp.HeaderSetCookieSecure, inthttp.HeaderSetCookieSameSiteLax, "Path=/"} - if timeout > 0 { + if timeout >= 0 { directives = append(directives, fmt.Sprintf("%s=%d", inthttp.HeaderSetCookieMaxAge, int(timeout.Seconds()))) } return directives diff --git a/internal/authz/oidc_test.go b/internal/authz/oidc_test.go index fdca6ba..387d309 100644 --- a/internal/authz/oidc_test.go +++ b/internal/authz/oidc_test.go @@ -86,6 +86,33 @@ var ( }, } + logoutWithNoSession = &envoy.CheckRequest{ + Attributes: &envoy.AttributeContext{ + Request: &envoy.AttributeContext_Request{ + Http: &envoy.AttributeContext_HttpRequest{ + Id: "request-id", + Scheme: "https", Host: "example.com", Path: "/logout?some-params", + Method: "GET", + }, + }, + }, + } + + logoutWithSession = &envoy.CheckRequest{ + Attributes: &envoy.AttributeContext{ + Request: &envoy.AttributeContext_Request{ + Http: &envoy.AttributeContext_HttpRequest{ + Id: "request-id", + Scheme: "https", Host: "example.com", Path: "/logout?some-params", + Method: "GET", + Headers: map[string]string{ + inthttp.HeaderCookie: defaultCookieName + "=test-session-id", + }, + }, + }, + }, + } + requestedAppURL = "https://localhost:443/final-app" validAuthState = &oidc.AuthorizationState{ Nonce: newNonce, @@ -116,6 +143,10 @@ var ( ClientId: "test-client-id", ClientSecret: "test-client-secret", Scopes: []string{"openid", "email"}, + Logout: &oidcv1.LogoutConfig{ + Path: "/logout", + RedirectUri: "http://idp-test-server/logout?with-params", + }, } dynamicOIDCConfig = &oidcv1.OIDCConfig{ @@ -251,6 +282,27 @@ func TestOIDCProcess(t *testing.T) { requireStoredTokens(t, store, newSessionID, false) }, }, + { + name: "matches logout: request with no sessionId", + req: logoutWithNoSession, + responseVerify: func(t *testing.T, resp *envoy.CheckResponse) { + require.Equal(t, int32(codes.Unauthenticated), resp.GetStatus().GetCode()) + requireStandardResponseHeaders(t, resp) + requireRedirectResponse(t, resp.GetDeniedResponse(), "http://idp-test-server/logout", url.Values{"with-params": {""}}) + requireDeleteCookie(t, resp.GetDeniedResponse()) + }, + }, + { + name: "matches logout: request with sessionId", + req: logoutWithSession, + responseVerify: func(t *testing.T, resp *envoy.CheckResponse) { + require.Equal(t, int32(codes.Unauthenticated), resp.GetStatus().GetCode()) + requireStandardResponseHeaders(t, resp) + requireRedirectResponse(t, resp.GetDeniedResponse(), "http://idp-test-server/logout", url.Values{"with-params": {""}}) + requireDeleteCookie(t, resp.GetDeniedResponse()) + requireStoredState(t, store, sessionID, false) + }, + }, } for _, tt := range requestToAppTests { @@ -535,7 +587,7 @@ func TestOIDCProcess(t *testing.T) { } for _, tt := range callbackTests { - t.Run("request matches callback: "+tt.name, func(t *testing.T) { + t.Run("matches callback: "+tt.name, func(t *testing.T) { idpServer.Start() t.Cleanup(func() { idpServer.Stop() @@ -839,20 +891,29 @@ func TestOIDCProcessWithFailingSessionStore(t *testing.T) { // So there's no expected communication with any external server. requestToAppTests := []struct { name string + req *envoy.CheckRequest storeErrors map[int]bool }{ { name: "app request - fails to get token response from given session ID", + req: withSessionHeader, storeErrors: map[int]bool{getTokenResponse: true}, }, { name: "app request (redirect to IDP) - fails to remove old session", + req: withSessionHeader, storeErrors: map[int]bool{removeSession: true}, }, { name: "app request (redirect to IDP) - fails to set new authorization state", + req: withSessionHeader, storeErrors: map[int]bool{setAuthorizationState: true}, }, + { + name: "logout request - fails to remove session", + req: logoutWithSession, + storeErrors: map[int]bool{removeSession: true}, + }, } for _, tt := range requestToAppTests { @@ -860,7 +921,7 @@ func TestOIDCProcessWithFailingSessionStore(t *testing.T) { store.errs = tt.storeErrors t.Cleanup(func() { store.errs = nil }) resp := &envoy.CheckResponse{} - require.NoError(t, h.Process(ctx, withSessionHeader, resp)) + require.NoError(t, h.Process(ctx, tt.req, resp)) requireSessionErrorResponse(t, resp) }) } @@ -1038,18 +1099,56 @@ func TestMatchesCallbackPath(t *testing.T) { {"http://example.com:8080/callback", "example.com:8080", "/callback", true}, } - sessions := &mockSessionStoreFactory{store: oidc.NewMemoryStore(&oidc.Clock{}, time.Hour, time.Hour)} - for _, tt := range tests { t.Run(tt.callback, func(t *testing.T) { - h, err := NewOIDCHandler(&oidcv1.OIDCConfig{CallbackUri: tt.callback}, nil, sessions, oidc.Clock{}, nil) - require.NoError(t, err) - got := h.(*oidcHandler).matchesCallbackPath(telemetry.NoopLogger(), &envoy.AttributeContext_HttpRequest{Host: tt.host, Path: tt.path}) + got := matchesCallbackPath(telemetry.NoopLogger(), + &oidcv1.OIDCConfig{CallbackUri: tt.callback}, + &envoy.AttributeContext_HttpRequest{Host: tt.host, Path: tt.path}) require.Equal(t, tt.want, got) }) } } +func TestMatchesLogoutPath(t *testing.T) { + var ( + logoutPathConfig = &oidcv1.LogoutConfig{Path: "/logout"} + emptyLogoutPathConfig = &oidcv1.LogoutConfig{} + ) + + tests := []struct { + name string + logoutConfig *oidcv1.LogoutConfig + reqPath string + want bool + }{ + {"with-config", logoutPathConfig, "/logout", true}, + {"with-config", logoutPathConfig, "/logout/", false}, + {"with-config", logoutPathConfig, "/logout?query#fragment", true}, + {"with-config", logoutPathConfig, "/other", false}, + {"with-config", logoutPathConfig, "/logout-nope", false}, + {"empty-config", emptyLogoutPathConfig, "/logout", false}, + {"empty-config", emptyLogoutPathConfig, "/logout/", false}, + {"empty-config", emptyLogoutPathConfig, "/logout?query#fragment", false}, + {"empty-config", emptyLogoutPathConfig, "/other", false}, + {"empty-config", emptyLogoutPathConfig, "/logout-nope", false}, + {"nil-config", nil, "/logout", false}, + {"nil-config", nil, "/logout/", false}, + {"nil-config", nil, "/logout?query#fragment", false}, + {"nil-config", nil, "/other", false}, + {"nil-config", nil, "/logout-nope", false}, + } + + for _, tt := range tests { + t.Run(tt.name+" "+tt.reqPath, func(t *testing.T) { + got := matchesLogoutPath(telemetry.NoopLogger(), + &oidcv1.OIDCConfig{Logout: tt.logoutConfig}, + &envoy.AttributeContext_HttpRequest{Path: tt.reqPath}) + require.Equal(t, tt.want, got) + }) + } + +} + func TestEncodeTokensToHeaders(t *testing.T) { const ( idToken = "id-token" @@ -1387,6 +1486,16 @@ func requireCookie(t *testing.T, response *envoy.DeniedHttpResponse) { require.Equal(t, "__Host-authservice-session-id-cookie=new-session-id; HttpOnly; Secure; SameSite=Lax; Path=/", cookieHeader) } +func requireDeleteCookie(t *testing.T, response *envoy.DeniedHttpResponse) { + var cookieHeader string + for _, header := range response.GetHeaders() { + if header.GetHeader().GetKey() == inthttp.HeaderSetCookie { + cookieHeader = header.GetHeader().GetValue() + } + } + require.Equal(t, "__Host-authservice-session-id-cookie=deleted; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0", cookieHeader) +} + func requireTokensInResponse(t *testing.T, resp *envoy.OkHttpResponse, cfg *oidcv1.OIDCConfig, idToken, accessToken string) { var ( gotIDToken, gotAccessToken string