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:
- No migrations yet:
hopak sync && hopak dev - Migrations in use:
hopak migrate new add_users, fill in theCREATE TABLE users(...)inup(), thenhopak migrate up. The model was already created above — it just needs a DB table to live in.
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 }.
requireAuth()→ aBeforemiddleware. ReadsAuthorization: Bearer <token>, verifies withjose, setsctx.user. ThrowsUnauthorized(401) on missing/invalid tokens.signToken(user)→ async, returns a signed JWT. Any field listed inclaimsis copied fromuserinto the payload (idgoes intosub).
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')],
});
- Runs AFTER
requireAuth(). Readsctx.user.role. - Multiple roles are OR-of:
requireRole('admin', 'editor'). - No
ctx.user→ 401. Role mismatch → 403.
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.