Skip to content

ADR-024: Auth/Authz Strategy ​

Status ​

Accepted -- 2026-02-15 Revised -- 2026-02-17 (v0.2.0: Gateway, Session interceptors, Security fixes) Revised -- 2026-02-20 (v0.3.0: Proto-based authorization, corrected dependencies, removed deleted trusted-headers, marked OTel as unimplemented) Revised -- 2026-10-08 (reconciled with the implemented package surface and its limits, and with the identity lifetime in streaming calls; accepted decisions unchanged)

Context ​

Connectum — a universal gRPC/ConnectRPC framework — has no built-in authentication or authorization. Each team writes custom auth interceptors, there is no standard for context propagation, and no best practices for JWT handling.

The project board references Envoy ext_authz, JWT authentication, JWT claim authorization, and credential injection as requirements.

Key requirements:

  1. Generic auth mechanism (not locked to JWT)
  2. JWT convenience layer (covers 80% of use cases)
  3. Declarative authorization (RBAC/claims)
  4. Context propagation (in-process + cross-service)
  5. Test utilities for auth scenarios

Constraints:

  • Zero changes to @connectum/core (ADR-003 layer rules)
  • Standard ConnectRPC Interceptor type (composable with existing interceptors)
  • Optional dependency — users who don't need auth don't install it

Decision ​

Create a new @connectum/auth package (Layer 1) with interceptor factories, auth context propagation, and test utilities.

1. Package Architecture ​

Layer 1 package with zero internal dependencies:

DependencyTypePurpose
josedependencyJWT verification, JWKS, signing
@connectrpc/connectdependencyInterceptor type, ConnectError, Code
@connectum/coredependencySanitizableError protocol
@bufbuild/protobufdependencyProto reflection for proto-based authz

Update (2026-10-08): Since 1.3 @connectrpc/connect, @connectum/core, and @bufbuild/protobuf are peer dependencies of @connectum/auth (see Peer dependencies); jose remains the runtime dependency. The table above records the decision at its date.

2. Interceptor Factories ​

2.1 createAuthInterceptor() — Generic Authentication ​

Pluggable authentication for credentials supplied to its extractor (API keys, opaque tokens, custom schemes). The interceptor receives request headers, not the TLS peer certificate. Direct application-level peer-certificate/SAN reading is deferred; mTLS identity must be supplied through a trusted gateway or other integration, as recorded in ADR-029.

typescript
/**
 * Create a generic authentication interceptor.
 *
 * Extracts credentials from request headers, verifies them using
 * a user-provided callback, and stores the resulting AuthContext
 * in AsyncLocalStorage for downstream access.
 *
 * @param options - Authentication options
 * @returns ConnectRPC interceptor
 *
 * @example API key authentication
 * ```typescript
 * import { createAuthInterceptor } from '@connectum/auth';
 *
 * const auth = createAuthInterceptor({
 *   extractCredentials: (req) => req.header.get('x-api-key'),
 *   verifyCredentials: async (apiKey) => {
 *     const user = await db.findByApiKey(apiKey);
 *     if (!user) throw new Error('Invalid API key');
 *     return {
 *       subject: user.id,
 *       roles: user.roles,
 *       scopes: [],
 *       claims: {},
 *       type: 'api-key',
 *     };
 *   },
 * });
 * ```
 */
export function createAuthInterceptor(options: AuthInterceptorOptions): Interceptor;

2.2 createJwtAuthInterceptor() — JWT Convenience ​

Pre-built JWT verification with JWKS support, key rotation, and standard claim mapping.

typescript
/**
 * Create a JWT authentication interceptor.
 *
 * Convenience wrapper around createAuthInterceptor() that handles
 * JWT extraction from Authorization header, verification via jose,
 * and standard claim mapping to AuthContext.
 *
 * Supports:
 * - JWKS remote key sets (with automatic caching and rotation)
 * - HMAC symmetric secrets
 * - Asymmetric public keys (RSA, EC, Ed25519)
 * - Issuer and audience validation
 * - Custom claim-to-role/scope mapping
 *
 * @param options - JWT authentication options
 * @returns ConnectRPC interceptor
 *
 * @example JWKS-based JWT auth (Auth0, Keycloak, etc.)
 * ```typescript
 * import { createJwtAuthInterceptor } from '@connectum/auth';
 *
 * const jwtAuth = createJwtAuthInterceptor({
 *   jwksUri: 'https://auth.example.com/.well-known/jwks.json',
 *   issuer: 'https://auth.example.com/',
 *   audience: 'my-api',
 *   claimsMapping: {
 *     roles: 'realm_access.roles',
 *     scopes: 'scope',
 *   },
 * });
 * ```
 */
