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,
    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 }.

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.