diff --git a/.traefik.yml b/.traefik.yml index 5853fa7..8eb5fce 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -62,6 +62,8 @@ testData: - /metrics headers: # Custom headers to set with templated values from claims and tokens + # NOTE: If you encounter "can't evaluate field AccessToken in type bool" errors, + # you may need to escape the templates. See the headers section in configuration below. - name: "X-User-Email" value: "{{.Claims.email}}" - name: "X-User-ID" @@ -384,6 +386,28 @@ configuration: Templates support Go template syntax including conditionals and iteration. Variable names are case-sensitive - use .Claims not .claims. + IMPORTANT: Template Escaping + If you encounter the error "can't evaluate field AccessToken in type bool" when + starting Traefik, this means Traefik is trying to evaluate the template expressions + before passing them to the plugin. To fix this, you need to escape the templates + using one of these methods: + + 1. Use YAML literal style (recommended): + headers: + - name: "Authorization" + value: | + Bearer {{.AccessToken}} + + 2. Use single quotes: + headers: + - name: "Authorization" + value: 'Bearer {{.AccessToken}}' + + 3. For inline double quotes, escape the braces: + headers: + - name: "Authorization" + value: "Bearer {{"{{.AccessToken}}"}}" + Examples: - name: "X-User-Email", value: "{{.Claims.email}}" - name: "Authorization", value: "Bearer {{.AccessToken}}" diff --git a/README.md b/README.md index 3a07d50..8247c22 100644 --- a/README.md +++ b/README.md @@ -377,16 +377,22 @@ spec: scopes: - roles # Appended to defaults: ["openid", "profile", "email", "roles"] headers: + # Using YAML literal style to prevent Traefik from pre-evaluating templates - name: "X-User-Email" - value: "{{.Claims.email}}" + value: | + {{.Claims.email}} - name: "X-User-ID" - value: "{{.Claims.sub}}" + value: | + {{.Claims.sub}} - name: "Authorization" - value: "Bearer {{.AccessToken}}" + value: | + Bearer {{.AccessToken}} - name: "X-User-Roles" - value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}" + value: | + {{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}} - name: "X-Is-Admin" - value: "{{if eq .Claims.role \"admin\"}}true{{else}}false{{end}}" + value: | + {{if eq .Claims.role "admin"}}true{{else}}false{{end}} ``` ### With PKCE Enabled @@ -577,14 +583,19 @@ http: - /health - /metrics headers: + # Using YAML literal style to prevent Traefik from pre-evaluating templates - name: "X-User-Email" - value: "{{.Claims.email}}" + value: | + {{.Claims.email}} - name: "X-User-ID" - value: "{{.Claims.sub}}" + value: | + {{.Claims.sub}} - name: "Authorization" - value: "Bearer {{.AccessToken}}" + value: | + Bearer {{.AccessToken}} - name: "X-User-Roles" - value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}" + value: | + {{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}} ``` ## Advanced Configuration @@ -649,17 +660,49 @@ Templates can access the following variables: - `{{.IdToken}}` - The raw ID token string (same as AccessToken in most configurations) - `{{.RefreshToken}}` - The raw refresh token string -**Example configuration:** +**⚠️ Important: Template Escaping** + +If you encounter the error `can't evaluate field AccessToken in type bool` when starting Traefik, this indicates that Traefik is attempting to evaluate the template expressions before passing them to the plugin. This is a known issue when using template syntax in Traefik plugin configurations. + +**Solution:** Use one of these escaping methods: + +1. **Use YAML literal style (recommended):** +```yaml +headers: + - name: "Authorization" + value: | + Bearer {{.AccessToken}} +``` + +2. **Use single quotes:** +```yaml +headers: + - name: "Authorization" + value: 'Bearer {{.AccessToken}}' +``` + +3. **Escape the template braces (for inline double quotes):** +```yaml +headers: + - name: "Authorization" + value: "Bearer {{"{{.AccessToken}}"}}" +``` + +**Working example configuration:** ```yaml headers: - name: "X-User-Email" - value: "{{.Claims.email}}" + value: | + {{.Claims.email}} - name: "X-User-ID" - value: "{{.Claims.sub}}" + value: | + {{.Claims.sub}} - name: "Authorization" - value: "Bearer {{.AccessToken}}" + value: | + Bearer {{.AccessToken}} - name: "X-User-Name" - value: "{{.Claims.given_name}} {{.Claims.family_name}}" + value: | + {{.Claims.given_name}} {{.Claims.family_name}} ``` **Advanced template examples:** @@ -668,20 +711,23 @@ Conditional logic: ```yaml headers: - name: "X-Is-Admin" - value: "{{if eq .Claims.role \"admin\"}}true{{else}}false{{end}}" + value: | + {{if eq .Claims.role "admin"}}true{{else}}false{{end}} ``` Array handling: ```yaml headers: - name: "X-User-Roles" - value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}" + value: | + {{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}} ``` **Notes:** - Variable names are case-sensitive (use `.Claims`, not `.claims`) - Missing claims will result in `` in the header value - The middleware validates templates during startup and logs errors for invalid templates +- If templates are not properly escaped, Traefik may attempt to evaluate them during configuration parsing, leading to errors ### Default Headers Set for Downstream Services @@ -804,14 +850,18 @@ logLevel: debug 3. **No matching public key found**: The JWKS endpoint might be unavailable or the token's key ID (kid) doesn't match any key in the JWKS. 4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list. 5. **Access denied: You do not have any of the allowed roles or groups**: The user doesn't have any of the roles or groups specified in `allowedRolesAndGroups`. -6. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure: +6. **"can't evaluate field AccessToken in type bool" error**: This error occurs when Traefik attempts to evaluate template expressions in the headers configuration before passing them to the plugin. To fix this: + - Use YAML literal style for header values: `value: |` followed by the template on the next line + - Or use single quotes: `value: 'Bearer {{.AccessToken}}'` + - See the [Templated Headers](#templated-headers) section for complete examples +7. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure: - Do NOT manually add the `offline_access` scope. Google rejects this scope as invalid. - The middleware automatically applies the required Google parameters (`access_type=offline` and `prompt=consent`). - Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity. - Verify you're using a version of the middleware that includes the Google OAuth compatibility fix. - For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md). -7. **Keycloak: Claims Missing from ID Token (e.g., email, roles)** +8. **Keycloak: Claims Missing from ID Token (e.g., email, roles)** If you are using Keycloak and claims like `email`, `roles`, or `groups` are missing from the ID Token, this plugin may not function as expected (e.g., for domain restrictions or RBAC). * **Solution**: This plugin validates the **ID Token**. You **must** configure Keycloak client mappers to add all necessary claims (email, roles, groups, etc.) to the ID Token. diff --git a/templated_header_issue55_test.go b/templated_header_issue55_test.go new file mode 100644 index 0000000..104f65c --- /dev/null +++ b/templated_header_issue55_test.go @@ -0,0 +1,467 @@ +package traefikoidc + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "text/template" +) + +// TestIssue55TemplateExecutionWithWrongTypes tests what happens when templates +// receive wrong data types during execution - this reproduces the exact error +// from GitHub issue #55: "can't evaluate field AccessToken in type bool" +func TestIssue55TemplateExecutionWithWrongTypes(t *testing.T) { + testCases := []struct { + name string + templateText string + templateData interface{} + expectError bool + errorContains string + }{ + { + name: "correct map data", + templateText: "Bearer {{.AccessToken}}", + templateData: map[string]interface{}{ + "AccessToken": "valid-token", + }, + expectError: false, + }, + { + name: "boolean as root context - reproduces issue #55", + templateText: "Bearer {{.AccessToken}}", + templateData: true, + expectError: true, + errorContains: "can't evaluate field AccessToken in type bool", + }, + { + name: "string as root context", + templateText: "Bearer {{.AccessToken}}", + templateData: "just a string", + expectError: true, + errorContains: "can't evaluate field AccessToken in type string", + }, + { + name: "nil as root context", + templateText: "Bearer {{.AccessToken}}", + templateData: nil, + expectError: false, // nil renders as + errorContains: "", + }, + { + name: "map with wrong field type", + templateText: "Bearer {{.AccessToken}}", + templateData: map[string]interface{}{ + "AccessToken": true, // boolean instead of string + }, + expectError: false, // This should work, template will convert bool to string + }, + { + name: "nested claims access with correct data", + templateText: "User: {{.Claims.email}}", + templateData: map[string]interface{}{ + "Claims": map[string]interface{}{ + "email": "user@example.com", + }, + }, + expectError: false, + }, + { + name: "nested claims with wrong structure", + templateText: "User: {{.Claims.email}}", + templateData: map[string]interface{}{ + "Claims": "not a map", // string instead of map + }, + expectError: true, + errorContains: "can't evaluate field email in type", // interface{} or string + }, + { + name: "array as root context", + templateText: "Bearer {{.AccessToken}}", + templateData: []string{"item1", "item2"}, + expectError: true, + errorContains: "can't evaluate field AccessToken in type []string", + }, + { + name: "integer as root context", + templateText: "Bearer {{.AccessToken}}", + templateData: 42, + expectError: true, + errorContains: "can't evaluate field AccessToken in type int", + }, + { + name: "empty template data map", + templateText: "Bearer {{.AccessToken}}", + templateData: map[string]interface{}{}, + expectError: false, // Should render as "Bearer " + }, + { + name: "complex nested structure", + templateText: "{{.Claims.sub}} - {{.Claims.groups}} - {{.AccessToken}}", + templateData: map[string]interface{}{ + "AccessToken": "token123", + "Claims": map[string]interface{}{ + "sub": "user-id", + "groups": "admin,users", + }, + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpl, err := template.New("test").Parse(tc.templateText) + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, tc.templateData) + + if tc.expectError { + if err == nil { + t.Fatalf("Expected error but got none, output: %q", buf.String()) + } + if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) { + t.Errorf("Expected error to contain %q, got %q", tc.errorContains, err.Error()) + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + }) + } +} + +// TestIssue55TemplateParsingValidation ensures templates are parsed correctly +// and validates the template data structure used in the middleware +func TestIssue55TemplateParsingValidation(t *testing.T) { + testCases := []struct { + name string + headerTemplates []TemplatedHeader + shouldError bool + }{ + { + name: "valid bearer token template", + headerTemplates: []TemplatedHeader{ + {Name: "Authorization", Value: "Bearer {{.AccessToken}}"}, + }, + shouldError: false, + }, + { + name: "multiple valid templates", + headerTemplates: []TemplatedHeader{ + {Name: "Authorization", Value: "Bearer {{.AccessToken}}"}, + {Name: "X-User-Email", Value: "{{.Claims.email}}"}, + {Name: "X-User-ID", Value: "{{.Claims.sub}}"}, + }, + shouldError: false, + }, + { + name: "template with conditional logic", + headerTemplates: []TemplatedHeader{ + {Name: "X-Auth-Info", Value: "{{if .AccessToken}}Bearer {{.AccessToken}}{{else}}No Token{{end}}"}, + }, + shouldError: false, + }, + { + name: "invalid template syntax", + headerTemplates: []TemplatedHeader{ + {Name: "Bad-Template", Value: "{{.AccessToken"}, + }, + shouldError: true, + }, + { + name: "empty template value", + headerTemplates: []TemplatedHeader{ + {Name: "Empty-Header", Value: ""}, + }, + shouldError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, header := range tc.headerTemplates { + tmpl, err := template.New(header.Name).Parse(header.Value) + + if tc.shouldError { + if err == nil { + t.Errorf("Expected template parsing to fail for %s", header.Name) + } + } else { + if err != nil { + t.Errorf("Failed to parse template for header %s: %v", header.Name, err) + continue + } + + // Test execution with correct data structure + templateData := map[string]interface{}{ + "AccessToken": "test-access-token", + "IDToken": "test-id-token", + "RefreshToken": "test-refresh-token", + "Claims": map[string]interface{}{ + "email": "test@example.com", + "sub": "user123", + }, + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, templateData) + if err != nil { + t.Errorf("Failed to execute valid template: %v", err) + } + } + } + }) + } +} + +// TestIssue55MiddlewareHeaderTemplating simulates the actual middleware flow +// to ensure templated headers work correctly in request processing +func TestIssue55MiddlewareHeaderTemplating(t *testing.T) { + // Test cases that simulate real-world usage + testCases := []struct { + name string + headers []TemplatedHeader + accessToken string + idToken string + claims map[string]interface{} + expectedValues map[string]string + }{ + { + name: "authorization header with access token", + headers: []TemplatedHeader{ + {Name: "Authorization", Value: "Bearer {{.AccessToken}}"}, + }, + accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + expectedValues: map[string]string{ + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }, + }, + { + name: "multiple headers with claims", + headers: []TemplatedHeader{ + {Name: "X-User-Email", Value: "{{.Claims.email}}"}, + {Name: "X-User-Groups", Value: "{{.Claims.groups}}"}, + {Name: "X-Auth-Token", Value: "{{.AccessToken}}"}, + }, + accessToken: "token123", + claims: map[string]interface{}{ + "email": "user@example.com", + "groups": "admin,developers", + }, + expectedValues: map[string]string{ + "X-User-Email": "user@example.com", + "X-User-Groups": "admin,developers", + "X-Auth-Token": "token123", + }, + }, + { + name: "complex template expressions", + headers: []TemplatedHeader{ + {Name: "X-User-Info", Value: "{{.Claims.sub}} ({{.Claims.email}})"}, + {Name: "X-Auth-Header", Value: "Bearer {{.AccessToken}} | ID: {{.IDToken}}"}, + }, + accessToken: "access-token", + idToken: "id-token", + claims: map[string]interface{}{ + "sub": "user-12345", + "email": "john@example.com", + }, + expectedValues: map[string]string{ + "X-User-Info": "user-12345 (john@example.com)", + "X-Auth-Header": "Bearer access-token | ID: id-token", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Parse all templates + headerTemplates := make(map[string]*template.Template) + for _, header := range tc.headers { + tmpl, err := template.New(header.Name).Parse(header.Value) + if err != nil { + t.Fatalf("Failed to parse template for %s: %v", header.Name, err) + } + headerTemplates[header.Name] = tmpl + } + + // Create template data (simulating what the middleware does) + templateData := map[string]interface{}{ + "AccessToken": tc.accessToken, + "IDToken": tc.idToken, + "RefreshToken": "refresh-token", // Default value + "Claims": tc.claims, + } + + // Create a test request + req := httptest.NewRequest("GET", "/test", nil) + + // Execute templates and set headers + for headerName, tmpl := range headerTemplates { + var buf bytes.Buffer + err := tmpl.Execute(&buf, templateData) + if err != nil { + t.Fatalf("Failed to execute template for %s: %v", headerName, err) + } + req.Header.Set(headerName, buf.String()) + } + + // Verify all expected headers are set correctly + for headerName, expectedValue := range tc.expectedValues { + actualValue := req.Header.Get(headerName) + if actualValue != expectedValue { + t.Errorf("Header %s: expected %q, got %q", headerName, expectedValue, actualValue) + } + } + }) + } +} + +// TestIssue55JSONConfigParsing tests that JSON configuration with wrong types +// is properly rejected to prevent the boolean type error +func TestIssue55JSONConfigParsing(t *testing.T) { + testCases := []struct { + name string + jsonConfig string + expectedError bool + description string + }{ + { + name: "valid JSON configuration", + jsonConfig: `{ + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{.AccessToken}}" + } + ] + }`, + expectedError: false, + description: "Properly formatted JSON with string values", + }, + { + name: "JSON with boolean value", + jsonConfig: `{ + "headers": [ + { + "name": "Authorization", + "value": true + } + ] + }`, + expectedError: true, + description: "Boolean value instead of string template", + }, + { + name: "JSON with number value", + jsonConfig: `{ + "headers": [ + { + "name": "Authorization", + "value": 123 + } + ] + }`, + expectedError: true, + description: "Number value instead of string template", + }, + { + name: "JSON with null value", + jsonConfig: `{ + "headers": [ + { + "name": "Authorization", + "value": null + } + ] + }`, + expectedError: false, // JSON unmarshaling null to string results in empty string + description: "Null value instead of string template", + }, + { + name: "JSON with array value", + jsonConfig: `{ + "headers": [ + { + "name": "Authorization", + "value": ["Bearer", "{{.AccessToken}}"] + } + ] + }`, + expectedError: true, + description: "Array value instead of string template", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var config struct { + Headers []TemplatedHeader `json:"headers"` + } + + err := json.Unmarshal([]byte(tc.jsonConfig), &config) + + if tc.expectedError { + if err == nil { + t.Errorf("Expected error for %s, but parsing succeeded", tc.description) + } + } else { + if err != nil { + t.Errorf("Unexpected error for %s: %v", tc.description, err) + } + } + }) + } +} + +// TestIssue55RegressionScenario tests the exact scenario that would cause +// the "can't evaluate field AccessToken in type bool" error +func TestIssue55RegressionScenario(t *testing.T) { + // This test documents what NOT to do and ensures we catch it + t.Run("direct boolean context execution", func(t *testing.T) { + tmpl, err := template.New("test").Parse("{{.AccessToken}}") + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + var buf bytes.Buffer + // This is what would cause the issue - passing a boolean as template data + err = tmpl.Execute(&buf, true) + + if err == nil { + t.Fatalf("Expected error when executing template with boolean context") + } + + expectedError := "can't evaluate field AccessToken in type bool" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error containing %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("correct map context execution", func(t *testing.T) { + tmpl, err := template.New("test").Parse("{{.AccessToken}}") + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + var buf bytes.Buffer + // This is the correct way - passing a map with the expected fields + err = tmpl.Execute(&buf, map[string]interface{}{ + "AccessToken": "test-token", + }) + + if err != nil { + t.Fatalf("Unexpected error with correct template data: %v", err) + } + + if buf.String() != "test-token" { + t.Errorf("Expected 'test-token', got %q", buf.String()) + } + }) +} diff --git a/templated_header_traefik_integration_test.go b/templated_header_traefik_integration_test.go new file mode 100644 index 0000000..e5fe63d --- /dev/null +++ b/templated_header_traefik_integration_test.go @@ -0,0 +1,263 @@ +package traefikoidc + +import ( + "bytes" + "context" + "net/http" + "testing" + "text/template" +) + +// TestTraefikConfigurationParsing tests various ways Traefik might pass configuration +// to the plugin, specifically focusing on the headers field +func TestTraefikConfigurationParsing(t *testing.T) { + testCases := []struct { + name string + config *Config + expectError bool + description string + }{ + { + name: "valid configuration with templated headers", + config: &Config{ + ProviderURL: "https://accounts.google.com", + ClientID: "test-client", + ClientSecret: "test-secret", + SessionEncryptionKey: "test-encryption-key-32-bytes-long", + CallbackURL: "/oauth2/callback", + Headers: []TemplatedHeader{ + {Name: "Authorization", Value: "Bearer {{.AccessToken}}"}, + }, + }, + expectError: false, + description: "Standard configuration should work", + }, + { + name: "configuration with multiple headers", + config: &Config{ + ProviderURL: "https://accounts.google.com", + ClientID: "test-client", + ClientSecret: "test-secret", + SessionEncryptionKey: "test-encryption-key-32-bytes-long", + CallbackURL: "/oauth2/callback", + Headers: []TemplatedHeader{ + {Name: "Authorization", Value: "Bearer {{.AccessToken}}"}, + {Name: "X-User-Email", Value: "{{.Claims.email}}"}, + {Name: "X-User-ID", Value: "{{.Claims.sub}}"}, + }, + }, + expectError: false, + description: "Multiple headers should work", + }, + { + name: "empty headers configuration", + config: &Config{ + ProviderURL: "https://accounts.google.com", + ClientID: "test-client", + ClientSecret: "test-secret", + SessionEncryptionKey: "test-encryption-key-32-bytes-long", + CallbackURL: "/oauth2/callback", + Headers: []TemplatedHeader{}, + }, + expectError: false, + description: "Empty headers should not cause issues", + }, + { + name: "nil headers configuration", + config: &Config{ + ProviderURL: "https://accounts.google.com", + ClientID: "test-client", + ClientSecret: "test-secret", + SessionEncryptionKey: "test-encryption-key-32-bytes-long", + CallbackURL: "/oauth2/callback", + Headers: nil, + }, + expectError: false, + description: "Nil headers should be handled gracefully", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create a simple next handler + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Try to create the middleware + ctx := context.Background() + handler, err := New(ctx, next, tc.config, "test-middleware") + + if tc.expectError { + if err == nil { + t.Errorf("Expected error for %s, but got none", tc.description) + } + } else { + if err != nil { + t.Errorf("Unexpected error for %s: %v", tc.description, err) + } else { + // Verify that the middleware was created successfully + middleware, ok := handler.(*TraefikOidc) + if !ok { + t.Fatalf("Handler is not of type *TraefikOidc") + } + + // Check that templates were parsed correctly + if len(tc.config.Headers) > 0 { + if len(middleware.headerTemplates) != len(tc.config.Headers) { + t.Errorf("Expected %d templates, got %d", + len(tc.config.Headers), len(middleware.headerTemplates)) + } + + // Verify each template can be executed + for headerName, tmpl := range middleware.headerTemplates { + testData := map[string]interface{}{ + "AccessToken": "test-token", + "Claims": map[string]interface{}{ + "email": "test@example.com", + "sub": "user123", + }, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, testData); err != nil { + t.Errorf("Failed to execute template for header %s: %v", + headerName, err) + } + } + } + } + } + }) + } +} + +// TestTemplateParsingDuringInitialization specifically tests template parsing +// during middleware initialization to catch any issues that might occur +func TestTemplateParsingDuringInitialization(t *testing.T) { + // Test various template expressions that might cause issues + templateTests := []struct { + name string + templateValue string + shouldFail bool + }{ + { + name: "simple access token", + templateValue: "{{.AccessToken}}", + shouldFail: false, + }, + { + name: "bearer token format", + templateValue: "Bearer {{.AccessToken}}", + shouldFail: false, + }, + { + name: "nested claim access", + templateValue: "{{.Claims.email}}", + shouldFail: false, + }, + { + name: "multiple template expressions", + templateValue: "User: {{.Claims.email}}, Token: {{.AccessToken}}", + shouldFail: false, + }, + { + name: "invalid template syntax", + templateValue: "{{.AccessToken", + shouldFail: true, + }, + { + name: "empty template", + templateValue: "", + shouldFail: false, + }, + } + + for _, tt := range templateTests { + t.Run(tt.name, func(t *testing.T) { + // Test template parsing directly + tmpl := template.New("test") + _, err := tmpl.Parse(tt.templateValue) + + if tt.shouldFail { + if err == nil { + t.Errorf("Expected template parsing to fail for %q", tt.templateValue) + } + } else { + if err != nil { + t.Errorf("Template parsing failed for %q: %v", tt.templateValue, err) + } + } + }) + } +} + +// TestIssue55ReproductionAttempt attempts to reproduce the exact scenario +// from GitHub issue #55 where the error occurs during configuration +func TestIssue55ReproductionAttempt(t *testing.T) { + // Create a configuration exactly as reported by the user + config := &Config{ + ProviderURL: "https://accounts.google.com", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + SessionEncryptionKey: "test-session-encryption-key-32-bytes-long", + CallbackURL: "/oauth2/callback", + LogoutURL: "/oauth2/logout", + LogLevel: "debug", + Scopes: []string{"openid", "profile", "email"}, + Headers: []TemplatedHeader{ + { + Name: "Authorization", + Value: "Bearer {{.AccessToken}}", + }, + }, + } + + // Create a mock HTTP handler + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Try to initialize the middleware + ctx := context.Background() + handler, err := New(ctx, next, config, "test-oidc") + + if err != nil { + t.Fatalf("Failed to create middleware: %v", err) + } + + // Verify the middleware was created correctly + middleware, ok := handler.(*TraefikOidc) + if !ok { + t.Fatalf("Handler is not of type *TraefikOidc") + } + + // Check that the header template was parsed + if len(middleware.headerTemplates) != 1 { + t.Errorf("Expected 1 header template, got %d", len(middleware.headerTemplates)) + } + + // Verify the template exists for the Authorization header + authTmpl, exists := middleware.headerTemplates["Authorization"] + if !exists { + t.Fatal("Authorization template not found") + } + + // Test executing the template + templateData := map[string]interface{}{ + "AccessToken": "test-access-token", + "Claims": map[string]interface{}{ + "email": "user@example.com", + }, + } + + var buf bytes.Buffer + if err := authTmpl.Execute(&buf, templateData); err != nil { + t.Errorf("Failed to execute Authorization template: %v", err) + } + + expectedValue := "Bearer test-access-token" + if buf.String() != expectedValue { + t.Errorf("Expected %q, got %q", expectedValue, buf.String()) + } +}