Monitoring and Security

Authentication and Authorization


Learning Objectives

  • You remember the terms authentication and authorization.
  • You can set up a third-party authentication and authorization service Better Auth in a web application.

Here, we briefly visit the concepts of authentication and authorization, and then set up a third-party authentication and authorization service called Better Auth to demonstrate the use of a third-party tool for authentication.

Authentication and authorization

Authentication and authorization are basic concepts in building secure web applications. Authentication focuses on verifying the identity of a user, while authorization focuses verifying that the user has the necessary permissions to access a resource or perform an action.

In web applications, authentication is typically done so that the user initially authenticates with credentials (e.g. username and password), and if the credentials are correct, they receive a token (e.g. JWT) from the server. Then, on subsequent requests, the user sends the token as a part of the request, and the server verifies the token to authenticate the user.

Authentication, on the other hand, is then done using some sort of access control mechanism. As an example, if the application uses a database, the user might only have access to specific rows that they have created themselves — this needs to be explicitly checked in the application code. Additional access control mechanisms can be implemented using e.g. role-based access control (RBAC), attribute-based access control (ABAC), or policy-based access control (PBAC), each of which provide constraints on what a user — or a group of users — can do.

Methods for authentication and authorization are discussed in more detail in the Users and Security part of the Web Software Development course.

Although the Web Software Development course explicitly instructs how to create your own authentication and authorization system, it often makes sense to use a library or a service that provides these functionalities. Let’s look into this next.

Better Auth

There exists a range of authentication frameworks and libraries that can be used to implement authentication and authorization in web applications. One of them is Better Auth, which we add to our walking skeleton next.

Server-side setup

Installing Better Auth

In the server folder of the walking skeleton, run the following command.

deno install npm:better-auth npm:kysely-postgres-js

This adds better auth and a PostgresJS dialect to the deno.json file. After this, the deno.json file should look as follows (note that we’ve moved to using postgres directly from npm):

{
  "imports": {
    "@hono/hono": "jsr:@hono/hono@4.6.5",
    "better-auth": "npm:better-auth@1.2.2",
    "kysely-postgres-js": "npm:kysely-postgres-js@2.0.0",
    "postgres": "npm:postgres@3.4.5",
    "ioredis": "npm:ioredis@5.4.2"
  }
}

Environment variables

Then, modify the project.env file in the root folder of the project, and add the following lines to it:

BETTER_AUTH_SECRET=dab-secret
BETTER_AUTH_URL=http://localhost:8000

In production, you would use something else than the above secret and address. The secret should be a long, random string, and the address should be the address of your production server.

Further, modify the database configurations so that they use the name of the database service (in our case database) instead of the external name of the database service (in our case postgresql_database). That is, the whole config should look like the following:

POSTGRES_USER=username
POSTGRES_PASSWORD=password
POSTGRES_DB=database

FLYWAY_USER=username
FLYWAY_PASSWORD=password
FLYWAY_URL=jdbc:postgresql://database:5432/database

PGUSER=username
PGPASSWORD=password
PGDATABASE=database
PGHOST=database
PGPORT=5432

BETTER_AUTH_SECRET=dab-secret
BETTER_AUTH_URL=http://localhost:8000

If you do not change the host name as shown above, you might end up seeing a very cryptic error “Cannot read properties of undefined (reading ‘replace’)”.

Database schema

Then, create a database migration file to the folder database-migrations. Use the next number as a prefix, e.g. if the latest is V4__, use V5__. Use better_auth_schema as the name — e.g. V5__better_auth_schema.sql. Add the following content to the migration file:

CREATE TABLE "app_user" (
    "id" VARCHAR(255) NOT NULL,
    "name" VARCHAR(255) NOT NULL,
    "email" VARCHAR(255) NOT NULL,
    "emailVerified" BOOLEAN NOT NULL,
    "image" VARCHAR(255),
    "createdAt" TIMESTAMP NOT NULL,
    "updatedAt" TIMESTAMP NOT NULL,
    PRIMARY KEY ("id")
);

