DEV Community

Cover image for SvelteKit Surreal Database Authentication
Jonathan Gamble
Jonathan Gamble

Posted on

SvelteKit Surreal Database Authentication

SvelteKit Surreal Login

TL;DR

I created a login for Surreal Database and SvelteKit. The core server function can be reused in ANY TS SSR Framework!

Setup

Create a new SvelteKit project with TS.

npx sv create surreal-auth
Enter fullscreen mode Exit fullscreen mode

Install latest version of Surreal JS. I am not using alpha for this demo.

npm i -D surrealdb
Enter fullscreen mode Exit fullscreen mode

Surreal JS SDK

Unfortunately, you must manually handle errors with try and catch. I am hoping to get this fixed with destructuring. Here I created some helper functions to remedy this and put all the database functions in one place.

Connect

export async function surrealConnect({
    namespace,
    database,
    url
}: {
    namespace: string,
    database: string,
    url: string
}) {

    const db = new Surreal();

    try {

        await db.connect(url, {
            namespace,
            database
        });

    } catch (e) {

        if (e instanceof SurrealDbError) {
            return {
                data: null,
                error: e
            };
        }

        if (e instanceof Error) {
            return {
                data: null,
                error: e
            };
        }

        return {
            data: null,
            error: new Error('Unknown error during SurrealDB connection')
        };
    }
    return {
        data: db,
        error: null
    };
}
Enter fullscreen mode Exit fullscreen mode

Login

