Caddy-auth-json: A plugin for JSON-based auth services

Hi all,

Quick introduction first, since I’ve been showing up on a few issues and PRs lately.

I’ve spent the past week or so digging into the Caddy codebase, mostly the HTTP modules: how handlers are registered, how the Caddyfile adapter turns directives into JSON, and how matchers and placeholders work under the hood. While looking into a opened issue onforward_auth, I came across a gap around auth services that return JSON responses, which led me to build this plugin caddy-auth-json.

The problem

forward_auth works nicely when your auth service answers with headers. It looks at the status code, and it can copy headers like Remote-User onto the request.

Plenty of services don’t do that. They return a JSON document instead:

{
  "manage": true,
  "read": true,
  "user": { "id": "u-42" }
}

Caddy can’t read that. Response matchers only see the status and the headers, so there’s no way to route on manage or pass user.id to your backend. Cloud Foundry works exactly this way for its service dashboards, which is what got me looking at it (there’s an open issue about it, #6914).

The plugin

caddy-auth-json. It calls your auth service, pulls named fields out of the JSON reply, and puts them into Caddy variables. From there you use ordinary matchers.

xcaddy build --with github.com/faiyaz032/caddy-auth-json
example.com {
	auth_json {
		endpoint http://auth-service:9091/verify
		forward_headers Cookie
		user_id /user/id
		claim can_manage /manage
	}

	@can_manage expression {vars.can_manage} == true

	route {
		handle /admin/* {
			route {
				reverse_proxy @can_manage app:8080
				respond "Not allowed" 403
			}
		}
		handle {
			reverse_proxy app:8080 {
				header_up X-User {http.auth.user.id}
			}
		}
	}
}

Nothing is tied to those field names. claim and user_id take any pointer, so user_id /sub or claim roles /data/roles work just as well. The README has examples for a few common response shapes.

Two decisions worth mentioning

Fields are addressed with JSON Pointer (RFC 6901), not dots. A JSON key can itself contain a dot, so {"a.b": 1, "a": {"b": 2}} is legal JSON holding two different values, and dot notation can’t tell them apart. With pointers /a.b and /a/b are clearly different things.

Values keep the type they decoded as. A JSON true stays a real boolean, so {vars.can_manage} == true works rather than making you compare against the string "true". Arrays stay arrays too, so "admin" in {vars.roles} does what you’d hope.

What it doesn’t do

It makes its own request to the auth service rather than reading a proxied response, so the protected backend’s response is never buffered and streaming is unaffected. The flip side is that it doesn’t help if you want to branch on some arbitrary upstream’s JSON body. That would need a change in core.

There was a related proposal in #7797 to parse JSON response bodies inside reverse_proxy. That PR was closed with the suggestion that a narrower design mapping selected JSON fields to vars or headers would be easier to reason about, and that this would probably be better suited as a plugin. This plugin takes that approach specifically for the authentication use case.

It currently doesn’t send a request body, so it can’t talk to endpoints that require a form POST, such as OAuth 2.0 introspection.

Where it’s at

v0.1.0. It has tests and I’ve run the Cloud Foundry scenario end to end, but nobody has used it in production yet, including me. So please treat it accordingly.

Feedback very welcome, especially on the Caddyfile surface. If the option names feel wrong or something doesn’t fit how you’d expect a Caddy plugin to behave, I’d rather hear it now while it’s still v0 and I can change things freely.