Skip to content
GuidesDocs 1.3.x

Auth Context ​

All authentication interceptors in @connectum/auth store the verified identity in AuthContext via AsyncLocalStorage. This makes the identity available to any code running within the request scope -- service handlers, other interceptors, and utility functions.

Accessing Auth Context in Handlers ​

Optional Access ​

Use getAuthContext() when the handler can work without an identity. This helper only reads the current scope; it does not verify credentials. A method skipped by the authentication interceptor does not gain an identity merely because a token is present:

typescript
import { getAuthContext } from '@connectum/auth';

function getProduct(req: GetProductRequest) {
  const auth = getAuthContext(); // undefined if not authenticated

  if (auth) {
    // Show personalized pricing for logged-in users
    return getProductWithPricing(req, auth.subject);
  }

  return getPublicProduct(req);
}

Required Access ​

Use requireAuthContext() when the handler requires authentication. It throws Unauthenticated if no auth context exists:

typescript
import { requireAuthContext } from '@connectum/auth';

function updateProfile(req: UpdateProfileRequest) {
  const auth = requireAuthContext(); // throws if not authenticated

  console.log(`User: ${auth.subject}, roles: ${auth.roles}`);
  // ...
}

Streaming Handlers ​

The identity follows the whole call, not just the moment it was verified. A server-streaming or bidirectional handler is usually an async function*, and its code runs in pieces: before the first yield, after each yield, after every await, and in finally. Every one of those pieces sees the identity of the call it belongs to, over HTTP/2 and over the in-process localClient() alike:

typescript
async function* watchOrders(req: WatchOrdersRequest) {
  const auth = requireAuthContext();       // the verified caller
  const cursor = await openCursor(auth.subject);
  try {
    for await (const order of cursor) {
      yield order;
      requireAuthContext();                // still the same caller after the consumer resumed us
    }
  } finally {
    await cursor.close({ owner: requireAuthContext().subject }); // runs after a cancel, a deadline or a server shutdown too
  }
}

A few details are worth knowing:

  • The identity is scoped to the call, not to the process or the caller. When code calls a local client while holding its own identity, the handler sees the identity that was verified for that call, and the caller's own identity is unchanged after the call returns.
  • The generator that produces the request messages of a client-streaming or bidirectional call belongs to the caller and runs under the caller's own identity (or none), in-process and over HTTP alike; it never observes the identity that was verified for the callee. Its finally after a cancellation runs in the context that raised the abort.
  • Cleanup that is triggered by a cancellation, a deadline, or the server shutting down runs under the identity of the call it belongs to, even though the signal that triggered it comes from elsewhere.
  • The identity scope replaces only the identity itself. Other AsyncLocalStorage values (a trace context, a tenant) are left as the surrounding code set them during iteration, so the in-process caller's own scopes stay visible to the handler; cleanup after a cancellation runs under the values that were active when the stream was created.
  • A listener that the handler registers with ctx.signal.addEventListener("abort", ...) runs in the context of whatever raised the abort, not under the call's identity. Capture the identity (const auth = requireAuthContext()) before registering the listener and use that variable inside it.
  • Leaving a consumer loop early with break is still not a cancellation: the handler stays suspended at its last yield until the call ends in another way, as it did before.
  • A custom interceptor that opens an identity scope itself with authContextStorage.run(context, () => next(req)) covers only the call that opens the stream. For streaming responses it must also scope every operation on the returned iterator (next, return and throw), as the built-in interceptors do.

AuthContext Shape ​

The AuthContext object contains the following fields:

FieldTypeDescription
subjectstringUser identifier (from JWT sub, gateway header, or session)
namestring | undefinedDisplay name
rolesreadonly string[]User roles
scopesreadonly string[]OAuth scopes or permissions
claimsReadonly<Record<string, unknown>>Raw claims from the token or session
typestringCredential type chosen by the verifier; not a closed enumeration
expiresAtDate | undefinedCredential expiration time, when available

Cross-Service Propagation ​

Enable the authentication interceptor's propagateHeaders to write the verified identity into the current request's headers. It does not send a downstream request itself. To carry those fields on ctx.call or ctx.stream, also list the chosen headers in the server's header propagation configuration; independent clients need an outgoing interceptor.

typescript
const jwtAuth = createJwtAuthInterceptor({
  jwksUri: '...',
  propagateHeaders: true,
});

