Manual Recipes Authentication

Authentication

elements man recipes/authentication Read as markdown

Form-based authentication: two pages, /signin and /signup, sharing one schema and one set of rpc handlers. Credentials store as bcrypt hashes in Postgres. Each rpc validates against the hash and calls session.login(). The reactive session updates both pages immediately on success, no full-page reload.

The Elements schema setup automatically installs the pgcrypto extension, so crypt() and genSalt() are available in migrations and rpc.

Migration

elements create migration 'add users' -tables=users

app/migrations/<timestamp>-add-users.migration.sql:

-- add users

-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create table users (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  handle text not null unique,
  passwordHash text not null
);

create trigger usersTouchUpdatedAt
  before update on users
  for each row execute function touchUpdatedAt();

The two columns added on top of the scaffold are handle (unique, so no two users share one) and passwordHash (stores the bcrypt hash, never the plaintext).

Shared auth rpc

app/shared/services/auth.ts:

import { sql, session, AuthError } from "@elements/app";

interface User {
  id: string;
  handle: string;
}

/** @rpc */
export function signinUser(handle: string, password: string) {
  let user = sql<User>(
    `select id, handle from users
     where handle = ${handle}
       and passwordHash = crypt(${password}, passwordHash)`,
  ).first();

  if (!user) {
    throw new AuthError("invalid handle or password");
  }

  session.login({ userId: user.id, userName: user.handle });
}

/** @rpc */
export function signupUser(handle: string, password: string) {
  let user = sql<{ id: string }>(
    `insert into users (handle, passwordHash)
     values (${handle}, crypt(${password}, genSalt('bf', 12)))
     returning id`,
  ).firstOrThrow();

  session.login({ userId: user.id, userName: handle });
}

/** @rpc */
export function logoutUser() {
  session.logout();
}

The signin query reads passwordHash = crypt(${password}, passwordHash). Both occurrences are the same column. crypt() re-hashes the submitted password using the algorithm and salt encoded in the stored hash and compares the result, all in one expression. The plaintext never leaves Postgres and TypeScript never sees the stored hash.

Signup uses genSalt('bf', 12) to mint a fresh bcrypt salt for the new user. bf is bcrypt; pick a different scheme only if you understand the trade-off.

Pass the cost. The second argument is the work factor, and it is the whole point of bcrypt: it is how long one hash takes, and therefore how long one guess takes for someone holding a stolen users table. pgcrypto defaults bf to 6, which is far too cheap on modern hardware. Measured on the bundled Postgres:

crypt('x', gen_salt('bf'))       4.1 ms     cost 6, the default
crypt('x', gen_salt('bf', 12))   235 ms     cost 12

That is 57x more work per guess. Cost 12 is the right default today. Raise it as hardware gets faster; the cost is stored in the hash, so old rows keep verifying at the cost they were written with and you can re-hash on next signin.

AuthError is a safe error: its message reaches the browser as-is and the rpc client re-throws it.

Declaring session fields

session.login({ userId, userName }), session.get('userId'), and session.getOrThrow('userName') only typecheck once you declare those fields on SessionData. Do it once per app in app/types/session.d.ts. The scaffold ships this file with the block commented out:

declare module "@elements/app" {
  interface SessionData {
    userId: string;
    userName: string;
  }
}

SessionData is a global augmentation: declare it once and every session.login/get/getOrThrow across the app is typed against it. Without it, keyof SessionData is empty, so session.login({ userId }) reports "expected 0 arguments" and session.get('userId') rejects the key.

Signin page

elements create page signin

Update app/pages/signin/index.ts and template.html with the contents below.

app/pages/signin/index.ts:

import signin from "#app/pages/signin/template";

export default function route(req, res) {
  return new signin();
}

app/pages/signin/template.html:

import "./style.css";
import { session } from "@elements/app";
import { signinUser, logoutUser } from "#app/shared/services/auth";

function attemptSignin(handle: string, password: string): string {
  try {
    signinUser(handle, password);
    return "";
  } catch (err: any) {
    return err.message;
  }
}

<html class="signin"
      (private handle: string = "",
       private password: string = "",
       private error: string = "")>
  <div e:if={session.isLoggedIn()}>
    <p>welcome, {session.get('userName')}</p>
    <button onclick={() => logoutUser()}>log out</button>
  </div>

  <form e:else onsubmit={() => {
    error = attemptSignin(handle, password);
    if (!error) {
      handle = "";
      password = "";
    }
  }}>
    <h1>sign in</h1>

    <input type="text" placeholder="handle" value={handle} required>
    <input type="password" placeholder="password" value={password} required>

    <button type="submit">sign in</button>

    <p e:if={error} class="error">{error}</p>

    <a href="/signup">need an account? sign up</a>
  </form>
</html>

Signup page

elements create page signup

Update app/pages/signup/index.ts and template.html.

app/pages/signup/index.ts:

import signup from "#app/pages/signup/template";

export default function route(req, res) {
  return new signup();
}

app/pages/signup/template.html:

import "./style.css";
import { session } from "@elements/app";
import { signupUser, logoutUser } from "#app/shared/services/auth";

function attemptSignup(handle: string, password: string): string {
  try {
    signupUser(handle, password);
    return "";
  } catch (err: any) {
    return err.message;
  }
}

<html class="signup"
      (private handle: string = "",
       private password: string = "",
       private error: string = "")>
  <div e:if={session.isLoggedIn()}>
    <p>welcome, {session.get('userName')}</p>
    <button onclick={() => logoutUser()}>log out</button>
  </div>

  <form e:else onsubmit={() => {
    error = attemptSignup(handle, password);
    if (!error) {
      handle = "";
      password = "";
    }
  }}>
    <h1>sign up</h1>

    <input type="text" placeholder="handle" value={handle} required>
    <input type="password" placeholder="password" value={password} required>

    <button type="submit">sign up</button>

    <p e:if={error} class="error">{error}</p>

    <a href="/signin">have an account? sign in</a>
  </form>
</html>

session.isLoggedIn() is reactive in the browser. The moment a signin or signup rpc returns successfully, the welcome branch renders without a page reload. Either page works as a deep link: the user lands on /signin or /signup directly and follows the link if they picked the wrong one.

Routes

Register the pages in index.ts alongside the scaffold's existing structure:

import signin from "#app/pages/signin";
import signup from "#app/pages/signup";

// ...
app.route("/signin", signin);
app.route("/signup", signup);

Notes

  • session.login({ userId, userName }). userId is required and marks the session as logged in. userName is the display name. For this recipe the two are different: id is a uuid, the handle is human-readable.
  • crypt() and genSalt() come from the pgcrypto extension. Elements installs the extension at project setup, so they're always available.
  • The unique constraint on handle means a signup with a taken name throws a SqlError. The current template surfaces the raw Postgres message. For a friendlier UX, catch in the rpc and rethrow with a clearer AuthError("that handle is taken").
  • Session expiry is an app-wide policy set by session.expires in config.jsoc (the scaffold sets '30d'). It applies to every session; session.login() takes only the session data (userId, userName, and any fields you declare on SessionData). There is no per-login expiry override.