Caddy Security integration with a GraphQL backend

Explore the versatility of Caddy, an open-source web server, and its Caddy Security plugin in our latest article. We walk through setup, discuss its limitations for complex apps, and offer solutions through backend integration. Using Apollo Server and TypeGraphQL, we demonstrate how Caddy can flexibly manage role-based access control, making it a valuable tool for enterprise applications.

What is Caddy?

Caddy is one of the more popular web servers; according to the description from their website: “Caddy 2 is a powerful, enterprise-ready, open source web server with automatic HTTPS written in Go”. At Nubisoft we usually use it as a replacement for nginx’s reverse proxy capability, but one of the most valuable features that Caddy provides is automatic HTTPS which greatly simplifies obtaining and renewing TLS certificates.

What is Caddy Security?

Caddy was designed with modularity in mind and allows for the creation of dedicated plugins. One of those plugins is Caddy Security. This plugin enables authentication, authorization, multi-factor authentication as well as supports different authentication methods such as LDAP, OAuth 2.0, SAML, and more. Where Caddy Security excels is the simplicity of initial setup. Basically, there are 4 steps: add a security block with a chosen identity provider, setup an authentication portal, create authorization policies and add authorize to a chosen route.

So first we setup a security block for a local provider (it stores users in JSON file on a local filesystem):

security {
        local identity store localdb {
            realm local
            path {$HOME}/.local/caddy/users.json
        }

Then we setup the authentication portal:

authentication portal myportal {
		crypto default token lifetime 3600
		crypto key sign-verify {env.JWT_SHARED_KEY}
		enable identity store localdb
		cookie domain nubisoft.com
                {...}
	}

We create an authorization policy:

authorization policy admins_policy {
	set auth url https://nubisoft.com
		allow roles nubisoft/admin
		crypto key verify {env.JWT_SHARED_KEY}
			acl rule {
				comment allow admins
				match role nubisoft/admin
				allow stop log info
			}
		}

And add that policy to one of our routes:

nubisoft.com {
	route /admins* {
		authorize with admins_policy
		respond * "assetq - admins" 200
	}
}

Voila, with those 4 simple steps we’ll get to the login screen:

Great for simple apps but?

The usual approach when creating web apps is that we develop a frontend app and a backend app. Frontend communicates with the backend via HTTP and the backend communicates with the database, and implements business logic. What Caddy Security does is in principle very simple: it wraps Caddy webserver routing with its authorization policies. That approach is perfect for internal apps with, no roles and a small amount of users. But the moment that this becomes a real issue is when you need to restrict access to some of the resources based on a given role.

Integrating with a typical backend solution

What is quite cool about Caddy Security is that it will add JWT token to every request and that token also includes roles defined in a users.json file.

For example, we have a user called webadmin with a role admin for an organization Nubisoft:

    {
      {...}
      "username": "webadmin",
      "email_address": {
        "address": "webadmin@localdomain.local",
        "domain": "localdomain.local"
      },
      "email_addresses": [
        {
          "address": "webadmin@localdomain.local",
          "domain": "localdomain.local"
        }
      ],
      "passwords": [
        {...}
      ],
      "roles": [
        {
          "name": "admin",
          "organization": "nubisoft"
        }
      ]
    }

So our plan was:

  • add specific roles to particular users so we can extract them from JWT
  • validate those roles per request
  • restrict access whenever the required role doesn’t match

In our particular case, the backend was using Apollo Server and TypeGraphQL so all of our critical endpoints had to be annotated like this with @Authorized:

  @Authorized<Role>(['nubisoft/admin'])
  @Query(() => [User])
  async users(): Promise<User[]> {
    const data = await this.usersRepository.findUsers();
    return data;
  }

Code wise we’ve created an authorization middleware that would parse Caddy Security’s JWT, and extract roles from that:

  try {
    if (allowedRoles.length === 0) {
      return true;
    }

    const roles = getRolesFromAccessToken(accessToken);

    if (roles.find(role => role === 'nubisoft/admin')) {
      return true;
    }

    if (roles.find(role => allowedRoles.includes(role))) {
      return true;
    }

    return false;
  } catch (e) {
    return false;
  }

And basically, that’s it. With that approach we can quite simply integrate Caddy Security with any kind of existing backend server – it’s not initially obvious but because of the design of Caddy Security it’s possible.

Another way to achieve such behavior would be to create dedicated API routes for each role. So for example we’d create a block in Caddyfile for a route /api/admin and add policy authorization.

With a usual REST API that shouldn’t be an issue, but GraphQL only exposes a single endpoint by design so that wasn’t really the path that we could take here.

Summary

This article delves into the functionality and application of Caddy, a versatile open-source web server, and its Caddy Security plugin. In this article, we’ve taken a deep dive into how to integrate Caddy Security with a typical backend server with authentication and authorization.

There are lots of different auth solutions on the market; self-hosted and quite complex like Keycloak or Authentik or managed (and expensive) like Okta or Auth0, but for simple apps Caddy Security is a perfectly viable solution that doesn’t require complicated setup.

Leave a Reply

Your email address will not be published. Required fields are marked *