Authentication
Netron separates authentication (who is the caller?) from authorisation (is this caller allowed to do this?). Two managers, one shared policy engine, composable policies.
Import path:
import {
AuthenticationManager,
AuthorizationManager,
PolicyEngine,
BuiltInPolicies,
} from '@omnitron-dev/titan/netron/auth';The two managers
| Manager | Job |
AuthenticationManager | Validate credentials, produce an auth context |
AuthorizationManager | Evaluate policies against the auth context |
flowchart LR
Req[Incoming RPC call]
Req --> Authn[AuthenticationManager<br/>validate JWT / token]
Authn -->|valid| Ctx[AuthContext<br/>userId, roles, scopes]
Authn -->|invalid| F1[TitanError<br/>UNAUTHORIZED / 401]
Ctx --> Authz[AuthorizationManager<br/>evaluate policies]
Authz -->|allowed| Method[Method body]
Authz -->|denied| F2[TitanError<br/>FORBIDDEN / 403]These are wired by the titan-auth ecosystem module (or by your own
integration). You rarely construct them directly.
One invocation decision across transports
HTTP and the persistent Netron transports use the same authorization decision:
registered service ACLs, then declared method requirements, then optional
authDefaultDeny. A missing authentication or authorization manager never disables
an explicitly protected method. Configure the required identity and policy providers;
requests without sufficient authority are refused before the method executes.
With authDefaultDeny: true, a published method needs an ACL or an explicit
authorization declaration. A bare @Public() is exposure, not permission. Declare
intentional anonymous access explicitly. An unavailable service instance or missing
authorization metadata produces a service-unavailable error rather than bypassing
checks. The HTTP pipeline requires the peer logger during configuration so failed
checks have the normal diagnostic path.
Service selection happens before authorization. The exact published service identity is used for ACL and policy evaluation, including batch HTTP calls. Selecting a version never evaluates another version's permissions.
Forwarding through a gateway
Publishing a remote Interface at another Netron endpoint creates a delegation
boundary. Register an AuthorizationManager ACL for the forwarded service or
method at that gateway. Netron checks the actual incoming caller against that
ACL before forwarding; the gateway's upstream credentials do not grant access
to its clients. Missing ACLs refuse the request, including anonymous requests
and HTTP authentication exemptions. ACL revocation applies to subsequent calls
and property operations on existing connections.
The upstream service still applies its own guards. Definitions carry transport restrictions, not executable authorization policies, so the gateway must declare its own delegation policy. Trusted local calls through an interface retain their in-process contract. Prefer narrow method grants and explicit service versions.
Auth context
Every authenticated call carries an auth context with:
{
userId: string;
roles: string[];
permissions: string[];
scopes?: string[]; // optional
token?: { /* … */ }; // optional, integration extras
metadata?: Record<string, any>; // optional
}This is what AuthConfig policies match against. (userId, roles,
and permissions are required; scopes is optional.)
@Auth(config) — configuring a method
AuthConfig (from @omnitron-dev/titan/decorators):
interface AuthConfig {
roles?: string[]; // ANY role grants access
permissions?: string[]; // ALL required
scopes?: string[]; // ALL OAuth2 scopes required
policies?: string[] | { all: string[] } | { any: string[] } | PolicyExpression;
allowAnonymous?: boolean;
inherit?: boolean; // inherit class-level policies
override?: boolean; // override class-level policies
}Usage:
import { Public, Auth } from '@omnitron-dev/titan/decorators';
@Service('orders@1.0.0')
class OrdersService {
@Public()
@Auth({ scopes: ['orders:read'] })
async list() { /* … */ }
@Public()
@Auth({ roles: ['admin'] })
async deleteAll() { /* … */ }
@Public()
@Auth({ allowAnonymous: true }) // explicit anonymous
async ping() { /* … */ }
@Public()
@Auth({ policies: { any: ['policy:admin', 'policy:resource-owner'] } })
async modifyResource(id: string) { /* … */ }
}The same configuration is also accepted inline through @Public:
@Public({ auth: { scopes: ['orders:read'] } })
async list() { /* … */ }Pick one style per project.
BuiltInPolicies — reusable policy definitions
The BuiltInPolicies namespace produces policy definitions you can
register with the policy engine:
import { BuiltInPolicies } from '@omnitron-dev/titan/netron/auth';
const policies = [
BuiltInPolicies.requireRole('admin'),
BuiltInPolicies.requireAnyRole(['admin', 'support']),
BuiltInPolicies.requireAllRoles(['user', 'verified']),
BuiltInPolicies.requirePermission('users:read'),
// …~18 helpers in the source: requireAnyPermission, requireScope,
// requireResourceOwner, requireIP, requireRateLimit, …
];
// Register them with the engine:
policyEngine.registerPolicies(policies); // array form; single: registerPolicy(p)Reference by name in AuthConfig:
@Auth({ policies: ['role:admin'] })
async deleteAll() { /* … */ }The policy name follows the convention shown in the helper
(role:admin, role:any:admin,support, etc.) — the registration
sets the name based on the helper's logic.
Custom policies
A policy is a PolicyDefinition:
import type { PolicyDefinition } from '@omnitron-dev/titan/netron/auth';
const IsResourceOwner: PolicyDefinition = {
name: 'resource:owner',
description: 'Caller must own the resource referenced by the first argument',
evaluate: async (context) => {
const resourceId = context.method?.args?.[0]; // method args live here
const resource = await ordersRepo.findById(resourceId); // close over your deps
const allowed = resource?.userId === context.auth?.userId;
return {
allowed,
reason: allowed ? 'Owner' : 'Not owner',
};
},
};
policyEngine.registerPolicies([IsResourceOwner]);
// Then in your service:
@Public()
@Auth({ policies: ['resource:owner'] })
async getOrder(orderId: string) { /* … */ }The context passed to evaluate is an ExecutionContext
({ auth?, service?, method?: { name, args }, resource?, environment?, request? }).
Note there is no context.args (use context.method?.args) and
no DI hook on the context — close over the dependencies your
policy needs. The exact shapes live in netron/auth/policy-engine.ts
and netron/auth/types.ts.
Class-level vs method-level
Both work; method-level overrides class-level for that method:
@Service('orders@1.0.0')
@Auth({ scopes: ['orders:*'] }) // class-level default
class OrdersService {
@Public() // inherits class-level
async list() { /* … */ }
@Public()
@Auth({ allowAnonymous: true }) // override
async listPublic() { /* … */ }
@Public()
@Auth({ roles: ['admin'] }) // override
async deleteAll() { /* … */ }
}Use inherit / override flags in AuthConfig to control
combination semantics for fine-grained cases.
Install HTTP authority before listening
Configure native authentication and cookie security before starting the HTTP
transport. Do not reach into a transport's private middleware pipeline from an
afterStart hook: the socket can already accept requests at that point.
import { CookieTokenTransport, CsrfManager } from '@omnitron-dev/titan/netron/auth';
const csrf = new CsrfManager({
headerName: 'X-CSRF-Token',
cookie: { name: 'app_csrf', path: '/', secure: true, sameSite: 'Lax' },
});
netron.configureAuth(authenticationManager, authorizationManager, {
tokenTransport: new CookieTokenTransport({
accessCookie: { name: 'app_session', path: '/', httpOnly: true, secure: true, sameSite: 'Lax' },
csrf,
}),
httpSecurity: {
authenticateEndpoint: 'disabled', // Auth.login owns the application's sign-in flow
origin: { allowedOrigins: ['https://app.example'], alwaysEnforce: true },
csrf: { csrf, accessCookieName: 'app_session', exempt: ['Auth.login'] },
},
});The HTTP server installs these gates during peer binding, before listen, for
both single and batch invocations. Configuring HTTP security after transport
startup is rejected. Exempting an initial login from double-submit CSRF does not
exempt it from the Origin gate.
httpSecurity.authenticateEndpoint controls the built-in /netron/authenticate
endpoint. It defaults to 'enabled' for existing clients. Applications with their
own native sign-in service should set it to 'disabled': the built-in route then
returns 404 without executing the credential provider or returning an auth context.
When enabled, it enforces the configured Origin policy even when no cookie is sent.
The RPC CSRF exemption list does not exempt this endpoint from Origin validation.
The Node HTTP transport rejects declared or streamed request bodies above
maxRequestSize with HTTP 413 before invoking a service. Rejection must not escape
the Node request callback or terminate the server. A client disconnect also cancels
a pending response stream, including when the response becomes ready after the
connection has already closed.
The credential verifier can resolve an opaque session against a database; a JWT is not required. Disable the authentication token cache if role/session revocation must affect the very next request and there is no explicit cache-invalidation protocol. Sensitive state transitions should recheck session ownership inside the same transaction that changes the credential.
issueTokens and clearTokens publish cookies through the native request scope.
Never accept token-issuance metadata or authContext from a client. The HTTP
transport reconstructs authority from the verified credential. A service using
withAuthRLSContext can read the current server scope through
getCurrentRLSAuthContext(); anonymous invocations must receive their own empty
scope rather than inherit an enclosing actor.
Verification failure versus service failure
A rejected credential and an unavailable credential verifier have different
meanings. Return the ordinary invalid-credential outcome for a token that the
verifier successfully checked and refused. Throw AuthenticationUnavailableError
for a database, identity-provider or equivalent verification infrastructure failure.
The native authentication timeout uses the same unavailable result.
import { AuthenticationUnavailableError } from '@omnitron-dev/titan/netron/auth';
async function validateToken(token: string) {
let session;
try { session = await sessions.resolve(token); }
catch (cause) {
throw new AuthenticationUnavailableError(cause instanceof Error ? cause : undefined);
}
if (!session) throw new Error('invalid_session');
return session.authContext;
}An unavailable verifier returns HTTP 503 with stable error code
authentication_unavailable; it does not execute an anonymous handler or clear
cookies. This distinction is preserved in single requests and both sequential
and parallel batches. It lets an editor retain its draft during an outage without
granting authority from a cached browser identity. The public response omits the
underlying database/provider error.
Failure semantics
The Netron enforcement path throws a plain TitanError carrying an
error code (not the AuthError/PermissionError subclasses):
| Failure | Thrown | Code / Status |
| No credentials | TitanError | UNAUTHORIZED / 401 |
| Invalid token | TitanError | UNAUTHORIZED / 401 |
| Authenticated but policy denied | TitanError | FORBIDDEN / 403 |
Discriminate on the code, not the class — e.g.
err.code === ErrorCode.UNAUTHORIZED vs ErrorCode.FORBIDDEN (or the
401/403 HTTP status). The AuthError / PermissionError classes do
exist in the errors module (both extend HttpError extends
TitanError), but the auth layer does not instantiate them, so
instanceof AuthError will not match a thrown auth error.
Anti-patterns
Auth checks in method bodies. Defeats the policy framework. Use
@Auth(...). Inline checks duplicate the policy and drift from the rest of the codebase.Single super-scope (
'admin'). A flat scope model collapses under growth. Prefer hierarchical scopes (orders:*,orders:read,orders:write) so you can grant least privilege.Auth as the only check. Auth says who can call. It does not say whether the input is valid or whether the resource is in the right state. Combine with
@Validateand domain checks.