export async function surrealLogin({
    db,
    namespace,
    database,
    username,
    password
}: {
    db: Surreal,
    namespace: string,
    database: string,
    username: string,
    password: string
}) {

    try {

        const signinData = await db.signin({
            namespace,
            database,
            variables: {
                username,
                password
            },
            access: 'user'
        });

        return {
            data: signinData,
            error: null
        };

    } catch (e) {

        if (e instanceof SurrealDbError) {
            return {
                data: null,
                error: e
            };
        }

        if (e instanceof Error) {
            return {
                data: null,
                error: e
            };
        }

        return {
            data: null,
            error: new Error('Unknown error during login')
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

Register

export async function surrealRegister({
    db,
    namespace,
    database,
    username,
    password
}: {
    db: Surreal,
    namespace: string,
    database: string,
    username: string,
    password: string
}) {

    try {

        const signupData = await db.signup({
            namespace,
            database,
            variables: {
                username,
                password
            },
            access: 'user'
        });

        return {
            data: signupData,
            error: null
        };

    } catch (e) {

        if (e instanceof SurrealDbError) {
            return {
                data: null,
                error: e
            };
        }

        if (e instanceof Error) {
            return {
                data: null,
                error: e
            };
        }

        return {
            data: null,
            error: new Error('Unknown error during registration')
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

Change Password

export async function surrealChangePassword({
    db,
    currentPassword,
    newPassword,
    userId
}: {
    db: Surreal,
    currentPassword: string,
    newPassword: string,
    userId: string
}) {

    try {

        const query = `
            UPDATE $id
            SET password = crypto::argon2::generate($new)
            WHERE crypto::argon2::compare(password, $old)
        `;

        const [result] = await db.query<[{
            id: string,
            password: string,
            username: string
        }][]>(query, {
            id: new StringRecordId(userId),
            old: currentPassword,
            new: newPassword
        });

        if (!result) {
            return {
                data: null,
                error: new Error("Password change failed")
            };
        }

        return {
            data: result[0],
            error: null
        };

    } catch (error) {

        if (error instanceof SurrealDbError) {
            return {
                data: null,
                error
            };
        }

        if (error instanceof Error) {
            return {
                error,
                data: null
            };
        }
        return {
            error: new Error('Unknown query error'),
            data: null
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ You need to use the correct namespace, database, and access user when performing surreal functions.

Surreal Server

I wanted this function to be reusable in any framework, with inspiration from supabase-ssr. We can setup our server function to handle cookies and our credentials.

export function surrealServer({
    cookies: {
        cookieName,
        setCookie,
        getCookie
    },
    credentials: {
        url,
        namespace,
        database
    }
}: {
    cookies: {
        cookieName?: string,
        setCookie: SetCoookieFn,
        getCookie: GetCookieFn
    },
    credentials: {
        url: string,
        namespace: string,
        database: string
    }
}) {

    const tokenName = cookieName || 'surreal_token';

    const surrealToken = getCookie(tokenName);
...
Enter fullscreen mode Exit fullscreen mode

Connect Wrapper

async function connect() {

    const { data: db, error: connectError } = await surrealConnect({
        namespace,
        database,
        url
    });

    if (connectError) {
        return {
            data: null,
            error: connectError
        };
    }

    if (surrealToken) {

        await db.authenticate(surrealToken);

        return {
            data: db,
            error: null
        };
    }

    // No token, ensure logged out

    logout();

    return {
        data: db,
        error: null
    };
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ Check for token, and authenticate if there is one.

Login and Register

async function login(username: string, password: string) {

    logout();

    const { data: db, error: dbError } = await connect();

    if (dbError) {
        return {
            db: null,
            error: dbError
        };
    }

    const {
        data: token,
        error: loginError
    } = await surrealLogin({
        db,
        namespace,
        database,
        username,
        password
    });

    if (loginError) {
        return {
            db: null,
            error: loginError
        };
    }

    setCookie(
        tokenName,
        token,
        TOKEN_COOKIE_OPTIONS
    );

    return {
        db,
        error: null
    };
};

async function register(username: string, password: string) {

    logout();

    const { data: db, error: dbError } = await connect();

    if (dbError) {
        return {
            db: null,
            error: dbError
        };
    }

    const {
        data: token,
        error: registerError
    } = await surrealRegister({
        db,
        namespace,
        database,
        username,
        password
    });

    if (registerError) {
        return {
            db: null,
            error: registerError
        };
    }

    setCookie(
        tokenName,
        token,
        TOKEN_COOKIE_OPTIONS
    );

    return {
        db,
        error: null
    };
};
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ Register or Login then save the token. This is a cookie wrapper function that you can set to work in any framework.

Change Password

async function changePassword(oldPassword: string, newPassword: string) {

    const userId = getUser();

    if (!userId) {
        return {
            data: null,
            error: new Error('Not authenticated')
        };
    }

    const { data: db, error: dbError } = await connect();

    if (dbError) {
        return {
            data: null,
            error: dbError
        };
    }

    const { data, error: changeError } = await surrealChangePassword({
        db,
        currentPassword: oldPassword,
        newPassword,
        userId
    });


    if (changeError) {
        return {
            data: null,
            error: changeError
        };
    }

    return {
        data,
        error: null
    };
}
Enter fullscreen mode Exit fullscreen mode

Logout

function logout() {

    const token = getCookie(tokenName);

    if (token) {
        // delete cookie equivalent
        setCookie(tokenName, '', {
            ...TOKEN_COOKIE_OPTIONS,
            maxAge: 0            
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ We can delete a cookie by setting it to expire. No need for an extraneous deleteCookie function.

Get User

function getUser() {

    const token = getCookie(tokenName);

    if (!token) {
        return null;
    }

    return decodeJwt(token).ID as string;
}

async function getUserInfo() {

    const { data: db, error: dbError } = await connect();

    if (dbError) {
        return null;
    }

    const info = await db.info();

    if (!info?.id) {
        return null;
    }

    return info.id.toString();
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ We DO NOT want to make an extraneous call to the database for every server call, but on secure queries, like change password, we do. To avoid extra fetching, we can decode the JWT and use getUser, otherwise, we can get the user data directly from the database with getUserInfo.

export function decodeJwt(token: string) {
    try {
        const [, payloadB64] = token.split('.');
        return JSON.parse(
            Buffer.from(payloadB64, 'base64url').toString()
        ) as JwtPayload;
    } catch {
        return {};
    }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ We need try and catch to prevent a problem with JSON.parse() or Buffer in case of data corruption.

Server Hook

For SvelteKit, we can use the hooks.server.ts file to allow our surreal server to be available everywhere.

import type { Handle } from '@sveltejs/kit';
import {
    PRIVATE_SURREALDB_URL,
    PRIVATE_SURREALDB_NAMESPACE,
    PRIVATE_SURREALDB_DATABASE
} from '$env/static/private';
import { surrealServer } from './lib/surreal/surreal-server';

const config = {
    url: PRIVATE_SURREALDB_URL,
    namespace: PRIVATE_SURREALDB_NAMESPACE,
    database: PRIVATE_SURREALDB_DATABASE
};

export const handle: Handle = async ({ event, resolve }) => {

    event.locals.surreal = surrealServer({
        cookies: {
            setCookie: (name, value, options) => 
                event.cookies.set(name, value, options),
            getCookie: (name) => event.cookies.get(name)
        },
        credentials: {
            url: config.url,
            namespace: config.namespace,
            database: config.database
        }
    });

    return resolve(event);
};
Enter fullscreen mode Exit fullscreen mode

We use the event in hooks with native cookie functions. This can work in ANY framework.

Remember to update the types.

import type { surrealServer } from '$lib/surreal/surreal-server';

declare global {
    namespace App {
        // interface Error {}
        interface Locals {
            surreal: ReturnType<typeof surrealServer>;
        }
        // interface PageData {}
        // interface PageState {}
        // interface Platform {}
    }
}

export {};
Enter fullscreen mode Exit fullscreen mode

Auth Guards

Hide page from unauthorized users:

import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ locals: { surreal } }) => {

    const userId = surreal.getUser();

    if (!userId) {
        redirect(303, '/login');
    }
};
Enter fullscreen mode Exit fullscreen mode

Hide Login page from session users:

export const load: PageServerLoad = async ({ locals: { surreal } }) => {

    const userId = surreal.getUser();

    if (userId) {
        redirect(303, '/');
    }
};
Enter fullscreen mode Exit fullscreen mode

Server Actions

    login: async ({ request, locals: { surreal } }) => {

        const formData = await request.formData();

        const { username, password } = Object.fromEntries(formData);

        if (typeof username !== 'string' || typeof password !== 'string') {
            error(500, 'Invalid form data');
        }

        const {
            error: loginError
        } = await surreal.login(username, password);

        if (loginError) {
            return {
                error: loginError.message
            };
        }

        redirect(303, '/');
    },

    logout: async ({ locals: { surreal } }) => {
        surreal.logout();
        redirect(303, '/');
    }
Enter fullscreen mode Exit fullscreen mode

If we're just logging in, we don't need to verify username and password strings. If we did, we could use valibot.

const registerSchema = v.object({
    username: v.pipe(
        v.string(),
        v.minLength(3, 'Username must be at least 3 characters long')
    ),
    password: v.pipe(
        v.string(),
        v.minLength(3, 'Password must be at least 3 characters long')
    )
});

...

export const actions: Actions = {

    register: async ({ request, locals: { surreal } }) => {

        const formData = await request.formData();

        const data = Object.fromEntries(formData);

        const result = v.safeParse(registerSchema, data);

        if (!result.success) {
            error(400, result.issues[0].message);
        }

        const { username, password } = result.output;

        const {
            error: registerError
        } = await surreal.register(username, password);

        if (registerError) {
            error(500, registerError.message);
        }

        redirect(303, '/');
    }
};
Enter fullscreen mode Exit fullscreen mode

Or, we could manually check for something simple.

export const actions: Actions = {

    changePassword: async ({ request, locals: { surreal } }) => {

        const formData = await request.formData();

        const { oldPassword, newPassword } = Object.fromEntries(formData);

        if (typeof oldPassword !== 'string'
            || typeof newPassword !== 'string'
            || newPassword.length < 3) {

            error(500, 'Invalid password data');
        }

        const {
            data: passwordChangeData,
            error: passwordChangeError
        } = await surreal.changePassword(oldPassword, newPassword);

        if (passwordChangeError) {
            return {
                error: passwordChangeError.message
            };
        }

        if (passwordChangeData?.password) {
            return {
                success: true
            };
        }

        return {
            success: true
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

Surreal Alpha is going to start handling REFRESH tokens, but this is the latest stable version for now.

And that's all folks!

Repo: GitHub
Example Schema: GitHub

J

Top comments (0)