auth.js 4.24 KB
import * as v from 'valibot'
import { HANDLE_PATTERN } from '@voko/access'

const SendCodeBody = v.object({
  email: v.pipe(v.string(), v.email()),
  locale: v.optional(v.string()),
})

const RegisterBody = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(v.string(), v.minLength(8)),
  verification_code: v.pipe(v.string(), v.minLength(1)),
  locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']),
  // 主人 id:Agent 的全名是「名字@主人 id」。
  handle: v.pipe(v.string(), v.regex(HANDLE_PATTERN, 'handle must be 2-32 characters: lowercase letters, digits, _ or -, starting with a letter or digit')),
})

const LoginBody = v.variant('method', [
  v.object({ method: v.literal('password'), email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(1)) }),
  v.object({
    method: v.literal('google'),
    credential: v.pipe(v.string(), v.minLength(1)),
    oauth_flow: v.picklist(['access_token', 'authorization_code']),
    redirect_uri: v.optional(v.string()),
  }),
])

const RefreshBody = v.object({ refresh_token: v.pipe(v.string(), v.minLength(1)) })

/**
 * 账号:邮箱验证码注册 + 密码登录,或 Google 登录(配置了 GOOGLE_OAUTH_CLIENT_ID 时)。
 * @param {import('fastify').FastifyInstance} app
 * @param {Record<string, any>} services
 */
export function registerAuthRoutes(app, services) {
  const { auth } = services

  app.post('/api/voko/auth/send-code', async (request) => {
    const body = v.parse(SendCodeBody, request.body)
    await auth.sendCode({ email: body.email, purpose: 'register', locale: body.locale, ip: request.ip })
    return { ok: true }
  })

  app.post('/api/voko/auth/register', async (request) => {
    const body = v.parse(RegisterBody, request.body)
    if (await services.ownerHandles.isTaken(request.ctx, { handle: body.handle })) throw codedError(`The id ${body.handle} is already taken`, 'CONFLICT')
    return sessionResponse(await services.register(body))
  })

  app.post('/api/voko/auth/login', async (request) => {
    const body = v.parse(LoginBody, request.body)
    switch (body.method) {
      case 'password':
        return sessionResponse(await auth.login({ email: body.email, password: body.password, ip: request.ip }))
      case 'google':
        if (!services.oauthEnabled) throw codedError('Google login is not configured', 'BAD_REQUEST')
        return sessionResponse(await auth.oauthLogin({ provider: 'google', code: body.credential, oauth_flow: body.oauth_flow, redirect_uri: body.redirect_uri }))
      default:
        throw new Error(`Unhandled login method: ${/** @type {any} */ (body).method}`)
    }
  })

  app.post('/api/voko/auth/refresh', async (request) => {
    const body = v.parse(RefreshBody, request.body)
    return sessionResponse(await auth.refresh(body))
  })

  app.post('/api/voko/auth/logout', async (request) => {
    const body = v.parse(RefreshBody, request.body)
    return auth.logout(body)
  })
}

/**
 * @param {{ access_token: string, refresh_token: string, user: { id: string, email: string } }} result
 */
function sessionResponse(result) {
  return {
    access_token: result.access_token,
    refresh_token: result.refresh_token,
    user: { id: result.user.id, email: result.user.email },
  }
}

/**
 * @param {string} message
 * @param {string} code
 */
export function codedError(message, code) {
  const err = /** @type {Error & { code: string }} */ (new Error(message))
  err.code = code
  return err
}

/**
 * 从 Bearer access token 解析当前用户;缺失或无效即 401。
 * @param {import('fastify').FastifyRequest} request
 * @param {Record<string, any>} services
 * @returns {Promise<{ id: string, email: string, role: string|null }>}
 */
export async function requireUser(request, services) {
  const header = request.headers.authorization
  const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null
  if (match === null) throw codedError('Authorization bearer token is required', 'TOKEN_INVALID')
  const claims = await services.auth.verifyAccessToken(match[1])
  const user = await services.userRepo.findById(String(claims.sub))
  if (!user) throw codedError(`User not found: ${claims.sub}`, 'TOKEN_INVALID')
  return { id: user.id, email: user.email, role: user.role === undefined ? null : user.role }
}