Implementing Register and Login in Cloudflare Workers with D1

Implementing Register and Login in Cloudflare Workers with D1

Recently, I was building on a new Cloudflare Workers backend and needed a very simple authentication system, that would allow registration and login for a frontend application. After searching around I didn’t find a good example or tutorial about this topic, so I decided to build my own.

You can see the full code of this example in this authentication-using-d1-example github repo.

In this example, I will be using itty-router-openapi to generate OpenAPI 3 compliant schemas and validate the input parameters and workers-qb to generate SQL queries that will make the code more readable.

Table of Contents

  1. Project setup
  2. Users register endpoint
  3. Users login endpoint
  4. Authenticating routes
  5. Application router
  6. Final Result

1. Project setup

First, install dependencies with the following commands.

npm install --save-dev wrangler@^3.8.0
npm install --save workers-qb@^1.1.1
npm install --save @cloudflare/itty-router-openapi@^1.0.3

For this project you will need to define a SALT_TOKEN that is used in the password hash generation, this can be any string and you can generate it in a password generator or just write something yourself. It is also recommended, that after you follow this tutorial you move this variable from the wrangler.toml into a Cloudflare secret.

You will also need a D1 database, but this can always be adapted to use another database or your own PostgreSQL instance.

After you have installed and logged in wrangler, go ahead and create a new D1 database with the following command.

wrangler d1 create <db-name>  --experimental-backend

Update your wrangler.toml with the SALT_TOKEN and D1 binding.

name = "d1-auth-example"
main = "src/index.ts"
compatibility_date = "2023-05-27"


[[d1_databases]]
binding = "DB"
database_name = "d1-auth-example"
database_id = "e90ca6b1-4de8-4b88-821a-0b7af3e40dc2"

[vars]
SALT_TOKEN = "sadfasdghjyt45645t"

Now bootstrap your database by creating the initial tables for storing users and user sessions.

wrangler d1 execute DB --local --command="create table users
(
    id       integer primary key autoincrement,
    email    text not null unique,
    password text not null,
    name     text not null
);"

wrangler d1 execute DB --local --command="create table users_sessions
(
    session_id integer primary key autoincrement,
    user_id    integer not null
        constraint users_sessions_users_id_fk
            references users
            on update cascade on delete cascade,
    token      text not null,
    expires_at integer    not null
);"

2. Users register endpoint

The password must not be saved in raw text in the database and for this, we can use a very simple SHA-256 to generate a hash that represents the password.

This function receives the raw password + the SALT_TOKEN we defined earlier to generate a unique token.

async function hashPassword(password: string, salt: string): Promise<string> {
    const utf8 = new TextEncoder().encode(`${salt}:${password}`);

    const hashBuffer = await crypto.subtle.digest({name: 'SHA-256'}, utf8);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray
       .map((bytes) => bytes.toString(16).padStart(2, '0'))
       .join('');
}

Then in the actual endpoint code we just need to call this function when we are inserting the new user.

import { Email, OpenAPIRoute } from '@cloudflare/itty-router-openapi';
import { z } from 'zod'

export class AuthRegister extends OpenAPIRoute {
    static schema = {
        tags: ['Auth'],
        summary: 'Register user',
        requestBody: {
            name: String,
            email: new Email(),
            password: z.string().min(8).max(16),
        },
        responses: {
            '200': {
                description: "Successful response",
                schema: {
                    success: Boolean,
                    result: {
                        user: {
                            email: String,
                            name: String
                        }
                    }
                },
            },
            '400': {
                description: "Error",
                schema: {
                    success: Boolean,
                    error: String
                },
            },
        },
    };

