@Authorized & Principals
Protect routes with required permissions, restrict allowed principal kinds and inject the resolved principal into handlers.
Routes opt into authentication via two complementary decorators:
@Authorized(...permissions)— gates the route on a list of permissions and, optionally, allowed principal kinds.@CurrentUser()/@CurrentApiKey()— injects the resolved principal as a handler argument.
Either one is enough to mount the authentication middleware on the route — you can use them independently or together.
@Authorized(...permissions)
import { Authorized } from 'exjs-controllers/decorators/Authorized'
@JsonController('/admin')
class AdminController {
@Authorized('admin:read')
@Get('/users')
list() { /* ... */ }
@Authorized('admin:write')
@Delete('/users/:id')
remove(@Param('id') id: string) { /* ... */ }
}Permissions are passed verbatim as requiredPermissions to your authorizationChecker. The semantics — exact match, prefix match, scope hierarchy, etc. — are entirely up to your implementation.
Restricting principal kinds
The object form lets you also restrict which principal kinds may call the route:
@Authorized({
permissions: ['orders:read'],
kinds: ['api-key'] // only machine clients
})
@Get('/orders')
list() { /* ... */ }Acceptable values are 'user' and 'api-key'. When omitted, both kinds are accepted.
Calling @Authorized with no permissions
@Authorized() (no arguments) is a valid declaration — the route still requires a valid principal, just without any specific permission check.
@CurrentUser and @CurrentApiKey
Inject the resolved principal into a handler argument.
import { CurrentUser, CurrentApiKey } from 'exjs-controllers/decorators/CurrentUser'
@Get('/me')
me(@CurrentUser() user: AuthenticatedUser) {
return user
}
@Get('/jobs')
@Authorized('jobs:enqueue')
enqueue(@CurrentApiKey() apiKey: ApiKey) {
return { actor: apiKey.id }
}Both decorators accept { optional?: boolean }. When optional: true, missing or wrong-kind principals resolve to undefined instead of throwing.
@Get('/me-or-anon')
maybeMe(@CurrentUser({ optional: true }) user?: AuthenticatedUser) {
return user ?? { anonymous: true }
}Kind mismatch
Asking for a user when the resolved principal is an api-key (or vice versa) results in a 403 Forbidden — unless you opted in with { optional: true }.
Lifecycle summary
For each protected route, the framework runs this middleware before the handler:
- Call
currentUserChecker({ request, response }). - If it throws → respond
401. - If it returns
null:- Route has
@Authorized→ respond401. - Route only declares
@CurrentUser({ optional: true })→ continue, principal isundefined.
- Route has
- If a principal is resolved, check
allowedKinds(from@Authorizedand/or principal decorators). - Call
authorizationChecker(action, principal, kind, requiredPermissions). - If the checker returns anything other than
true→ respond403. - Attach the resolved principal to the request and continue.
OpenAPI integration
When a route is protected, the generated OpenAPI operation includes a security block that:
- Lists every security scheme whose
kindsintersect with the route's allowed kinds. - Uses the route's
permissionsarray as the scope list per scheme.
Combined with the schemes you declare in openApiSecuritySchemes, this is enough for Scalar (or any OpenAPI viewer) to render the right "Authorize" buttons and gate "Send Request" on a valid token. See OpenAPI and Scalar for the rest of the wiring.