CREATE TABLE "session" (
    "id" VARCHAR(255) NOT NULL,
    "userId" VARCHAR(255) NOT NULL,
    "token" VARCHAR(255) NOT NULL,
    "expiresAt" TIMESTAMP NOT NULL,
    "ipAddress" VARCHAR(255),
    "userAgent" VARCHAR(255),
    "createdAt" TIMESTAMP NOT NULL,
    "updatedAt" TIMESTAMP NOT NULL,
    PRIMARY KEY ("id"),
    FOREIGN KEY ("userId") REFERENCES "app_user"("id")
);

CREATE TABLE "account" (
    "id" VARCHAR(255) NOT NULL,
    "userId" VARCHAR(255) NOT NULL,
    "accountId" VARCHAR(255) NOT NULL,
    "providerId" VARCHAR(255) NOT NULL,
    "accessToken" VARCHAR(255),
    "refreshToken" VARCHAR(255),
    "accessTokenExpiresAt" TIMESTAMP,
    "refreshTokenExpiresAt" TIMESTAMP,
    "scope" VARCHAR(255),
    "idToken" VARCHAR(255),
    "password" VARCHAR(255),
    "createdAt" TIMESTAMP NOT NULL,
    "updatedAt" TIMESTAMP NOT NULL,
    PRIMARY KEY ("id"),
    FOREIGN KEY ("userId") REFERENCES "app_user"("id")
);

CREATE TABLE "verification" (
    "id" VARCHAR(255) NOT NULL,
    "identifier" VARCHAR(255) NOT NULL,
    "value" VARCHAR(255) NOT NULL,
    "expiresAt" TIMESTAMP NOT NULL,
    "createdAt" TIMESTAMP NOT NULL,
    "updatedAt" TIMESTAMP NOT NULL,
    PRIMARY KEY ("id")
);

The above reflects the Core Schema of Better Auth. The schema includes tables for users, sessions, accounts, and verifications. The quotes in the table and column names are used to explicitly define the names as case-sensitive. This is important because Better Auth uses case-sensitive names for the tables and columns.

Authentication setup

Next, create a file called auth.js in the server folder, and add the following code to it:

import { betterAuth } from "better-auth";
import { PostgresJSDialect } from "kysely-postgres-js";
import postgres from "postgres";

const dialect = new PostgresJSDialect({
  postgres: postgres(),
});

export const auth = betterAuth({
  database: {
    dialect: dialect,
    type: "postgresql",
  },
  emailAndPassword: {
    enabled: true,
  },
  user: {
    modelName: "app_user",
  },
});

The above code sets up Better Auth with a PostgresJS dialect. It enables email and password authentication, and sets the user model name to app_user — by default, the model name is user, which is a reserved keyword in PostgreSQL.

Hono setup

Finally, with the above configuration in place, modify the app.js file in the server folder to include the following code:

// other imports
import { auth } from "./auth.js";

const app = new Hono();

app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
// other routes etc

The above code adds a route to the application that handles authentication requests. The auth.handler function is a middleware from Better Auth that handles authentication requests.

Phew! Now, the server-side setup for Better Auth is complete. Next, let’s move on to the client-side setup.

Client-side setup

Installing Better Auth

Next, in the client folder of the walking skeleton, add better auth to the project.

deno install npm:better-auth

At this point, the package.json file in the client folder should look something like the following. The version numbers of the dependencies might differ to some extent.

{
  "name": "client",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "@astrojs/mdx": "4.1.0",
    "@astrojs/svelte": "7.0.5",
    "@deno/astro-adapter": "0.2.0",
    "astro": "5.4.1",
    "better-auth": "1.2.2",
    "svelte": "5.21.0"
  }
}

Auth utils

Then, create a folder called auth to client/src/utils, and add a file called auth.js to the folder. Add the following code to the file:

import { createAuthClient } from "better-auth/svelte";

export const authClient = createAuthClient();

The above code creates an auth client that connects to the local server (assuming that the API is at the same host). The client is used to make requests to the server for authentication and authorization.

Registration and login component

Then, create a folder called auth to client/src/components and create a file called RegistrationAndLoginForm.svelte in the folder. Add the following code to the file:

