DEV Community

Jonathan Gamble
Jonathan Gamble

Posted on

SvelteKit SurrealDB Login with GitHub

I wanted to make a working proof of concept that you can login with an oAuth provider directly in the database.

To be clear:

Surreal Database should have this built in, and I am hoping they will in the future just like Gel, Firebase and Supabase!

Thanks to Zorimyll for pseudocode in Discord.

Login With GitHub

TL;DR

I make a fully working SurrealDB SvelteKit login with GitHub button that automatically merges with existing users and their email! See Repo.

Pre-Config

We must first get rid of the username concept from this first post, and use email. This means refactoring all username references to email. This includes using valibot v.email() and type="email" in the inputs. For sake of time, I'm not going to do that here, but you can view the source code below to see the changes.

  • If you want to add the username capability, it should be optional in this scenario.
  • Forgot Password is a feature this needs, but we would need to setup an email provider for that (maybe in a later post).
  • I also didn't add the emailConfirmed field, which may be useful.

Schema

-- =====================================
-- USERS TABLE
-- =====================================

DEFINE TABLE users SCHEMAFULL
    PERMISSIONS
        FOR select WHERE $auth.id = id OR $auth.role = 'admin'
        FOR create, update, delete WHERE $auth.role = 'admin' OR $auth.scope = 'auth';

DEFINE FIELD email
    ON TABLE users
    TYPE string
    ASSERT $value != '' AND string::is::email($value)
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD password
    ON TABLE users
    TYPE string
    ASSERT $value != ''
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD name
    ON TABLE users
    TYPE option<string>
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD avatar_url
    ON TABLE users
    TYPE option<string>
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD OVERWRITE created_at
  ON TABLE users
  TYPE datetime
  DEFAULT time::now()
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD OVERWRITE updated_at
    ON TABLE users
    TYPE datetime
    VALUE time::now()
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE INDEX users_email_unique
    ON TABLE users
    COLUMNS email
    UNIQUE;

DEFINE ACCESS OVERWRITE user
    ON DATABASE
    TYPE RECORD
    SIGNUP (
        CREATE users CONTENT {
            email:    string::lowercase($email),
            password: crypto::argon2::generate($password)
        } RETURN AFTER
    )
    SIGNIN (
        SELECT * FROM users
        WHERE email = string::lowercase($email)
          AND crypto::argon2::compare(password, $password)
        LIMIT 1
    )
    DURATION
        FOR TOKEN 1h,
        FOR SESSION NONE;

-- =====================================
-- AUTH_ACCOUNTS TABLE
-- (per provider login -> user)
-- =====================================

DEFINE TABLE auth_accounts SCHEMAFULL
    PERMISSIONS
        FOR select WHERE $auth.role = 'admin' OR $auth.scope = 'auth'
        FOR create, update, delete WHERE $auth.role = 'admin' OR $auth.scope = 'auth';

DEFINE FIELD user
    ON TABLE auth_accounts
    TYPE record<users>
    ASSERT $value != NONE
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD provider
    ON TABLE auth_accounts
    TYPE string
    ASSERT $value != ''
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD provider_id
    ON TABLE auth_accounts
    TYPE string
    ASSERT $value != ''
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD created_at
    ON TABLE auth_accounts
    TYPE datetime
    DEFAULT time::now()
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE INDEX auth_accounts_provider_unique
    ON TABLE auth_accounts
    COLUMNS provider, provider_id
    UNIQUE;

DEFINE INDEX auth_accounts_user_idx
    ON TABLE auth_accounts
    COLUMNS user;

-- =====================================
-- AUTH_PROVIDERS TABLE
-- (stores client/secret for GitHub etc.)
-- =====================================

DEFINE TABLE auth_providers SCHEMAFULL
    PERMISSIONS
        FOR select WHERE $auth.role = 'admin' OR $auth.scope = 'auth'
        FOR create, update, delete WHERE $auth.role = 'admin';

