Docs / Auth

Auth

@hopak/auth — JWT, credentials, OAuth (GitHub + Google), RBAC, scaffolded as files.

Authentication for Hopak.js — JWT, credential signup/login, OAuth (GitHub + Google), and role-based access control. One package, one mental model.

Install

The fast path — scaffold everything with one CLI command:

hopak use auth
# → installs @hopak/auth + jose
# → creates app/middleware/auth.ts
# → creates app/routes/api/auth/{signup,login,me}.ts
# → creates app/models/user.ts (only if missing)
# → adds JWT_SECRET to .env.example

Then materialise the users table:

The manual path:

bun add @hopak/auth jose

Peer deps: @hopak/core, @hopak/common, jose ^5.6.0 || ^6.0.0. Works on Bun ≥ 1.3.

Five-minute auth

// app/middleware/auth.ts — one source of truth for this app's auth
import { jwtAuth } from '@hopak/auth';

const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET is not set.');

export const { requireAuth, signToken } = jwtAuth({ secret });
// app/routes/api/auth/signup.ts
import { defineRoute } from '@hopak/core';
import { credentialsSignup } from '@hopak/auth';
import user from '../../../models/user';
import { signToken } from '../../../middleware/auth';

export const POST = defineRoute({
  handler: credentialsSignup({ model: user, sign: signToken }),
});
// Any protected route
import { crud } from '@hopak/core';
import post from '../../models/post';
import { requireAuth } from '../../middleware/auth';

export const POST = crud.create(post, { before: [requireAuth()] });

That’s the whole loop: hash on signup, verify on login, gate with requireAuth().

jwtAuth — sign + verify

const auth = jwtAuth({
  secret: process.env.JWT_SECRET!, // 32+ bytes
  expiresIn: '7d',                 // default '7d', accepts jose durations
  algorithm: 'HS256',              // default HS256; switch when ready to manage keys
  claims: ['id', 'role'],          // fields copied into the JWT + back onto ctx.user
});

Returns { requireAuth, signToken }.

Installing this package augments RequestContext so every handler gets typed access to ctx.user?: AuthUser — populated after requireAuth() ran earlier in the chain, undefined otherwise.

Credential endpoints

credentialsSignup and credentialsLogin are route handlers — drop them into defineRoute({ handler: ... }).

credentialsSignup({ model: user, sign: signToken });
// POST → validates body against the model, hashes `password`,
//        inserts, strips sensitive fields, returns { user, token }.

credentialsLogin({ model: user, sign: signToken });
// POST → looks up the row by `email` (override with `identifier`),
//        verifies the password, returns { token }.

Both use Bun.password.hash / verify — argon2id by default.

Override the password field or lookup column when your schema differs:

credentialsLogin({ model: user, sign: signToken, identifier: 'username', passwordField: 'hashed' });

requireRole — RBAC

import { requireRole } from '@hopak/auth';
import { requireAuth } from '../../middleware/auth';

export const DELETE = crud.remove(post, {
  before: [requireAuth(), requireRole('admin')],
});

Empty list throws at build time:

requireRole(); // Error: requireRole(): pass at least one role name.

OAuth (GitHub, Google)

Sub-paths expose provider-specific *Start / *Callback route handlers. State is verified statelessly with HMAC over your existing JWT_SECRET — no cookie store, no session table.

// app/routes/api/auth/github/start.ts
import { defineRoute } from '@hopak/core';
import { githubStart } from '@hopak/auth/oauth/github';

export const GET = defineRoute({
  handler: githubStart({
    callbackUrl: 'http://localhost:3000/api/auth/github/callback',
    stateSecret: process.env.JWT_SECRET!,
  }),
});
// app/routes/api/auth/github/callback.ts
import { defineRoute } from '@hopak/core';
import { githubCallback } from '@hopak/auth/oauth/github';
import user from '../../../../models/user';
import { signToken } from '../../../../middleware/auth';

