DEV Community

Cover image for Authentication and Authorization in KickJS — Strategies, Roles, and Type-Safe Decorators
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Authentication and Authorization in KickJS — Strategies, Roles, and Type-Safe Decorators

For most of last year, the cheapest way to ship a security regression in a TypeScript API was a single typo:

@Roles('owener', 'admin')   // shipped. nobody noticed. forever-403.
Enter fullscreen mode Exit fullscreen mode

@Roles accepted any string. Tests still passed because they used the spelled-correctly literal. The route silently rejected every request. KickJS v5 fixes this: @Roles is now narrowed by your project's own role union via a one-file declare module augmentation, so misspellings become a compile error before they leave the editor. This post walks the full auth surface in KickJS — AuthAdapter, the AuthStrategy interface, @Authenticated, @Roles, @Public — at a conceptual level, with small generic snippets you can map onto any KickJS app.

The shape of auth in KickJS

KickJS keeps auth deliberately small. There are three concepts to learn, and they compose cleanly.

  1. AuthAdapter — an AppAdapter (the same plug used for observability, swagger, db) constructed via createAuthAdapter(). You hand it a defaultPolicy ('protected' or 'public') and an array of strategies. It registers a per-request middleware that runs strategies in order, attaches the resolved user to req.user, and returns 401 on failure.
  2. AuthStrategy — an interface: name: string plus validate(req): Promise<AuthUser | null>. Built-ins live in @forinda/kickjs-auth (JwtStrategy, ApiKeyStrategy); custom strategies are just classes implementing the interface.
  3. Decorators@Authenticated('strategy-name') opts a controller or route into a specific strategy; @Roles(...) narrows the user's role; @Public() marks a route as anonymous. They compose at any level — controller-class, method, or both.

The big idea: strategy choice and role check are separate axes. @Authenticated says who is allowed to identify you; @Roles says what they need to be. Some routes only need the first (a synthetic system user has no roles); some need both (regular CRUD needs JWT plus an authorized role).

Wiring AuthAdapter

A typical adapter file looks like this:

export function createAuthAdapter(): AppAdapter {
  return AuthAdapter({
    defaultPolicy: 'protected',
    strategies: [
      JwtStrategy({ /* ...covered next... */ }),
      new ServiceTokenStrategy(),
    ],
  })
}
Enter fullscreen mode Exit fullscreen mode

Two production-grade habits to copy:

  • defaultPolicy: 'protected'. Every route requires a valid user unless explicitly @Public(). The opposite default ('public') is the path to forgetting @Authenticated on a sensitive endpoint and shipping it. Default-deny costs you @Public() on three routes (health, login, refresh) and protects everything else automatically.
  • Refuse to construct against an unresolved secret. If your env layer keeps secrets symbolic in committed config and a resolver swaps them at boot, the adapter must throw when it finds a placeholder value — otherwise it would silently sign or verify with the literal placeholder string. Steal the pattern: any factory that consumes a secret should refuse to build against an unresolved sentinel.

The adapter is a factory, not a class. KickJS auth v4 dropped construct signatures on AuthAdapter and JwtStrategy; calling them with new raises TS7009. If you're migrating from v3, that's the diff to make.

JWT with claim mapping

JwtStrategy does the heavy lifting — Bearer parsing, signature verify, expiry. The interesting bit is mapPayload, which turns a verified JWT payload into your app's user shape:

JwtStrategy({
  secret: env.JWT_SECRET,
  algorithms: ['HS256'],
  mapPayload: (payload): AppAuthUser => {
    if (payload.iss !== env.JWT_ISSUER) throw new Error('iss mismatch')
    const claimed = Array.isArray(payload.roles) ? payload.roles : []
    const roles = claimed.filter((r): r is AppRole => KNOWN_ROLES.has(r))
    return { id: String(payload.sub ?? ''), roles }
  },
})
Enter fullscreen mode Exit fullscreen mode

Three things worth flagging:

Issuer/audience enforcement inside mapPayload. Some JwtStrategy versions don't forward issuer/audience to the underlying verifier. The workaround is small: do the comparison yourself and throw on mismatch. Throwing inside mapPayload bubbles up as a strategy failure — the adapter returns 401, the handler never runs. If a future version adds first-class options, the migration is deleting a few lines.

Role filtering as defense in depth. Filter the JWT's roles claim through your known role union — drop anything else silently. Real validation belongs at issue time (the login path checks against your membership store), but defense in depth is cheap here and stops a class of bugs where downstream code accidentally trusts an unknown role string.

Returning a typed user. The return type extends AuthUser with whatever your app needs (id, email, a typed roles array, maybe a jti for revocation). Because of the augmentation we'll see next, downstream @Roles(...) calls share that exact union.

A second strategy for a second audience

Sooner or later you have routes that aren't part of your normal user-token flow — service-to-service callers, a control plane, a CI bot, an internal admin endpoint. Stuffing them into the same JWT issuer means either issuing privileged tokens out of the user login path or carrying a flag in user tokens that breaks isolation. Don't do either. Add a second strategy.

A minimal shared-token strategy looks like this:

