Logto x Cloudflare Workers: How to secure your workers from public access?
Tags: webdev, javascript, opensource, serverless
Published: 2024-05-30 08:05:05

In this article, we introduced how to secure your Cloudflare Workers APIs with Logto. We used Hono as the web application framework to streamline development.

---
[Cloudflare Workers](https://developers.cloudflare.com/workers/) (use Workers for short in following content) provides a serverless execution environment that allows you to create new applications or augment existing ones without configuring or maintaining infrastructure.

With Workers, you can build your serverless applications and deploy instantly across the globe for exceptional performance, reliability, and scale. Workers not only offers exceptional performance but also provides a remarkably generous free plan and affordable paid plans. Whether you're an individual developer or a large-scale team, Workers empowers you to rapidly develop and deliver products while minimizing operational overhead.

Workers are publicly accessible by default, necessitating protection measures to prevent attacks and misuse. [Logto](https://logto.io/) delivers a comprehensive, user-friendly, and scalable identity service that can safeguards Workers and all other web services.

This article delves into the process of securing your Workers using Logto.

# Build a Workers sample

Let's first build a Workers sample project with Hono on local machine.

```
// src/index.ts

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method === 'GET' && request.url.endsWith('/greet')) {
      return new Response('Hello Workers!', { status: 200 });
    } else {
      return new Response('Not Found', { status: 404 });
    }
  },
};
```
We use Wrangler CLI to deploy the sample to Cloudflare, hence we can access to the path.
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dm6aiczbx4ulj8pus52z.png)

# Guard Workers APIs
In order to compare public accessible API and protected API, we add a `GET /auth/greet` API which requires specific scopes to access.

```
// src/index.ts

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method === 'GET' && request.url.endsWith('/auth/greet')) {
      await authorizationValidator(request, env, ['greet:visitor']);
      return new Response('Hello Workers! (Authenticated)', { status: 200 });
    } else if (request.method === 'GET' && request.url.endsWith('/greet')) {
      return new Response('Hello Workers!', { status: 200 });
    } else {
      return new Response('Not Found', { status: 404 });
    }
  },
};
```
We can not access to the corresponding API without proper permission.
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m396lj660baiaa9af8qc.png)
In order to properly manage the access to Workers APIs, we introduce Logto.




# Setup Logto

Register an account if you do not have one.

We use Machine-to-machine (M2M) as an example to access the protected Workers APIs because it's straight forward. If you want to grant access to your web app users, the setup is quite similar, but you should use “User” role instead of “Machine-to-machine” role.

1. Enter [Logto Admin Console](https://auth.logto.io/sign-in) and go to “API resource” tab, create an API resource named “Workers sample API” with resource indicator to be `https://sample.workers.dev/` . Also create a permission `greet:visitor` for this API resource.
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/biiuolve3d528dte1tko.png)

2. Create “Workers admin role”, which is a “Machine-to-machine” role, and assign the greet:visitor scope to this role.
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o10mer48vn12zcrfrnjx.png)

3. Create a M2M app and assign the “Workers admin role” to the app.
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9uh323xsulnwwnltc3w3.png)


# Update Workers auth validator
Since Logto uses JWT access token under the hood, we need to implement the JWT validation logic in Workers.

Since the JWT access token is issued by Logto, we need to:

1. Get corresponding public key to verify the signature.
2. Verify the JWT access token's consumer to be Workers APIs.

