Authentication

Guard-based authentication with JWT, Session, Database Tokens, and more. Configure guards, protect routes, and use ctx.auth to access the user and guard methods.

Configuration

Configure guards in config/auth.ts. Each guard is a factory function called lazily per request.

config/auth.ts
import type { AuthConfig } from '@tekir/auth'
import { JwtGuard, SessionGuard, DatabaseTokenGuard } from '@tekir/auth'
import { db } from '#services'
import env from '#env'

export default {
  defaultGuard: 'jwt',

  guards: {
    jwt: () => new JwtGuard({
      secret: env.APP_KEY,
      expiresIn: 3600,
      findUser: async (id) => await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
    }),

    session: () => new SessionGuard({
      findUser: async (id) => await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
    }),

    api: () => new DatabaseTokenGuard({
      db,
      prefix: 'oat_',
      expiresIn: 30 * 86400,
      findUser: async (id) => await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
    })
  }
} satisfies AuthConfig

Guards

A guard extracts credentials, validates them, and returns the authenticated user. All guards throw HTTP 401 on failure.

JWT Guard

Reads a Bearer token from the Authorization header, verifies the HMAC-SHA256 signature, and resolves the user. No external JWT library needed.

Verification pins the expected algorithm: the token header must declare alg: HS256 (and typ: JWT when present), so alg: none and algorithm-confusion tokens are rejected before the signature is checked. A valid exp claim and a sub claim are both required; a token without a finite exp never validates, so JWTs cannot live forever.

import { JwtGuard } from '@tekir/auth'
import { db } from '#services'
import env from '#env'

const guard = new JwtGuard({
  secret: env.APP_KEY,             // HMAC-SHA256 signing secret
  expiresIn: 3600,                 // token lifetime in seconds (default 3600)
  findUser: async (id) => {
    return await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
  }
})

Generate a token after login:

import type { HttpContext } from '@tekir/core'
import { service } from '@tekir/core'
import type { Auth, JwtGuard } from '@tekir/auth'
import { db, hash } from '#services'

const auth = service<Auth>('auth')

export async function login({ body, response }: HttpContext) {
  const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [body.email])
  if (!user || !(await hash.verify(body.password, user.password))) {
    return response.unauthorized({ message: 'Invalid credentials' })
  }

  const { token, expiresAt } = await auth.guard<JwtGuard>('jwt').generate(user)
  return response.ok({ token, expiresAt })
}

// The client sends the token on subsequent requests:
// Authorization: Bearer <token>

Custom expiry and claims:

const { token, expiresAt } = await auth.guard<JwtGuard>('jwt').generate(user, {
  expiresIn: 60 * 60 * 24 * 7, // 7 days
  claims: { role: user.role, plan: 'pro' }
})

Session Guard

Stores the user ID in the server-side session. Regenerates the session ID on login to prevent fixation attacks.

import { SessionGuard } from '@tekir/auth'
import { db } from '#services'

const guard = new SessionGuard({
  sessionKey: 'auth_user_id',  // key used inside the session (default 'auth_user_id')
  findUser: async (id) => {
    return await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
  }
})
export async function login({ body, response, session }: HttpContext) {
  const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [body.email])
  if (!user || !(await hash.verify(body.password, user.password))) {
    return response.unauthorized({ message: 'Invalid credentials' })
  }

  // Write user ID to session + regenerate session ID
  await session.regenerate()
  session.put('auth_user_id', user.id)

  return response.redirect('/dashboard')
}

Database Token Guard

Issues opaque bearer tokens. The stored value is a keyed HMAC-SHA256 of the token, peppered with your APP_KEY, so a database leak alone cannot be used to forge or replay tokens, since the key is never stored alongside the data. The plain-text token is returned once at generation time and never written to the database. The table is auto-created.

The guard requires an APP_KEY to key the HMAC. It reads config.appKey (or falls back to process.env.APP_KEY); if the key is missing or too short the guard throws a clear error at setup.

import { DatabaseTokenGuard } from '@tekir/auth'
import { db } from '#services'
import env from '#env'

const guard = new DatabaseTokenGuard({
  db,                          // Database instance
  appKey: env.APP_KEY,        // required: keys the stored token HMAC
                              // (falls back to process.env.APP_KEY)
  prefix: 'oat_',             // token prefix (default 'oat_')
  expiresIn: 2592000,          // 30 days in seconds
  table: 'auth_tokens',       // table name (default 'auth_tokens', auto-created)
  findUser: async (id) => {
    return await db.queryOne('SELECT * FROM users WHERE id = ?', [id])
  }
})

Migration: the stored token format changed. The hash column now holds an APP_KEY-keyed HMAC instead of a plain SHA-256, so all previously stored tokens are invalidated and must be regenerated with auth.generate(). The column schema is unchanged, only its contents. Setting APP_KEY is now mandatory for this guard.