export const GET = defineRoute({
  handler: githubCallback({
    model: user,
    callbackUrl: 'http://localhost:3000/api/auth/github/callback',
    sign: signToken,
    stateSecret: process.env.JWT_SECRET!,
  }),
});

Env vars: GITHUB_OAUTH_ID, GITHUB_OAUTH_SECRET (or GOOGLE_*). The callback links users by email (linkBy: 'email' by default), creates a new row if no match, and returns { token }. callbackUrl must match the one passed to the start handler — both providers take the same parameter set.

GitHub users with a private email still sign in: when /user returns email: null, the provider falls back to /user/emails and picks the primary verified address.

hashPassword

Added in 1.0

credentialsSignup hashes the password field for you. If your model also hashes in a beforeCreate hook, use hashPassword in the hook — it returns already-hashed values untouched, so the two paths can coexist without producing a double hash that never matches at login:

import { hashPassword } from '@hopak/auth';

export default model('user', { /* … */ }, {
  hooks: {
    beforeCreate: async (data) => ({
      ...data,
      password: await hashPassword(String(data.password)),
    }),
  },
});

PKCE

Added in 1.0

Both providers support PKCE (RFC 7636). The code verifier is bound to the browser via an HttpOnly cookie set by the start handler and checked by the callback. Defaults: on for Google (which enforces it), off for GitHub (whose OAuth apps ignore it) — flip with pkce: true | false on the start options and callback params.

Override the new-user shape when your model has extra required fields:

githubCallback({
  model: user,
  sign: signToken,
  stateSecret: process.env.JWT_SECRET!,
  createUser: (profile) => ({
    email: profile.email,
    name: profile.name ?? 'New User',
    plan: 'free',
  }),
  onFirstLogin: async (row, profile) => {
    await sendWelcomeEmail(row.email, profile.name);
  },
});

Google is identical — import from @hopak/auth/oauth/google.

If the required env vars (GITHUB_OAUTH_ID / GITHUB_OAUTH_SECRET, GOOGLE_OAUTH_ID / GOOGLE_OAUTH_SECRET) aren’t set, the start / callback handlers throw ConfigError (500 with a generic client message) — the specific env-var name stays server-side in the logs.

Extending AuthUser

Carry more claims by augmenting the AuthUser interface:

// app/types/auth.ts
declare module '@hopak/auth' {
  interface AuthUser {
    tenantId: number;
  }
}

Then tell jwtAuth to pass the field through:

jwtAuth({ secret, claims: ['id', 'role', 'tenantId'] });

ctx.user.tenantId is now typed inside every authenticated handler.

Low-level primitives

The built-in providers (githubCallback, googleCallback) are thin wrappers. When you need a provider we don’t ship, build on the same primitives exported from @hopak/auth:

oauthCallback(params, exchangeAndFetch)

The shared callback flow — verifies state, calls your exchangeAndFetch(code) to turn the provider code into a ProviderProfile, finds-or-creates the local user, signs a token.

import { defineRoute } from '@hopak/core';
import { oauthCallback, type ProviderProfile } from '@hopak/auth';
import user from '../../../models/user';
import { signToken } from '../../../middleware/auth';

export const GET = defineRoute({
  handler: oauthCallback(
    { model: user, sign: signToken, stateSecret: process.env.JWT_SECRET! },
    async (code): Promise<ProviderProfile> => {
      const { id, email, name } = await exchangeWithMyProvider(code);
      return { providerId: id, email, name };
    },
  ),
});

params accepts the same linkBy / createUser / onFirstLogin options as the GitHub/Google callbacks.

signState(secret) / verifyState(secret, token)

HMAC-SHA256-signed stateless state. Every OAuth start handler should call signState(process.env.JWT_SECRET!) and pass the result as the state URL param; the callback handler verifies it. 5-minute expiry baked into the signed payload — nothing stored server-side.

Use these directly only if you’re writing a custom start handler; githubStart / googleStart already call them for you.