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:
getRequestEventis a SvelteKit utility that gets information about the current request — which route is being requested, what cookies are attached to it, and so on.redirect_uristells the auth server where to send the data once the user has logged in. It’s just using the current origin plus thisCALLBACKURL
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);
}
);