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)
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 |
2. Interceptor Factories
2.1 createAuthInterceptor() — Generic Authentication
Pluggable authentication for any credential type (API keys, mTLS, opaque tokens, custom schemes).
/**
* 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: 'public-access',
* methods: ['public.v1.PublicService/*'],
* effect: 'allow',
* },
* {
* name: 'admin-only',
* methods: ['admin.v1.AdminService/*'],
* requires: { roles: ['admin'] },
* effect: 'allow',
* },
* ],
* });
* ```
*
* @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, trusted IP via x-real-ip) against a list of expected values. Supports exact match and CIDR ranges.
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;9-step authorization decision flow:
1. resolveMethodAuth(req.method) -- read proto options (WeakMap-cached)
2. public = true --> skip (allow without authn)
3. Get auth context -- lazy: don't throw yet
4. requires defined, no context --> throw Unauthenticated
4b. requires defined, has context --> satisfiesRequirements? allow : deny
5. policy = "allow" --> allow
6. policy = "deny" --> deny
7. Evaluate programmatic rules -- unconditional rules work without context
8. Fallback: authorize callback --> requires auth context
9. Apply defaultPolicy --> deny without context = UnauthenticatedProto 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.
import { createProtoAuthzInterceptor, 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.*/*'], 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)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;4. Interceptor Chain Position
Auth/authz interceptors are positioned immediately after errorHandler and before all other interceptors:
errorHandler → AUTH → AUTHZ → timeout → bulkhead → circuitBreaker → retry → fallback → validation → serializerRationale:
errorHandlerfirst — catches all errors including auth errorsAUTHsecond — reject unauthenticated requests before consuming timeout/bulkhead resourcesAUTHZthird — reject unauthorized requests before any processing
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() instead — it provides the same trusted-headers-reading functionality with header-based trust verification (shared secret or x-real-ip CIDR matching).
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.
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 withAuthContext<T>(context: AuthContext, fn: () => T | Promise<T>): Promise<T>;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()
│ ├── 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()
├── src/testing/
│ ├── index.ts
│ ├── mock-context.ts
│ ├── test-jwt.ts
│ └── with-context.ts
├── tests/
│ ├── unit/
│ └── integration/
├── package.json
├── tsconfig.json
└── README.mdArchitecture Diagram
Interceptor Chain Flow
Consequences
Positive
- Universal auth primitives — generic
createAuthInterceptor()works with any credential type - 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
- Zero coupling with core — only depends on
@connectrpc/connectandjose - 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()withtrustSourceverification (shared secret or CIDR), fail-closed - ALS fragility in streams — Mitigation: context set at stream creation, documented
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
Cannot support API keys, mTLS, opaque tokens. Violates universal framework principle.
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
contextValues available in handlers but NOT in interceptors. AsyncLocalStorage works everywhere in async call stack.
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
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 |
