Auth
@hopak/auth — JWT, облікові дані, OAuth (GitHub + Google), RBAC, у вигляді згенерованих файлів.
Автентифікація для Hopak.js — JWT, реєстрація/вхід за обліковими даними, OAuth (GitHub + Google) та рольовий доступ. Один пакет, один підхід.
Встановлення
Швидкий шлях — згенерувати все однією CLI-командою:
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
Потім матеріалізуйте таблицю users:
- Ще без міграцій:
hopak sync && hopak dev - Міграції в роботі:
hopak migrate new add_users, заповнітьCREATE TABLE users(...)уup(), потімhopak migrate up. Модель уже створена вище — їй лише потрібна таблиця БД.
Ручний шлях:
bun add @hopak/auth jose
Peer-залежності: @hopak/core, @hopak/common, jose ^5.6.0 || ^6.0.0.
Працює на Bun ≥ 1.3.
П’ятихвилинна автентифікація
// 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()] });
Оце й весь цикл: хешуємо на signup, перевіряємо на login, закриваємо через requireAuth().
jwtAuth — підпис і перевірка
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
});
Повертає { requireAuth, signToken }.
requireAuth()→ мідлварBefore. ЧитаєAuthorization: Bearer <token>, перевіряє черезjose, встановлюєctx.user. КидаєUnauthorized(401) при відсутньому/невірному токені.signToken(user)→ асинхронний, повертає підписаний JWT. Будь-яке поле зі спискуclaimsкопіюється зuserу payload (idіде уsub).
Встановлення цього пакета розширює RequestContext, щоб у кожному
обробнику був типізований доступ до ctx.user?: AuthUser —
заповнений, якщо раніше в ланцюжку відпрацював requireAuth(),
інакше undefined.
Ендпоїнти для облікових даних
credentialsSignup та credentialsLogin — це обробники
маршрутів; передавайте їх у 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 }.
Обидва використовують Bun.password.hash / verify — типово argon2id.
Коли схема інша, перевизначте поле паролю або колонку пошуку:
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')],
});
- Виконується ПІСЛЯ
requireAuth(). Читаєctx.user.role. - Кілька ролей трактуються як OR:
requireRole('admin', 'editor'). - Немає
ctx.user→ 401. Роль не збігається → 403.
Порожній список падає на етапі виклику:
requireRole(); // Error: requireRole(): pass at least one role name.
OAuth (GitHub, Google)
Під-шляхи експортують специфічні для провайдерів обробники
*Start / *Callback. Стан перевіряється без сервера — HMAC над
Вашим наявним JWT_SECRET: ні кукі-сховища, ні таблиці сесій.
// 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-змінні: GITHUB_OAUTH_ID, GITHUB_OAUTH_SECRET (або GOOGLE_*).
Колбек лінкує користувачів за email (linkBy: 'email' типово),
створює новий рядок, якщо збігу нема, і повертає { token }.
Перевизначайте форму нового користувача, коли у моделі є додаткові обов’язкові поля:
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 — аналогічно: імпорт із @hopak/auth/oauth/google.
Якщо потрібні env-змінні (GITHUB_OAUTH_ID / GITHUB_OAUTH_SECRET,
GOOGLE_OAUTH_ID / GOOGLE_OAUTH_SECRET) не задані, обробники
start / callback кидають ConfigError (500 з узагальненим
повідомленням клієнту) — ім’я конкретної env-змінної залишається
лише в логах сервера.
Розширення AuthUser
Додавайте власні claim-и через розширення інтерфейсу AuthUser:
// app/types/auth.ts
declare module '@hopak/auth' {
interface AuthUser {
tenantId: number;
}
}
Потім скажіть jwtAuth пропускати це поле:
jwtAuth({ secret, claims: ['id', 'role', 'tenantId'] });
ctx.user.tenantId тепер типізовано в кожному автентифікованому обробнику.
Низькорівневі примітиви
Вбудовані провайдери (githubCallback, googleCallback) — це тонкі
обгортки. Коли потрібен провайдер, якого ми не постачаємо, збирайте
його на тих самих примітивах, що експортує @hopak/auth:
oauthCallback(params, exchangeAndFetch)
Спільний колбек-потік — перевіряє state, викликає Вашу
exchangeAndFetch(code), щоб перетворити код провайдера на
ProviderProfile, шукає-або-створює локального користувача,
підписує токен.
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 приймає ті самі опції linkBy / createUser /
onFirstLogin, що і GitHub/Google-колбеки.
signState(secret) / verifyState(secret, token)
Stateless-стан із підписом HMAC-SHA256. Кожен OAuth start-обробник
має викликати signState(process.env.JWT_SECRET!) і передати
результат як URL-параметр state; callback-обробник його
перевірить. 5-хвилинний термін дії вшитий у підписаний payload —
нічого не зберігається на сервері.
Використовуйте їх напряму, лише якщо пишете власний start-обробник;
githubStart / googleStart уже викликають їх за вас.