@connectum/auth
Authentication and authorization interceptors for ConnectRPC services. Provides server-side factories for the most common auth patterns -- generic pluggable auth, JWT (via jose), gateway-injected headers, session-based auth, internal service-to-service auth, and declarative authorization rules -- plus client-side factories (createClientBearerInterceptor, createClientGatewayInterceptor) for outbound service-to-service calls. All server interceptors propagate AuthContext through AsyncLocalStorage so handlers can access the authenticated identity without explicit parameter passing.
Layer: 1 (Protocol)
Related Guides
- Auth & Authz Overview -- when and why to use authentication
- JWT -- JWKS, HMAC, public key configuration
- Gateway -- pre-authenticated gateway headers
- Session -- session-based auth with better-auth/lucia
- Authorization (RBAC) -- declarative rules and proto-based authz
- Context & Testing -- AsyncLocalStorage context propagation
Full API Reference
Complete TypeScript API documentation: API Reference
Installation
pnpm add @connectum/authRequires: Node.js 22+
Dependencies: @connectrpc/connect, @connectum/core, jose
Quick Start
import { createServer } from '@connectum/core';
import { createJwtAuthInterceptor, createAuthzInterceptor } from '@connectum/auth';
const jwtAuth = createJwtAuthInterceptor({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com/',
audience: 'my-api',
});
const authz = createAuthzInterceptor({
defaultPolicy: 'deny',
rules: [
{ name: 'public', methods: ['public.v1.PublicService/*'], effect: 'allow' },
{ name: 'admin', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
],
});
const server = createServer({
services: [routes],
interceptors: [jwtAuth, authz],
});
await server.start();Access the authenticated identity in handlers:
import { requireAuthContext } from '@connectum/auth';
const handler = {
async getUser(req) {
const auth = requireAuthContext(); // throws Unauthenticated if missing
return { user: await db.getUser(auth.subject) };
},
};Auth Context
All authentication interceptors produce an AuthContext and store it in AsyncLocalStorage. Downstream interceptors and handlers access it via getAuthContext() or requireAuthContext().
interface AuthContext {
readonly subject: string; // User/service identifier
readonly name?: string; // Human-readable display name
readonly roles: ReadonlyArray<string>; // Assigned roles
readonly scopes: ReadonlyArray<string>; // Granted scopes
readonly claims: Readonly<Record<string, unknown>>; // Raw credential claims
readonly type: string; // Credential type ("jwt", "api-key", etc.)
readonly expiresAt?: Date; // Credential expiration
}API Reference
createAuthInterceptor(options)
Generic, pluggable authentication interceptor. Extracts credentials from the request, verifies them via a user-provided callback, and stores the resulting AuthContext in AsyncLocalStorage.
function createAuthInterceptor(options: AuthInterceptorOptions): Interceptor;By default, extracts a Bearer token from the Authorization header. Override extractCredentials for custom extraction (API keys, custom headers, etc.).
import { createAuthInterceptor } from '@connectum/auth';
const auth = createAuthInterceptor({
verifyCredentials: async (token) => {
const user = await db.findByToken(token);
if (!user) throw new Error('Invalid token');
return {
subject: user.id,
roles: user.roles,
scopes: [],
claims: {},
type: 'api-key',
};
},
cache: { ttl: 60_000, maxSize: 500 },
});AuthInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
verifyCredentials | (credentials: string) => AuthContext | Promise<AuthContext> | (required) | Verify credentials and return auth context. Must throw on invalid credentials. |
extractCredentials | (req: { header: Headers }) => string | null | Promise<string | null> | Bearer token extractor | Extract credentials from request headers |
skipMethods | string[] | [] | Method patterns to skip authentication for |
propagateHeaders | boolean | false | Propagate auth context as headers for downstream services |
cache | CacheOptions | undefined | LRU cache for verification results |
propagatedClaims | string[] | undefined | Filter which claim keys are propagated in headers (all if undefined) |
createJwtAuthInterceptor(options)
JWT convenience wrapper built on jose. Supports JWKS remote key sets, HMAC secrets, and asymmetric public keys. Delegates to createAuthInterceptor internally.
function createJwtAuthInterceptor(options: JwtAuthInterceptorOptions): Interceptor;Key resolution priority: jwksUri > publicKey > secret. At least one must be provided.
import { createJwtAuthInterceptor } from '@connectum/auth';
// JWKS-based (Auth0, Keycloak, etc.)
const jwtAuth = createJwtAuthInterceptor({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com/',
audience: 'my-api',
maxTokenAge: '1h',
claimsMapping: {
roles: 'realm_access.roles',
scopes: 'scope',
},
});
// HMAC secret (testing / simple setups)
const jwtAuth = createJwtAuthInterceptor({
secret: process.env.JWT_SECRET,
issuer: 'my-service',
});SEC-002: Throws ConnectError(Code.Unauthenticated) when the JWT is missing a subject claim. No silent fallback.
JwtAuthInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
jwksUri | string | -- | JWKS endpoint URL for remote key set |
secret | string | -- | HMAC symmetric secret (HS256/HS384/HS512). Minimum key size enforced per RFC 7518. |
publicKey | CryptoKey | -- | Asymmetric public key (RSA, RSA-PSS, EC, EdDSA). Import via crypto.subtle.importKey(). |
issuer | string | string[] | -- | Expected token issuer(s) |
audience | string | string[] | -- | Expected token audience(s) |
algorithms | string[] | -- | Allowed algorithms |
maxTokenAge | number | string | -- | Maximum token age (seconds or duration string, e.g., "2h") |
claimsMapping | { subject?, name?, roles?, scopes? } | {} | Maps JWT claims to AuthContext fields. Supports dot-notation paths (e.g., "realm_access.roles"). |
skipMethods | string[] | [] | Method patterns to skip authentication for |
propagateHeaders | boolean | false | Propagate auth context as headers for downstream services |
createGatewayAuthInterceptor(options)
For services behind an API gateway (Kong, Envoy, Traefik, etc.) that has already performed authentication. Reads pre-authenticated identity from gateway-injected headers after verifying the request source.
Trust is established via a header value check (shared secret or CIDR-based IP matching), not via peerAddress.
function createGatewayAuthInterceptor(options: GatewayAuthInterceptorOptions): Interceptor;import { createGatewayAuthInterceptor } from '@connectum/auth';
const gatewayAuth = createGatewayAuthInterceptor({
headerMapping: {
subject: 'x-user-id',
name: 'x-user-name',
roles: 'x-user-roles',
},
trustSource: {
header: 'x-gateway-secret',
expectedValues: [process.env.GATEWAY_SECRET],
},
});Mapped headers and the trust header are always stripped -- including for methods listed in skipMethods -- to prevent downstream spoofing even on public endpoints.
GatewayAuthInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
headerMapping | GatewayHeaderMapping | (required) | Maps gateway headers to AuthContext fields. subject is required. |
trustSource | { header: string; expectedValues: string[] } | (required) | Trust verification. Supports exact match and CIDR notation (e.g., "10.0.0.0/8"). |
stripHeaders | string[] | [] | Additional headers to strip after extraction |
skipMethods | string[] | [] | Method patterns to skip authentication for |
propagateHeaders | boolean | false | Propagate auth context as headers for downstream services |
defaultType | string | "gateway" | Default credential type when not provided by gateway |
GatewayHeaderMapping
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | Header containing the authenticated subject |
name | string | No | Header containing the display name |
roles | string | No | Header containing roles (JSON array or comma-separated) |
scopes | string | No | Header containing scopes (space-separated) |
type | string | No | Header containing credential type |
claims | string | No | Header containing JSON-encoded claims |
createSessionAuthInterceptor(options)
Session-based authentication for frameworks like better-auth, lucia, etc. Two-step process: verify the session token, then map raw session data to AuthContext.
Unlike createAuthInterceptor, this interceptor passes the full request Headers to verifySession, enabling cookie-based auth flows.
function createSessionAuthInterceptor(options: SessionAuthInterceptorOptions): Interceptor;import { createSessionAuthInterceptor } from '@connectum/auth';
const sessionAuth = createSessionAuthInterceptor({
verifySession: (token, headers) => auth.api.getSession({ headers }),
mapSession: (session) => ({
subject: session.user.id,
name: session.user.name,
roles: [],
scopes: [],
claims: session.user,
type: 'session',
}),
cache: { ttl: 60_000 },
});SessionAuthInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
verifySession | (token: string, headers: Headers) => unknown | Promise<unknown> | (required) | Verify session token and return raw session data. Must throw on invalid sessions. |
mapSession | (session: unknown) => AuthContext | Promise<AuthContext> | (required) | Map raw session data to AuthContext |
extractToken | (req: { header: Headers }) => string | null | Promise<string | null> | Bearer token extractor | Custom token extraction |
cache | CacheOptions | undefined | LRU cache for session verification results |
skipMethods | string[] | [] | Method patterns to skip authentication for |
propagateHeaders | boolean | false | Propagate auth context as headers for downstream services |
propagatedClaims | string[] | undefined | Filter which claim keys are propagated in headers (all if undefined) |
createInternalAuthInterceptor(options)
Available since 1.1.0.
Internal (service-to-service) authentication interceptor (ADR-029). For methods marked internal in proto options, it authorizes the call from a configurable per-service trust source instead of an end-user token, rejecting a missing or invalid marker as Code.Unauthenticated. Non-internal methods are a no-op pass-through.
function createInternalAuthInterceptor(options: InternalAuthInterceptorOptions): Interceptor;Internal methods skip end-user (JWT) authentication, so feed getInternalMethods(services) into the JWT interceptor's skipMethods (alongside getPublicMethods(services)); this interceptor then enforces the internal trust marker on those same methods.
import {
createInternalAuthInterceptor,
meshIdentityTrust,
createJwtAuthInterceptor,
} from '@connectum/auth';
import { getInternalMethods, getPublicMethods } from '@connectum/auth/proto';
import services from '#gen/services.js';
// JWT skips both public and internal methods; the internal interceptor
// then enforces the trust marker on the internal ones.
const jwtAuth = createJwtAuthInterceptor({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
skipMethods: [...getPublicMethods(services), ...getInternalMethods(services)],
});
const internalAuth = createInternalAuthInterceptor({
internalMethods: getInternalMethods(services),
trustSource: meshIdentityTrust({
allowlist: [
{ principal: 'cluster.local/ns/default/sa/trips', roles: ['worker'] },
],
}),
});Chain order (load-bearing). This interceptor MUST run before createProtoAuthzInterceptor: it populates the AuthContext that proto-authz's internal rule consumes.
errorHandler -> (jwtAuth | internalAuth) -> protoAuthzFor an internal method, createProtoAuthzInterceptor composes the marker inclusively with the existing roles/scopes model:
internal+ no identity (noAuthContext) ->Unauthenticatedinternal+ norequires-> allow (any trusted internal caller)internal+requires { roles | scopes }-> the existing roles/scopes check against the sameAuthContext
Each shipped trust source strips its own trust header after extraction on the internal path (accept and reject) to prevent a spoofed marker from being propagated downstream.
InternalAuthInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
trustSource | InternalTrustSource | (required) | Pluggable trust source that authorizes an internal call -- use meshIdentityTrust, signedTokenTrust, sharedSecretTrust, or a custom function. Returning null (or throwing) rejects the call as Unauthenticated. |
internalMethods | readonly string[] | (required) | Method patterns enforced as internal ("Service/Method", "Service/*", or "*"). Typically getInternalMethods(services). All other methods pass through unchanged. |
Proto contract (additive)
optional bool internal was added to both ServiceAuth and MethodAuth in connectum.auth.v1. Marking a method internal instead of public removes world-open exposure while keeping it reachable by trusted callers (ADR-029).
meshIdentityTrust(options)
Available since 1.1.0.
Production-default trust source. Verifies a mesh-forwarded peer principal (an Istio short-form ServiceAccount principal cluster.local/ns/<ns>/sa/<name>, or a SPIFFE id) against an allow-list. Because the mesh issues each workload its own mTLS identity, matching the forwarded principal against the allow-list is per-service by construction -- compromising one workload cannot forge another's identity. The identity header is stripped after extraction (anti-spoofing).
function meshIdentityTrust(options: MeshIdentityTrustOptions): InternalTrustSource;import { createInternalAuthInterceptor, meshIdentityTrust } from '@connectum/auth';
import { getInternalMethods } from '@connectum/auth/proto';
import services from '#gen/services.js';
const internalAuth = createInternalAuthInterceptor({
internalMethods: getInternalMethods(services),
trustSource: meshIdentityTrust({
allowlist: [
{ principal: 'cluster.local/ns/default/sa/trips', roles: ['worker'], name: 'trips-service' },
{ principal: 'cluster.local/ns/default/sa/billing', scopes: ['charge'] },
],
}),
});MeshIdentityTrustOptions
| Option | Type | Default | Description |
|---|---|---|---|
allowlist | readonly MeshIdentityEntry[] | (required) | Permitted mesh identities. A non-empty list is enforced at construction (throws otherwise). A request whose principal is not on the list is rejected. |
header | string | "x-forwarded-client-principal" | Header carrying the mesh-forwarded peer identity |
type | string | "mesh" | Credential type set on the resulting AuthContext |
MeshIdentityEntry
| Field | Type | Required | Description |
|---|---|---|---|
principal | string | Yes | Forwarded peer identity to match (Istio ServiceAccount principal or SPIFFE id) |
roles | readonly string[] | No | Roles granted to this caller (compose via requires { roles }) |
scopes | readonly string[] | No | Scopes granted to this caller (compose via requires { scopes }) |
name | string | No | Human-readable name for the calling service |
signedTokenTrust(options)
Available since 1.1.0.
Non-mesh, per-service trust source. Each caller signs a short-lived JWT with its own private key; this trust source verifies it against that service's published JWKS, so compromising service A's key forges only A.
The keyset is selected by the token's claimed iss (issuers[iss].jwksUri), and verification is pinned to that same issuer -- each issuer gets its own createRemoteJWKSet, so no verification call ever receives a keyset spanning more than one issuer. A token claiming iss: "B" but signed with A's key is rejected. (A single shared JWKS holding multiple services' keys does NOT contain compromise, because jose resolves the signing key by kid independently of iss.)
The framework ships only the verification primitive; key issuance, rotation, and JWKS publication belong to the deployment (SPIRE / the IdP / the mesh).
function signedTokenTrust(options: SignedTokenTrustOptions): InternalTrustSource;import { createInternalAuthInterceptor, signedTokenTrust } from '@connectum/auth';
import { getInternalMethods } from '@connectum/auth/proto';
import services from '#gen/services.js';
const internalAuth = createInternalAuthInterceptor({
internalMethods: getInternalMethods(services),
trustSource: signedTokenTrust({
issuers: {
'trips-service': {
jwksUri: 'https://trips/.well-known/jwks.json',
claimsMapping: { roles: 'roles' },
},
'billing-service': { jwksUri: 'https://billing/.well-known/jwks.json' },
},
}),
});SignedTokenTrustOptions
| Option | Type | Default | Description |
|---|---|---|---|
issuers | Readonly<Record<string, SignedTokenIssuer>> | (required) | Per-issuer JWKS config keyed by the token's iss value. At least one issuer is enforced at construction (throws otherwise). |
header | string | "x-internal-token" | Header carrying the service token (bare token or Bearer <token>) |
type | string | "service" | Credential type set on the resulting AuthContext |
SignedTokenIssuer
| Field | Type | Default | Description |
|---|---|---|---|
jwksUri | string | (required) | The issuer's JWKS endpoint URL (its own keyset only) |
audience | string | string[] | -- | Expected audience(s) for tokens from this issuer |
algorithms | string[] | ["RS256"] | Allowed signing algorithms |
maxTokenAge | number | string | -- | Maximum token age (seconds or string like "2h") |
claimsMapping | { subject?, name?, roles?, scopes? } | {} | Maps token claims to AuthContext (dot-notation). Subject defaults to the sub claim, else the issuer. |
sharedSecretTrust(options)
Available since 1.1.0.
DEV-ONLY
A single shared secret is not per-service: every legitimate caller holds the same secret, so one compromise forges all internal identities. Use meshIdentityTrust (mesh) or signedTokenTrust (non-mesh per-service JWT) in production. This factory exists only for local development and single-tenant low-trust-boundary setups.
Constant-time compares a single shared secret against the trust header. The header is stripped after extraction (anti-spoofing).
function sharedSecretTrust(options: SharedSecretTrustOptions): InternalTrustSource;import { createInternalAuthInterceptor, sharedSecretTrust } from '@connectum/auth';
import { getInternalMethods } from '@connectum/auth/proto';
import services from '#gen/services.js';
const internalAuth = createInternalAuthInterceptor({
internalMethods: getInternalMethods(services),
trustSource: sharedSecretTrust({
secret: process.env.INTERNAL_SECRET ?? '',
subject: 'internal-batch',
roles: ['worker'],
}),
});SharedSecretTrustOptions
| Option | Type | Default | Description |
|---|---|---|---|
secret | string | (required) | Shared secret, constant-time compared against the header value. A non-empty secret is enforced at construction (throws otherwise). |
header | string | "x-internal-secret" | Header carrying the shared secret |
subject | string | "internal" | Subject identity assigned to a trusted call |
roles | readonly string[] | [] | Roles granted to a trusted caller |
scopes | readonly string[] | [] | Scopes granted to a trusted caller |
type | string | "internal" | Credential type set on the resulting AuthContext |
createAuthzInterceptor(options?)
Declarative rules-based authorization interceptor. Evaluates rules against AuthContext from the authentication interceptor. Must be placed after an auth interceptor in the chain.
function createAuthzInterceptor(options?: AuthzInterceptorOptions): Interceptor;import { createAuthzInterceptor } from '@connectum/auth';
const authz = createAuthzInterceptor({
defaultPolicy: 'deny',
rules: [
{ name: 'public', methods: ['public.v1.PublicService/*'], effect: 'allow' },
{ name: 'admin-only', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
{ name: 'write-scope', methods: ['data.v1.DataService/Write*'], requires: { scopes: ['write'] }, effect: 'allow' },
],
});Rules are evaluated in order; the first matching rule wins. If no rule matches, the optional authorize callback is invoked. If neither rules nor callback produce a decision, defaultPolicy applies.
- Roles: user must have at least one of the required roles
- Scopes: user must have all required scopes
AuthzInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
defaultPolicy | AuthzEffect | "deny" | Policy when no rule matches and no callback is defined |
rules | AuthzRule[] | [] | Declarative authorization rules, evaluated in order |
authorize | (context: AuthContext, req: { service, method }) => boolean | Promise<boolean> | -- | Programmatic authorization callback (fallback after rules) |
skipMethods | string[] | [] | Method patterns to skip authorization for |
AuthzRule
| Field | Type | Description |
|---|---|---|
name | string | Rule name for logging and debugging |
methods | string[] | Method patterns to match |
effect | AuthzEffect | "allow" or "deny" |
requires | { roles?: string[]; scopes?: string[] } | Required roles/scopes for the rule to apply |
Proto-Based Authorization
Define authorization rules directly in .proto files using custom method and service options. The createProtoAuthzInterceptor reads these options at runtime via @bufbuild/protobuf reflection.
Proto Definitions
Add connectum/auth/v1/options.proto to your proto directory:
syntax = "proto2";
package connectum.auth.v1;
import "google/protobuf/descriptor.proto";
message AuthRequirements {
repeated string roles = 1; // any-of semantics
repeated string scopes = 2; // all-of semantics
}
message MethodAuth {
optional bool public = 1;
optional AuthRequirements requires = 2;
optional string policy = 3; // "allow" or "deny"
optional bool internal = 4; // service-to-service (since 1.1.0)
}
message ServiceAuth {
optional string default_policy = 1;
optional AuthRequirements default_requires = 2;
optional bool public = 3;
optional bool internal = 4; // service-to-service (since 1.1.0)
}
extend google.protobuf.MethodOptions {
optional MethodAuth method_auth = 50100;
}
extend google.protobuf.ServiceOptions {
optional ServiceAuth service_auth = 50101;
}Usage in .proto Files
import "connectum/auth/v1/options.proto";
service UserService {
option (connectum.auth.v1.service_auth) = {
default_policy: "deny"
};
rpc GetProfile(GetProfileRequest) returns (GetProfileResponse) {
option (connectum.auth.v1.method_auth) = { public: true };
}
rpc UpdateProfile(UpdateProfileRequest) returns (UpdateProfileResponse) {
option (connectum.auth.v1.method_auth) = {
requires: { roles: ["admin"] }
};
}
}See examples/auth for a complete demo showing both proto-based and code-based authorization side by side.
createProtoAuthzInterceptor(options?)
Creates an interceptor that reads authorization configuration from protobuf custom options. When proto options do not resolve the decision, falls back to programmatic rules and callbacks.
function createProtoAuthzInterceptor(options?: ProtoAuthzInterceptorOptions): Interceptor;Decision flow:
- Read proto options via
resolveMethodAuth(req.method) public = true→ skip (allow without authentication)internal = true→ if no auth context, throwUnauthenticated; if norequires, allow (any trusted internal caller); else fall through to step 4requiresdefined → if no auth context, throwUnauthenticated; check roles/scopes → allow or denypolicy = "allow"→ allowpolicy = "deny"→ deny- Fallback: evaluate programmatic
rules - Fallback: call
authorizecallback - Apply
defaultPolicy
import { createProtoAuthzInterceptor } from '@connectum/auth';
// Proto options only
const authz = createProtoAuthzInterceptor();
// With fallback rules
const authz = createProtoAuthzInterceptor({
defaultPolicy: 'deny',
rules: [
{ name: 'admin', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
],
authorize: (ctx, req) => ctx.roles.includes('superadmin'),
});ProtoAuthzInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
defaultPolicy | AuthzEffect | "deny" | Policy when no proto option and no rule match |
rules | AuthzRule[] | [] | Programmatic fallback rules (evaluated after proto options) |
authorize | (context: AuthContext, req: { service, method }) => boolean | Promise<boolean> | -- | Programmatic authorization callback (fallback after rules) |
resolveMethodAuth(method)
Resolves the effective authorization configuration for an RPC method by merging service-level defaults with method-level overrides.
function resolveMethodAuth(method: DescMethod): ResolvedMethodAuth;Results are cached in a WeakMap keyed by DescMethod. Priority: method → service → default.
interface ResolvedMethodAuth {
readonly public: boolean;
readonly internal: boolean;
readonly policy: "allow" | "deny" | undefined;
readonly requires: { readonly roles: readonly string[]; readonly scopes: readonly string[] } | undefined;
}getPublicMethods(services)
Extracts public method patterns from service descriptors. Use with skipMethods in auth interceptors.
function getPublicMethods(services: readonly DescService[]): string[];import { getPublicMethods } from '@connectum/auth/proto';
const publicMethods = getPublicMethods([UserService, HealthService]);
// ["user.v1.UserService/GetProfile", "grpc.health.v1.Health/Check"]
const authn = createJwtAuthInterceptor({
jwksUri: '...',
skipMethods: publicMethods,
});getInternalMethods(services)
Available since 1.1.0.
Extracts internal method patterns from service descriptors. Mirrors getPublicMethods: internal methods also skip end-user (JWT) authentication, so feed these into the JWT interceptor's skipMethods -- but, unlike public methods, they still require an internal trust marker enforced by createInternalAuthInterceptor.
function getInternalMethods(services: readonly DescService[]): string[];import { getInternalMethods, getPublicMethods } from '@connectum/auth/proto';
// JWT auth skips both public and internal methods;
// the internal interceptor then enforces the trust marker on internal ones.
const jwtAuth = createJwtAuthInterceptor({
jwksUri: '...',
skipMethods: [...getPublicMethods(services), ...getInternalMethods(services)],
});Client-Side Interceptors
Outbound ConnectRPC transports can attach authentication headers automatically using the client-side factories from @connectum/auth. These complement the server-side interceptors above and are meant for service-to-service calls.
createClientBearerInterceptor(options)
Attaches an Authorization: Bearer <token> header to every outgoing request. Accepts either a static token string or an async factory function (for refreshable access tokens).
function createClientBearerInterceptor(options: ClientBearerInterceptorOptions): Interceptor;import { createGrpcTransport } from '@connectrpc/connect-node';
import { createClientBearerInterceptor } from '@connectum/auth';
// Static token
const transport = createGrpcTransport({
baseUrl: 'http://upstream:5000',
httpVersion: '2',
interceptors: [createClientBearerInterceptor({ token: process.env.SERVICE_TOKEN! })],
});
// Async token factory (refresh flow)
const refreshing = createGrpcTransport({
baseUrl: 'http://upstream:5000',
httpVersion: '2',
interceptors: [
createClientBearerInterceptor({
token: async () => (await getAccessToken()).accessToken,
}),
],
});ClientBearerInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
token | string | () => string | Promise<string> | (required) | Static Bearer token or async factory invoked before each request |
createClientGatewayInterceptor(options)
Attaches gateway shared-secret and auth context headers for trusted service-to-service communication. Designed to be consumed by a server-side createGatewayAuthInterceptor that reconstructs the AuthContext without re-authenticating.
Sets the following headers on each outgoing request:
x-gateway-secret-- shared secret for trust verificationx-auth-subject-- authenticated subject identifierx-auth-roles-- JSON-encoded roles array (optional)
function createClientGatewayInterceptor(options: ClientGatewayInterceptorOptions): Interceptor;import { createGrpcTransport } from '@connectrpc/connect-node';
import { createClientGatewayInterceptor } from '@connectum/auth';
const transport = createGrpcTransport({
baseUrl: 'http://internal-service:5000',
httpVersion: '2',
interceptors: [
createClientGatewayInterceptor({
secret: process.env.GATEWAY_SECRET!,
subject: 'order-service',
roles: ['service', 'order-writer'],
}),
],
});ClientGatewayInterceptorOptions
| Option | Type | Default | Description |
|---|---|---|---|
secret | string | (required) | Shared secret sent as x-gateway-secret |
subject | string | (required) | Calling service identifier (sent as x-auth-subject) |
roles | string[] | undefined | Service roles serialized as JSON to x-auth-roles |
Pair with the server-side interceptor
The receiving service should run createGatewayAuthInterceptor with a trustSource.header of x-gateway-secret and a matching shared secret so the AuthContext is reconstructed on arrival.
Context Utilities
authContextStorage
The AsyncLocalStorage<AuthContext> instance used by all auth interceptors. Automatically isolated per async context (request).
getAuthContext()
Returns the current AuthContext or undefined if no auth interceptor is active.
import { getAuthContext } from '@connectum/auth';
const auth = getAuthContext();
if (auth) {
console.log(`Authenticated as ${auth.subject}`);
}requireAuthContext()
Returns the current AuthContext or throws ConnectError(Code.Unauthenticated). Use when authentication is mandatory.
import { requireAuthContext } from '@connectum/auth';
const auth = requireAuthContext(); // throws if not authenticatedHeader Utilities
Serialization and deserialization of AuthContext to/from HTTP headers for cross-service context propagation.
setAuthHeaders(headers, context, propagatedClaims?)
Serialize AuthContext to request headers. Used internally by auth interceptors when propagateHeaders is enabled.
Headers for roles, scopes, and claims are silently dropped if the serialized value exceeds 8192 bytes. This prevents oversized headers from causing transport-level failures.
import { setAuthHeaders } from '@connectum/auth';
setAuthHeaders(req.header, authContext);
// With claims filter:
setAuthHeaders(req.header, authContext, ['email', 'org_id']);parseAuthHeaders(headers)
Deserialize AuthContext from request headers. Returns undefined if the required x-auth-subject header is missing.
Only use in trusted environments (behind mTLS, service mesh, etc.).
import { parseAuthHeaders } from '@connectum/auth';
const context = parseAuthHeaders(req.header);AUTH_HEADERS
Standard header names used for auth context propagation:
| Constant | Header Name | Description |
|---|---|---|
SUBJECT | x-auth-subject | Authenticated subject identifier |
TYPE | x-auth-type | Credential type |
NAME | x-auth-name | Display name |
ROLES | x-auth-roles | JSON-encoded roles array |
SCOPES | x-auth-scopes | Space-separated scopes |
CLAIMS | x-auth-claims | JSON-encoded claims object |
Cache
LruCache<T>
Minimal in-memory LRU cache with TTL expiration. Uses Map insertion order for LRU eviction. No external dependencies.
The constructor throws RangeError("ttl must be a positive number") if ttl is zero or negative.
import { LruCache } from '@connectum/auth';
const cache = new LruCache<string>({ ttl: 60_000, maxSize: 500 });
cache.set('key', 'value');
const value = cache.get('key'); // undefined after TTL
cache.clear();
cache.size; // number of entriesCacheOptions
| Option | Type | Default | Description |
|---|---|---|---|
ttl | number | (required) | Entry time-to-live in milliseconds. Must be a positive number. |
maxSize | number | 1000 | Maximum number of cached entries |
Errors
AuthzDeniedError
Extends ConnectError with Code.PermissionDenied. Carries server-side details (rule name, required roles/scopes) while exposing only "Access denied" to the client via the SanitizableError protocol from @connectum/core.
import { AuthzDeniedError } from '@connectum/auth';| Property | Type | Description |
|---|---|---|
clientMessage | string | Always "Access denied" (safe for clients) |
ruleName | string | Name of the rule that denied access |
authzDetails | AuthzDeniedDetails | Full details including required roles/scopes |
serverDetails | Record<string, unknown> | Structured details for server-side logging |
Method Pattern Matching
matchesMethodPattern(serviceName, methodName, patterns)
Shared utility for matching gRPC methods against skip/rule patterns. Used internally by all interceptors.
import { matchesMethodPattern } from '@connectum/auth';
matchesMethodPattern('user.v1.UserService', 'GetUser', ['user.v1.UserService/*']); // true
matchesMethodPattern('user.v1.UserService', 'GetUser', ['user.v1.UserService/GetUser']); // true
matchesMethodPattern('user.v1.UserService', 'GetUser', ['*']); // true
matchesMethodPattern('user.v1.UserService', 'GetUser', ['admin.v1.AdminService/*']); // falsePattern types:
"*"-- matches all methods"Service/*"-- matches all methods of a service"Service/Method"-- matches an exact method
Testing Utilities
The @connectum/auth/testing subpath export provides helpers for testing authenticated handlers.
import { createMockAuthContext, createTestJwt, withAuthContext, TEST_JWT_SECRET } from '@connectum/auth/testing';| Export | Description |
|---|---|
createMockAuthContext | Create a mock AuthContext with sensible defaults |
createTestJwt | Generate a signed HS256 JWT for testing |
TEST_JWT_SECRET | Pre-shared HMAC secret for test JWTs |
withAuthContext | Run a function within a given AuthContext (wraps AsyncLocalStorage.run) |
generateRsaTestKeypair | (1.1.0) Generate an RSA (RS256) test keypair + public JWK |
startTestJwksServer | (1.1.0) Start an in-process JWKS server publishing the public JWK(s) |
createTestJwtRS256 | (1.1.0) Mint an RS256 test JWT verified through the production JWKS branch |
TEST_JWT_KID | (1.1.0) Default kid shared by the RS256 helpers ("connectum-test-key") |
RS256 + JWKS test helpers
Available since 1.1.0.
The production-realistic auth path with an external IdP is RS256 tokens validated through a JWKS endpoint -- createJwtAuthInterceptor({ jwksUri }), the jose.createRemoteJWKSet branch. Unlike createTestJwt (HS256-only), these helpers exercise that asymmetric path: generateRsaTestKeypair produces an RSA keypair plus a public JWK (carrying kid/alg: "RS256"/use: "sig"), startTestJwksServer publishes it at /.well-known/jwks.json on a random loopback port, and createTestJwtRS256 mints a token with a matching kid header. The options argument of createTestJwtRS256 and its kid field are required -- the kid must match the published JWK or key selection fails.
import {
generateRsaTestKeypair,
startTestJwksServer,
createTestJwtRS256,
} from '@connectum/auth/testing';
import { createJwtAuthInterceptor } from '@connectum/auth';
const keypair = await generateRsaTestKeypair();
// { privateKey, publicKey, publicJwk, kid }
const jwks = await startTestJwksServer(keypair.publicJwk); // or an array of JWKs
const auth = createJwtAuthInterceptor({
jwksUri: jwks.url,
issuer: 'https://issuer.example',
audience: 'my-api',
algorithms: ['RS256'],
});
const token = await createTestJwtRS256(
keypair.privateKey,
{ sub: 'user-123', roles: ['admin'], scope: 'read write' },
{ kid: keypair.kid, issuer: 'https://issuer.example', audience: 'my-api' },
);
// ...exercise the interceptor with `Authorization: Bearer ${token}`...
await jwks.close();Security Considerations
- Header stripping: Auth interceptors strip all
x-auth-*headers from incoming requests before processing to prevent spoofing from external clients. - SEC-001:
propagatedClaimsoption filters which claims are included in propagated headers to prevent leaking sensitive data. - SEC-002:
createJwtAuthInterceptorthrows on missing JWT subject claim instead of silently falling back. - HMAC key validation: Minimum key size enforced per RFC 7518 (32 bytes for HS256, 48 for HS384, 64 for HS512).
- Header size limits: Both
setAuthHeadersandparseAuthHeadersenforce 8192-byte limits on roles, scopes, and claims headers to prevent abuse.setAuthHeaderssilently drops oversized headers;parseAuthHeadersignores them. - Gateway header stripping:
createGatewayAuthInterceptorstrips all mapped headers and the trust header on every request -- including skipped methods -- to prevent downstream spoofing.
Exports Summary
| Export | Subpath | Description |
|---|---|---|
createAuthInterceptor | . | Generic pluggable authentication interceptor |
createJwtAuthInterceptor | . | JWT authentication interceptor (jose) |
createGatewayAuthInterceptor | . | Gateway-injected headers authentication interceptor |
createSessionAuthInterceptor | . | Session-based authentication interceptor |
createInternalAuthInterceptor | . | Internal (service-to-service) trust marker interceptor |
meshIdentityTrust | . | Trust source: mesh-forwarded peer identity (Istio/SPIFFE) |
signedTokenTrust | . | Trust source: per-service signed token via issuer-bound JWKS |
sharedSecretTrust | . | Trust source: dev-only single shared secret |
createClientBearerInterceptor | . | Client-side Bearer token interceptor |
createClientGatewayInterceptor | . | Client-side gateway service-to-service auth interceptor |
createAuthzInterceptor | . | Declarative rules-based authorization interceptor |
authContextStorage | . | AsyncLocalStorage instance for auth context |
getAuthContext | . | Get current auth context (or undefined) |
requireAuthContext | . | Get current auth context or throw Unauthenticated |
setAuthHeaders | . | Serialize AuthContext to headers |
parseAuthHeaders | . | Deserialize AuthContext from headers |
matchesMethodPattern | . | Method pattern matching utility |
LruCache | . | In-memory LRU cache with TTL |
AuthzDeniedError | . | Authorization denied error class |
AUTH_HEADERS | . | Standard auth header name constants |
AuthzEffect | . | Authorization effect constants (ALLOW, DENY) |
createProtoAuthzInterceptor | . | Proto-based authorization interceptor |
AuthContext, AuthInterceptorOptions, JwtAuthInterceptorOptions, GatewayAuthInterceptorOptions, GatewayHeaderMapping, SessionAuthInterceptorOptions, InternalAuthInterceptorOptions, InternalTrustSource, MeshIdentityEntry, MeshIdentityTrustOptions, SignedTokenIssuer, SignedTokenTrustOptions, SharedSecretTrustOptions, ClientBearerInterceptorOptions, ClientGatewayInterceptorOptions, AuthzInterceptorOptions, AuthzRule, ProtoAuthzInterceptorOptions, CacheOptions, InterceptorFactory, AuthzDeniedDetails | . | TypeScript types |
createProtoAuthzInterceptor | ./proto | Proto-based authorization interceptor |
resolveMethodAuth | ./proto | Resolve proto auth config for a method |
getPublicMethods | ./proto | Extract public method patterns from services |
getInternalMethods | ./proto | Extract internal method patterns from services |
AuthRequirements, MethodAuth, ServiceAuth | ./proto | Generated proto message types |
AuthRequirementsSchema, MethodAuthSchema, ServiceAuthSchema | ./proto | Generated proto schemas |
method_auth, service_auth | ./proto | Proto extension descriptors |
createMockAuthContext | ./testing | Create mock AuthContext for tests |
createTestJwt | ./testing | Generate signed HS256 test JWTs |
TEST_JWT_SECRET | ./testing | Pre-shared HMAC secret for tests |
withAuthContext | ./testing | Run function within AuthContext |
generateRsaTestKeypair | ./testing | Generate an RSA (RS256) test keypair + public JWK |
startTestJwksServer | ./testing | Start an in-process JWKS server for tests |
createTestJwtRS256 | ./testing | Mint an RS256 test JWT (production JWKS branch) |
TEST_JWT_KID | ./testing | Default kid for the RS256 test helpers |
RsaTestKeypair, TestJwksServer | ./testing | TypeScript types for the RS256 helpers |
Related Packages
- @connectum/core -- Server that accepts interceptors (peer dependency)
- @connectum/interceptors -- Resilience interceptors (complementary, typically placed before auth in the chain)
- @connectum/otel -- OpenTelemetry instrumentation (complementary)