    async handle(request: Request, env: any, context: any, data: Record<string, any>) {
        let user
        try {
            user = await context.qb.insert({
                tableName: 'users',
                data: {
                    email: data.body.email,
                    name: data.body.name,
                    password: await hashPassword(data.body.password, env.SALT_TOKEN),
                },
                returning: '*'
            }).execute()
        } catch (e) {
            return new Response(JSON.stringify({
                success: false,
                errors: "User with that email already exists"
            }), {
                headers: {
                    'content-type': 'application/json;charset=UTF-8',
                },
                status: 400,
            })
        }

        return {
            success: true,
            result: {
                user: {
                    email: user.results.email,
                    name: user.results.name,
                }
            }
        }
    }
}

Here you can see that we are defining the requestBody that are our endpoint parameters and the responses This will be used by itty-router-openapi to validate the input parameters before our code gets executed.

Then inside the handle function we are trying to insert a new user, if an exception is raised during this, it is very likely due to the uniqueness of the email, the user is trying to register.

3. Users login endpoint

Now for the login endpoint, all we need to do is call the function that generates the password hash from the raw password and the SALT_TOKEN and check against the database, if a user is found, generate a session token and return it.

import { Email, OpenAPIRoute } from '@cloudflare/itty-router-openapi';
import { z } from 'zod'

export class AuthLogin extends OpenAPIRoute {
    static schema = {
        tags: ['Auth'],
        summary: 'Login user',
        requestBody: {
            email: new Email(),
            password: z.string().min(8).max(16),
        },
        responses: {
            '200': {
                description: "Successful response",
                schema: {
                    success: Boolean,
                    result: {
                        session: {
                            token: String,
                            expires_at: String
                        }
                    }
                },
            },
            '400': {
                description: "Error",
                schema: {
                    success: Boolean,
                    error: String
                },
            },
        },
    };

    async handle(request: Request, env: any, context: any, data: Record<string, any>) {
        const user = await context.qb.fetchOne({
            tableName: 'users',
            fields: '*',
            where: {
                conditions: [
                    'email = ?1',
                    'password = ?2'
                ],
                params: [
                    data.body.email,
                    await hashPassword(data.body.password, env.SALT_TOKEN)
                ]
            },
        }).execute()

        if (!user.results) {
            return new Response(JSON.stringify({
                success: false,
                errors: "Unknown user"
            }), {
                headers: {
                    'content-type': 'application/json;charset=UTF-8',
                },
                status: 400,
            })
        }

        let expiration = new Date();
        // Set the new session expiration to 7 days in the future
        expiration.setDate(expiration.getDate() + 7);

        const session = await context.qb.insert({
            tableName: 'users_sessions',
            data: {
                user_id: user.results.id,
                token: await hashPassword((Math.random() + 1).toString(3), env.SALT_TOKEN),
                expires_at: expiration.getTime()
            },
            returning: '*'
        }).execute()

        return {
            success: true,
            result: {
                session: {
                    token: session.results.token,
                    expires_at: session.results.expires_at,
                }
            }
        }
    }
}

4. Authenticating routes

Now for authenticating users, we just need to validate that the frontend client is sending a session token, then check if is in the database and it has not expired yet.

As said above, this is a very simple authentication system, and this can be improved if necessary, a very good improvement could be to instead of using a string token for sessions, use a JWT to encapsulate the token, then this authentication function could validate that the session token is valid without querying the database and improving the performance of the application.

export function getBearer(request: Request): null | string {
    const authHeader = request.headers.get('Authorization')
    if (!authHeader || authHeader.substring(0, 6) !== 'Bearer') {
        return null
    }
    return authHeader.substring(6).trim()
}


export async function authenticateUser(request: Request, env: any, context: any) {
    const token = getBearer(request)
    let session

    if (token) {
        session = await context.qb.fetchOne({
            tableName: 'users_sessions',
            fields: '*',
            where: {
                conditions: [
                    'token = ?1',
                    'expires_at > ?2',
                ],
                params: [
                    token,
                    new Date().getTime()
                ]
            },
        }).execute()
    }

    if (!token || !session.results) {
        return new Response(JSON.stringify({
            success: false,
            errors: "Authentication error"
        }), {
            headers: {
                'content-type': 'application/json;charset=UTF-8',
            },
            status: 401,
        })
    }

    // set the user_id for endpoint routes to be able to reference it
    env.user_id = session.results.user_id
}