To filter which claims are written into x-auth-claims, use propagatedClaims on createAuthInterceptor (generic) or createSessionAuthInterceptor. It is not an option of createJwtAuthInterceptor or createGatewayAuthInterceptor; enabling their propagation writes all claims that fit the header limit. The session snippet uses the validated mapSession helper from the session guide:

typescript
const sessionAuth = createSessionAuthInterceptor({
  verifySession: verifySessionToken,
  mapSession,
  propagateHeaders: true,
  propagatedClaims: ['email', 'org_id'], // optional: filter sensitive claims
});

Propagated Headers ​

HeaderContent
x-auth-subjectUser ID
x-auth-typeAuth type
x-auth-nameDisplay name
x-auth-rolesJSON-encoded roles array
x-auth-scopesSpace-separated scopes
x-auth-claimsJSON-encoded filtered claims

Each field is handled on its own terms when the headers are written:

  • subject, type and name have control characters removed (tab is kept) and are then cut to a length limit. The same removal and cut apply when a receiver reads them.
  • roles and claims are JSON-encoded, so their content is escaped by the encoding and never altered.
  • Each scope is written exactly as granted, joined by single spaces. Scopes are not cleaned: a scope is an RFC 6749 scope-token, and editing one could make it equal a privilege nobody granted.
  • NUL, CR, LF and characters above U+00FF are rejected, not removed. A header value is a byte string, so the platform cannot write a character above U+00FF (a Cyrillic name, for example). With propagateHeaders on, a subject, type, name or scope that contains one of them ends the request with Unauthenticated (text Authentication failed) before the identity is cached, so the next request with the same credential is verified again. Removing a line break would turn two different subjects (al\nice and alice) into the same header value. With propagateHeaders off the same identity is admitted. setAuthHeaders itself throws a TypeError that names the field (AuthContext.subject, type, name or scopes) and sets no header.

Limits and sizes:

  • x-auth-subject is cut to 512 characters, x-auth-type to 128 and x-auth-name to 256. An over-long value is truncated, not rejected, so two subjects that share their first 512 characters become indistinguishable downstream.
  • x-auth-roles, x-auth-scopes and x-auth-claims have a limit of 8192 characters for the serialized value. A value over the limit is not truncated: the sender leaves that header out, and a receiver ignores it and uses an empty roles, scopes or claims value.

Loading @connectum/auth from two module instances (for example src and dist imports mixed in one process) emits a CONNECTUM_AUTH_DUP_INIT process warning once. Both instances share one auth context storage; the warning points to the duplicated import.

createGatewayAuthInterceptor can map these identity headers, but it still requires its configured trustSource marker. Forwarded identity headers alone do not establish caller trust; see client auth interceptors for a client that attaches the gateway marker.

Testing ​

The @connectum/auth/testing subpath export provides helpers for unit and integration tests.

Mock Auth Context ​

Run a handler with a mock auth context:

typescript
import { createMockAuthContext, withAuthContext } from '@connectum/auth/testing';

const result = await withAuthContext(
  createMockAuthContext({ subject: 'user-1', roles: ['admin'] }),
  () => myHandler(request),
);

The identity is scoped to the callback, not to a value it returns. If the handler is an async function*, iterate it inside the callback: a generator that is created inside the callback but consumed after it has returned does not see the identity.

Test JWT ​

Generate a signed JWT for integration tests:

typescript
import { createTestJwt, TEST_JWT_SECRET } from '@connectum/auth/testing';

const token = await createTestJwt({ sub: 'user-1', roles: ['admin'] });

// Map the roles claim explicitly when verifying this token.
// createJwtAuthInterceptor({ secret: TEST_JWT_SECRET, claimsMapping: { roles: 'roles' } })

Full Test Example ​

Bun

Import the runner from bun:test instead of node:test and run the file with bun test. Everything else is identical -- node:assert works under Bun.

typescript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Code, ConnectError } from '@connectrpc/connect';
import { createMockAuthContext, withAuthContext } from '@connectum/auth/testing';

describe('updateProfile', () => {
  it('should update the profile for an authenticated user', async () => {
    const auth = createMockAuthContext({
      subject: 'user-42',
      name: 'Alice',
      roles: ['user'],
    });

    const result = await withAuthContext(auth, () =>
      updateProfile({ name: 'Alice Updated' }),
    );

    assert.strictEqual(result.name, 'Alice Updated');
  });

  it('should reject unauthenticated requests', () => {
    assert.throws(
      () => updateProfile({ name: 'Nope' }),
      (err: unknown) => err instanceof ConnectError && err.code === Code.Unauthenticated,
    );
  });
});