mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
fixup! fixup! fixup! fixup! Simplify all the things.
This commit is contained in:
@@ -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}}"
|
||||
|
||||
@@ -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 `<no value>` 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.
|
||||
|
||||
@@ -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 <no value>
|
||||
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": "[email protected]",
|
||||
},
|
||||
},
|
||||
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 <no value>"
|
||||
},
|
||||
{
|
||||
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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"groups": "admin,developers",
|
||||
},
|
||||
expectedValues: map[string]string{
|
||||
"X-User-Email": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
},
|
||||
expectedValues: map[string]string{
|
||||
"X-User-Info": "user-12345 ([email protected])",
|
||||
"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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
},
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user