CORS
When a single-page app (SPA) in the browser calls Wicket's discovery document, JWKS, token, or UserInfo endpoints directly, you need cross-origin support. This page explains what Wicket v0.1.4 does and doesn't do here, and gives a verified host middleware implementation.
The short version
- Wicket writes no
Access-Control-*response headers, and doesn't handleOPTIONSpreflight requests. Nothing in the code emits these headers. - Wicket does provide decision data: the list of cross-origin paths in the options,
opts.Cors.CorsPaths, and a service,services.CorsPolicyService, that decides whether an origin is allowed based on clients'AllowedCorsOrigins. But no component inside Wicket calls them. - So cross-origin support has to be handled by the host itself at the outermost layer of the middleware chain, and it can reuse the two pieces of decision data above directly.
What in Wicket relates to CORS
CorsOptions
WicketOptions.Cors has three fields:
| Field | Default | Actual effect in v0.1.4 |
|---|---|---|
CorsPaths | See below | Just a list of paths; Wicket doesn't read it internally |
CorsPolicyName | "wicket" | Only checked for non-empty at startup; when empty, srv.UseWicket() panics. Plays no other role |
PreflightCacheDuration | nil | No code reads it |
Default value of CorsPaths (from printing opts.Cors.CorsPaths):
/.well-known/openid-configuration
/.well-known/openid-configuration/jwks
/connect/token
/connect/userinfo
/connect/revocationOf these, /connect/revocation exists in v0.1.4 only as a path constant; no token revocation endpoint is registered, and requests to it land in the host's own handler.
CorsPolicyService and Client.AllowedCorsOrigins
// package services
type CorsPolicyService interface {
IsOriginAllowed(string) bool
}The implementation registered in the container iterates over the AllowedCorsOrigins of all current clients and returns true if any client lists the origin. Things to note:
- Comparison is an exact, character-by-character match: case, port, and trailing slash must all match the
Originheader the browser sends. For example, writehttps://app.example.com, nothttps://app.example.com/. - Wildcards are not supported.
- The decision is "global": as long as one client allows an origin, that origin is allowed on all CORS paths, regardless of which client is making the request (preflight requests don't carry a client identity anyway).
- The service is registered as Transient and reads the current client list at resolution time, keeping a snapshot. If you provide a changing client list with
cfg.SetClientsProvider, resolve the service on every request rather than resolving it once at startup and reusing it.
What happens when Wicket receives OPTIONS
Wicket's endpoint router matches by path only and ignores the method, so a preflight request that reaches Wicket is processed as a normal protocol request. In testing:
| Preflight request | Wicket's response |
|---|---|
OPTIONS /connect/token | 400, with response body invalid_request |
OPTIONS /.well-known/openid-configuration | 405 Method Not Allowed |
OPTIONS /connect/userinfo | 405 Method Not Allowed |
Neither response carries CORS headers, so the browser treats the preflight as failed. Preflight requests must therefore be answered by the host before they reach Wicket.
Host middleware
The following middleware handles only the paths in opts.Cors.CorsPaths, uses Wicket's CorsPolicyService to decide on the origin, and answers preflight requests itself. It has been compiled and run on top of the quick start example.
import (
"net/http"
"strings"
"github.com/gonewx/godi"
"github.com/gonewx/wicket/services"
)
// withCORS handles only wicket's cross-origin paths; all other requests pass through unchanged.
func withCORS(p godi.Provider, corsPaths []string, next http.Handler) http.Handler {
paths := make(map[string]bool, len(corsPaths))
for _, path := range corsPaths {
paths[strings.ToLower(path)] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" || !paths[strings.ToLower(r.URL.Path)] {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Origin")
// Resolve per request: the policy service reads the current client list at resolution time
allowed := godi.GetRequired[services.CorsPolicyService](p).IsOriginAllowed(origin)
// Preflight request: finish here and don't pass it to wicket (wicket doesn't handle OPTIONS)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
if allowed {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Methods", "GET, POST")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Max-Age", "600")
}
w.WriteHeader(http.StatusNoContent)
return
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
next.ServeHTTP(w, r)
})
}When wiring, place it outside hosting.ScopeMiddleware so that preflight requests never enter Wicket:
opts := godi.GetRequired[*options.WicketOptions](root)
// ... wire clients (including AllowedCorsOrigins), cfg.Freeze()
srv, err := server.DefaultServer(root, server.WithLogger(logger))
if err != nil {
log.Fatal(err)
}
wicketHandler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))
handler := withCORS(root, opts.Cors.CorsPaths, wicketHandler)Declare the allowed origins on the client:
client.AllowedCorsOrigins = []string{"https://app.example.com"}Test results (origin https://app.example.com is declared on the client, https://evil.example.com is not):
| Request | Result |
|---|---|
Preflight OPTIONS /connect/token, declared origin | 204, with Access-Control-Allow-Origin: https://app.example.com plus methods, headers, and Max-Age |
Preflight OPTIONS /connect/token, undeclared origin | 204, with no Access-Control-Allow-* headers; the browser blocks the follow-up request |
GET discovery document, declared origin | 200, with Access-Control-Allow-Origin |
GET discovery document, undeclared origin | 200, without Access-Control-Allow-Origin; the browser doesn't let scripts read the response |
POST /connect/token, declared origin | 200, the token response carries Access-Control-Allow-Origin |
A few design notes:
- Don't set
Access-Control-Allow-Credentials. These endpoints pass credentials through request parameters or theAuthorizationheader and don't rely on cookies, so there's no need to allow cross-origin requests with cookies. - Always include
Vary: Origin. The response differs byOrigin; without it, an intermediate cache might return one origin's CORS headers to another origin. - Choose
Max-Ageyourself. Wicket doesn't useopts.Cors.PreflightCacheDuration; if you want to configure it centrally, read it in the middleware and convert it to seconds. - Allow only the paths you need. The authorize endpoint
/connect/authorizeand the end session endpoint are full-page browser navigations and don't need CORS; the introspection endpoint is called by backend APIs and shouldn't be exposed to browsers either. If you need extra paths, append them to the list passed to the middleware; there's no need to modifyopts.Cors.CorsPaths.
Clients in the browser
An SPA running in the browser can't keep a client secret. When an SPA calls the token endpoint directly, use authorization code + PKCE and configure that client not to require a secret (a public client). Don't put a machine client's secret into frontend code just to get past CORS.
If you don't want to reuse Wicket's decision logic
If your origin allowlist has nothing to do with client configuration (for example, it is managed centrally by a gateway), replace CorsPolicyService in the middleware with your own set lookup and keep the rest of the logic. You can also use any mature third-party CORS middleware, as long as it meets two conditions: it covers the Wicket paths listed above, and it wraps the Wicket middleware from outside, so that preflight requests end before they reach Wicket.