DEFINE FIELD provider
    ON TABLE auth_providers
    TYPE string
    ASSERT $value != ''
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD client_id
    ON TABLE auth_providers
    TYPE string
    ASSERT $value != ''
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE FIELD client_secret
    ON TABLE auth_providers
    TYPE string
    ASSERT $value != ''
    PERMISSIONS
        FOR select, create, update, delete FULL;

DEFINE INDEX auth_providers_provider_unique
    ON TABLE auth_providers
    COLUMNS provider
    UNIQUE;

DEFINE FUNCTION OVERWRITE fn::github_login(
    $code: string
) {
    ------------------------------------------------------------
    -- 0) Load GitHub OAuth config from auth_providers
    ------------------------------------------------------------
    LET $provider = (
        SELECT client_id, client_secret
        FROM auth_providers
        WHERE provider = "github"
        LIMIT 1
    )[0];

    IF $provider = NONE {
        THROW {
            stage:   "missing_github_config",
            message: "No auth_providers row found for provider 'github'"
        };
    };

    IF $provider.client_id = NONE OR $provider.client_secret = NONE {
        THROW {
            stage:   "invalid_github_config",
            message: "auth_providers.github is missing client_id or client_secret",
            provider: $provider
        };
    };

    LET $github_client_id  = $provider.client_id;
    LET $github_secret_id  = $provider.client_secret;

    ------------------------------------------------------------
    -- 1) Exchange code for access token
    ------------------------------------------------------------
    LET $token_res = http::post(
        "https://github.com/login/oauth/access_token",
        {
            "client_id":     $github_client_id,
            "client_secret": $github_secret_id,
            "code":          $code
        },
        {
            "Accept": "application/vnd.github+json"
        }
    );

    IF $token_res = NONE {
        THROW {
            stage:   "token_request_failed",
            message: "GitHub /access_token returned NONE",
            token_res: $token_res
        };
    };

    LET $access_token = $token_res.access_token;

    IF $access_token = NONE {
        THROW {
            stage:   "missing_access_token",
            message: "GitHub did not return access_token",
            token_res: $token_res
        };
    };

    ------------------------------------------------------------
    -- 2) Fetch GitHub user profile
    ------------------------------------------------------------
    LET $headers = {
        "Accept":       "application/vnd.github+json",
        "Authorization": string::concat("Bearer ", $access_token)
    };

    LET $u = http::get("https://api.github.com/user", $headers);

    IF $u = NONE {
        THROW {
            stage:   "user_fetch_failed",
            message: "GitHub /user returned NONE",
            token_res: $token_res
        };
    };

    IF $u.id = NONE {
        THROW {
            stage:   "missing_github_id",
            message: "GitHub user has no id",
            user: $u
        };
    };

    ------------------------------------------------------------
    -- 3) Require email
    ------------------------------------------------------------
    IF $u.email = NONE {
        THROW {
            stage:   "missing_email",
            message: "GitHub account has no public email associated",
            user: $u
        };
    };

    LET $email     = string::lowercase($u.email);
    LET $github_id = type::string($u.id);

    ------------------------------------------------------------
    -- 4) If GitHub account already linked → load & return user
    ------------------------------------------------------------
    LET $existing_account = (
        SELECT *
        FROM auth_accounts
        WHERE provider    = "github"
          AND provider_id = $github_id
        LIMIT 1
    )[0];

    IF $existing_account != NONE {
        IF $existing_account.user = NONE {
            THROW {
                stage:   "linked_account_missing_user_ref",
                message: "auth_account exists but has no user field",
                account: $existing_account
            };
        };

        LET $user_from_account = (
            SELECT *
            FROM $existing_account.user
            LIMIT 1
        )[0];

        IF $user_from_account = NONE {
            THROW {
                stage:   "linked_account_missing_user",
                message: "auth_account exists but user record not found",
                account: $existing_account
            };
        };

        RETURN $user_from_account;
    };

    ------------------------------------------------------------
    -- 5) No auth_account yet → try to find user by email
    ------------------------------------------------------------
    LET $existing_user = (
        SELECT *
        FROM users
        WHERE email = $email
        LIMIT 1
    )[0];

    IF $existing_user != NONE {
        LET $linked_auth_account = (
            CREATE auth_accounts CONTENT {
                user:        $existing_user.id,
                provider:    "github",
                provider_id: $github_id
            } RETURN AFTER
        )[0];

        IF $linked_auth_account = NONE OR $linked_auth_account.id = NONE {
            THROW {
                stage:   "auth_account_link_existing_failed",
                message: "Failed to create auth_accounts row for existing user",
                user:    $existing_user
            };
        };

        RETURN $existing_user;
    };

    ------------------------------------------------------------
    -- 6) Still no user → create new user, link, return
    ------------------------------------------------------------
    LET $new_user = (
        CREATE users CONTENT {
            email:      $email,
            name:       $u.name,
            avatar_url: $u.avatar_url,
            password:   rand::string(32)
        } RETURN AFTER
    )[0];

    IF $new_user = NONE {
        THROW {
            stage:       "user_creation_failed",
            message:     "Failed to create user record from GitHub data",
            github_user: $u
        };
    };

    IF $new_user.id = NONE {
        THROW {
            stage:   "user_creation_missing_id",
            message: "Created users row has no id",
            user:    $new_user
        };
    };

    LET $new_auth_account = (
        CREATE auth_accounts CONTENT {
            user:        $new_user.id,
            provider:    "github",
            provider_id: $github_id
        } RETURN AFTER
    )[0];

    IF $new_auth_account = NONE OR $new_auth_account.id = NONE {
        THROW {
            stage:   "auth_account_creation_failed",
            message: "Failed to create auth_accounts row for new user",
            user:    $new_user
        };
    };

    RETURN $new_user;
};