These constants can be configured in `wrangler.toml` file [[1](https://developers.cloudflare.com/workers/configuration/environment-variables/)] and will be deployed as Workers' environment variables. You can also manage the environment variables manually on Cloudflare Dashboard.

```
// src/error.ts

export class AuthenticationError extends Error {
  name = 'AuthenticationError';

  constructor(
    message: string,
    public readonly error?: unknown
  ) {
    super(message);
  }
}

export class ServerError extends Error {
  name = 'ServerError';
}
// src/index.ts

/** Build API authorization */
import { createRemoteJWKSet, jwtVerify } from 'jose';

const buildGetJwkSet = async (issuerEndpoint: URL) => {
  const appendedEndpoint = new URL('/oidc/.well-known/openid-configuration', issuerEndpoint);
  const fetched = await fetch(appendedEndpoint, {
    headers: {
      'content-type': 'application/json',
    },
  });
  const json = await fetched.json();
  const result = z.object({ jwks_uri: z.string(), issuer: z.string() }).parse(json);
  const { jwks_uri: jwksUri, issuer } = result;

  return Object.freeze([createRemoteJWKSet(new URL(jwksUri)), issuer] as const);
};

export const verifyTokenWithScopes = async (
  token: string,
  env: Env,
  requiredScopes: string[] = []
) => {
  const issuerEndpoint = env.ISSUER_ENDPOINT;
  const workerResourceIndicator = env.WORKER_RESOURCE_INDICATOR;

  console.log('token', token);
  console.log('issuerEndpoint', issuerEndpoint);

  if (typeof issuerEndpoint !== 'string') {
    throw new ServerError('The env variable `ISSUER_ENDPOINT` is not set.');
  }

  if (typeof workerResourceIndicator !== 'string') {
    throw new ServerError('The env variable `WORKER_RESOURCE_INDICATOR` is not set.');
  }

  console.log('workerResourceIndicator', workerResourceIndicator);
  console.log('requiredScopes', requiredScopes);

  const [getKey, issuer] = await buildGetJwkSet(new URL(issuerEndpoint));
  try {
    const {
      payload: { scope },
    } = await jwtVerify(token, getKey, {
      issuer,
      audience: workerResourceIndicator,
    });

    const scopes = typeof scope === 'string' ? scope.split(' ') : [];
    if (!requiredScopes.every((scope) => scopes.includes(scope))) {
      throw new AuthenticationError('The token does not have required scopes.');
    }
  } catch (error) {
    throw new AuthenticationError('JWT verification failed.', error);
  }

  return true;
};

async function authorizationValidator(
  request: Request,
  env: Env,
  requiredScopes: string[] = []
): Promise<Request> {
  // Check if the Authorization header exists
  const authHeader = request.headers.get('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    throw new AuthenticationError('Unauthorized, Bearer auth required.');
  }

  // Extract the token from the Authorization header
  const token = authHeader.split(' ')[1];
  if (!token) {
    throw new AuthenticationError('Unauthorized, missing Bearer token.');
  }

  // Perform additional validation or processing with the token if needed
  await verifyTokenWithScopes(token, env, requiredScopes);

  // Return the authorized request
  return request;
}
/** Build API authorization */

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    try {
      if (request.method === 'GET' && request.url.endsWith('/auth/greet')) {
        // Check required scopes
        await authorizationValidator(request, env, ['greet:visitor']);
        return new Response('Hello Workers! (authenticated)', { status: 200 });
      } else if (request.method === 'GET' && request.url.endsWith('/greet')) {
        return new Response('Hello Workers!', { status: 200 });
      } else {
        return new Response('Not Found', { status: 404 });
      }
    } catch (error) {
      return errorHandler(request, env, ctx, error);
    }
  },
};
```

After deploying the Workers project to Cloudflare, we can test whether APIs are successfully protected.

1. Get access token
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ksimk8fmw815ufxnr6we.png)
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8dbjgkjm2hjknkcej9my.png)
2. Request Workers `GET /auth/greet` API
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zbe5fp8vcgqopzdu3fk5.png)


# Conclusion
With the step-by-step guide in this article, you should be able to use Logto to build guard for your Workers APIs.

In this article, we've employed the Wrangler CLI for local development and deployment of Workers projects. Cloudflare additionally offers robust and versatile [Workers APIs](https://developers.cloudflare.com/api/operations/worker-script-list-workers) to facilitate deployment and management.

Consider developing a SaaS application. The Cloudflare API empowers you to deploy dedicated Workers for each tenant at ease, in the mean time, Logto ensures that access tokens remain exclusive to their respective tenants. This granular control prevents unauthorized access across tenants, enhancing security and data privacy for your SaaS app users.

Logto's adaptable and robust architecture caters to the diverse authentication and authorization needs of various applications. Whether you're building a complex SaaS platform or a simple web app, Logto provides the flexibility and scalability to meet your specific requirements.


{% cta https://logto.io/?ref=dev %} Try Logto Cloud for free {% endcta %}