Here you can see that we are expecting the session token to be placed in the Authorization header, but this piece can easily be adapted to expect the session token to be inside a cookie or anywhere needed.

5. Application router

To wrap things up, it’s just needed to setup the application router that tight things together.

Due to limitations in the current implementation of itty-router-openapi, it’s very important that the endpoint routes are defined in the following way:

  1. Endpoints that don’t require Auth
  2. Authentication middleware
  3. Endpoints that require Auth

For the testing purposes we will also create a search endpoint that retrieves repos from the github api.

export class GetSearch extends OpenAPIRoute {
	static schema = {
		tags: ["Search"],
		summary: "Search repositories by a query parameter",
		parameters: {
			q: Query(String, {
				description: "The query to search for",
				default: "cloudflare workers",
			}),
		},
		responses: {
			"200": {
                description: "Successful response",
				schema: {
					repos: [
						{
							name: "itty-router-openapi",
							description:
								"OpenAPI 3 schema generator and validator for Cloudflare Workers",
							stars: "80",
							url: "https://github.com/cloudflare/itty-router-openapi",
						},
					],
				},
			},
            "401": {
                description: "Not authenticated",
                schema: {
                    "success": false,
                    "errors": "Authentication error"
                },
            },
		},
	};

	async handle(request: Request, env, ctx, data: Record<string, any>) {
		const url = `https://api.github.com/search/repositories?q=${data.query.q}`;

		const resp = await fetch(url, {
			headers: {
				Accept: "application/vnd.github.v3+json",
				"User-Agent": "RepoAI - Cloudflare Workers ChatGPT Plugin Example",
			},
		});

		if (!resp.ok) {
			return new Response(await resp.text(), { status: 400 });
		}

		const json = await resp.json();

		// @ts-ignore
		const repos = json.items.map((item: any) => ({
			name: item.name,
			description: item.description,
			stars: item.stargazers_count,
			url: item.html_url,
		}));

		return {
			repos: repos,
		};
	}
}

And now the router code that defines all paths to endpoints.

import { OpenAPIRouter } from "@cloudflare/itty-router-openapi";
import { GetSearch } from "./search";
import { authenticateUser, AuthLogin, AuthRegister } from "./auth";
import { D1QB } from "workers-qb";

export const router = OpenAPIRouter({
    schema: {
       info: {
          title: "Authentication using D1",
           version: '1.0',
       },
       security: [
          {
             bearerAuth: [],
          },
       ],
    },
    docs_url: "/",
});

router.registry.registerComponent('securitySchemes', 'bearerAuth', {
    type: 'http',
    scheme: 'bearer',
})

// 1. Endpoints that don't require Auth
router.post('/api/auth/register', AuthRegister);
router.post('/api/auth/login', AuthLogin);


// 2. Authentication middleware
router.all('/api/*', authenticateUser)


// 3. Endpoints that require Auth
router.get("/api/search", GetSearch);


// 404 for everything else
router.all("*", () => new Response("Not Found.", { status: 404 }));


export default {
    fetch: async (request, env, ctx) => {
       // Inject query builder in every endpoint
       const qb = new D1QB(env.DB)
       // qb.setDebugger(true)
       return router.handle(request, env, {...ctx, qb: qb})
    },
};

6. Final Result

Now when executing the project and opening the browser at http://127.0.0.1:8787/ you will see a swagger interface were you can test the endpoints.

Swagger interface

Then you may call the search test endpoint, and be greeted with the error message saying that you are not authenticated.

Trying to access an endpoint un-authenticated

Then after calling the register endpoint with the desired credentials and then calling the login endpoint with the same credentials as before, you will receive a bearer token and an expiration date.

Now use the token to set your authentication in the swagger.

Authentication

Now calling the search test endpoint will actually return results.

Search results

You can see the full code of this example in this authentication-using-d1-example github repo.