@api-policy/server
v2.0.0
Published
Complete API authorization framework. JWT, API Key, Gateway JWT, permissions, routing.
Downloads
174
Maintainers
Readme
@api-policy/server
API authorization framework for Node.js. JWT, API Key, and Gateway JWT authentication with a bitmask permission engine.
Built on @api-policy/core. All core builders (role, perm, owner, and, or, not, evaluate, explain) are re-exported from this package — no need to install core separately.
npm install @api-policy/server josePermission Model
Users carry per-resource bitmasks. Each resource is independent — product permissions never affect order.
import { PERM, PERM_ALL } from '@api-policy/server'
// Regular user
user.perms = {
product: PERM.READ | PERM.WRITE, // = 3
order: PERM.READ, // = 1
}
// Admin: wildcard grants access to all resources
user.perms = { '*': PERM_ALL } // = 31Permission bits:
| Constant | Value | Bit |
|----------|-------|-----|
| PERM.READ | 1 | 1 << 0 |
| PERM.WRITE | 2 | 1 << 1 |
| PERM.DELETE | 4 | 1 << 2 |
| PERM.APPROVE | 8 | 1 << 3 |
| PERM.EXECUTE | 16 | 1 << 4 |
| PERM_ALL | 31 | all bits |
Resolution order: resource-specific → wildcard '*' → 0 (deny)
If a user has both product: 1 and '*': 31, the specific mask (1) wins for product.
UserContext
Shape of the authenticated user passed through your system:
type UserContext = {
id: string
perms?: Record<string, number> // per-resource bitmasks
roles?: string[] // optional role array
tenantId?: string
tenantType?: string
}JWT payload expected by the default JWT auth method:
{
"sub": "user-123",
"perms": { "product": 7, "order": 1 },
"roles": ["editor"],
"tid": "tenant-abc"
}Authentication
JWT
import {
createAuthPolicyEngine,
createJwtAuthMethod,
createDefaultJwtVerify,
} from '@api-policy/server'
// Symmetric (HS256)
const jwtMethod = createJwtAuthMethod(
{ secret: process.env.JWT_SECRET! },
createDefaultJwtVerify({ secret: process.env.JWT_SECRET! })
)
// Asymmetric (RS256, ES256, EdDSA, etc.)
const jwtMethod = createJwtAuthMethod(
{ publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' },
createDefaultJwtVerify({ publicKey: process.env.JWT_PUBLIC_KEY!, algorithm: 'EdDSA' })
)
// Custom verify function (e.g. JWKS endpoint)
const jwtMethod = createJwtAuthMethod(
{ issuer: 'https://auth.example.com' },
async (token, config) => {
// your custom verification logic
// return ok(payload) or err({ code: 'TOKEN_EXPIRED', message: '...' })
}
)Reads from Authorization: Bearer <token> header.
API Key
import { createApiKeyAuthMethod } from '@api-policy/server'
const apiKeyMethod = createApiKeyAuthMethod({
lookup: async (key) => {
const record = await db.apiKeys.findOne({ key })
if (!record) return null
return {
ownerId: record.userId,
perms: { '*': PERM_ALL },
tenantId: record.tenantId,
}
},
})Reads from X-API-Key header.
Gateway JWT
For BFF → backend flows. Verifies two JWTs per request: a short-lived Gateway JWT that identifies the trusted BFF, and the user's Access JWT forwarded from the client.
Client → BFF (verify access JWT, sign Gateway JWT) → Backend (verify both)import { createGatewayJwtAuthMethod, createDefaultJwtVerify } from '@api-policy/server'
const gatewayMethod = createGatewayJwtAuthMethod({
gateway: {
// Ed25519 public key of the BFF (64 hex chars = 32 bytes)
// Omit for local dev — skips gateway verification entirely
publicKeyHex: process.env.BFF_PUBLIC_KEY_HEX,
expectedIssuer: 'bff-worker',
expectedAudience: 'my-backend',
// header: 'x-gateway-auth', // default
// clockSkewSeconds: 5, // default — keep ≤10s for NTP-synced envs
// validateJti: async (jti) => cache.setNX(jti, 1), // optional replay protection
},
accessJwt: {
config: { secret: process.env.JWT_SECRET! },
verifyFn: createDefaultJwtVerify({ secret: process.env.JWT_SECRET! }),
},
})Headers expected:
| Header | Value |
|--------|-------|
| x-gateway-auth | Bearer <gateway_jwt> — identifies the BFF |
| Authorization | Bearer <access_jwt> — user's token (forwarded as-is) |
Gateway JWT payload (identity only, no user context):
{
"iss": "bff-worker",
"aud": "my-backend",
"sub": "worker-id-1",
"exp": "<now + 30s>",
"jti": "<uuid>"
}User context is extracted from the access JWT payload (sub, perms, tid, roles) — never from headers.
Local dev mode — omit publicKeyHex to skip gateway verification:
createGatewayJwtAuthMethod({
gateway: {
// no publicKeyHex → x-gateway-auth header not required
expectedIssuer: 'bff-worker',
expectedAudience: 'my-backend',
},
accessJwt: { config: { secret: 'dev-secret' }, verifyFn: createDefaultJwtVerify(...) },
})AuthPolicyEngine
Combines multiple auth methods. Tries each in order until one succeeds.
import { createAuthPolicyEngine, AUTH_METHOD } from '@api-policy/server'
const engine = createAuthPolicyEngine(
[jwtMethod, apiKeyMethod, gatewayMethod],
{ defaultMethods: [AUTH_METHOD.JWT, AUTH_METHOD.API_KEY] }
)
const result = await engine.resolve('jwt', ctx)
if (result.ok) {
const { user, method } = result.data
} else {
// result.errors[0] is a typed error (NotAuthenticated, InvalidCredentials, etc.)
}AuthRequirement can be:
true— try default methods'jwt'|'apiKey'|'gatewayJwt'— specific method['jwt', 'apiKey']— try list in order
PermissionEngine
Standalone permission checker. Used by adapters internally, or call directly in handlers.
import {
createPermissionEngine,
PERM,
BOUNDARY,
} from '@api-policy/server'
const engine = createPermissionEngine()
// check() — full flow: roles → allowOwner → bitmask → boundary
const result = engine.check(
user,
{
resource: 'product',
action: PERM.WRITE,
boundary: BOUNDARY.TENANT,
},
{ tenantId: 'tenant-abc', ownerId: 'user-123' }
)
if (!result.ok) {
throw result.error // PermissionDenied or BoundaryViolation
}
// can() — simple bitmask check, no boundary
if (engine.can(user, 'product', PERM.WRITE)) {
// user has WRITE on product
}
// compile() — pre-bind spec for repeated checks
const canDelete = engine.compile({
resource: 'product',
action: PERM.DELETE,
boundary: BOUNDARY.TENANT,
})
const allowed = canDelete(user, resource)Permission check flow
1. Roles gate — deny if user does not have required role(s)
2. Owner bypass — allow immediately if resource.ownerId === user.id (when allowOwner: true)
3. Capability check — deny if (user.perms[resource] & action) !== action
4. Boundary check — deny if tenant/owner constraint not satisfiedBoundaries
| Value | Check |
|-------|-------|
| BOUNDARY.GLOBAL | No boundary check |
| BOUNDARY.TENANT | user.tenantId === resource.tenantId |
| BOUNDARY.OWNER | user.id === resource.ownerId |
| BOUNDARY.SELF | Same as OWNER (alias) |
Roles gate
// OR logic (default): user has ANY of the listed roles
{ roles: ['admin', 'moderator'], resource: 'post', action: PERM.DELETE, boundary: BOUNDARY.GLOBAL }
// AND logic: user must have ALL listed roles
{ roles: ['admin', 'superuser'], requireAllRoles: true, ... }Roles are checked first. If the user fails the role check, allowOwner does not save it.
allowOwner
engine.check(user, {
resource: 'post',
action: PERM.WRITE,
boundary: BOUNDARY.GLOBAL,
allowOwner: true,
}, { ownerId: post.authorId })The resource owner bypasses bitmask and boundary checks entirely. Non-owners go through the normal flow.
Do not use allowOwner when owners should have restricted actions (e.g. cannot approve their own request).
Route Definition
import { defineRoute, definePublicRoute, PERM, BOUNDARY } from '@api-policy/server'
const getPost = defineRoute({
method: 'GET',
path: '/posts/:id',
auth: true,
permission: {
resource: 'post',
action: PERM.READ,
boundary: BOUNDARY.TENANT,
},
handler: async (ctx) => {
const post = await db.posts.findById(ctx.params.id)
return ok(post)
},
})
const createPost = defineRoute({
method: 'POST',
path: '/posts',
auth: 'jwt',
permission: {
resource: 'post',
action: PERM.WRITE,
boundary: BOUNDARY.GLOBAL,
roles: ['editor', 'admin'],
},
input: { body: PostCreateSchema },
handler: async (ctx) => {
const post = await db.posts.create(ctx.body)
return ok(post)
},
})
const publicHealth = definePublicRoute({
method: 'GET',
path: '/health',
handler: async () => ok({ status: 'ok' }),
})Route config options:
| Field | Type | Description |
|-------|------|-------------|
| auth | boolean \| AuthMethodName \| AuthMethodName[] | Auth requirement |
| permission | PermissionSpec | Permission check |
| loadResource | (ctx) => Promise<ResourceContext> | Load resource for boundary check |
| input | { body?, query?, params? } | Validation schemas (Zod/TypeBox) |
| successStatus | number | Override response status (default: POST=201, others=200) |
Admin pattern
No special bypass flag. Admin is a user with full permission on the wildcard resource:
// Assign in your auth method / lookup function
user.perms = { '*': PERM_ALL }
// Or specific resources with full access
user.perms = {
'*': PERM_ALL,
'audit-log': PERM.READ, // even admins can't write audit logs
}The engine resolves: perms['order'] ?? perms['*'] ?? 0. Resource-specific mask always wins.
Type-safe resources
Pass your resource union as a generic to catch typos at compile time:
type Resource = 'product' | 'order' | 'post'
const user: UserContext<Resource> = {
id: 'user-123',
perms: {
product: PERM.READ | PERM.WRITE,
order: PERM.READ,
// post: ... ← TS error if you typo 'psot'
},
}
const spec: PermissionSpec<Resource> = {
resource: 'product', // TS enforces valid resource names
action: PERM.WRITE,
boundary: BOUNDARY.GLOBAL,
}Using core builders in handlers
All @api-policy/core exports are available from @api-policy/server. Use toSubject() to bridge UserContext to the core engine's subject shape:
import { evaluate, or, role, owner, toSubject } from '@api-policy/server'
const allowed = evaluate(
or(role('admin'), owner('authorId')),
{
subject: toSubject(ctx.user, 'post'), // picks user.perms['post']
resource: post,
}
)Errors
All errors are typed and carry a code field:
import {
NotAuthenticated,
InvalidCredentials,
TokenExpired,
TokenMalformed,
PermissionDenied,
BoundaryViolation,
Forbidden,
NotFound,
Conflict,
RateLimitExceeded,
InternalError,
getHttpStatus,
} from '@api-policy/server'
// Each error factory accepts an optional message:
throw PermissionDenied({ message: 'Insufficient role' })
// Map to HTTP status:
getHttpStatus(error) // → 403Build-time tools
Route linter
Catch misconfigured routes at build time:
import { lintRoutes, formatLintResult } from '@api-policy/server'
const result = lintRoutes(routes)
if (!result.valid) {
console.error(formatLintResult(result))
process.exit(1)
}Permission registry
Validate that all perms references in your routes match your declared permissions:
import { createPermissionRegistry, validatePermissions } from '@api-policy/server'
const registry = createPermissionRegistry({
product: { READ: true, WRITE: true, DELETE: true },
order: { READ: true },
})
const result = validatePermissions(routes, registry)License
MIT
