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