@forinda/kickjs-auth
Pluggable authentication — JWT, API key, OAuth, and Passport.js bridge.
AuthStrategy
interface AuthStrategy {
name: string
validate(req: any): Promise<AuthUser | null> | AuthUser | null
}AuthUser
interface AuthUser {
[key: string]: any
}Decorators
function Authenticated(strategy?: string): ClassDecorator & MethodDecorator
function Public(): MethodDecorator
function Roles(...roles: string[]): MethodDecorator
function Can(action: string, resource: string): MethodDecorator
function Policy(resource: string): ClassDecorator@Can checks AuthorizationService.can(user, action, resource) before the handler runs and returns 403 on deny. @Policy('name') registers a class whose methods are actions (view, update, delete, …) that back can().
AuthAdapter
const AuthAdapter: AdapterFactory<AuthAdapterOptions>
interface AuthAdapterOptions {
strategies: AuthStrategy[]
defaultPolicy?: 'protected' | 'open'
onUnauthorized?: (req: any, res: any) => void
onForbidden?: (req: any, res: any) => void
}Built with defineAdapter() — call it as AuthAdapter({ strategies: [...] }) and pass the result to bootstrap({ adapters: [...] }).
JwtStrategy
class JwtStrategy implements AuthStrategy {
name = 'jwt'
constructor(options: JwtStrategyOptions)
}
interface JwtStrategyOptions {
secret: string | Buffer
algorithms?: string[]
tokenFrom?: 'header' | 'query' | 'cookie'
headerName?: string
headerPrefix?: string
queryParam?: string
cookieName?: string
mapPayload?: (payload: any) => AuthUser
/**
* Extra `jsonwebtoken.verify()` options — issuer/audience/subject,
* clockTolerance, maxAge, ignoreExpiration, ignoreNotBefore, etc.
* `algorithms` is excluded (already top-level).
*/
verifyOptions?: JwtVerifyOptions
}
type JwtVerifyOptions = Omit<import('jsonwebtoken').VerifyOptions, 'algorithms' | 'complete'>Forward jsonwebtoken's claim-validation options without abusing mapPayload:
JwtStrategy({
secret: process.env.JWT_SECRET!,
algorithms: ['HS256'],
verifyOptions: {
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
clockTolerance: 30,
maxAge: '15m',
},
})ApiKeyStrategy
class ApiKeyStrategy implements AuthStrategy {
name = 'api-key'
constructor(options: ApiKeyStrategyOptions)
}
interface ApiKeyStrategyOptions {
keys?: Record<string, ApiKeyUser>
validate?: (key: string) => Promise<AuthUser | null> | AuthUser | null
from?: Array<'header' | 'query'>
headerName?: string
queryParam?: string
}OAuthStrategy
class OAuthStrategy implements AuthStrategy {
name: string // 'oauth-{provider}'
constructor(options: OAuthStrategyOptions)
getAuthorizationUrl(state?: string): string
}
interface OAuthStrategyOptions {
provider: 'google' | 'github' | 'discord' | 'microsoft' | 'custom'
clientId: string
clientSecret: string
callbackUrl: string
endpoints?: OAuthEndpoints
scopes?: string[]
mapProfile?: (profile: any, tokens: OAuthTokens) => AuthUser | Promise<AuthUser>
}PassportBridge
class PassportBridge implements AuthStrategy {
constructor(name: string, passportStrategy: any)
}AuthorizationService
Programmatic authorization checks against @Policy()-registered classes. Registered as a DI singleton (@Service()) — inject via the class token (AuthorizationService); there is no separate symbol token.
@Service()
class AuthorizationService {
constructor(options?: AuthorizationServiceOptions)
can(user: AuthUser, action: string, resource: string, resourceInstance?: any): Promise<boolean>
}
interface AuthorizationServiceOptions {
/**
* How to handle `@Can()` calls that reference a missing policy or action.
*
* - `'warn'` (default) — log once per (resource, action) and deny. Catches
* typos and renamed methods without breaking prod traffic.
* - `'error'` — throw `PolicyMissingError`. Use in CI/test builds.
* - `'silent'` — legacy behavior; deny with no log.
*/
onMiss?: 'warn' | 'error' | 'silent'
/** Short-circuit allow-list — `'resource.action'` or just `'resource'`. */
allow?: string[]
/** Short-circuit deny-list — takes precedence over `allow`. */
deny?: string[]
/**
* Resolve the set of resource IDs a user can `action`. Back this with
* a ReBAC engine (OpenFGA, SpiceDB, Cedar). If unset,
* `AuthorizationService.listObjects()` throws `NotImplementedError`
* so callers can fall back to `findAll + filter with can()`.
*/
listObjects?: (user: AuthUser, action: string, resource: string) => Promise<readonly string[]>
}
class PolicyMissingError extends Error {
readonly resource: string
readonly action: string
}
class NotImplementedError extends Error {}listObjects — ReBAC seam
listObjects(user, action, resource): Promise<readonly string[]> returns the set of resource IDs the user can action. Mirror of can() for list endpoints that need WHERE id IN (...) pushdown instead of row-by-row filtering.
Back it with a ReBAC engine:
import { OpenFGAClient } from '@openfga/sdk'
const fga = new OpenFGAClient({ ... })
AuthAdapter({
strategies: [...],
policy: {
listObjects: async (user, action, resource) => {
const res = await fga.listObjects({
user: `user:${user.id}`,
relation: action,
type: resource,
})
return res.objects.map((o) => o.replace(`${resource}:`, ''))
},
},
})In a handler:
@Get('/flocks')
async list(ctx: RequestContext) {
const ids = await this.authz.listObjects(ctx.user!, 'view', 'flock')
return ctx.json(await db.query.flocks.findMany({ where: inArray(flocks.id, ids) }))
}Use authz.supportsListObjects() to branch into a findAll + filter with can() fallback when no implementation is configured.
AuthAdapter forwards options.policy to its internal AuthorizationService:
AuthAdapter({
strategies: [...],
policy: { onMiss: process.env.NODE_ENV === 'test' ? 'error' : 'warn' },
})- Returns
falsewhen no@Policy(resource)is registered, when the policy class has no method namedaction, or when the method returns a falsy value. resourceInstanceis forwarded as the second argument to the policy method (first argument is the user).@Can(action, resource)is the decorator equivalent for controller methods and is enforced byAuthAdapter.
@Service()
class PostService {
@Autowired() private authz!: AuthorizationService
async update(user: AuthUser, id: string, data: UpdateDto) {
const post = await this.repo.findById(id)
if (!(await this.authz.can(user, 'update', 'post', post))) {
throw new HttpException(HttpStatus.FORBIDDEN)
}
return this.repo.update(id, data)
}
}Policy auto-discovery
function loadPolicies(modules: Record<string, unknown>): numberPolicy classes only self-register when their file is imported. Call loadPolicies(import.meta.glob('./modules/**/*.policy.ts', { eager: true })) once at startup (before bootstrap()) so @Policy() decorators fire. Returns the number of classes discovered.
PasswordService
Password hashing, verification, rehash detection, and plaintext policy validation. Registered as a DI singleton — inject via the class token (PasswordService); there is no separate symbol token.
@Service()
class PasswordService {
constructor(config?: PasswordConfig)
hash(password: string): Promise<string>
verify(hash: string, password: string): Promise<boolean>
needsRehash(hash: string): boolean
validate(password: string, policy?: PasswordPolicy): PasswordValidationResult
}
interface PasswordConfig {
/** Hashing algorithm (default: 'scrypt'). */
algorithm?: 'scrypt' | 'argon2id' | 'bcrypt'
// scrypt options
/** CPU/memory cost N (default: 16384) */
cost?: number
/** Block size r (default: 8) */
blockSize?: number
/** Parallelism p (default: 1) */
parallelism?: number
/** Derived key length in bytes (default: 64) */
keyLength?: number
/** Salt length in bytes for scrypt (default: 16) */
saltLength?: number
// argon2id options
/** Memory cost in KiB (default: 65536 = 64 MiB) */
memoryCost?: number
/** Iterations (default: 3) */
timeCost?: number
// bcrypt options
/** Salt rounds (default: 12) */
rounds?: number
}
interface PasswordPolicy {
minLength?: number // default 8
maxLength?: number // default 128
requireUppercase?: boolean
requireLowercase?: boolean
requireDigit?: boolean
requireSpecial?: boolean
}
interface PasswordValidationResult {
valid: boolean
errors: string[]
}Algorithms and peer dependencies
scrypt(default) — Node built-in, no extra install.argon2id— requirespnpm add argon2.bcrypt— requirespnpm add bcryptjs(preferred, pure JS) orpnpm add bcrypt(native).
Missing peer deps surface as a runtime error on the first hash()/verify() call.
Rehash on login
verify() auto-detects the algorithm encoded in the stored hash, so rotating algorithm or tuning cost parameters does not break existing users. Call needsRehash(hash) after a successful verify() and persist a fresh hash when it returns true:
const ok = await pw.verify(user.passwordHash, plaintext)
if (ok && pw.needsRehash(user.passwordHash)) {
await userRepo.update(user.id, { passwordHash: await pw.hash(plaintext) })
}needsRehash() returns true when the hash's algorithm differs from config.algorithm, or when the encoded cost parameters (scrypt cost/blockSize/parallelism/keyLength, argon2 memoryCost/timeCost, bcrypt rounds) no longer match the configured values.
Constants
const AUTH_USER: symbol // DI token for current user
const AUTH_META: {
AUTHENTICATED: symbol
PUBLIC: symbol
ROLES: symbol
STRATEGY: symbol
}