Documentation/titan/Authentication
In this section

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

ManagerJob
AuthenticationManagerValidate credentials, produce an auth context
AuthorizationManagerEvaluate 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):

FailureThrownCode / Status
No credentialsTitanErrorUNAUTHORIZED / 401
Invalid tokenTitanErrorUNAUTHORIZED / 401
Authenticated but policy deniedTitanErrorFORBIDDEN / 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 @Validate and domain checks.