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

Values are sanitized when written and when read:

  • Control characters are removed, except tab, line feed and carriage return.
  • 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,
    );
  });
});