From c38572955a351efb697ed120015c35497f31381a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 4 Aug 2025 23:27:14 +1000 Subject: [PATCH] Support wildcard domain --- README.md | 2 + cors_integration_test.go | 89 +++++++++++++++++++++ internal/cors/middleware.go | 68 ++++++++++++++++ internal/cors/wildcard.go | 100 ++++++++++++++++++++++++ internal/cors/wildcard_test.go | 138 +++++++++++++++++++++++++++++++++ k8s/deployment.yaml | 118 ---------------------------- k8s/ingress-example.yaml | 44 ----------- k8s/namespace.yaml | 4 - main.go | 8 +- web/index.html | 50 ++++++------ 10 files changed, 426 insertions(+), 195 deletions(-) create mode 100644 cors_integration_test.go create mode 100644 internal/cors/middleware.go create mode 100644 internal/cors/wildcard.go create mode 100644 internal/cors/wildcard_test.go delete mode 100644 k8s/deployment.yaml delete mode 100644 k8s/ingress-example.yaml delete mode 100644 k8s/namespace.yaml diff --git a/README.md b/README.md index 3c0a4a7..3db110b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ webauthn: cors: allowed_origins: - "https://your-domain.com" # Your domain with protocol + # Wildcard domains are supported for subdomains: + - "*.your-domain.com" # Matches api.your-domain.com, app.your-domain.com, etc. auth: session_secret: "your-secure-secret-key" # Generate a secure random string diff --git a/cors_integration_test.go b/cors_integration_test.go new file mode 100644 index 0000000..2c6e3d5 --- /dev/null +++ b/cors_integration_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "passkey-auth/internal/cors" +) + +func TestWildcardCORSIntegration(t *testing.T) { + // Create a simple test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Create CORS middleware with wildcard support + corsMiddleware := cors.WildcardCORS(cors.Config{ + AllowedOrigins: []string{"*.junv.cc", "https://static.example.com"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"*"}, + AllowCredentials: true, + }) + + // Wrap the test handler + handler := corsMiddleware(testHandler) + + tests := []struct { + name string + origin string + expectAllowed bool + expectedOrigin string + }{ + { + name: "wildcard subdomain match", + origin: "https://api.junv.cc", + expectAllowed: true, + expectedOrigin: "https://api.junv.cc", + }, + { + name: "wildcard base domain match", + origin: "https://junv.cc", + expectAllowed: true, + expectedOrigin: "https://junv.cc", + }, + { + name: "static domain match", + origin: "https://static.example.com", + expectAllowed: true, + }, + { + name: "no match", + origin: "https://evil.com", + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a preflight OPTIONS request + req := httptest.NewRequest("OPTIONS", "/", nil) + req.Header.Set("Origin", tt.origin) + req.Header.Set("Access-Control-Request-Method", "POST") + + // Record the response + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Check CORS headers + allowOriginHeader := w.Header().Get("Access-Control-Allow-Origin") + + if tt.expectAllowed { + if allowOriginHeader == "" { + t.Errorf("Expected Access-Control-Allow-Origin header, but got none") + } + + // For wildcard matches, should return the specific origin + if tt.expectedOrigin != "" && allowOriginHeader != tt.expectedOrigin { + t.Errorf("Expected Access-Control-Allow-Origin: %s, got: %s", tt.expectedOrigin, allowOriginHeader) + } + } else { + if allowOriginHeader != "" { + t.Errorf("Expected no Access-Control-Allow-Origin header, but got: %s", allowOriginHeader) + } + } + }) + } +} diff --git a/internal/cors/middleware.go b/internal/cors/middleware.go new file mode 100644 index 0000000..26ddcca --- /dev/null +++ b/internal/cors/middleware.go @@ -0,0 +1,68 @@ +package cors + +import ( + "net/http" + "strings" + + "github.com/rs/cors" +) + +// Config holds the CORS configuration with wildcard support +type Config struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + AllowCredentials bool +} + +// WildcardCORS creates a CORS handler with wildcard domain support +func WildcardCORS(config Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // Determine allowed origins for this request + var allowedOrigins []string + + // Separate wildcard and static origins + var wildcardPatterns []string + var staticOrigins []string + + for _, configuredOrigin := range config.AllowedOrigins { + if strings.Contains(configuredOrigin, "*") { + wildcardPatterns = append(wildcardPatterns, configuredOrigin) + } else { + staticOrigins = append(staticOrigins, configuredOrigin) + } + } + + // Check if origin matches any wildcard pattern + if len(wildcardPatterns) > 0 && origin != "" { + wildcardMatcher := NewWildcardMatcher(wildcardPatterns) + if wildcardMatcher.MatchOrigin(origin) { + // For wildcard matches, allow the specific origin + allowedOrigins = []string{origin} + } + } + + // If no wildcard match, use static origins + if len(allowedOrigins) == 0 { + allowedOrigins = staticOrigins + } else { + // If we had a wildcard match, also include static origins + allowedOrigins = append(allowedOrigins, staticOrigins...) + } + + // Create a new CORS instance for this request with the determined origins + c := cors.New(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: config.AllowedMethods, + AllowedHeaders: config.AllowedHeaders, + AllowCredentials: config.AllowCredentials, + }) + + // Use the rs/cors handler + c.Handler(next).ServeHTTP(w, r) + }) + } +} diff --git a/internal/cors/wildcard.go b/internal/cors/wildcard.go new file mode 100644 index 0000000..6003d67 --- /dev/null +++ b/internal/cors/wildcard.go @@ -0,0 +1,100 @@ +package cors + +import ( + "strings" +) + +// WildcardMatcher provides wildcard domain matching for CORS origins +type WildcardMatcher struct { + patterns []string +} + +// NewWildcardMatcher creates a new wildcard matcher with the given patterns +func NewWildcardMatcher(patterns []string) *WildcardMatcher { + return &WildcardMatcher{ + patterns: patterns, + } +} + +// MatchOrigin checks if the given origin matches any of the wildcard patterns +func (m *WildcardMatcher) MatchOrigin(origin string) bool { + for _, pattern := range m.patterns { + if m.matchPattern(origin, pattern) { + return true + } + } + return false +} + +// matchPattern checks if origin matches a specific pattern +// Supports patterns like: +// - "*.example.com" matches "api.example.com", "auth.example.com", etc. +// - "*.*.example.com" matches "api.v1.example.com", etc. +// - "example.com" matches exactly "example.com" +func (m *WildcardMatcher) matchPattern(origin, pattern string) bool { + // Remove protocol from origin if present + origin = strings.TrimPrefix(origin, "https://") + origin = strings.TrimPrefix(origin, "http://") + + // Remove port if present + if colonIndex := strings.LastIndex(origin, ":"); colonIndex != -1 && colonIndex > strings.LastIndex(origin, "]") { + origin = origin[:colonIndex] + } + + // Exact match + if origin == pattern { + return true + } + + // Wildcard match + if strings.Contains(pattern, "*") { + return m.wildcardMatch(origin, pattern) + } + + return false +} + +// wildcardMatch performs wildcard matching +func (m *WildcardMatcher) wildcardMatch(origin, pattern string) bool { + // Handle simple case: *.domain.com + if strings.HasPrefix(pattern, "*.") { + suffix := pattern[2:] // Remove "*." + + // Check if origin ends with the suffix and has at least one subdomain + if strings.HasSuffix(origin, "."+suffix) { + // Ensure there's a subdomain (not just the suffix itself) + prefix := strings.TrimSuffix(origin, "."+suffix) + // Make sure the prefix doesn't contain dots (single-level subdomain wildcard) + // If you want multi-level subdomains, remove this check + return !strings.Contains(prefix, ".") + } + + // Also check if origin exactly matches the suffix (without subdomain) + return origin == suffix + } + + // For more complex patterns, we could implement more sophisticated matching + // For now, handle the common *.domain.com case + return false +} + +// GetAllowedOrigins returns the actual allowed origins for a request +// This expands wildcard patterns based on the request origin +func (m *WildcardMatcher) GetAllowedOrigins(requestOrigin string, staticOrigins []string) []string { + allowedOrigins := make([]string, 0, len(staticOrigins)) + + for _, origin := range staticOrigins { + if strings.Contains(origin, "*") { + // This is a wildcard pattern + if m.matchPattern(requestOrigin, origin) { + // Add the actual request origin instead of the pattern + allowedOrigins = append(allowedOrigins, requestOrigin) + } + } else { + // This is a static origin, add as-is + allowedOrigins = append(allowedOrigins, origin) + } + } + + return allowedOrigins +} diff --git a/internal/cors/wildcard_test.go b/internal/cors/wildcard_test.go new file mode 100644 index 0000000..89abfd9 --- /dev/null +++ b/internal/cors/wildcard_test.go @@ -0,0 +1,138 @@ +package cors + +import ( + "testing" +) + +func TestWildcardMatcher(t *testing.T) { + tests := []struct { + name string + patterns []string + origin string + expected bool + }{ + { + name: "exact match", + patterns: []string{"example.com"}, + origin: "example.com", + expected: true, + }, + { + name: "exact match with https", + patterns: []string{"example.com"}, + origin: "https://example.com", + expected: true, + }, + { + name: "wildcard subdomain match", + patterns: []string{"*.junv.cc"}, + origin: "api.junv.cc", + expected: true, + }, + { + name: "wildcard subdomain match with https", + patterns: []string{"*.junv.cc"}, + origin: "https://auth.junv.cc", + expected: true, + }, + { + name: "wildcard subdomain match with port", + patterns: []string{"*.junv.cc"}, + origin: "https://dev.junv.cc:3000", + expected: true, + }, + { + name: "wildcard base domain match", + patterns: []string{"*.junv.cc"}, + origin: "junv.cc", + expected: true, + }, + { + name: "wildcard no match - different domain", + patterns: []string{"*.junv.cc"}, + origin: "api.example.com", + expected: false, + }, + { + name: "wildcard no match - multi-level subdomain", + patterns: []string{"*.junv.cc"}, + origin: "api.v1.junv.cc", + expected: false, + }, + { + name: "multiple patterns - first match", + patterns: []string{"*.junv.cc", "*.example.com"}, + origin: "api.junv.cc", + expected: true, + }, + { + name: "multiple patterns - second match", + patterns: []string{"*.junv.cc", "*.example.com"}, + origin: "api.example.com", + expected: true, + }, + { + name: "no match", + patterns: []string{"*.junv.cc", "*.example.com"}, + origin: "api.other.com", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher := NewWildcardMatcher(tt.patterns) + result := matcher.MatchOrigin(tt.origin) + if result != tt.expected { + t.Errorf("MatchOrigin() = %v, expected %v for origin %s with patterns %v", + result, tt.expected, tt.origin, tt.patterns) + } + }) + } +} + +func TestGetAllowedOrigins(t *testing.T) { + tests := []struct { + name string + patterns []string + requestOrigin string + expected []string + }{ + { + name: "wildcard match includes request origin", + patterns: []string{"*.junv.cc", "https://static.com"}, + requestOrigin: "https://api.junv.cc", + expected: []string{"https://api.junv.cc", "https://static.com"}, + }, + { + name: "no wildcard match returns static origins", + patterns: []string{"*.junv.cc", "https://static.com"}, + requestOrigin: "https://other.com", + expected: []string{"*.junv.cc", "https://static.com"}, + }, + { + name: "multiple wildcards, one matches", + patterns: []string{"*.junv.cc", "*.example.com"}, + requestOrigin: "https://api.junv.cc", + expected: []string{"https://api.junv.cc"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher := NewWildcardMatcher(tt.patterns) + result := matcher.GetAllowedOrigins(tt.requestOrigin, tt.patterns) + + if len(result) != len(tt.expected) { + t.Errorf("GetAllowedOrigins() returned %d origins, expected %d", len(result), len(tt.expected)) + return + } + + for i, expected := range tt.expected { + if result[i] != expected { + t.Errorf("GetAllowedOrigins()[%d] = %v, expected %v", i, result[i], expected) + } + } + }) + } +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml deleted file mode 100644 index 6800dc0..0000000 --- a/k8s/deployment.yaml +++ /dev/null @@ -1,118 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: passkey-auth-config - namespace: passkey-auth -data: - config.yaml: | - server: - port: "8080" - host: "0.0.0.0" - - webauthn: - rp_display_name: "Passkey Auth" - rp_id: "your-domain.com" - rp_origins: - - "https://your-domain.com" - - database: - path: "/data/passkey-auth.db" - - cors: - allowed_origins: - - "https://your-domain.com" - - auth: - session_secret: "your-session-secret-change-me" - require_approval: true - ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: passkey-auth-storage - namespace: passkey-auth -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: passkey-auth - namespace: passkey-auth - labels: - app: passkey-auth -spec: - replicas: 1 - selector: - matchLabels: - app: passkey-auth - template: - metadata: - labels: - app: passkey-auth - spec: - containers: - - name: passkey-auth - image: passkey-auth:latest - ports: - - containerPort: 8080 - env: - - name: CONFIG_PATH - value: "/config/config.yaml" - - name: DATABASE_PATH - value: "/data/passkey-auth.db" - volumeMounts: - - name: config - mountPath: /config - readOnly: true - - name: data - mountPath: /data - livenessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 5 - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "100m" - volumes: - - name: config - configMap: - name: passkey-auth-config - - name: data - persistentVolumeClaim: - claimName: passkey-auth-storage - ---- -apiVersion: v1 -kind: Service -metadata: - name: passkey-auth-service - namespace: passkey-auth - labels: - app: passkey-auth -spec: - selector: - app: passkey-auth - ports: - - port: 80 - targetPort: 8080 - protocol: TCP - type: ClusterIP diff --git a/k8s/ingress-example.yaml b/k8s/ingress-example.yaml deleted file mode 100644 index 222c4ec..0000000 --- a/k8s/ingress-example.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: example-app-ingress - namespace: default - annotations: - nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth" - nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth" - nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID" -spec: - ingressClassName: nginx - rules: - - host: your-app.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: your-app-service - port: - number: 80 - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: passkey-auth-ingress - namespace: passkey-auth - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / -spec: - ingressClassName: nginx - rules: - - host: your-domain.com - http: - paths: - - path: /auth - pathType: Prefix - backend: - service: - name: passkey-auth-service - port: - number: 80 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml deleted file mode 100644 index a47739d..0000000 --- a/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: passkey-auth diff --git a/main.go b/main.go index e6a8d7a..173138a 100644 --- a/main.go +++ b/main.go @@ -8,11 +8,11 @@ import ( "time" "github.com/gorilla/mux" - "github.com/rs/cors" "github.com/sirupsen/logrus" "passkey-auth/internal/auth" "passkey-auth/internal/config" + "passkey-auth/internal/cors" "passkey-auth/internal/database" "passkey-auth/internal/handlers" ) @@ -74,15 +74,15 @@ func main() { // Static files for admin UI router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET") - // Setup CORS - c := cors.New(cors.Options{ + // Setup CORS with wildcard support + corsHandler := cors.WildcardCORS(cors.Config{ AllowedOrigins: cfg.CORS.AllowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"*"}, AllowCredentials: true, }) - handler := c.Handler(router) + handler := corsHandler(router) // Start server port := os.Getenv("PORT") diff --git a/web/index.html b/web/index.html index 1184732..a8dae04 100644 --- a/web/index.html +++ b/web/index.html @@ -76,7 +76,7 @@ justify-content: center; margin-top: 1rem; } - + .grid { grid-template-columns: 1fr; text-align: center; @@ -117,18 +117,18 @@

Sign in to your account

Use your passkey for secure authentication

- +
- +
- +