Generate and manage tokens via ctx.auth:

// Inside a protected route, ctx.auth has the methods
export async function createToken({ auth, body, request, headers, response }: HttpContext) {
  const { token, id } = await auth.generate({
    name: 'Mobile App',
    expiresIn: 30 * 86400,
    metadata: {
      ip: request.ip,
      userAgent: headers['user-agent']
    }
  })

  return response.created({ token, id })
}
// List all tokens for the authenticated user
export async function listTokens({ auth, response }: HttpContext) {
  const tokens = await auth.list()
  return response.ok(tokens)
}

// Revoke all tokens for the authenticated user
export async function revokeTokens({ auth, response }: HttpContext) {
  await auth.revokeAll()
  return response.ok({ message: 'All tokens revoked' })
}

Access Token Guard

The simplest bearer-token guard. You supply a verifier callback; it handles extraction and error-throwing.

import { AccessTokenGuard } from '@tekir/auth'
import { db } from '#services'

const guard = new AccessTokenGuard(
  async (token: string) => {
    return await db.queryOne('SELECT * FROM users WHERE api_token = ?', [token])
  },
  { headerName: 'authorization', prefix: 'Bearer' }
)

Basic Auth Guard

Decodes Authorization: Basic headers. Useful for internal tooling and webhooks.

import { BasicAuthGuard } from '@tekir/auth'
import { db, hash } from '#services'

const guard = new BasicAuthGuard({
  verifyCredentials: async (username, password) => {
    const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [username])
    if (!user) return null
    return (await hash.verify(password, user.password)) ? user : null
  }
})

Protecting Routes

Use authenticate() per-route with .use() or on a group. Multiple guards are tried in order.

start/routes.ts
import type { TekirApp } from '@tekir/core'
import { authenticate, silentAuth, guest } from '@tekir/auth'

export default function({ router }: TekirApp) {
  // Protect with the default guard
  router.get('/profile', showProfile).use(authenticate())

  // Protect with a specific guard
  router.get('/api/me', showProfile).use(authenticate('api'))

  // Try multiple guards in order (first to succeed wins)
  router.get('/dashboard', showDashboard).use(authenticate(['jwt', 'session']))

  // Protect an entire group
  router.group(() => {
    router.get('/posts', listPosts)
    router.post('/posts', createPost)
  }).prefix('/api').use(authenticate('jwt'))
}

guest() is the inverse, passes only unauthenticated users:

// Prevent authenticated users from accessing the login page
router.get('/login', showLogin).use(guest())

Silent Auth

silentAuth() checks authentication without blocking. Use it on public pages that optionally show user info.

import { authenticate, silentAuth, guest } from '@tekir/auth'

// Silent auth checks if the user is logged in without blocking.
// If authenticated: auth.user is set, auth.isAuthenticated = true
// If not: auth.user = null, auth.isAuthenticated = false
router.useGlobal([silentAuth()])

// In your handler:
export function home({ auth, response }: HttpContext) {
  if (auth.isAuthenticated) {
    return response.ok({ greeting: `Hello ${auth.user!.name}` })
  }
  return response.ok({ greeting: 'Hello guest' })
}

The Auth Object

After the auth middleware runs, ctx.auth has both properties and methods bound to the active guard and current user:

export async function show({ auth, response }: HttpContext) {
  // Properties
  auth.user              // the authenticated user object
  auth.isAuthenticated   // true inside a protected route
  auth.guard             // name of the guard that succeeded ('jwt', 'session', 'api')

  // Methods: bound to the active guard and current user
  await auth.generate()         // generate a token (JWT / DatabaseToken)
  await auth.logout()           // logout (clears session or revokes token)
  await auth.list()             // list all tokens (DatabaseToken guard)
  await auth.revokeAll()        // revoke all tokens (DatabaseToken guard)

  return response.ok({ id: auth.user!.id, email: auth.user!.email })
}
  • auth.user: the authenticated user
  • auth.isAuthenticated: always true in protected routes
  • auth.guard: name of the guard that succeeded
  • auth.generate(options?): generate a token (JWT/DatabaseToken)
  • auth.logout(): logout via the active guard
  • auth.list(): list tokens (DatabaseToken guard)
  • auth.revokeAll(): revoke all tokens (DatabaseToken guard)

Generating Tokens

Inside a protected route, call auth.generate(options?) directly. For login endpoints (before middleware runs), use auth.guard().generate().

Logging Out

Call auth.logout() to delegate to the active guard automatically:

// The simplest way, ctx.auth.logout() delegates to the active guard:
export async function logout({ auth, response }: HttpContext) {
  await auth.logout()
  return response.ok({ message: 'Logged out' })
}

// Session guard: clears session key + regenerates session ID
// JWT guard: no server-side state: client discards the token
// DatabaseToken guard: revokes the current token