mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
d493d44cbb
Signed-off-by: jnfrati <nicofrati@gmail.com>
468 lines
15 KiB
Go
468 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/dexidp/dex/connector"
|
|
"github.com/dexidp/dex/storage"
|
|
)
|
|
|
|
// recordingLogoutConnector implements connector.LogoutCallbackConnector and
|
|
// records the connectorData it was invoked with so tests can assert what was
|
|
// passed down.
|
|
type recordingLogoutConnector struct {
|
|
gotConnectorData []byte
|
|
returnURL string
|
|
}
|
|
|
|
func (c *recordingLogoutConnector) LogoutURL(_ context.Context, connectorData []byte, _ string) (string, error) {
|
|
c.gotConnectorData = connectorData
|
|
return c.returnURL, nil
|
|
}
|
|
|
|
func (c *recordingLogoutConnector) HandleLogoutCallback(_ context.Context, _ *http.Request) error {
|
|
return nil
|
|
}
|
|
|
|
var _ connector.LogoutCallbackConnector = (*recordingLogoutConnector)(nil)
|
|
|
|
func TestHandleLogoutNoSessions(t *testing.T) {
|
|
httpServer, server := newTestServer(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/logout", nil)
|
|
server.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusNotFound, rr.Code)
|
|
}
|
|
|
|
func TestHandleLogoutMethodNotAllowed(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("PUT", "/logout", nil)
|
|
server.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusMethodNotAllowed, rr.Code)
|
|
}
|
|
|
|
func TestHandleLogoutPOST(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/logout", nil)
|
|
server.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
body := rr.Body.String()
|
|
require.Contains(t, body, "No active session")
|
|
}
|
|
|
|
func TestHandleLogoutNoHint(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/logout", nil)
|
|
server.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
body := rr.Body.String()
|
|
require.Contains(t, body, "No active session")
|
|
require.NotContains(t, body, "successfully logged out")
|
|
}
|
|
|
|
func TestHandleLogoutInvalidHint(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/logout?id_token_hint=invalid-token", nil)
|
|
server.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestHandleLogoutWithValidHint(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := t.Context()
|
|
|
|
clientID := "test-client"
|
|
postLogoutURI := "https://example.com/done"
|
|
userID := "test-user"
|
|
connectorID := "mock"
|
|
|
|
require.NoError(t, server.storage.CreateClient(ctx, storage.Client{
|
|
ID: clientID,
|
|
Secret: "secret",
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
PostLogoutRedirectURIs: []string{postLogoutURI},
|
|
}))
|
|
|
|
require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{
|
|
UserID: userID,
|
|
ConnectorID: connectorID,
|
|
Nonce: "testnonce",
|
|
CreatedAt: time.Now(),
|
|
LastActivity: time.Now(),
|
|
}))
|
|
|
|
idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{
|
|
UserID: userID, Username: "testuser", Email: "test@example.com",
|
|
}, []string{"openid"}, "", "", "", connectorID, time.Now())
|
|
require.NoError(t, err)
|
|
|
|
logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s&state=mystate",
|
|
url.QueryEscape(idToken), url.QueryEscape(postLogoutURI))
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil))
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
body := rr.Body.String()
|
|
require.Contains(t, body, "successfully logged out")
|
|
require.Contains(t, body, "Back to Application")
|
|
require.Contains(t, body, postLogoutURI)
|
|
require.Contains(t, body, "state=mystate")
|
|
|
|
// Session deleted.
|
|
_, err = server.storage.GetAuthSession(ctx, userID, connectorID)
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
|
|
// Cookie cleared.
|
|
for _, c := range rr.Result().Cookies() {
|
|
if c.Name == "dex_session" {
|
|
require.Equal(t, -1, c.MaxAge)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleLogoutUnregisteredRedirectURI(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := t.Context()
|
|
clientID := "test-client"
|
|
require.NoError(t, server.storage.CreateClient(ctx, storage.Client{
|
|
ID: clientID,
|
|
Secret: "secret",
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
PostLogoutRedirectURIs: []string{"https://example.com/done"},
|
|
}))
|
|
|
|
idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{
|
|
UserID: "user1", Username: "testuser", Email: "test@example.com",
|
|
}, []string{"openid"}, "", "", "", "mock", time.Now())
|
|
require.NoError(t, err)
|
|
|
|
logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s",
|
|
url.QueryEscape(idToken), url.QueryEscape("https://evil.com/steal"))
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil))
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestHandleLogoutRedirectURIWithoutHint(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", "/logout?post_logout_redirect_uri=https://example.com/done", nil))
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestHandleLogoutRevokesRefreshTokens(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := t.Context()
|
|
clientID := "test-client"
|
|
postLogoutURI := "https://example.com/done"
|
|
userID := "test-user"
|
|
connectorID := "mock"
|
|
|
|
require.NoError(t, server.storage.CreateClient(ctx, storage.Client{
|
|
ID: clientID,
|
|
Secret: "secret",
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
PostLogoutRedirectURIs: []string{postLogoutURI},
|
|
}))
|
|
|
|
refreshID := storage.NewID()
|
|
require.NoError(t, server.storage.CreateRefresh(ctx, storage.RefreshToken{
|
|
ID: refreshID, Token: "token-value", ClientID: clientID, ConnectorID: connectorID,
|
|
Claims: storage.Claims{UserID: userID, Username: "testuser", Email: "test@example.com"},
|
|
Scopes: []string{"openid", "offline_access"}, CreatedAt: time.Now(), LastUsed: time.Now(),
|
|
}))
|
|
|
|
require.NoError(t, server.storage.CreateOfflineSessions(ctx, storage.OfflineSessions{
|
|
UserID: userID, ConnID: connectorID,
|
|
Refresh: map[string]*storage.RefreshTokenRef{
|
|
clientID: {ID: refreshID, ClientID: clientID, CreatedAt: time.Now(), LastUsed: time.Now()},
|
|
},
|
|
}))
|
|
|
|
require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{
|
|
UserID: userID, ConnectorID: connectorID, Nonce: "testnonce",
|
|
CreatedAt: time.Now(), LastActivity: time.Now(),
|
|
}))
|
|
|
|
idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{
|
|
UserID: userID, Username: "testuser", Email: "test@example.com",
|
|
}, []string{"openid"}, "", "", "", connectorID, time.Now())
|
|
require.NoError(t, err)
|
|
|
|
logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s",
|
|
url.QueryEscape(idToken), url.QueryEscape(postLogoutURI))
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil))
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
_, err = server.storage.GetRefresh(ctx, refreshID)
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
|
|
os, err := server.storage.GetOfflineSessions(ctx, userID, connectorID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, os.Refresh)
|
|
}
|
|
|
|
func TestHandleLogoutRepeat(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := t.Context()
|
|
clientID := "test-client"
|
|
postLogoutURI := "https://example.com/done"
|
|
userID := "test-user"
|
|
connectorID := "mock"
|
|
|
|
require.NoError(t, server.storage.CreateClient(ctx, storage.Client{
|
|
ID: clientID, Secret: "secret",
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
PostLogoutRedirectURIs: []string{postLogoutURI},
|
|
}))
|
|
|
|
require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{
|
|
UserID: userID, ConnectorID: connectorID, Nonce: "testnonce",
|
|
CreatedAt: time.Now(), LastActivity: time.Now(),
|
|
}))
|
|
|
|
idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{
|
|
UserID: userID, Username: "testuser", Email: "test@example.com",
|
|
}, []string{"openid"}, "", "", "", connectorID, time.Now())
|
|
require.NoError(t, err)
|
|
|
|
logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s",
|
|
url.QueryEscape(idToken), url.QueryEscape(postLogoutURI))
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil))
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Contains(t, rr.Body.String(), "successfully logged out")
|
|
|
|
// Second logout — session already deleted, shows "no active session".
|
|
rr = httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil))
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Contains(t, rr.Body.String(), "No active session")
|
|
}
|
|
|
|
func TestLogoutCallbackNoState(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", "/logout/callback", nil))
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestDiscoveryWithSessions(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil))
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
var d discovery
|
|
require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&d))
|
|
require.Equal(t, fmt.Sprintf("%s/logout", httpServer.URL), d.EndSession)
|
|
}
|
|
|
|
func TestDiscoveryWithoutSessions(t *testing.T) {
|
|
httpServer, server := newTestServer(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
rr := httptest.NewRecorder()
|
|
server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil))
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
var d discovery
|
|
require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&d))
|
|
require.Empty(t, d.EndSession)
|
|
}
|
|
|
|
// TestHandleLogoutFromCookie tests logout without id_token_hint,
|
|
// where the user is identified by their session cookie alone.
|
|
func TestHandleLogoutFromCookie(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := t.Context()
|
|
userID := "test-user"
|
|
connectorID := "mock"
|
|
nonce := "testnonce"
|
|
|
|
require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{
|
|
UserID: userID, ConnectorID: connectorID, Nonce: nonce,
|
|
CreatedAt: time.Now(), LastActivity: time.Now(),
|
|
}))
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/logout", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "dex_session",
|
|
Value: sessionCookieValue(userID, connectorID, nonce, server.sessionConfig.CookieEncryptionKey),
|
|
})
|
|
server.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Contains(t, rr.Body.String(), "successfully logged out")
|
|
|
|
// Session should be deleted.
|
|
_, err := server.storage.GetAuthSession(ctx, userID, connectorID)
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
|
|
// Cookie should be cleared.
|
|
for _, c := range rr.Result().Cookies() {
|
|
if c.Name == "dex_session" {
|
|
require.Equal(t, -1, c.MaxAge)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLogoutCallbackWithExpiredSession tests that /logout/callback
|
|
// returns an error when the session has expired or been deleted.
|
|
func TestLogoutCallbackWithExpiredSession(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
// No session created — cookie points to nonexistent session.
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/logout/callback", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "dex_session",
|
|
Value: sessionCookieValue("user-1", "mock", "nonce", server.sessionConfig.CookieEncryptionKey),
|
|
})
|
|
server.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestRevokeRefreshTokensReturnsConnectorData(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := context.Background()
|
|
userID := "user1"
|
|
connectorID := "mock"
|
|
expectedConnData := []byte(`{"RefreshToken":"abc"}`)
|
|
|
|
refreshID := storage.NewID()
|
|
require.NoError(t, server.storage.CreateRefresh(ctx, storage.RefreshToken{
|
|
ID: refreshID, Token: "tok", ClientID: "client1", ConnectorID: connectorID,
|
|
Claims: storage.Claims{UserID: userID}, CreatedAt: time.Now(), LastUsed: time.Now(),
|
|
}))
|
|
|
|
require.NoError(t, server.storage.CreateOfflineSessions(ctx, storage.OfflineSessions{
|
|
UserID: userID, ConnID: connectorID, ConnectorData: expectedConnData,
|
|
Refresh: map[string]*storage.RefreshTokenRef{"client1": {ID: refreshID, ClientID: "client1"}},
|
|
}))
|
|
|
|
connData := server.revokeRefreshTokens(ctx, userID, connectorID)
|
|
require.Equal(t, expectedConnData, connData)
|
|
|
|
_, err := server.storage.GetRefresh(ctx, refreshID)
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
|
|
os, err := server.storage.GetOfflineSessions(ctx, userID, connectorID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, os.Refresh)
|
|
require.Equal(t, expectedConnData, os.ConnectorData)
|
|
}
|
|
|
|
// TestTryUpstreamLogoutPrefersSessionConnectorData verifies that when the auth
|
|
// session has ConnectorData stored (from login), it takes precedence over the
|
|
// connectorData the caller passes in (which originates from the offline session).
|
|
func TestTryUpstreamLogoutPrefersSessionConnectorData(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sessionConnData []byte
|
|
callerConnData []byte
|
|
wantConnData []byte
|
|
}{
|
|
{
|
|
name: "session data wins over caller data",
|
|
sessionConnData: []byte(`{"IDToken":"session-token"}`),
|
|
callerConnData: []byte(`{"IDToken":"caller-token"}`),
|
|
wantConnData: []byte(`{"IDToken":"session-token"}`),
|
|
},
|
|
{
|
|
name: "caller data used when session data is empty",
|
|
sessionConnData: nil,
|
|
callerConnData: []byte(`{"IDToken":"caller-token"}`),
|
|
wantConnData: []byte(`{"IDToken":"caller-token"}`),
|
|
},
|
|
{
|
|
name: "empty when neither source has data",
|
|
sessionConnData: nil,
|
|
callerConnData: nil,
|
|
wantConnData: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
httpServer, server := newTestServerWithSessions(t, nil)
|
|
defer httpServer.Close()
|
|
|
|
ctx := t.Context()
|
|
userID := "test-user"
|
|
connectorID := "mock"
|
|
|
|
// Inject a recording connector with matching ResourceVersion so
|
|
// getConnector returns our mock instead of re-opening from storage.
|
|
rec := &recordingLogoutConnector{returnURL: "https://upstream.example.com/logout"}
|
|
server.mu.Lock()
|
|
server.connectors[connectorID] = Connector{
|
|
Type: "mockCallback",
|
|
ResourceVersion: "1",
|
|
Connector: rec,
|
|
}
|
|
server.mu.Unlock()
|
|
|
|
require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{
|
|
UserID: userID, ConnectorID: connectorID, Nonce: "nonce",
|
|
CreatedAt: time.Now(), LastActivity: time.Now(),
|
|
ConnectorData: tc.sessionConnData,
|
|
}))
|
|
|
|
redirectURL, ok := server.tryUpstreamLogout(ctx, userID, connectorID, tc.callerConnData,
|
|
"https://dex.example.com/cb", "state-123", "client-123")
|
|
require.True(t, ok)
|
|
require.Equal(t, "https://upstream.example.com/logout", redirectURL)
|
|
require.Equal(t, tc.wantConnData, rec.gotConnectorData)
|
|
})
|
|
}
|
|
}
|