Auth

So the front half of our login form is done; now we need to do the back half.

There’s no getting around this: auth is a bit of a pain. We just have to slog through it together.

Remember I mentioned Bluesky? The best bit about Bluesky is the thing that powers it — the Atmosphere Protocol, or atproto. We’re going to use it in Squirrel.

The first thing we have to do is add some tables to our database — a user table, a session table, and a pair of tables for storing ephemeral atproto state. This goes in lib/server/db/schema.ts:

import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const user = sqliteTable('user', {
	id: text('id').primaryKey(),
	handle: text('handle'),
	display_name: text('display_name'),
	avatar_url: text('avatar_url'),
	created_at: integer('created_at', { mode: 'timestamp' })
		.notNull()
		.$defaultFn(() => new Date()),
	updated_at: integer('updated_at', { mode: 'timestamp' })
		.notNull()
		.$onUpdate(() => new Date())
});

export const session = sqliteTable('session', {
	id: text('id')
		.notNull()
		.$defaultFn(() => crypto.randomUUID())
		.primaryKey(),
	user_id: text('user_id')
		.notNull()
		.references(() => user.id),
	created_at: integer('created_at', { mode: 'timestamp' })
		.notNull()
		.$defaultFn(() => new Date()),
	updated_at: integer('updated_at', { mode: 'timestamp' })
		.notNull()
		.$onUpdate(() => new Date()),
	expires_at: integer('expires_at', { mode: 'timestamp' }).notNull()
});

export const atproto_state = sqliteTable('atproto_state', {
	key: text('key').primaryKey(),
	value: text('value').notNull()
});

export const atproto_session = sqliteTable('atproto_session', {
	key: text('key').primaryKey(),
	value: text('value').notNull()
});

Since we’ve updated our schema, we need to create a migration and apply it, using the scripts that were added to our package.json by sv when we selected the Drizzle option:

pnpm db:generate
pnpm db:push

Now that we have our tables set up, we can install the atproto libraries we need:

pnpm add @atproto/{api,oauth-client-node}

The way it works is this: inside the login form, we’re going to construct a URL, send the user there so that they can login, then wherever they logged in is going to call back to our app with the data we need.

Create a new file, lib/server/atproto.ts with the following:

import { getRequestEvent } from '$app/server';
import {
	buildAtprotoLoopbackClientMetadata,
	NodeOAuthClient,
	type NodeSavedSession,
	type NodeSavedState
} from '@atproto/oauth-client-node';
import { db } from './db';
import { atproto_session, atproto_state } from './db/schema';
import { eq } from 'drizzle-orm';

const SCOPE = 'atproto rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview';
const CALLBACK = `api/atproto/callback`;

export function createClient() {
	const { url } = getRequestEvent();

	return new NodeOAuthClient({
		clientMetadata: buildAtprotoLoopbackClientMetadata({
			scope: SCOPE,
			redirect_uris: [`${url.origin.replace('localhost', '127.0.0.1')}/${CALLBACK}`]
		}),
		stateStore: {
			async get(key) {
				const [data] = await db.select().from(atproto_state).where(eq(atproto_state.key, key));
				return data && (JSON.parse(data.value) as NodeSavedState);
			},
			async set(key, value) {
				await db.insert(atproto_state).values({ key, value: JSON.stringify(value) });
			},
			async del(key) {
				await db.delete(atproto_state).where(eq(atproto_state.key, key));
			}
		},
		sessionStore: {
			async get(key) {
				const [data] = await db.select().from(atproto_session).where(eq(atproto_session.key, key));
				return data && (JSON.parse(data.value) as NodeSavedSession);
			},
			async set(key, value) {
				await db.insert(atproto_session).values({ key, value: JSON.stringify(value) });
			},
			async del(key) {
				await db.delete(atproto_session).where(eq(atproto_session.key, key));
			}
		}
	});
}

There’s a lot going on here! A couple of things to note:

To call that URL, two things need to happen. First, the app needs to use the loopback IP address, not localhost. We can do that like so:

pnpm dev --host

Second, we need to create the callback route. Let’s do that now. Unlike before, where we created a +page.svelte file, this time we’re creating a server route where we have full control over the response. Create routes/api/atproto/callback/+server.ts:

import { createClient } from '$lib/server/atproto';
import { db } from '$lib/server/db';
import { session, user } from '$lib/server/db/schema';
import { Agent } from '@atproto/api';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ cookies, url }) => {
	const client = createClient();

	const oauth = await client.callback(url.searchParams);
	const agent = new Agent(oauth.session);
	const user_id = oauth.session.did;

	const profile = await agent.getProfile({ actor: user_id });

	const handle = profile.data.handle ?? null;
	const displayName = profile.data.displayName ?? profile.data.handle ?? user_id;
	const avatar = profile.data.avatar ?? null;
	const now = new Date();
	const expires_at = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30);

	const session_id = await db.transaction(async (tx) => {
		await tx
			.insert(user)
			.values({
				id: user_id,
				handle,
				display_name: displayName,
				avatar_url: avatar
			})
			.onConflictDoUpdate({
				target: user.id,
				set: {
					handle,
					display_name: displayName,
					avatar_url: avatar
				}
			});

		const [data] = await tx.insert(session).values({ user_id, expires_at }).returning();

		return data.id;
	});

	cookies.set('session_id', session_id, {
		path: '/',
		expires: expires_at
	});

	redirect(302, '/');
};

Again, lots of stuff happening here! The bits to pay attention to are the database transaction, the cookies.set(...) call, and the redirect.

The last piece of the puzzle: we need to initiate the auth flow inside our login handler:

export const login = form(
	v.object({
		handle: v.pipe(v.string(), v.nonEmpty('Handle cannot be empty'))
	}),
	async (data) => {
+		const client = createClient();
+		const url = await client.authorize(data.handle);

+		redirect(302, url);
	}
);

We now have working auth!

For the purposes of this workshop, we’re going to add a little cheat, so that if you don’t have an atproto account, or it’s not working for some reason, we can still build the app:

+import { dev } from '$app/env';

export const login = form(
	v.object({
		handle: v.pipe(v.string(), v.nonEmpty('Handle cannot be empty'))
	}),
	async (data) => {
+		if (dev && data.handle === 'test') {
+			const { cookies } = getRequestEvent();

+			await db
+				.insert(user)
+				.values({
+					id: 'test',
+					handle: 'test',
+					display_name: 'test',
+					avatar_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAABYlAAAWJQFJUiTwAAAADElEQVQImWPIEfkPAAJvAYB6pUueAAAAAElFTkSuQmCC'
+				})
+				.onConflictDoNothing();

+			cookies.set('session_id', 'test', {
+				path: '/'
+			});

+			redirect(302, '/');
+		}

		const client = createClient();
		const url = await client.authorize(data.handle);

		redirect(302, url);
	}
);