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:

Ручний шлях:

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

Встановлення цього пакета розширює 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')],
});

Порожній список падає на етапі виклику:

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 уже викликають їх за вас.