Skip to content
This repository has been archived by the owner on Apr 22, 2024. It is now read-only.

Implement logout flow #30

Merged
merged 2 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 164 additions & 40 deletions e2e/common/testclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
}
4 changes: 4 additions & 0 deletions e2e/keycloak/authz-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
81 changes: 81 additions & 0 deletions e2e/keycloak/keycloak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
})
}
Loading