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:
- Generic auth mechanism (not locked to JWT)
- JWT convenience layer (covers 80% of use cases)
- Declarative authorization (RBAC/claims)
- Context propagation (in-process + cross-service)
- Test utilities for auth scenarios
Constraints:
- Zero changes to
@connectum/core(ADR-003 layer rules) - Standard ConnectRPC
Interceptortype (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:
| Dependency | Type | Purpose |
|---|---|---|
jose | dependency | JWT verification, JWKS, signing |
@connectrpc/connect | dependency | Interceptor type, ConnectError, Code |
@connectum/core | dependency | SanitizableError protocol |
@bufbuild/protobuf | dependency | Proto reflection for proto-based authz |
Update (2026-10-08): Since 1.3
@connectrpc/connect,@connectum/core, and@bufbuild/protobufare peer dependencies of@connectum/auth(see Peer dependencies);joseremains 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.
/**
* 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.
/**
* 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.
/**
* 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 onpeerAddress— unavailable in ConnectRPC interceptors. Trust is now established via header verification.
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.).
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.
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 viaWeakMap.getPublicMethods(services: DescService[]): string[]— extract public method patterns from service descriptors. Returns patterns in"ServiceTypeName/MethodName"format for use withskipMethods.getInternalMethods(services: DescService[]): string[]— extract internal method patterns for the JWT interceptor'sskipMethods; the internal auth interceptor still requires a trusted marker. See the accepted ADR-029 and internal-method guide.
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.
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, afteryield, infinally) 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. Anabortlistener registered by the handler onctx.signalstill 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.
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:
errorHandlerfirst — normalizes errors thrown or rejected by downstream interceptors, including auth errors inside this chainAUTHsecond — reject unauthenticated requests before consuming timeout/bulkhead resourcesAUTHZthird — 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/otelshipscreateOtelInterceptor()for RPC, transport, tracing, and metrics instrumentation. It does not readAuthContextand does not addenduser.*attributes. A custom OTel interceptor can callgetAuthContext()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 recordsenduser.*; that claim was wrong.
7. ext_authz: NOT Included
Decision: Do NOT include Envoy ext_authz implementation.
Rationale:
- Infrastructure-level concern (Envoy-specific), not application framework
- Most users will not need it — Envoy/Istio handle this at mesh level
- Violates universal framework principle
- 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:
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.mdArchitecture 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
- Pluggable auth primitives — generic
createAuthInterceptor()verifies credentials provided to its extractor; peer-certificate access is not part of the current interceptor surface - JWT best practices out-of-the-box — JWKS caching, key rotation, standard claim validation via
jose - Declarative authorization — rule-based RBAC eliminates boilerplate; rules are auditable
- Standard context propagation — dual mechanism covers in-process and cross-service
- Separate auth package — uses the
@connectum/coreSanitizableErrortype protocol and declares core, ConnectRPC, and protobuf as peers;joseis its runtime dependency - Composable — standard ConnectRPC
Interceptor, works withcreateMethodFilterInterceptor() - Testable — built-in test utilities eliminate test boilerplate
- OTel-composable —
getAuthContext()makes auth data available for custom OTel interceptors to enrich spans
Negative
- Additional package — 7th package in monorepo. Mitigation: modular pattern, install only what's needed
- jose dependency — ~50KB for JWT. Mitigation: tree-shakeable if only using generic auth
- Chain order is user's responsibility. Mitigation: clear documentation and examples
- AsyncLocalStorage overhead — <1us per context switch. Mitigation: Node.js ALS is mature
- No built-in token refresh — client-side concern, out of scope
Risks
- jose breaking changes — Mitigation: pin
jose@^6, wrap API internally - Security vulnerabilities — Mitigation: rely on
josefor crypto, security review, comprehensive tests - Overlap with infrastructure auth — Mitigation: document when to use app-level vs infra-level auth
- 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. - 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()aroundnext(req)does not cover its body. Mitigation: the authentication interceptors scope the identity around the creation of the response iterator and each of itsnext,returnandthrow(noenterWith), and@connectum/corebinds 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
- Create
packages/auth/package structure - Implement
createAuthInterceptor()with AsyncLocalStorage context - Implement
getAuthContext(),requireAuthContext(),parseAuthHeaders() - Unit tests (>90% coverage)
Phase 2: JWT + Authorization
- Implement
createJwtAuthInterceptor()with jose integration - Implement
createAuthzInterceptor()with rule engine - Implement
createTrustedHeadersReader()with fail-closed - Unit + integration tests
Phase 3: Test Utilities
- Implement
@connectum/auth/testingsub-export - Integration tests with full auth chain
Phase 4: Documentation & Examples
- README.md, authentication guide, authorization guide
- Example:
with-jwt-auth/
References
- ADR-003: Package Decomposition
- ADR-006: Resilience Patterns
- ADR-014: Method Filter Interceptor
- ADR-023: Uniform Registration API
- jose library
- ConnectRPC Interceptors
- Envoy ext_authz
- OpenTelemetry Semantic Conventions: End User
Changelog
| Date | Author | Change |
|---|---|---|
| 2026-02-15 | Software Architect | Initial ADR: Auth/Authz Strategy |
| 2026-02-17 | Software Architect | v0.2.0 Revision: Gateway/Session interceptors, LRU cache, Security fixes (SEC-001, SEC-002, SEC-005) |
| 2026-02-20 | Software Architect | v0.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-08 | Documentation review | Reconciled 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-08 | Connectum maintainers | Corrected 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. |