export class ServiceTokenStrategy implements AuthStrategy {
  readonly name = 'service-token'
  async validate(req: Request): Promise<AppAuthUser | null> {
    const expected = (process.env.SERVICE_TOKEN ?? '').trim()
    if (!expected) return null
    const header = req.headers?.authorization ?? ''
    if (!header.toLowerCase().startsWith('bearer ')) return null
    const provided = Buffer.from(header.slice(7).trim())
    const want = Buffer.from(expected)
    if (provided.length !== want.length) return null
    return timingSafeEqual(provided, want)
      ? { id: 'service', roles: [] }
      : null
  }
}
Enter fullscreen mode Exit fullscreen mode

A few notes worth internalizing:

  • timingSafeEqual. Constant-time comparison stops the trivial timing attack on simple-token auth. If you're going to ship a shared bearer at all, do this.
  • Read process.env directly when you want HMR and test-stubs to "just work". Cached env snapshots are great for app config but get in the way for strategies that need to reflect runtime changes during tests.
  • Synthetic users are fine. A service caller doesn't need a row in your users table. A constant { id: 'service', roles: [] } is enough — the strategy is the gate.

Now look at registration order:

strategies: [
  JwtStrategy({ /* ... */ }),
  new ServiceTokenStrategy(),
],
Enter fullscreen mode Exit fullscreen mode

Order matters. KickJS picks "the first strategy that returns a user." JWT runs first, so user routes resolve their JWT-derived user. The service strategy only matches when a route opts in via @Authenticated('service-token'):

@Controller()
@Authenticated('service-token')
export class InternalOpsController { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The combination of (default-deny + ordered strategies + opt-in @Authenticated('strategy-name')) is what keeps the two audiences cleanly separated.

Type-safe @Roles via AuthUser augmentation

Here's the piece that earns its keep. @Roles(...) in KickJS v5 is generic over AuthUser['roles'][number]. Inside @forinda/kickjs-auth the relevant type is roughly:

type Role = AuthUser['roles'][number]
function Roles(...roles: Role[]): MethodDecorator & ClassDecorator
Enter fullscreen mode Exit fullscreen mode

By default AuthUser['roles'] is string[], so Role resolves to string — which is why old @Roles('typo') compiled. The fix is a one-file declaration merge in your app:

// src/types/auth-augment.d.ts
import type { AppRole } from '@/auth/roles'

declare module '@forinda/kickjs-auth' {
  interface AuthUser {
    roles: AppRole[]
  }
}

export {}
Enter fullscreen mode Exit fullscreen mode

Define your role union in exactly one place:

// src/auth/roles.ts
export type AppRole = 'owner' | 'admin' | 'editor' | 'viewer'
Enter fullscreen mode Exit fullscreen mode

After the augmentation, AuthUser['roles'] is AppRole[] everywhere — including inside the framework, including inside the Roles decorator's generic. The package uses an IsAny-pivoted conditional to pick this up cleanly: if user code augments AuthUser, Role resolves to your union; if not, it falls back to string so unconfigured projects still compile.

Concretely:

@Controller()
@Authenticated('jwt')
export class PostsController {
  @Post('/')
  @Roles('owner', 'admin')
  async create() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

@Roles('owner', 'admin') typechecks. @Roles('owener', 'admin') doesn't — TS reports Argument of type '"owener"' is not assignable to parameter of type 'AppRole'.

That's the proof. The same typo that used to ship is now a red squiggle in the IDE and a CI failure on pnpm typecheck. The cost was a six-line .d.ts file. The payoff: adding a new role is one line in roles.ts, and TypeScript tells you every controller that already references it correctly — and refuses to compile any that misspell it.

Two small bits of polish that make this nicer to live with:

  • Make the augmentation visible to tsc. Drop a side-effect import in src/index.ts (or in a barrel re-exported by it) so the file is part of the compilation unit. tsc won't apply a declare module it never sees.
  • Keep the union in one file. Import the role type everywhere from the same module — including inside the mapPayload filter. When the union grows, adding a literal updates the filter, the augmentation, and every @Roles call site in lockstep.

Where @Roles doesn't belong

The service-token controller above used @Authenticated('service-token') and no @Roles(...). That's deliberate. The synthetic service user has roles: []. If you slapped @Roles('admin') on a method, the check would fail — the synthetic user has none of your normal roles, by design.

Privileged-channel gating is the strategy choice itself: passing the strategy is the authorization. Your normal role union stays scoped to user-facing routes, which keeps two concerns from leaking into each other. If you later need richer role semantics on the privileged channel, give it its own union and its own decorator (@ServiceRoles(...)) kept disjoint from the user role type on purpose.

The takeaway for your own designs: not every protected route needs @Roles. If the strategy can only ever produce one kind of user, the strategy is the gate.

Putting it together

The mental model fits on one index card:

  1. Pick a default policy. Default-deny unless you have a very good reason.
  2. List your audiences. Each audience is one strategy with one name.
  3. Order them by which one should win when more than one could match.
  4. On controllers, pick the audience with @Authenticated('name'). On @Public() routes, opt out explicitly.
  5. On routes, pick the role with @Roles(...) — but only when the strategy actually issues users with roles.
  6. Augment AuthUser['roles'] once with your real role union. Now misspellings are a build error.

Auth surfaces are usually where frameworks accumulate accidental complexity, and most of the bugs that result are cheap to ship and expensive to find. KickJS keeps the surface narrow, makes the safe defaults the easy ones, and pushes role correctness from runtime into the compiler. After you've lived with the augmentation for a sprint, dropping back to "any string is a valid role" feels reckless.

References

Top comments (0)