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

Commit

Permalink
Implement logout flow (#30)
Browse files Browse the repository at this point in the history
* Implement logout flow

* nits
  • Loading branch information
sergicastro authored Feb 21, 2024
1 parent d639e20 commit 49b2915
Show file tree
Hide file tree
Showing 5 changed files with 424 additions and 61 deletions.
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

0 comments on commit 49b2915

Please sign in to comment.