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:

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.74.0
npm install --save workers-qb@^1.5.2
npm install --save hono@^4.5.11
npm install --save chanfana@^2.0.4"

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 = "2024-09-01"


[[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 {z} from 'zod'
import {OpenAPIRoute} from "chanfana";
import {D1QB} from "workers-qb";
import {User, UserSession} from "../types";

export class AuthRegister extends OpenAPIRoute {
    schema = {
        tags: ['Auth'],
        summary: 'Register user',
        request: {
            body: {
                content: {
                    'application/json': {
                        schema: z.object({
                            name: z.string(),
                            email: z.string().email(),
                            password: z.string().min(8).max(16),
                        }),
                    },
                },
            },
        },
        responses: {
            '200': {
                description: "Successful response",
                content: {
                    'application/json': {
                        schema: z.object({
                            success: z.boolean(),
                            result: z.object({
                                user: z.object({
                                    email: z.string(),
                                    name: z.string()
                                })
                            })
                        }),
                    },
                },
            },
            '400': {
                description: "Error",
                content: {
                    'application/json': {
                        schema: z.object({
                            success: z.boolean(),
                            error: z.string()
                        }),
                    },
                },
            },
        },
    };

    async handle(c) {
        // Validate inputs
        const data = await this.getValidatedData<typeof this.schema>()

        // Get query builder for D1
        const qb = new D1QB(c.env.DB)

        try {
            // Try to insert a new user
            await qb.insert<{ email: string, name: string, }>({
                tableName: 'users',
                data: {
                    email: data.body.email,
                    name: data.body.name,
                    password: await hashPassword(data.body.password, c.env.SALT_TOKEN),
                },
            }).execute()
        } catch (e) {
            // Insert failed due to unique constraint on the email column
            return Response.json({
                success: false,
                errors: "User with that email already exists"
            }, {
                status: 400,
            })
        }

        // Returning an object, automatically gets converted into a json response
        return {
            success: true,
            result: {
                user: {
                    email: data.body.email,
                    name: data.body.name,
                }
            }
        }
    }
}

Here you can see that we are defining the request.body that are our endpoint parameters and the responses This will be used by chanfana 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 {z} from 'zod'
import {OpenAPIRoute} from "chanfana";
import {D1QB} from "workers-qb";
import {User, UserSession} from "../types";

export class AuthLogin extends OpenAPIRoute {
    schema = {
        tags: ['Auth'],
        summary: 'Login user',
        request: {
            body: {
                content: {
                    'application/json': {
                        schema: z.object({
                            email: z.string().email(),
                            password: z.string().min(8).max(16),
                        }),
                    },
                },
            },
        },
        responses: {
            '200': {
                description: "Successful response",
                content: {
                    'application/json': {
                        schema: z.object({
                            success: z.boolean(),
                            result: z.object({
                                session: z.object({
                                    token: z.string(),
                                    expires_at: z.number().int()
                                })
                            })
                        }),
                    },
                },
            },
            '400': {
                description: "Error",
                content: {
                    'application/json': {
                        schema: z.object({
                            success: z.boolean(),
                            error: z.string()
                        }),
                    },
                },
            },
        },
    };

    async handle(c) {
        // Validate inputs
        const data = await this.getValidatedData<typeof this.schema>()

        // Get query builder for D1
        const qb = new D1QB(c.env.DB)

        // Try to fetch the user
        const user = await qb.fetchOne<User>({
            tableName: 'users',
            fields: '*',
            where: {
                conditions: [
                    'email = ?1',
                    'password = ?2'
                ],
                params: [
                    data.body.email,
                    await hashPassword(data.body.password, c.env.SALT_TOKEN)
                ]
            },
        }).execute()

        // User not found, provably wrong password
        if (!user.results) {
            return Response.json({
                success: false,
                errors: "Unknown user"
            }, {
                status: 400,
            })
        }

        // User found, define expiration date for new session token
        let expiration = new Date();
        expiration.setDate(expiration.getDate() + 7);

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

        // Returning an object, automatically gets converted into a json response
        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(c, next) {
    const token = getBearer(c.req.raw)

    // Get query builder for D1
    const qb = new D1QB(c.env.DB)

    let session

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

    if (!token || !session.results) {
        return Response.json({
            success: false,
            errors: "Authentication error"
        }, {
            status: 401,
        })
    }

    // This will be accessible from the endpoints as c.get('user_uuid')
    c.set('user_uuid', session.results.user_uuid)

    await next()
}

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 chanfana, 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.

import {z} from "zod";
import {OpenAPIRoute} from "chanfana";

export class GetSearch extends OpenAPIRoute {
    schema = {
        tags: ["Search"],
        summary: "Search repositories by a query parameter",
        request: {
            query: z.object({
                q: z.string().default('cloudflare workers').openapi({
                    description: "The query to search for"
                })
            })
        },
        responses: {
            "200": {
                description: "Successful response",
                content: {
                    'application/json': {
                        schema: z.object({
                            success: z.boolean(),
                            result: z.object({
                                name: z.string(),
                                description: z.string(),
                                stars: z.number().int(),
                                url: z.string()
                            }).array()
                        }),
                    },
                },
            },
            "401": {
                description: "Not authenticated",
                schema: {
                    "success": false,
                    "errors": "Authentication error"
                },
            },
        },
    };

    async handle(c) {
        // Validate inputs
        const data = await this.getValidatedData<typeof this.schema>()

        const resp = await fetch(`https://api.github.com/search/repositories?q=${data.query.q}`, {
            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,
        }));

        // Returning an object, automatically gets converted into a json response
        return {
            success: true,
            result: repos,
        };
    }
}

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

import {GetSearch} from "./endpoints/search";
import {authenticateUser, AuthLogin, AuthRegister} from "./foundation/auth";
import {Hono} from "hono";
import {fromHono} from "chanfana";
import {Bindings} from "./types";

// Start a Hono app
const app = new Hono<{ Bindings: Bindings }>()

// Setup OpenAPI registry
const openapi = fromHono(app, {
    schema: {
        info: {
            title: "Authentication using D1",
            version: '1.0',
        },
        security: [
            {
                bearerAuth: [],
            },
        ],
    },
    docs_url: "/",
})
openapi.registry.registerComponent('securitySchemes', 'bearerAuth', {
    type: 'http',
    scheme: 'bearer',
})

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


// 2. Authentication middleware
openapi.use('/api/*', authenticateUser)


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


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

// Export the Hono app
export default app

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.