// main_test.go package traefikoidc import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "math/big" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gorilla/sessions" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" "golang.org/x/time/rate" ) type MockHTTPClient struct { mock.Mock } func (m *MockHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { args := m.Called(req) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*http.Response), args.Error(1) } type MockSessionStore struct { mock.Mock } func (m *MockSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) { args := m.Called(r, name) if session, ok := args.Get(0).(*sessions.Session); ok { return session, args.Error(1) } return nil, args.Error(1) } func (m *MockSessionStore) New(r *http.Request, name string) (*sessions.Session, error) { args := m.Called(r, name) return args.Get(0).(*sessions.Session), args.Error(1) } func (m *MockSessionStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { args := m.Called(r, w, s) return args.Error(0) } type MockTokenVerifier struct { mock.Mock } func (m *MockTokenVerifier) VerifyToken(token string) error { args := m.Called(token) return args.Error(0) } type MockJWTVerifier struct { mock.Mock } func (m *MockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { args := m.Called(jwt, token) return args.Error(0) } type TraefikOidcTestSuite struct { suite.Suite oidc *TraefikOidc mockHTTPClient *MockHTTPClient mockStore *MockSessionStore mockTokenVerifier *MockTokenVerifier mockJWTVerifier *MockJWTVerifier } func (suite *TraefikOidcTestSuite) SetupTest() { suite.mockHTTPClient = new(MockHTTPClient) suite.mockStore = new(MockSessionStore) suite.mockTokenVerifier = new(MockTokenVerifier) suite.mockJWTVerifier = new(MockJWTVerifier) config := &Config{ ProviderURL: "https://example.com", ClientID: "test-client-id", ClientSecret: "test-client-secret", CallbackURL: "/callback", LogoutURL: "/logout", SessionEncryptionKey: "test-encryption-key", Scopes: []string{"openid", "email", "profile"}, } suite.oidc = &TraefikOidc{ clientID: config.ClientID, clientSecret: config.ClientSecret, redirURLPath: config.CallbackURL, logoutURLPath: config.LogoutURL, store: suite.mockStore, httpClient: &http.Client{Transport: suite.mockHTTPClient}, jwkCache: &JWKCache{}, tokenBlacklist: NewTokenBlacklist(), tokenCache: NewTokenCache(), logger: NewLogger("debug"), limiter: rate.NewLimiter(rate.Every(time.Second), 100), authURL: "https://example.com/auth", tokenURL: "https://example.com/token", jwksURL: "https://example.com/.well-known/jwks.json", tokenVerifier: suite.mockTokenVerifier, jwtVerifier: suite.mockJWTVerifier, } } func (suite *TraefikOidcTestSuite) TestServeHTTP_AuthenticatedUser() { req := httptest.NewRequest("GET", "http://example.com", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["authenticated"] = true claims := map[string]interface{}{ "exp": float64(time.Now().Add(time.Hour).Unix()), } claimsJSON, _ := json.Marshal(claims) encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON) mockToken := fmt.Sprintf("header.%s.signature", encodedClaims) session.Values["id_token"] = mockToken suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.mockTokenVerifier.On("VerifyToken", mockToken).Return(nil) nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) suite.oidc.next = nextHandler suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusOK, rw.Code) suite.Equal("OK", rw.Body.String()) } func (suite *TraefikOidcTestSuite) TestServeHTTP_CallbackPath() { req := httptest.NewRequest("GET", "http://example.com"+suite.oidc.redirURLPath+"?code=test_code&state=test_state", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["csrf"] = "test_state" session.Values["incoming_path"] = "/original_path" suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) claims := map[string]interface{}{ "exp": float64(time.Now().Add(time.Hour).Unix()), "email": "test@example.com", } claimsJSON, _ := json.Marshal(claims) encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON) mockToken := fmt.Sprintf("header.%s.signature", encodedClaims) tokenResponse := map[string]interface{}{ "id_token": mockToken, } tokenResponseJSON, _ := json.Marshal(tokenResponse) suite.mockHTTPClient.On("RoundTrip", mock.MatchedBy(func(req *http.Request) bool { return strings.Contains(req.URL.String(), "token") })).Return(&http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(tokenResponseJSON))), }, nil) suite.mockTokenVerifier.On("VerifyToken", mockToken).Return(nil) suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusFound, rw.Code) suite.Equal("/original_path", rw.Header().Get("Location")) } func (suite *TraefikOidcTestSuite) TestVerifyToken() { token := "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Rfa2lkIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkxMjJ9.ZmFrZV9zaWduYXR1cmU" suite.mockTokenVerifier.On("VerifyToken", token).Return(nil) err := suite.oidc.verifyToken(token) suite.Require().NoError(err) } func (suite *TraefikOidcTestSuite) TestBuildAuthURL() { authURL := suite.oidc.buildAuthURL("http://example.com/callback", "test_state", "test_nonce") suite.Contains(authURL, suite.oidc.authURL) suite.Contains(authURL, "client_id="+suite.oidc.clientID) suite.Contains(authURL, "redirect_uri=http%3A%2F%2Fexample.com%2Fcallback") suite.Contains(authURL, "state=test_state") suite.Contains(authURL, "nonce=test_nonce") } func (suite *TraefikOidcTestSuite) TestJWKToPEM() { jwk := &JWK{ N: base64.RawURLEncoding.EncodeToString(big.NewInt(12345).Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(65537).Bytes()), } pem, err := jwkToPEM(jwk) suite.Require().NoError(err) suite.NotEmpty(pem) } func (suite *TraefikOidcTestSuite) TestTokenBlacklist() { tb := NewTokenBlacklist() token := "test_token" expiration := time.Now().Add(time.Hour) tb.Add(token, expiration) suite.True(tb.IsBlacklisted(token)) tb.Cleanup() suite.True(tb.IsBlacklisted(token)) tb.Add("expired_token", time.Now().Add(-time.Hour)) tb.Cleanup() suite.False(tb.IsBlacklisted("expired_token")) } func (suite *TraefikOidcTestSuite) TestTokenCache() { tc := NewTokenCache() token := "test_token" expiration := time.Now().Add(time.Hour) tc.Set(token, expiration) info, exists := tc.Get(token) suite.True(exists) suite.Equal(token, info.Token) suite.Equal(expiration, info.ExpiresAt) tc.Delete(token) _, exists = tc.Get(token) suite.False(exists) tc.Set("expired_token", time.Now().Add(-time.Hour)) tc.Cleanup() _, exists = tc.Get("expired_token") suite.False(exists) } func TestTraefikOidcSuite(t *testing.T) { suite.Run(t, new(TraefikOidcTestSuite)) } func (suite *TraefikOidcTestSuite) TestGenerateNonce() { nonce, err := generateNonce() suite.NoError(err) suite.Len(nonce, 44) // Base64 encoded 32 bytes } func (suite *TraefikOidcTestSuite) TestBuildFullURL() { url := buildFullURL("https", "example.com", "/path") suite.Equal("https://example.com/path", url) url = buildFullURL("", "example.com", "/path") suite.Equal("http://example.com/path", url) } func (suite *TraefikOidcTestSuite) TestExchangeCodeForToken() { ctx := context.Background() code := "test_code" redirectURL := "http://example.com/callback" expectedToken := map[string]interface{}{ "access_token": "test_access_token", "id_token": "test_id_token", } tokenJSON, _ := json.Marshal(expectedToken) suite.mockHTTPClient.On("RoundTrip", mock.Anything).Return(&http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(tokenJSON)), }, nil).Once() token, err := suite.oidc.exchangeCodeForToken(ctx, code, redirectURL) suite.NoError(err) suite.Equal(expectedToken, token) } func (suite *TraefikOidcTestSuite) TestHandleLogout() { req := httptest.NewRequest("GET", "http://example.com/logout", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["id_token"] = "test_token" suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.oidc.handleLogout(rw, req) suite.Equal(http.StatusForbidden, rw.Code) suite.Equal("Logged out\n", rw.Body.String()) } func (suite *TraefikOidcTestSuite) TestExtractClaims() { tokenString := "header.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.signature" claims, err := extractClaims(tokenString) suite.NoError(err) suite.Equal("1234567890", claims["sub"]) suite.Equal("John Doe", claims["name"]) suite.Equal(float64(1516239022), claims["iat"]) } func (suite *TraefikOidcTestSuite) TestDiscoverProviderMetadata() { providerURL := "https://example.com" expectedMetadata := &ProviderMetadata{ Issuer: "https://example.com", AuthURL: "https://example.com/auth", TokenURL: "https://example.com/token", JWKSURL: "https://example.com/.well-known/jwks.json", } metadataJSON, _ := json.Marshal(expectedMetadata) httpClient := &http.Client{ Transport: suite.mockHTTPClient, } suite.mockHTTPClient.On("RoundTrip", mock.Anything).Return(&http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(metadataJSON)), }, nil) metadata, err := discoverProviderMetadata(providerURL, *httpClient) suite.NoError(err) suite.Equal(expectedMetadata, metadata) } func (suite *TraefikOidcTestSuite) TestDetermineScheme() { req := httptest.NewRequest("GET", "http://example.com", nil) scheme := suite.oidc.determineScheme(req) suite.Equal("http", scheme) req.Header.Set("X-Forwarded-Proto", "https") scheme = suite.oidc.determineScheme(req) suite.Equal("https", scheme) suite.oidc.forceHTTPS = true scheme = suite.oidc.determineScheme(req) suite.Equal("https", scheme) } func (suite *TraefikOidcTestSuite) TestDetermineHost() { req := httptest.NewRequest("GET", "http://example.com", nil) host := suite.oidc.determineHost(req) suite.Equal("example.com", host) req.Header.Set("X-Forwarded-Host", "forwarded.example.com") host = suite.oidc.determineHost(req) suite.Equal("forwarded.example.com", host) } func (suite *TraefikOidcTestSuite) TestIsUserAuthenticated() { session := sessions.NewSession(suite.mockStore, cookieName) session.Values["authenticated"] = true session.Values["id_token"] = "valid.eyJleHAiOjk5OTk5OTk5OTl9.signature" suite.mockTokenVerifier.On("VerifyToken", "valid.eyJleHAiOjk5OTk5OTk5OTl9.signature").Return(nil) authenticated := suite.oidc.isUserAuthenticated(session) suite.True(authenticated) } func (suite *TraefikOidcTestSuite) TestInitiateAuthentication() { req := httptest.NewRequest("GET", "http://example.com", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.oidc.initiateAuthentication(rw, req, session, "http://example.com/callback") suite.Equal(http.StatusFound, rw.Code) location := rw.Header().Get("Location") suite.Contains(location, suite.oidc.authURL) suite.Contains(location, "redirect_uri=http%3A%2F%2Fexample.com%2Fcallback") } func (suite *TraefikOidcTestSuite) TestRevokeToken() { token := "valid.eyJleHAiOjk5OTk5OTk5OTl9.signature" suite.oidc.RevokeToken(token) _, exists := suite.oidc.tokenCache.Get(token) suite.False(exists) suite.True(suite.oidc.tokenBlacklist.IsBlacklisted(token)) } func (suite *TraefikOidcTestSuite) TestServeHTTP_InvalidSession() { req := httptest.NewRequest("GET", "http://example.com", nil) rw := httptest.NewRecorder() suite.mockStore.On("Get", req, cookieName).Return((*sessions.Session)(nil), fmt.Errorf("invalid session")) suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusInternalServerError, rw.Code) suite.Contains(rw.Body.String(), "Session error") } func (suite *TraefikOidcTestSuite) TestServeHTTP_ExpiredToken() { req := httptest.NewRequest("GET", "http://example.com", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["authenticated"] = true session.Values["id_token"] = "expired.eyJleHAiOjF9.signature" // expired token suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusFound, rw.Code) // Should redirect to authentication } func (suite *TraefikOidcTestSuite) TestHandleCallback_InvalidState() { req := httptest.NewRequest("GET", "http://example.com"+suite.oidc.redirURLPath+"?code=test_code&state=invalid_state", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["csrf"] = "valid_state" suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusBadRequest, rw.Code) suite.Contains(rw.Body.String(), "Invalid state parameter") } func (suite *TraefikOidcTestSuite) TestHandleCallback_TokenExchangeError() { req := httptest.NewRequest("GET", "http://example.com"+suite.oidc.redirURLPath+"?code=invalid_code&state=test_state", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["csrf"] = "test_state" suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.mockHTTPClient.On("RoundTrip", mock.Anything).Return(&http.Response{ StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"error": "invalid_grant"}`)), }, nil) suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusUnauthorized, rw.Code) suite.Contains(rw.Body.String(), "Authentication failed") } func (suite *TraefikOidcTestSuite) TestVerifyToken_RateLimitExceeded() { suite.oidc.limiter = rate.NewLimiter(rate.Every(time.Hour), 1) // Set a very low limit // Use up the only allowed request suite.oidc.limiter.Allow() err := suite.oidc.VerifyToken("some_token") suite.Error(err) suite.Contains(err.Error(), "rate limit exceeded") } func (suite *TraefikOidcTestSuite) TestVerifyToken_BlacklistedToken() { token := "blacklisted_token" suite.oidc.tokenBlacklist.Add(token, time.Now().Add(time.Hour)) err := suite.oidc.VerifyToken(token) suite.Error(err) suite.Contains(err.Error(), "token is blacklisted") } func (suite *TraefikOidcTestSuite) TestExtractClaims_InvalidToken() { invalidToken := "invalid.token.format" claims, err := extractClaims(invalidToken) suite.Error(err) suite.Nil(claims) } func (suite *TraefikOidcTestSuite) TestDiscoverProviderMetadata_HTTPError() { providerURL := "https://example.com" httpClient := &http.Client{ Transport: suite.mockHTTPClient, } suite.mockHTTPClient.On("RoundTrip", mock.Anything).Return(&http.Response{ StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("Internal Server Error")), }, nil) metadata, err := discoverProviderMetadata(providerURL, *httpClient) suite.Error(err) suite.Nil(metadata) suite.Contains(err.Error(), "failed to fetch provider metadata: status code 500") } func (suite *TraefikOidcTestSuite) TestRevokeToken_InvalidToken() { invalidToken := "invalid.token" suite.oidc.RevokeToken(invalidToken) // Check that the invalid token is not added to the blacklist suite.False(suite.oidc.tokenBlacklist.IsBlacklisted(invalidToken)) } func TestTraefikOidc_ServeHTTP(t *testing.T) { type fields struct { next http.Handler name string store sessions.Store redirURLPath string logoutURLPath string issuerURL string jwkCache *JWKCache tokenBlacklist *TokenBlacklist jwksURL string clientID string clientSecret string authURL string tokenURL string scopes []string limiter *rate.Limiter forceHTTPS bool scheme string tokenCache *TokenCache httpClient *http.Client logger *Logger redirectURL string tokenVerifier TokenVerifier jwtVerifier JWTVerifier } type args struct { rw http.ResponseWriter req *http.Request } tests := []struct { name string fields fields args args }{ // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr := &TraefikOidc{ next: tt.fields.next, name: tt.fields.name, store: tt.fields.store, redirURLPath: tt.fields.redirURLPath, logoutURLPath: tt.fields.logoutURLPath, issuerURL: tt.fields.issuerURL, jwkCache: tt.fields.jwkCache, tokenBlacklist: tt.fields.tokenBlacklist, jwksURL: tt.fields.jwksURL, clientID: tt.fields.clientID, clientSecret: tt.fields.clientSecret, authURL: tt.fields.authURL, tokenURL: tt.fields.tokenURL, scopes: tt.fields.scopes, limiter: tt.fields.limiter, forceHTTPS: tt.fields.forceHTTPS, scheme: tt.fields.scheme, tokenCache: tt.fields.tokenCache, httpClient: tt.fields.httpClient, logger: tt.fields.logger, redirectURL: tt.fields.redirectURL, tokenVerifier: tt.fields.tokenVerifier, jwtVerifier: tt.fields.jwtVerifier, } tr.ServeHTTP(tt.args.rw, tt.args.req) }) } } func (suite *TraefikOidcTestSuite) TestBuildAuthURL_CustomScopes() { suite.oidc.scopes = []string{"openid", "email", "custom_scope"} authURL := suite.oidc.buildAuthURL("http://example.com/callback", "test_state", "test_nonce") suite.Contains(authURL, "scope=openid+email+custom_scope") } func (suite *TraefikOidcTestSuite) TestBuildAuthURL_EmptyScopes() { suite.oidc.scopes = []string{} authURL := suite.oidc.buildAuthURL("http://example.com/callback", "test_state", "test_nonce") suite.NotContains(authURL, "scope=") } func (suite *TraefikOidcTestSuite) TestDetermineScheme_ForceHTTPS() { suite.oidc.forceHTTPS = true req := httptest.NewRequest("GET", "http://example.com", nil) scheme := suite.oidc.determineScheme(req) suite.Equal("https", scheme) } func (suite *TraefikOidcTestSuite) TestHandleLogout_CustomLogoutURL() { suite.oidc.logoutURLPath = "/custom-logout" req := httptest.NewRequest("GET", "http://example.com/custom-logout", nil) rw := httptest.NewRecorder() session := sessions.NewSession(suite.mockStore, cookieName) session.Values["id_token"] = "test_token" suite.mockStore.On("Get", req, cookieName).Return(session, nil) suite.mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) suite.oidc.ServeHTTP(rw, req) suite.Equal(http.StatusForbidden, rw.Code) suite.Equal("Logged out\n", rw.Body.String()) } func (suite *TraefikOidcTestSuite) TestVerifyToken_RateLimitReached() { suite.oidc.limiter = rate.NewLimiter(rate.Every(time.Hour), 1) // Set a very low limit suite.oidc.limiter.Allow() // Use up the only allowed request err := suite.oidc.VerifyToken("some_token") suite.Error(err) suite.Contains(err.Error(), "rate limit exceeded") } func (suite *TraefikOidcTestSuite) TestVerifyToken_InvalidJWTFormat() { invalidToken := "invalid.jwt.format" err := suite.oidc.VerifyToken(invalidToken) suite.Error(err) suite.Contains(err.Error(), "failed to parse JWT") } func (suite *TraefikOidcTestSuite) TestDiscoverProviderMetadata_InvalidURL() { invalidURL := "invalid-url" httpClient := &http.Client{ Transport: suite.mockHTTPClient, } suite.mockHTTPClient.On("RoundTrip", mock.Anything).Return(nil, fmt.Errorf("invalid URL")) _, err := discoverProviderMetadata(invalidURL, *httpClient) suite.Error(err) suite.Contains(err.Error(), "failed to fetch provider metadata") }