Skip to main content

Token issuance & transports

Where the tokens go after a successful sign-in is a transport decision, not a service one. The same signin handler works whether tokens travel in the response body for the client to store, or in HttpOnly cookies the client never sees.

Service code says what to issue; the configured ITokenTransport decides how it reaches the client.

import { issueTokens, clearTokens, readRequestCookie } from '@omnitron-dev/titan/netron/auth';

Issuing

@Public()
async signin(dto: SigninDto) {
const { user, accessToken, refreshToken } = await this.svc.signin(dto);
issueTokens({ access: accessToken, refresh: refreshToken });
return { user, accessToken, refreshToken };
}

Return the tokens in the body as written above, even in cookie mode. Cookie mode strips them from the body before serialisation; bearer mode leaves the body untouched. Writing the handler once, for both, is the point — a handler that omits them cannot be switched to bearer, and one that omits issueTokens cannot be switched to cookies.

issueTokens is idempotent: the last call in a request wins.

Clearing

@Public()
async signout() {
await this.svc.revokeSession(...);
clearTokens();
}

Cookie mode emits Max-Age=0 for every registered cookie. Bearer mode is a no-op — the client clears its own storage.

readRequestCookie(name) reads from the current request's Cookie header and returns null when the header, or that cookie, is absent. It is the only supported way for service code to see a value the client never put in the body:

const refresh = request.refreshToken ?? readRequestCookie('omni_refresh');

That fallback order is deliberate. An explicit body value wins so the same handler serves a bearer client; the cookie answers for a cookie-mode client whose body carries nothing.

How the context is found

Both helpers use AsyncLocalStorage to reach the current request's metadata, so a @Public method does not have to accept a framework context argument. The HTTP transport opens that frame around every handler invocation.

Outside such a frame — a unit test calling the helper directly, or middleware holding its own context — pass the context explicitly:

issueTokens(ctx, { access, refresh }); // ctx: Pick<NetronMiddlewareContext, 'metadata'>
clearTokens(ctx);

Calling either with no ambient frame and no explicit context throws, naming the two ways out. It does not silently do nothing, which for a sign-out would mean a session the user believes is closed.

The transports

Netron.tokenTransport defaults to BearerTokenTransport.

TransportusesCookiesextract readsissue writes
bearerfalseAuthorization headernothing — the body carries the tokens
cookietruethe Cookie headerSet-Cookie, and strips the body
compositeif either doesboth, in orderdelegates

usesCookies is not decoration: two middlewares gate on it — the CSRF double-submit check and the origin check. Both skip when it is false, on the reasoning that a bearer client attaches nothing automatically and so cannot be made to act by a hostile page. A transport that carries cookies without declaring it would therefore turn off both protections in silence.

composite exists for a migration: accept both while clients move, then drop one. It reports usesCookies true if any member does, so the CSRF check stays on for the whole window rather than for the half that needs it.

CSRF is two pieces, and both are optional

Cookie mode does not turn CSRF protection on by itself. Two things have to be configured, and each defaults to absent:

  1. a csrf manager on CookieTokenTransport, which makes issue() emit a CSRF cookie (readable by client JS, unlike the auth cookies) on every sign-in and refresh, and clear it on sign-out;
  2. createCsrfMiddleware(...) installed on the server, which enforces double-submit on protected RPCs.
transport csrfmiddlewareresult
setinstalledprotected
setabsenta CSRF cookie nobody checks
absentinstalledevery cookie-authenticated request rejected — no cookie is ever issued to submit, so verification always fails
absentabsentno CSRF protection, and nothing says so

The third row fails loudly and immediately, which is the good case. The fourth is the one to watch: a cookie-mode deployment with neither piece behaves perfectly until somebody points a hostile page at it.

The middleware's own decisions are all fail-closed. A request whose Request object is missing gets an empty header set rather than a bypass; verification failure throws FORBIDDEN and logs auth.csrf_violation with the origin but no token bytes. It skips only where CSRF cannot apply: bearer transports, the exempt list (sign-in, refresh, sign-out — the calls that have no cookie yet), and requests arriving with no access cookie at all, which are bearer or anonymous.

What each mode assumes of the client

Cookie mode means the browser holds credentials the JavaScript cannot read — no token in localStorage to steal, and no token for the app to attach by hand. In exchange the request needs CSRF protection, because the browser now attaches credentials on its own.

Bearer mode is the reverse trade: the client is responsible for storage and for sending the header, and no CSRF check is required because nothing is attached automatically.