export function createJwtAuthInterceptor(options: JwtAuthInterceptorOptions): Interceptor;

2.3 createAuthzInterceptor() — Declarative Authorization ​

Rule-based authorization with RBAC support and programmatic callback escape hatch.

typescript
/**
 * Create an authorization interceptor.
 *
 * Evaluates declarative rules and/or a programmatic callback against
 * the AuthContext established by the authentication interceptor.
 *
 * IMPORTANT: This interceptor MUST run AFTER an authentication interceptor
 * (createAuthInterceptor or createJwtAuthInterceptor) in the chain.
 *
 * @param options - Authorization options
 * @returns ConnectRPC interceptor
 *
 * @example RBAC with declarative rules
 * ```typescript
 * import { createAuthzInterceptor } from '@connectum/auth';
 *
 * const authz = createAuthzInterceptor({
 *   defaultPolicy: 'deny',
 *   rules: [
 *     {
 *       name: 'authenticated-access',
 *       methods: ['public.v1.PublicService/*'],
 *       effect: 'allow',
 *     },
 *     {
 *       name: 'admin-only',
 *       methods: ['admin.v1.AdminService/*'],
 *       requires: { roles: ['admin'] },
 *       effect: 'allow',
 *     },
 *   ],
 * });
 * ```
 *
 * Rules are evaluated only after an AuthContext exists. Naming a rule "public"
 * does not bypass authentication; intentionally unauthenticated methods must be
 * skipped by both authentication and this interceptor (or handled with the
 * separate proto `public` option and matching authentication skip).
 *
 * @example Programmatic authorization callback
 * ```typescript
 * const authz = createAuthzInterceptor({
 *   authorize: async (ctx, req) => {
 *     return await permissionService.check({
 *       subject: ctx.subject,
 *       resource: req.service,
 *       action: req.method,
 *     });
 *   },
 * });
 * ```
 */
export function createAuthzInterceptor(options: AuthzInterceptorOptions): Interceptor;

2.4 createGatewayAuthInterceptor() — Gateway Pre-Auth (v0.2.0) ​

For services behind an API gateway that has already performed authentication. Reads pre-authenticated identity from gateway-injected headers.

Revision note (v0.2.0): Replaces createTrustedHeadersReader() which relied on peerAddress — unavailable in ConnectRPC interceptors. Trust is now established via header verification.

typescript
export function createGatewayAuthInterceptor(options: GatewayAuthInterceptorOptions): Interceptor;

Trust mechanism: Verifies a designated header value (shared secret or an IP address forwarded in a header such as x-real-ip) against expected values, including exact matches and CIDR ranges. CIDR matching is applied to the header value; the interceptor cannot read the TCP peer address. The gateway must overwrite client-supplied trust and identity headers, and direct access to the service must be restricted to that gateway.

2.5 createSessionAuthInterceptor() — Session-Based Auth (v0.2.0) ​

Convenience wrapper for session-based auth systems (better-auth, Lucia, etc.).

typescript
export function createSessionAuthInterceptor(options: SessionAuthInterceptorOptions): Interceptor;

Key difference from createAuthInterceptor(): Passes full Headers object to verifySession() callback, enabling cookie-based authentication. Includes built-in LRU cache support.

2.6 createProtoAuthzInterceptor() — Proto-Based Authorization (v0.3.0) ​

Reads authorization configuration from protobuf custom options (connectum.auth.v1) and applies declarative rules defined in .proto files. Falls back to programmatic rules and callbacks.

Available via @connectum/auth/proto subpath export.

typescript
export function createProtoAuthzInterceptor(options?: ProtoAuthzInterceptorOptions): Interceptor;

Authorization decision flow:

Each allow or deny decision is terminal. The complete condition-by-condition order, including missing-context outcomes, is documented in the proto authorization guide.

Proto reader utilities (also from @connectum/auth/proto):

  • resolveMethodAuth(method: DescMethod): ResolvedMethodAuth — resolve effective auth config by merging service-level defaults with method-level overrides. Results cached via WeakMap.
  • getPublicMethods(services: DescService[]): string[] — extract public method patterns from service descriptors. Returns patterns in "ServiceTypeName/MethodName" format for use with skipMethods.
  • getInternalMethods(services: DescService[]): string[] — extract internal method patterns for the JWT interceptor's skipMethods; the internal auth interceptor still requires a trusted marker. See the accepted ADR-029 and internal-method guide.
typescript
import { createProtoAuthzInterceptor, getInternalMethods, getPublicMethods, resolveMethodAuth } from '@connectum/auth/proto';