DEFINE ACCESS OVERWRITE github ON DATABASE TYPE RECORD
    SIGNUP (
        fn::github_login($code)
    )
    SIGNIN (
        fn::github_login($code)
    )
    DURATION
        FOR TOKEN 1h,
        FOR SESSION NONE;
;
Enter fullscreen mode Exit fullscreen mode

We have three main tables: users, auth_accounts, and auth_providers.

  • users is self-explanator
  • auth_accounts allows us to have multiple providers
  • auth_providers saves our client_id and client_secret. Surreal Cloud DOES NOT allow you to have environment variables, plus this could be helpful if you have an admin panel where you could update this.

GitHub Secret and Client ID

We must first create a GitHub oAuth App under GitHub Developer Settings. Save the client_secret and client_id. You can't get the secret later. You will also need two different apps for production and development.

GitHub Config

For this purpose, make sure the callback URL is set to http://localhost:5173/auth/callback or whatever is relevant to your framework.

Insert Data

There are no environment variables in Surreal, but we can save our data in a safe table we just created. This allows it to be available safely to any client.

CREATE auth_providers SET
    provider = "github",
    client_id = "<YOUR_GITHUB_CLIENT_ID>",
    client_secret = "<YOUR_GITHUB_CLIENT_SECRET>";
Enter fullscreen mode Exit fullscreen mode

GitHub Login

The key to the entire schema is the gitHub_login method.

  • It takes a code and gets an access token
  • It takes the access token and gets an email (must have email scope)
  • It gets the github ID and email and saves it to the database
  • I tried to throw every possible error for debugging purposes
  • It should be safe client side so that you can login from anywhere

Allow GitHub in the Network

Make sure to add allowed network calls in your Surreal Instance setup

  • api.github.com
  • github.com

Surreal Cloud Network

SvelteKit Changes

We added a callbackURL and some oauth credential management to our surrealServer setup.