<script>
  import { authClient } from "../../utils/auth.js";
  let { isLoginForm = false } = $props();
  const authFun = isLoginForm
    ? authClient.signIn.email
    : authClient.signUp.email;

  let email = $state("");
  let password = $state("");

  const registerOrLogin = async (e) => {
    e.preventDefault();

    const { data, error } = await authFun(
      {
        email,
        password,
        name: email,
      },
      {
        onError: (ctx) => {
          alert(ctx.error.message);
        },
        onSuccess: (ctx) => {
          window.location.href="/";
        },
      }
    );
  };
</script>

<form onsubmit={registerOrLogin}>
  <label for="email">Email</label>
  <input type="email" id="email" bind:value={email} />

  <label for="password">Password</label>
  <input type="password" id="password" bind:value={password} />

  <button type="submit">{isLoginForm ? "Login" : "Register"}</button>
</form>

The above is a dual-purpose component that can be used for both registration and login. The component takes a prop isLoginForm that determines whether the component is used for login or registration. The component uses the authClient to send requests to the server for authentication and authorization. When the request is successful, the user is redirected to the main page.

Registration page

Then, create a folder called auth to client/src/pages, and add a file register.astro to the folder. Place the following content to the file:

---
import RegistrationAndLoginForm from "../../components/auth/RegistrationAndLoginForm.svelte";
---

<RegistrationAndLoginForm client:visible />

The above code creates a registration form that allows the user to register with an email and password. The form sends a request to the server to register the user.

At this point, when we go to the address http://localhost:8000/auth/register, we can test the registration form — when you fill in the form, say with test@test.net for both the email and password, and click the register button, you should see an alert saying “Registration successful”.

In addition, when we login to the database, there should now be a row in both the app_user and the session table. The row in app_user corresponds to the registered user, while the row in session highlights that the user is authenticated. Furthermore, when you check the cookies in the browser, you should see a cookie called better_auth — the cookie is used to store the session token.

Login page

Next, create a file called login.astro to the client/src/pages/auth folder, and add the following content to the file:

---
import RegistrationAndLoginForm from "../../components/auth/RegistrationAndLoginForm.svelte";
---

<RegistrationAndLoginForm isLoginForm client:visible />

Now, the login page is also in place and available at http://localhost:8000/auth/login.

Sharing user state and showing user

To share user state across components, we can create a new file called userState.svelte.js in the client/src/states folder, and add the functionality for retrieving the user information to it. Below, the user state is stored in a stateful variable, and the user information is fetched from the session when the component is first loaded.

import { authClient } from "../utils/auth.js";

let userState = $state({ loading: true });
let userStatePromise = null;

const getUserFromSession = () => {
  if (userStatePromise) {
    return;
  }

  userStatePromise = userStatePromise || authClient.getSession();
  userStatePromise.then((session) => {
    if (session?.data?.user?.email) {
      userState = session?.data?.user;
    } else {
      userState = { email: null };
    }
  });
};

const useUserState = () => {
  if (import.meta.env.SSR) {
  } else if (!userState?.email) {
    getUserFromSession();
  }

  return {
    get loading() {
      return userState?.loading;
    },
    get email() {
      return userState?.email;
    },
  };
};

export { useUserState };

The userStatePromise is used to ensure that the user information is fetched only once per page load, and the loading property is used to indicate whether the user information is still being fetched.

With the above, we can e.g. create a UserInfo.svelte component that would show the user’s email if the user is logged in, and “Not logged in” if the user is not logged in.

To do this, create a file called UserInfo.svelte in the client/src/components/auth folder, and add the following content to the file:

<script>
  import { useUserState } from "../../states/userState.svelte.js";
  const userState = useUserState();
</script>

{#if userState.loading}
  <p>Loading...</p>
{:else if userState.email}
  <p>{userState.email}</p>
{:else}
  <p>No user info</p>
{/if}

Now, when the component is used in a page, it will show the user’s email if the user is logged in, and “No user info” if the user is not logged in.

Astro and middleware

In practice, if we would want to secure specific paths, we would add a middleware to Astro server that checks whether the user is authenticated already on the server. To make automated testing of the submitted exercises easier, we will not use Astro’s middleware in this course.