beta

Local Provider

Username/password login as an AuthProvider - argon2 password verification, Zod body validation inside the provider.

Problem

You want a POST /auth/login endpoint that accepts { email, password }, verifies the password against an argon2 hash in the database, and on success populates ctx.user with the matched user. The handler then issues a JWT.

Why an AuthProvider and not a plain controller?

Both work. Putting the credential check in an AuthProvider gives you three things:

  1. Consistency - login looks like every other authenticated route (one @UseGuard line), so reading the controller is trivial.
  2. Composability - you can stack the local provider in a multi-provider guard (AuthGuard(JwtAuth, LocalAuth)) for endpoints that accept either.
  3. Reusability - the same provider works for POST /auth/login and POST /auth/refresh-credentials without duplicating logic.

Pipeline note: provider runs before @ValidateBody

This is a critical ordering detail. MiiaJS executes guards (including AuthGuard) before the handler - and @ValidateBody runs as part of the handler's wrapping, not as a guard. So inside the provider's authenticate(), ctx.json() returns the raw, unvalidated body.

The provider must validate the body itself. Don't rely on @ValidateBody to have run yet - it hasn't.

Solution

// src/auth/providers/local-auth.provider.ts
import { Injectable, inject, UnauthorizedException, type RequestContext } from '@miiajs/core'
import type { AuthProvider } from '@miiajs/auth'
import { verify as verifyHash } from '@node-rs/argon2'
import { z } from 'zod'
import { UsersService } from '../../users/users.service.js'

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
})

@Injectable()
export class LocalAuth implements AuthProvider {
  private usersService = inject(UsersService)

  async authenticate(ctx: RequestContext) {
    const raw = await ctx.json().catch(() => null)
    const parsed = LoginSchema.safeParse(raw)
    if (!parsed.success) {
      throw new UnauthorizedException('Invalid credentials payload')
    }
    const { email, password } = parsed.data

    const found = await this.usersService.findByEmailForAuth(email)
    if (!found) throw new UnauthorizedException('Invalid credentials')

    const ok = await verifyHash(found.passwordHash, password)
    if (!ok) throw new UnauthorizedException('Invalid credentials')

    const { passwordHash: _, ...user } = found
    return user
  }
}

Why Invalid credentials for both missing-user and wrong-password? Don't leak which one was wrong - that's a user enumeration vector. Same error, every time.

Strip passwordHash before returning. Whatever the provider returns lands on ctx.user, which downstream handlers may serialize back to the client. Destructure the hash out (or build a dedicated public shape) - never let it escape the provider.

AuthService

Token issuance does not belong in the controller. Push it into a service so every route that needs a token calls the same method.

// src/auth/auth.service.ts
import { Injectable, inject } from '@miiajs/core'
import { JwtService } from '@miiajs/jwt'

@Injectable()
export class AuthService {
  private jwtService = inject(JwtService)

  async issueTokenFor(user: { id: number; email: string }) {
    return this.jwtService.sign({ sub: user.id, email: user.email })
  }
}

In a real app this service also owns register(), password reset, refresh token rotation, and so on - the controller stays thin.

Login controller

The provider has already placed the user on ctx.user. The handler only asks AuthService to mint a token.

// src/auth/auth.controller.ts
import { Controller, Post, Status, UseGuard, ValidateBody, inject, type RequestContext } from '@miiajs/core'
import { AuthGuard } from '@miiajs/auth'
import { AuthService } from './auth.service.js'
import { LocalAuth } from './providers/local-auth.provider.js'
import { RegisterSchema, type RegisterInput } from './schemas/register.schema.js'
import type { User } from '../users/users.schema.js'

@Controller('/auth')
export class AuthController {
  private authService = inject(AuthService)

  @Post('/login')
  @UseGuard(AuthGuard(LocalAuth))
  async login(ctx: RequestContext) {
    const accessToken = await this.authService.issueTokenFor(ctx.user as User)
    return { accessToken }
  }

  @Post('/register')
  @Status(201)
  @ValidateBody(RegisterSchema)
  async register(ctx: RequestContext) {
    const data = await ctx.json<RegisterInput>()
    return this.authService.register(data) // hash password, persist, issue token - inside the service
  }
}

Registration is not a LocalAuth use case - there's no existing user to authenticate. It's a normal route with @ValidateBody, delegating to AuthService.register().

The contrast between the two handlers is the key pipeline detail: login uses @UseGuard, so the provider consumes ctx.json() before the handler runs (that's why the provider validates the body itself). register uses @ValidateBody instead, which runs as part of the handler wrapping - no guard, no early body consumption.

Module wiring

// src/auth/auth.module.ts
import { Module } from '@miiajs/core'
import { AuthController } from './auth.controller.js'
import { AuthService } from './auth.service.js'
import { LocalAuth } from './providers/local-auth.provider.js'

@Module({
  controllers: [AuthController],
  providers: [AuthService, LocalAuth],
})
export class AuthModule {}

UsersService note

The provider calls users.findByEmailForAuth(email) - a dedicated method that returns the password hash. Keep a separate findById (or similar) for public reads that excludes the hash. Don't merge them, and never return the auth variant from an HTTP handler.

Security notes

Password hashing: argon2id

argon2id is the modern recommendation from OWASP. It's resistant to GPU attacks and has tunable memory cost. @node-rs/argon2 is fast (Rust-based) and works on Node, Bun, and Deno. If you have an existing bcrypt deployment, bcrypt.compare works the same way - swap the import.

Don't leak which half of the credentials is wrong

Both "user not found" and "wrong password" must return the same error (see the blockquote under Solution). Differentiating them turns your login endpoint into a user-enumeration oracle.

Never return the password hash

Whatever the provider returns lands on ctx.user, which downstream handlers may serialize back to the client (see the blockquote under Solution). Either destructure the hash out at the provider boundary, or keep a separate findByEmailForAuth / public-shape split at the UsersService level - which is exactly what the UsersService note above describes.

See also