// Proto options in .proto files control authorization:
// option (connectum.auth.v1.method_auth) = { public: true };
// option (connectum.auth.v1.service_auth) = { default_requires: { roles: ["admin"] } };

const authz = createProtoAuthzInterceptor({
  defaultPolicy: 'deny',
  rules: [
    { name: 'admin-fallback', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
  ],
});

3. Auth Context Propagation ​

Two complementary mechanisms:

3.1 AsyncLocalStorage (In-Process) ​

Primary mechanism for in-process context access. Zero-overhead, type-safe.

typescript
export const authContextStorage: AsyncLocalStorage<AuthContext>;
export function getAuthContext(): AuthContext | undefined;
export function requireAuthContext(): AuthContext; // throws ConnectError(Unauthenticated)

Update (2026-10-08): The store covers the whole life of a call, including every resumption of a generator handler (after await, after yield, in finally) and the cleanup that follows a cancellation, a deadline or a server shutdown, on HTTP/2 and on the in-process transport alike; a handler never observes the identity held by the caller of an in-process client. Before 1.3.0 (connectum#335) only the call that opened the stream was covered. An abort listener registered by the handler on ctx.signal still runs in the context of whatever raised the abort; capture the identity before registering it.

3.2 Request Headers (Cross-Service) ​

Secondary mechanism for service-to-service propagation, following the Envoy credential injection pattern.

typescript
export const AUTH_HEADERS = {
    SUBJECT: 'x-auth-subject',
    ROLES: 'x-auth-roles',
    SCOPES: 'x-auth-scopes',
    CLAIMS: 'x-auth-claims',
    NAME: 'x-auth-name',
    TYPE: 'x-auth-type',
} as const;

export function parseAuthHeaders(headers: Headers): AuthContext | undefined;

parseAuthHeaders() deserializes identity fields; it does not authenticate their sender. Use it only across a trusted mTLS or service-mesh boundary. For gateway traffic, use createGatewayAuthInterceptor() with a verified trust header and configure the gateway to overwrite that header and the mapped identity headers. On the sending side, propagateHeaders: true writes the verified context fields to request headers; it does not authenticate a receiving service or send a downstream RPC itself.

4. Interceptor Chain Position ​

Auth/authz interceptors are positioned immediately after errorHandler and before resilience and handler-processing interceptors:

With a fallback enabled, validation and the fallback move in front of the circuit breaker and retry; see Fallback position.

Rationale:

  1. errorHandler first — normalizes errors thrown or rejected by downstream interceptors, including auth errors inside this chain
  2. AUTH second — reject unauthenticated requests before consuming timeout/bulkhead resources
  3. AUTHZ third — reject unauthorized requests before downstream resilience and handler processing

errorHandler wraps the promise returned by the rest of the interceptor chain; it is not a process-wide error boundary and does not guarantee handling of errors raised later while a returned stream is consumed.

5. Trusted Headers Reader (Removed) ​

Removed in v0.3.0 (deleted from codebase). The original createTrustedHeadersReader() relied on peerAddress which is unavailable in ConnectRPC interceptors.

Use createGatewayAuthInterceptor() for gateway identity headers. It maps those headers into an AuthContext and checks a designated trust header, but it does not inspect the connection's peer address. Any IP-based trust value must be written by a trusted upstream that overwrites client input; restrict direct service access to that upstream. This replaces the removed peer-address reader, but does not provide equivalent peer-certificate or socket-address inspection.

6. OpenTelemetry Integration ​

Not implemented — the otelEnrichment option and @connectum/otel dependency are absent from the current implementation. Planned for future.

The getAuthContext() API makes auth context available for custom OTel interceptors to enrich spans with enduser.* attributes if needed.

Update (1.0.0; corrected 2026-10-08): @connectum/otel ships createOtelInterceptor() for RPC, transport, tracing, and metrics instrumentation. It does not read AuthContext and does not add enduser.* attributes. A custom OTel interceptor can call getAuthContext() when it runs inside the authenticated request scope and add attributes according to the application's data policy; place such enrichment after auth/authz (errorHandler → auth → authz → custom otel → validation), which keeps the §4 rule intact. The earlier form of this note claimed that the standard interceptor records enduser.*; that claim was wrong.

7. ext_authz: NOT Included ​

Decision: Do NOT include Envoy ext_authz implementation.

Rationale:

  1. Infrastructure-level concern (Envoy-specific), not application framework
  2. Most users will not need it — Envoy/Istio handle this at mesh level
  3. Violates universal framework principle
  4. Any Connectum service can implement ext_authz as a regular gRPC service

Instead: Document in examples/ and docs/ how to build ext_authz with Connectum.

8. Test Utilities ​

Sub-path export @connectum/auth/testing:

typescript
export function createMockAuthContext(overrides?: Partial<AuthContext>): AuthContext;
export function createTestJwt(payload: Record<string, unknown>, options?: { expiresIn?: string }): Promise<string>;
export const TEST_JWT_SECRET: string;
export function createTestJwtRS256(privateKey: CryptoKey, payload: Record<string, unknown>, options: { kid: string; issuer?: string; audience?: string; expiresIn?: string }): Promise<string>;
export function generateRsaTestKeypair(kid?: string): Promise<RsaTestKeypair>;
export function startTestJwksServer(jwks: JWK | readonly JWK[]): Promise<TestJwksServer>;
export const TEST_JWT_KID: string;
export function withAuthContext<T>(context: AuthContext, fn: () => T | Promise<T>): Promise<T>;

The testing subpath also exports the RS256/JWKS helpers shown above; see the generated API for their exact parameter and result types. RsaTestKeypair and TestJwksServer are exported types.


Package Structure ​

packages/auth/
├── src/
│   ├── index.ts
│   ├── types.ts
│   ├── context.ts
│   ├── auth-interceptor.ts
│   ├── jwt-auth-interceptor.ts
│   ├── authz-interceptor.ts
│   ├── headers.ts
│   ├── errors.ts                   # AuthzDeniedError, AuthzDeniedDetails
│   ├── method-match.ts             # matchesMethodPattern()
│   ├── authz-utils.ts              # satisfiesRequirements()
│   ├── internal-auth-interceptor.ts
│   ├── client-bearer-interceptor.ts
│   ├── client-gateway-interceptor.ts
│   ├── gateway-auth-interceptor.ts
│   ├── session-auth-interceptor.ts
│   └── cache.ts
├── src/proto/                      # @connectum/auth/proto subpath (v0.3.0)
│   ├── index.ts
│   ├── proto-authz-interceptor.ts  # createProtoAuthzInterceptor()
│   └── reader.ts                   # resolveMethodAuth(), getPublicMethods(), getInternalMethods()
├── src/testing/
│   ├── index.ts
│   ├── mock-context.ts
│   ├── test-jwt.ts
│   ├── test-jwt-rs256.ts
│   └── with-context.ts
├── tests/
│   ├── unit/
│   └── integration/
├── package.json
├── tsconfig.json
└── README.md

Architecture Diagram ​

The gateway and session interceptors are independent of the generic one:

Authorization reads the same context. Only the proto reader touches protobuf reflection:

Every interceptor above, and createDefaultInterceptors() and createMethodFilterInterceptor() from @connectum/interceptors, is typed as the Interceptor of @connectrpc/connect. The dashed core relationship is the type-only SanitizableError protocol used by AuthzDeniedError; it does not imply a runtime import of @connectum/core.

Interceptor Chain Flow ​

The sequence below illustrates one JWT-authenticated request. Gateway, session, internal, and deliberately public methods use different authentication paths.


Consequences ​

Positive ​

  1. Pluggable auth primitives — generic createAuthInterceptor() verifies credentials provided to its extractor; peer-certificate access is not part of the current interceptor surface
  2. JWT best practices out-of-the-box — JWKS caching, key rotation, standard claim validation via jose
  3. Declarative authorization — rule-based RBAC eliminates boilerplate; rules are auditable
  4. Standard context propagation — dual mechanism covers in-process and cross-service
  5. Separate auth package — uses the @connectum/core SanitizableError type protocol and declares core, ConnectRPC, and protobuf as peers; jose is its runtime dependency
  6. Composable — standard ConnectRPC Interceptor, works with createMethodFilterInterceptor()
  7. Testable — built-in test utilities eliminate test boilerplate
  8. OTel-composable — getAuthContext() makes auth data available for custom OTel interceptors to enrich spans

Negative ​

  1. Additional package — 7th package in monorepo. Mitigation: modular pattern, install only what's needed
  2. jose dependency — ~50KB for JWT. Mitigation: tree-shakeable if only using generic auth
  3. Chain order is user's responsibility. Mitigation: clear documentation and examples
  4. AsyncLocalStorage overhead — <1us per context switch. Mitigation: Node.js ALS is mature
  5. No built-in token refresh — client-side concern, out of scope

Risks ​

  1. jose breaking changes — Mitigation: pin jose@^6, wrap API internally
  2. Security vulnerabilities — Mitigation: rely on jose for crypto, security review, comprehensive tests
  3. Overlap with infrastructure auth — Mitigation: document when to use app-level vs infra-level auth
  4. Header spoofing — Mitigation: createGatewayAuthInterceptor() verifies a trust header fail-closed, while the trusted gateway overwrites client-supplied trust and identity headers and direct service access is restricted. CIDR checks use the forwarded header, not the socket peer address.
  5. ALS scope and generator handlers — a server-streaming or bidirectional handler is advanced by the transport after the interceptor has returned, so a single run() around next(req) does not cover its body. Mitigation: the authentication interceptors scope the identity around the creation of the response iterator and each of its next, return and throw (no enterWith), and @connectum/core binds the cleanup it triggers on cancellation to the context in which the call's iterator was created. (Corrected 2026-10-08: the original text claimed creation-time scoping was sufficient; it never was.)

Alternatives Considered ​

Alternative 1: Extend @connectum/interceptors ​

Rating: 4/10

Forces jose dependency on all interceptor users; violates SRP. Auth has different dependencies and lifecycle than resilience patterns.

Alternative 2: JWT-only interceptor ​

Rating: 5/10

JWT-only authentication cannot cover API keys or opaque tokens. mTLS peer-certificate inspection remains deferred because the interceptor surface has no peer-certificate access; deployments can pass identity through a trusted gateway or mesh.

Alternative 3: Include Envoy ext_authz ​

Rating: 3/10

Infrastructure-level concern, Envoy-specific. Document as example instead.

Alternative 4: ConnectRPC contextValues ​

Rating: 6/10

ConnectRPC exposes per-call contextValues on requests passed to interceptors and handlers. The auth package uses AsyncLocalStorage so shared helpers can read AuthContext through getAuthContext() without receiving a request or having contextValues threaded through each function call. This keeps context access available across the async request scope; contextValues remains a valid explicit alternative for application-specific data.

Alternative 5: Policy-as-code (OPA/Rego) ​

Rating: 5/10

Too heavy for embedded devices. Declarative rules + callback cover same use cases lighter. Users needing OPA can implement as authorize callback.


Implementation Plan ​

Historical proposed plan retained for provenance, not a current completion checklist. Its coverage target and example path were not measured or revalidated by this ADR; use the guides and generated API for the current package surface. The createTrustedHeadersReader() item below was superseded by the gateway interceptor in v0.2.0.

Phase 1: Core Auth ​

  1. Create packages/auth/ package structure
  2. Implement createAuthInterceptor() with AsyncLocalStorage context
  3. Implement getAuthContext(), requireAuthContext(), parseAuthHeaders()
  4. Unit tests (>90% coverage)

Phase 2: JWT + Authorization ​

  1. Implement createJwtAuthInterceptor() with jose integration
  2. Implement createAuthzInterceptor() with rule engine
  3. Implement createTrustedHeadersReader() with fail-closed
  4. Unit + integration tests

Phase 3: Test Utilities ​

  1. Implement @connectum/auth/testing sub-export
  2. Integration tests with full auth chain

Phase 4: Documentation & Examples ​

  1. README.md, authentication guide, authorization guide
  2. Example: with-jwt-auth/

References ​

  1. ADR-003: Package Decomposition
  2. ADR-006: Resilience Patterns
  3. ADR-014: Method Filter Interceptor
  4. ADR-023: Uniform Registration API
  5. jose library
  6. ConnectRPC Interceptors
  7. Envoy ext_authz
  8. OpenTelemetry Semantic Conventions: End User

Changelog ​

DateAuthorChange
2026-02-15Software ArchitectInitial ADR: Auth/Authz Strategy
2026-02-17Software Architectv0.2.0 Revision: Gateway/Session interceptors, LRU cache, Security fixes (SEC-001, SEC-002, SEC-005)
2026-02-20Software Architectv0.3.0 Revision: Proto-based authorization (createProtoAuthzInterceptor, @connectum/auth/proto), corrected dependencies (@connectum/core, @bufbuild/protobuf), removed deleted trusted-headers.ts, marked OTel as unimplemented
2026-10-08Documentation reviewReconciled with the implementation: peer-dependency kinds (since 1.3), header-only credential surface (no peer certificate), terminal proto-authz decision flow, contextValues availability in interceptors, getInternalMethods and RS256/JWKS test helpers, corrected the 1.0.0 OTel note (no enduser.* enrichment). Ratings, estimates, and accepted decisions unchanged.
2026-10-08Connectum maintainersCorrected Risk 5: creation-time ALS scoping never covered generator handlers; recorded the 1.3.0 identity lifetime for streaming calls and cancellation cleanup (connectum#335) as a dated note in §3.1.