export function surrealServer({
    cookies: { cookieName, setCookie, getCookie },
    credentials: { url, namespace, database },
    oauth: {
        github: {
            client_id: github_client_id
        }
    },
    callbackURL
}: {
    cookies: {
        cookieName?: string;
        setCookie: SetCoookieFn;
        getCookie: GetCookieFn;
    };
    oauth: {
        github: {
            client_id: string;
        }
    };
    credentials: {
        url: string;
        namespace: string;
        database: string;
    };
    callbackURL: string
}) {
Enter fullscreen mode Exit fullscreen mode

Which can be called like this in our hooks.

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
        },
        oauth: {
            github: {
                client_id: PUBLIC_GITHUB_CLIENT_ID
            }
        },
        callbackURL: event.url.origin + '/auth/callback'
    });

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

We only need the client_id to generate the login with GitHub URL.

Connection

We create a sign in function.

export async function surrealGHLogin({
    db,
    namespace,
    database,
    code
}: {
    db: Surreal;
    namespace: string;
    database: string;
    code: string;
}) {
    try {
        const signinData = await db.signin({
            namespace,
            database,
            variables: {
                code
            },
            access: 'github'
        });

        return {
            data: signinData,
            error: null
        };
    } catch (e) {
        if (e instanceof SurrealDbError) {
            console.log(e.message);
            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

And we call it inside our Surreal Server instance just like our other login methods.

async function loginWithCallback(url: URL) {

    logout();

    const code = url.searchParams.get('code');
    const state = url.searchParams.get('state');

    if (!code) {
        return {
            data: null,
            error: new Error('Missing authorization code')
        };
    }

    let next = '/';

    try {
        const parsed = state && JSON.parse(state);
        next = parsed?.next ?? '/';
    } catch { /* empty */ }

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

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

    const { data: token, error: loginError } = await surrealGHLogin({
        db,
        namespace,
        database,
        code
    });

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

    setCookie(tokenName, token, TOKEN_COOKIE_OPTIONS);

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

And call it in the /auth/callback route.

import type { PageServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';


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

    const {
        data: next,
        error: loginError
    } = await surreal.loginWithCallback(url);

    if (loginError) {
        error(400, loginError.message);
    }

    redirect(303, next);

}) satisfies PageServerLoad;
Enter fullscreen mode Exit fullscreen mode

We get the code and next path from the URL. Then we login and save the session.

Create the Redirect

Working backwards, we have to create the redirect in order to login.

export function createGitHubOAuthLoginUrl(
    redirect_uri: string,
    path: string,
    client_id: string,
    customParameters?: Record<string, string>,
    addScopes?: string[]
) {
    // Build scope string with default scopes and additional ones
    const baseScopes = ['read:user', 'user:email'];
    const scopes = addScopes ? [...baseScopes, ...addScopes] : baseScopes;

    const params: Record<string, string> = {
        client_id,
        redirect_uri,
        scope: scopes.join(' '),
        state: JSON.stringify({
            next: path,
            provider: 'github'
        })
    };

    // Add custom parameters if provided (e.g., 'login', 'allow_signup')
    if (customParameters) {
        Object.assign(params, customParameters);
    }

    return new URL(
        'https://github.com/login/oauth/authorize?' + new URLSearchParams(params).toString()
    ).toString();
}
Enter fullscreen mode Exit fullscreen mode

It is customizable, must require an email, and saves our next path to the state.

It can then be accessed in our server:

function getGitHubURL(next: string) {
    return createGitHubOAuthLoginUrl(
        callbackURL,
        next,
        github_client_id
    );
}
Enter fullscreen mode Exit fullscreen mode

And called from the form action:

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

    const formData = await request.formData();

    const next = formData.get('next') || '/';

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

    const githubURL = surreal.getGitHubURL(next);

    redirect(303, githubURL);
}
Enter fullscreen mode Exit fullscreen mode

And that's it! A working login with GitHub.

We could equally add any provider in the same way, and it will automatically link the providers by email!

Until next time...

Repo: GitHub

J

Top comments (0)