@connectum/auth 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +590 -0
- package/dist/index.d.ts +388 -0
- package/dist/index.js +637 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/index.d.ts +104 -0
- package/dist/testing/index.js +52 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-IH8aZeWZ.d.ts +311 -0
- package/package.json +69 -0
- package/src/auth-interceptor.ts +137 -0
- package/src/authz-interceptor.ts +158 -0
- package/src/cache.ts +66 -0
- package/src/context.ts +63 -0
- package/src/errors.ts +45 -0
- package/src/gateway-auth-interceptor.ts +203 -0
- package/src/headers.ts +149 -0
- package/src/index.ts +49 -0
- package/src/jwt-auth-interceptor.ts +208 -0
- package/src/method-match.ts +46 -0
- package/src/session-auth-interceptor.ts +120 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-context.ts +44 -0
- package/src/testing/test-jwt.ts +75 -0
- package/src/testing/with-context.ts +33 -0
- package/src/types.ts +326 -0
package/README.md
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# @connectum/auth
|
|
2
|
+
|
|
3
|
+
Authentication and authorization interceptors for Connectum.
|
|
4
|
+
|
|
5
|
+
**@connectum/auth** provides pluggable authentication, JWT verification, and declarative authorization for ConnectRPC services. Auth context propagates automatically via `AsyncLocalStorage` -- no manual parameter threading required.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Generic auth interceptor** -- bring your own credential extractor and verifier (API keys, mTLS, custom tokens)
|
|
10
|
+
- **JWT auth interceptor** -- built-in JWT verification via [jose](https://github.com/panva/jose) with JWKS, HMAC, and asymmetric key support
|
|
11
|
+
- **Gateway auth interceptor** -- extract pre-authenticated identity from API gateway headers (Kong, Envoy, etc.) with header-based trust verification
|
|
12
|
+
- **Session auth interceptor** -- session-based authentication for frameworks like better-auth, lucia, etc.
|
|
13
|
+
- **Authorization interceptor** -- declarative RBAC rules with first-match semantics and programmatic fallback
|
|
14
|
+
- **AsyncLocalStorage context** -- zero-boilerplate access to auth context from any handler
|
|
15
|
+
- **Header propagation** -- cross-service auth context forwarding (Envoy-style `x-auth-*` headers)
|
|
16
|
+
- **LRU cache** -- in-memory credential verification caching with TTL expiration
|
|
17
|
+
- **Testing utilities** -- mock contexts, test JWTs, and context injection helpers via `@connectum/auth/testing`
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @connectum/auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Peer dependencies**:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add @connectrpc/connect
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { createServer } from '@connectum/core';
|
|
35
|
+
import { createDefaultInterceptors } from '@connectum/interceptors';
|
|
36
|
+
import { createJwtAuthInterceptor } from '@connectum/auth';
|
|
37
|
+
import routes from '#gen/routes.js';
|
|
38
|
+
|
|
39
|
+
const jwtAuth = createJwtAuthInterceptor({
|
|
40
|
+
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
|
|
41
|
+
issuer: 'https://auth.example.com/',
|
|
42
|
+
audience: 'my-api',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const server = createServer({
|
|
46
|
+
services: [routes],
|
|
47
|
+
port: 5000,
|
|
48
|
+
interceptors: [
|
|
49
|
+
...createDefaultInterceptors(),
|
|
50
|
+
jwtAuth,
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await server.start();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Access the authenticated user in any handler:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { requireAuthContext } from '@connectum/auth';
|
|
61
|
+
|
|
62
|
+
const handler = {
|
|
63
|
+
async getProfile() {
|
|
64
|
+
const auth = requireAuthContext(); // throws Unauthenticated if missing
|
|
65
|
+
return { userId: auth.subject, roles: auth.roles };
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API Reference
|
|
71
|
+
|
|
72
|
+
### createAuthInterceptor(options)
|
|
73
|
+
|
|
74
|
+
Generic authentication interceptor. Extracts credentials from the request, verifies them via a user-provided callback, and stores the resulting `AuthContext` in `AsyncLocalStorage`.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { createAuthInterceptor } from '@connectum/auth';
|
|
78
|
+
|
|
79
|
+
const auth = createAuthInterceptor({
|
|
80
|
+
extractCredentials: (req) => req.header.get('x-api-key'),
|
|
81
|
+
verifyCredentials: async (apiKey) => {
|
|
82
|
+
const user = await db.findByApiKey(apiKey);
|
|
83
|
+
if (!user) throw new Error('Invalid API key');
|
|
84
|
+
return {
|
|
85
|
+
subject: user.id,
|
|
86
|
+
roles: user.roles,
|
|
87
|
+
scopes: [],
|
|
88
|
+
claims: {},
|
|
89
|
+
type: 'api-key',
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
skipMethods: ['grpc.health.v1.Health/*'],
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Options (`AuthInterceptorOptions`)**:
|
|
97
|
+
|
|
98
|
+
| Option | Type | Default | Description |
|
|
99
|
+
|--------|------|---------|-------------|
|
|
100
|
+
| `verifyCredentials` | `(credentials: string) => AuthContext \| Promise<AuthContext>` | **required** | Verify credentials, return context. Must throw on failure. |
|
|
101
|
+
| `extractCredentials` | `(req: { header: Headers }) => string \| null \| Promise<string \| null>` | Bearer token from `Authorization` header | Extract credential string from request |
|
|
102
|
+
| `skipMethods` | `string[]` | `[]` | Methods to skip (`"Service/Method"` or `"Service/*"`) |
|
|
103
|
+
| `propagateHeaders` | `boolean` | `false` | Set `x-auth-*` headers for downstream services |
|
|
104
|
+
| `cache` | `CacheOptions` | - | LRU cache for credentials verification results. Caches AuthContext by credential string. |
|
|
105
|
+
| `propagatedClaims` | `string[]` | - | Filter which claim keys are propagated in `x-auth-claims` header (SEC-001). When not set, all claims are propagated. |
|
|
106
|
+
|
|
107
|
+
### createJwtAuthInterceptor(options)
|
|
108
|
+
|
|
109
|
+
Convenience wrapper for JWT-based authentication. Handles token extraction from `Authorization: Bearer <token>`, verification via [jose](https://github.com/panva/jose), and standard claim mapping.
|
|
110
|
+
|
|
111
|
+
Key resolution priority: `jwksUri` > `secret` > `publicKey`.
|
|
112
|
+
|
|
113
|
+
A missing `sub` claim (and no `claimsMapping.subject` override) throws `ConnectError(Unauthenticated)` with message `"JWT missing subject claim"` (SEC-002).
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import { createJwtAuthInterceptor } from '@connectum/auth';
|
|
117
|
+
|
|
118
|
+
const jwtAuth = createJwtAuthInterceptor({
|
|
119
|
+
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
|
|
120
|
+
issuer: 'https://auth.example.com/',
|
|
121
|
+
audience: 'my-api',
|
|
122
|
+
claimsMapping: {
|
|
123
|
+
roles: 'realm_access.roles', // dot-notation for nested claims
|
|
124
|
+
scopes: 'scope',
|
|
125
|
+
},
|
|
126
|
+
skipMethods: ['grpc.health.v1.Health/*'],
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Options (`JwtAuthInterceptorOptions`)**:
|
|
131
|
+
|
|
132
|
+
| Option | Type | Default | Description |
|
|
133
|
+
|--------|------|---------|-------------|
|
|
134
|
+
| `jwksUri` | `string` | - | JWKS endpoint URL for remote key set |
|
|
135
|
+
| `secret` | `string` | - | HMAC symmetric secret (HS256/HS384/HS512) |
|
|
136
|
+
| `publicKey` | `CryptoKey` | - | Asymmetric public key |
|
|
137
|
+
| `issuer` | `string \| string[]` | - | Expected issuer(s) |
|
|
138
|
+
| `audience` | `string \| string[]` | - | Expected audience(s) |
|
|
139
|
+
| `algorithms` | `string[]` | - | Allowed algorithms |
|
|
140
|
+
| `maxTokenAge` | `number \| string` | - | Maximum token age. Number (seconds) or string (e.g., `"2h"`, `"7d"`). Passed to jose `jwtVerify`. |
|
|
141
|
+
| `claimsMapping` | `{ subject?, name?, roles?, scopes? }` | `{}` | Map JWT claims to AuthContext (supports dot-notation) |
|
|
142
|
+
| `skipMethods` | `string[]` | `[]` | Methods to skip |
|
|
143
|
+
| `propagateHeaders` | `boolean` | `false` | Propagate auth context as headers |
|
|
144
|
+
|
|
145
|
+
At least one of `jwksUri`, `secret`, or `publicKey` is required.
|
|
146
|
+
|
|
147
|
+
### createGatewayAuthInterceptor(options)
|
|
148
|
+
|
|
149
|
+
Authentication interceptor for services behind an API gateway (Kong, Envoy, AWS ALB, etc.) that has already performed authentication. Extracts auth context from gateway-injected headers after verifying trust.
|
|
150
|
+
|
|
151
|
+
Trust is established via a designated header (e.g., `x-gateway-secret`) rather than peer address, since ConnectRPC interceptors do not have access to peer info.
|
|
152
|
+
|
|
153
|
+
Gateway headers are **always** stripped from requests -- including skipped methods -- to prevent downstream spoofing.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { createGatewayAuthInterceptor } from '@connectum/auth';
|
|
157
|
+
|
|
158
|
+
const gatewayAuth = createGatewayAuthInterceptor({
|
|
159
|
+
headerMapping: {
|
|
160
|
+
subject: 'x-user-id',
|
|
161
|
+
name: 'x-user-name',
|
|
162
|
+
roles: 'x-user-roles',
|
|
163
|
+
scopes: 'x-user-scopes',
|
|
164
|
+
},
|
|
165
|
+
trustSource: {
|
|
166
|
+
header: 'x-gateway-secret',
|
|
167
|
+
expectedValues: [process.env.GATEWAY_SECRET],
|
|
168
|
+
},
|
|
169
|
+
skipMethods: ['grpc.health.v1.Health/*'],
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Options (`GatewayAuthInterceptorOptions`)**:
|
|
174
|
+
|
|
175
|
+
| Option | Type | Default | Description |
|
|
176
|
+
|--------|------|---------|-------------|
|
|
177
|
+
| `headerMapping` | `GatewayHeaderMapping` | **required** | Mapping from AuthContext fields to gateway header names |
|
|
178
|
+
| `trustSource` | `{ header: string; expectedValues: string[] }` | **required** | Trust verification: header name and accepted values (shared secrets or trusted IP ranges via CIDR) |
|
|
179
|
+
| `stripHeaders` | `string[]` | `[]` | Additional headers to strip from request after extraction |
|
|
180
|
+
| `skipMethods` | `string[]` | `[]` | Methods to skip authentication for (headers are still stripped) |
|
|
181
|
+
| `propagateHeaders` | `boolean` | `false` | Propagate auth context as `x-auth-*` headers for downstream services |
|
|
182
|
+
| `defaultType` | `string` | `"gateway"` | Default credential type when not provided by gateway |
|
|
183
|
+
|
|
184
|
+
**`GatewayHeaderMapping`**:
|
|
185
|
+
|
|
186
|
+
| Field | Type | Required | Description |
|
|
187
|
+
|-------|------|----------|-------------|
|
|
188
|
+
| `subject` | `string` | Yes | Header containing the authenticated subject |
|
|
189
|
+
| `name` | `string` | No | Header containing the display name |
|
|
190
|
+
| `roles` | `string` | No | Header containing JSON-encoded roles array (falls back to comma-separated parsing) |
|
|
191
|
+
| `scopes` | `string` | No | Header containing space-separated scopes |
|
|
192
|
+
| `type` | `string` | No | Header containing credential type |
|
|
193
|
+
| `claims` | `string` | No | Header containing JSON-encoded claims (ignored if >8192 bytes) |
|
|
194
|
+
|
|
195
|
+
Validation: `headerMapping.subject` must be non-empty, and `trustSource.expectedValues` must be non-empty. Both throw `Error` at construction time (fail-closed).
|
|
196
|
+
|
|
197
|
+
### createSessionAuthInterceptor(options)
|
|
198
|
+
|
|
199
|
+
Session-based authentication interceptor for frameworks like [better-auth](https://www.better-auth.com/) and lucia. Implements a two-step verification flow:
|
|
200
|
+
|
|
201
|
+
1. Extract token from request (default: `Authorization: Bearer <token>`)
|
|
202
|
+
2. Verify session via user-provided callback -- receives both the token **and** full request headers for cookie-based auth support
|
|
203
|
+
3. Map raw session data to `AuthContext` via user-provided mapper
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
import { createSessionAuthInterceptor } from '@connectum/auth';
|
|
207
|
+
import { betterAuth } from 'better-auth';
|
|
208
|
+
|
|
209
|
+
const auth = betterAuth({ /* DB adapter config */ });
|
|
210
|
+
|
|
211
|
+
const sessionAuth = createSessionAuthInterceptor({
|
|
212
|
+
verifySession: async (token, headers) => {
|
|
213
|
+
const session = await auth.api.getSession({ headers });
|
|
214
|
+
if (!session) throw new Error('Invalid session');
|
|
215
|
+
return session;
|
|
216
|
+
},
|
|
217
|
+
mapSession: (session) => ({
|
|
218
|
+
subject: session.user.id,
|
|
219
|
+
name: session.user.name,
|
|
220
|
+
roles: session.user.roles ?? [],
|
|
221
|
+
scopes: [],
|
|
222
|
+
claims: session.user,
|
|
223
|
+
type: 'session',
|
|
224
|
+
}),
|
|
225
|
+
cache: { ttl: 60_000 },
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Options (`SessionAuthInterceptorOptions`)**:
|
|
230
|
+
|
|
231
|
+
| Option | Type | Default | Description |
|
|
232
|
+
|--------|------|---------|-------------|
|
|
233
|
+
| `verifySession` | `(token: string, headers: Headers) => unknown \| Promise<unknown>` | **required** | Verify session token and return raw session data. Receives full request headers for cookie support. Must throw on failure. |
|
|
234
|
+
| `mapSession` | `(session: unknown) => AuthContext \| Promise<AuthContext>` | **required** | Map raw session data to `AuthContext`. |
|
|
235
|
+
| `extractToken` | `(req: { header: Headers }) => string \| null \| Promise<string \| null>` | Bearer token from `Authorization` header | Custom token extraction |
|
|
236
|
+
| `cache` | `CacheOptions` | - | LRU cache for session verification results |
|
|
237
|
+
| `skipMethods` | `string[]` | `[]` | Methods to skip authentication for |
|
|
238
|
+
| `propagateHeaders` | `boolean` | `false` | Propagate auth context as `x-auth-*` headers for downstream services |
|
|
239
|
+
| `propagatedClaims` | `string[]` | - | Filter which claim keys are propagated in `x-auth-claims` header. When not set, all claims are propagated. |
|
|
240
|
+
|
|
241
|
+
### createAuthzInterceptor(options)
|
|
242
|
+
|
|
243
|
+
Declarative rules-based authorization. Evaluates rules in order; first matching rule wins. Must run **after** an authentication interceptor.
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { createAuthzInterceptor } from '@connectum/auth';
|
|
247
|
+
|
|
248
|
+
const authz = createAuthzInterceptor({
|
|
249
|
+
defaultPolicy: 'deny',
|
|
250
|
+
rules: [
|
|
251
|
+
{
|
|
252
|
+
name: 'health-public',
|
|
253
|
+
methods: ['grpc.health.v1.Health/*'],
|
|
254
|
+
effect: 'allow',
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'admin-only',
|
|
258
|
+
methods: ['admin.v1.AdminService/*'],
|
|
259
|
+
effect: 'allow',
|
|
260
|
+
requires: { roles: ['admin'] },
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'users-read',
|
|
264
|
+
methods: ['user.v1.UserService/GetUser', 'user.v1.UserService/ListUsers'],
|
|
265
|
+
effect: 'allow',
|
|
266
|
+
requires: { scopes: ['read'] },
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Options (`AuthzInterceptorOptions`)**:
|
|
273
|
+
|
|
274
|
+
| Option | Type | Default | Description |
|
|
275
|
+
|--------|------|---------|-------------|
|
|
276
|
+
| `defaultPolicy` | `'allow' \| 'deny'` | `'deny'` | Policy when no rule matches |
|
|
277
|
+
| `rules` | `AuthzRule[]` | `[]` | Declarative rules (first match wins) |
|
|
278
|
+
| `authorize` | `(context, req) => boolean \| Promise<boolean>` | - | Programmatic fallback after rules |
|
|
279
|
+
| `skipMethods` | `string[]` | `[]` | Methods to skip authorization |
|
|
280
|
+
|
|
281
|
+
**AuthzRule**:
|
|
282
|
+
|
|
283
|
+
| Field | Type | Description |
|
|
284
|
+
|-------|------|-------------|
|
|
285
|
+
| `name` | `string` | Rule name (used in error messages) |
|
|
286
|
+
| `methods` | `string[]` | Method patterns: `"*"`, `"Service/*"`, `"Service/Method"` |
|
|
287
|
+
| `effect` | `'allow' \| 'deny'` | Effect when rule matches |
|
|
288
|
+
| `requires` | `{ roles?: string[], scopes?: string[] }` | Required roles (any-of) and/or scopes (all-of) |
|
|
289
|
+
|
|
290
|
+
### getAuthContext() / requireAuthContext()
|
|
291
|
+
|
|
292
|
+
Access the authenticated user context set by an auth interceptor.
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
import { getAuthContext, requireAuthContext } from '@connectum/auth';
|
|
296
|
+
|
|
297
|
+
// Returns AuthContext | undefined
|
|
298
|
+
const auth = getAuthContext();
|
|
299
|
+
|
|
300
|
+
// Returns AuthContext, throws ConnectError(Unauthenticated) if missing
|
|
301
|
+
const auth = requireAuthContext();
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**AuthContext**:
|
|
305
|
+
|
|
306
|
+
| Field | Type | Description |
|
|
307
|
+
|-------|------|-------------|
|
|
308
|
+
| `subject` | `string` | User/service identifier |
|
|
309
|
+
| `name` | `string?` | Display name |
|
|
310
|
+
| `roles` | `readonly string[]` | Assigned roles |
|
|
311
|
+
| `scopes` | `readonly string[]` | Granted scopes |
|
|
312
|
+
| `claims` | `Record<string, unknown>` | Raw credential claims |
|
|
313
|
+
| `type` | `string` | Credential type (`"jwt"`, `"api-key"`, etc.) |
|
|
314
|
+
| `expiresAt` | `Date?` | Credential expiration |
|
|
315
|
+
|
|
316
|
+
### LruCache
|
|
317
|
+
|
|
318
|
+
Minimal in-memory LRU cache with TTL expiration. Uses `Map` insertion order for LRU eviction. Used by `createAuthInterceptor` and `createSessionAuthInterceptor` for caching verification results.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import { LruCache } from '@connectum/auth';
|
|
322
|
+
|
|
323
|
+
const cache = new LruCache<{ userId: string }>({
|
|
324
|
+
ttl: 60_000, // 60 seconds
|
|
325
|
+
maxSize: 500, // default: 1000
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
cache.set('key', { userId: 'user-1' });
|
|
329
|
+
const value = cache.get('key'); // { userId: 'user-1' } or undefined (expired/missing)
|
|
330
|
+
cache.clear();
|
|
331
|
+
cache.size; // 0
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Constructor**: `new LruCache<T>(options: { ttl: number; maxSize?: number })`
|
|
335
|
+
|
|
336
|
+
| Option | Type | Default | Description |
|
|
337
|
+
|--------|------|---------|-------------|
|
|
338
|
+
| `ttl` | `number` | **required** | Cache entry time-to-live in milliseconds. Must be positive (throws `RangeError`). |
|
|
339
|
+
| `maxSize` | `number` | `1000` | Maximum number of cached entries |
|
|
340
|
+
|
|
341
|
+
**Methods**:
|
|
342
|
+
|
|
343
|
+
| Method | Signature | Description |
|
|
344
|
+
|--------|-----------|-------------|
|
|
345
|
+
| `get` | `(key: string) => T \| undefined` | Get cached value. Returns `undefined` if missing or expired. Moves entry to most-recently-used. |
|
|
346
|
+
| `set` | `(key: string, value: T) => void` | Set a value. Evicts LRU entry if at capacity. |
|
|
347
|
+
| `clear` | `() => void` | Remove all entries |
|
|
348
|
+
| `size` | `number` (getter) | Current number of entries |
|
|
349
|
+
|
|
350
|
+
**`CacheOptions`** (used by `AuthInterceptorOptions.cache` and `SessionAuthInterceptorOptions.cache`):
|
|
351
|
+
|
|
352
|
+
| Field | Type | Default | Description |
|
|
353
|
+
|-------|------|---------|-------------|
|
|
354
|
+
| `ttl` | `number` | **required** | Cache entry time-to-live in milliseconds |
|
|
355
|
+
| `maxSize` | `number` | - | Maximum number of cached entries |
|
|
356
|
+
|
|
357
|
+
### parseAuthHeaders(headers) / setAuthHeaders(headers, context, propagatedClaims?)
|
|
358
|
+
|
|
359
|
+
Serialize and deserialize `AuthContext` to/from HTTP headers for cross-service propagation.
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { parseAuthHeaders, setAuthHeaders } from '@connectum/auth';
|
|
363
|
+
|
|
364
|
+
// Read context from upstream headers (trusted environments only)
|
|
365
|
+
const context = parseAuthHeaders(req.header);
|
|
366
|
+
|
|
367
|
+
// Write context to outgoing headers
|
|
368
|
+
setAuthHeaders(outgoingHeaders, authContext);
|
|
369
|
+
|
|
370
|
+
// Write context with filtered claims (only propagate listed keys)
|
|
371
|
+
setAuthHeaders(outgoingHeaders, authContext, ['email', 'tenant_id']);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`setAuthHeaders` silently drops roles, scopes, or claims values that exceed 8192 bytes to prevent header size abuse.
|
|
375
|
+
|
|
376
|
+
### AUTH_HEADERS
|
|
377
|
+
|
|
378
|
+
Standard header names for auth context propagation:
|
|
379
|
+
|
|
380
|
+
| Constant | Value | Content |
|
|
381
|
+
|----------|-------|---------|
|
|
382
|
+
| `AUTH_HEADERS.SUBJECT` | `x-auth-subject` | Subject identifier |
|
|
383
|
+
| `AUTH_HEADERS.NAME` | `x-auth-name` | Display name |
|
|
384
|
+
| `AUTH_HEADERS.ROLES` | `x-auth-roles` | JSON-encoded roles array |
|
|
385
|
+
| `AUTH_HEADERS.SCOPES` | `x-auth-scopes` | Space-separated scopes |
|
|
386
|
+
| `AUTH_HEADERS.CLAIMS` | `x-auth-claims` | JSON-encoded claims object |
|
|
387
|
+
| `AUTH_HEADERS.TYPE` | `x-auth-type` | Credential type |
|
|
388
|
+
|
|
389
|
+
### AuthzEffect
|
|
390
|
+
|
|
391
|
+
Authorization rule effect constants:
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
import { AuthzEffect } from '@connectum/auth';
|
|
395
|
+
|
|
396
|
+
AuthzEffect.ALLOW // 'allow'
|
|
397
|
+
AuthzEffect.DENY // 'deny'
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Interceptor Chain Order
|
|
401
|
+
|
|
402
|
+
Auth interceptors should be placed **after** the default interceptor chain (error handler, timeout, bulkhead, etc.) and **before** business logic:
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
errorHandler -> timeout -> bulkhead -> circuitBreaker -> retry -> validation -> auth -> authz -> handler
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
import { createServer } from '@connectum/core';
|
|
410
|
+
import { createDefaultInterceptors } from '@connectum/interceptors';
|
|
411
|
+
import { createJwtAuthInterceptor, createAuthzInterceptor } from '@connectum/auth';
|
|
412
|
+
|
|
413
|
+
const server = createServer({
|
|
414
|
+
services: [routes],
|
|
415
|
+
interceptors: [
|
|
416
|
+
...createDefaultInterceptors(),
|
|
417
|
+
createJwtAuthInterceptor({ secret: process.env.JWT_SECRET }),
|
|
418
|
+
createAuthzInterceptor({ defaultPolicy: 'deny', rules: [...] }),
|
|
419
|
+
],
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Testing
|
|
424
|
+
|
|
425
|
+
The `@connectum/auth/testing` sub-export provides utilities for testing authenticated handlers and services.
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
# Imported separately from the main package
|
|
429
|
+
import { ... } from '@connectum/auth/testing';
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### createMockAuthContext(overrides?)
|
|
433
|
+
|
|
434
|
+
Create an `AuthContext` with sensible defaults. Overrides are shallow-merged.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import { createMockAuthContext } from '@connectum/auth/testing';
|
|
438
|
+
|
|
439
|
+
const ctx = createMockAuthContext();
|
|
440
|
+
// { subject: 'test-user', name: 'Test User', roles: ['user'], scopes: ['read'], claims: {}, type: 'test' }
|
|
441
|
+
|
|
442
|
+
const admin = createMockAuthContext({ subject: 'admin-1', roles: ['admin'] });
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### createTestJwt(payload, options?)
|
|
446
|
+
|
|
447
|
+
Create a signed HS256 JWT for integration tests. Uses a deterministic test secret.
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
import { createTestJwt, TEST_JWT_SECRET } from '@connectum/auth/testing';
|
|
451
|
+
import { createJwtAuthInterceptor } from '@connectum/auth';
|
|
452
|
+
|
|
453
|
+
const token = await createTestJwt(
|
|
454
|
+
{ sub: 'user-123', roles: ['admin'], scope: 'read write' },
|
|
455
|
+
{ expiresIn: '1h', issuer: 'test' },
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// Wire up the interceptor with the test secret
|
|
459
|
+
const auth = createJwtAuthInterceptor({ secret: TEST_JWT_SECRET, issuer: 'test' });
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Options:**
|
|
463
|
+
|
|
464
|
+
| Option | Type | Default | Description |
|
|
465
|
+
|--------|------|---------|-------------|
|
|
466
|
+
| `expiresIn` | `string` | `'1h'` | Expiration (jose duration format) |
|
|
467
|
+
| `issuer` | `string` | - | Token issuer |
|
|
468
|
+
| `audience` | `string` | - | Token audience |
|
|
469
|
+
|
|
470
|
+
### withAuthContext(context, fn)
|
|
471
|
+
|
|
472
|
+
Run a function with a pre-set `AuthContext` in `AsyncLocalStorage`. Use this to test handlers that call `getAuthContext()` or `requireAuthContext()`.
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
import { withAuthContext, createMockAuthContext } from '@connectum/auth/testing';
|
|
476
|
+
import { requireAuthContext } from '@connectum/auth';
|
|
477
|
+
|
|
478
|
+
await withAuthContext(createMockAuthContext({ subject: 'user-1' }), async () => {
|
|
479
|
+
const auth = requireAuthContext();
|
|
480
|
+
assert.strictEqual(auth.subject, 'user-1');
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### TEST_JWT_SECRET
|
|
485
|
+
|
|
486
|
+
Deterministic HMAC secret for test JWTs: `"connectum-test-secret-do-not-use-in-production"`.
|
|
487
|
+
|
|
488
|
+
## Integration with better-auth
|
|
489
|
+
|
|
490
|
+
[better-auth](https://www.better-auth.com/) is a modern authentication framework for TypeScript. It supports programmatic session verification and works directly with `createSessionAuthInterceptor`.
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import { betterAuth } from "better-auth";
|
|
494
|
+
import { createSessionAuthInterceptor } from '@connectum/auth';
|
|
495
|
+
|
|
496
|
+
const auth = betterAuth({ /* DB adapter config */ });
|
|
497
|
+
|
|
498
|
+
const betterAuthInterceptor = createSessionAuthInterceptor({
|
|
499
|
+
verifySession: async (token, headers) => {
|
|
500
|
+
const session = await auth.api.getSession({ headers });
|
|
501
|
+
if (!session) throw new Error("Invalid session");
|
|
502
|
+
return session;
|
|
503
|
+
},
|
|
504
|
+
mapSession: (session) => ({
|
|
505
|
+
subject: session.user.id,
|
|
506
|
+
name: session.user.name,
|
|
507
|
+
roles: session.user.roles ?? [],
|
|
508
|
+
scopes: [],
|
|
509
|
+
claims: session.user,
|
|
510
|
+
type: "better-auth",
|
|
511
|
+
}),
|
|
512
|
+
cache: { ttl: 60_000 },
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## Security Considerations
|
|
517
|
+
|
|
518
|
+
- **Header stripping**: `createAuthInterceptor` and `createSessionAuthInterceptor` strip all `x-auth-*` headers from incoming requests to prevent external spoofing. `createGatewayAuthInterceptor` strips all mapped gateway headers unconditionally -- including on skipped methods.
|
|
519
|
+
- **Header size limits**: `setAuthHeaders` silently drops roles, scopes, or claims values exceeding 8192 bytes. Gateway interceptor also ignores claims headers exceeding 8192 bytes.
|
|
520
|
+
- **Fail-closed trust**: `createGatewayAuthInterceptor` requires a non-empty `expectedValues` list and a non-empty `subject` mapping at construction time. Missing or mismatched trust header results in `Unauthenticated`.
|
|
521
|
+
- **JWT subject enforcement** (SEC-002): `createJwtAuthInterceptor` throws `Unauthenticated` when the JWT has no `sub` claim and no `claimsMapping.subject` override.
|
|
522
|
+
- **Claims filtering** (SEC-001): Use `propagatedClaims` to limit which claim keys are included in propagated `x-auth-claims` headers, preventing accidental leakage of sensitive token data.
|
|
523
|
+
- **HMAC key validation**: `createJwtAuthInterceptor` enforces minimum HMAC key sizes per RFC 7518 (32/48/64 bytes for HS256/HS384/HS512).
|
|
524
|
+
|
|
525
|
+
## Exports Summary
|
|
526
|
+
|
|
527
|
+
### Main export (`@connectum/auth`)
|
|
528
|
+
|
|
529
|
+
**Interceptor factories**:
|
|
530
|
+
- `createAuthInterceptor` -- generic pluggable authentication
|
|
531
|
+
- `createJwtAuthInterceptor` -- JWT convenience with jose
|
|
532
|
+
- `createGatewayAuthInterceptor` -- gateway-injected headers
|
|
533
|
+
- `createSessionAuthInterceptor` -- session-based auth
|
|
534
|
+
- `createAuthzInterceptor` -- declarative rules-based authorization
|
|
535
|
+
|
|
536
|
+
**Context management**:
|
|
537
|
+
- `getAuthContext` -- get current AuthContext (or undefined)
|
|
538
|
+
- `requireAuthContext` -- get current AuthContext (or throw)
|
|
539
|
+
- `authContextStorage` -- raw AsyncLocalStorage instance
|
|
540
|
+
|
|
541
|
+
**Header utilities**:
|
|
542
|
+
- `parseAuthHeaders` -- deserialize AuthContext from headers
|
|
543
|
+
- `setAuthHeaders` -- serialize AuthContext to headers
|
|
544
|
+
- `AUTH_HEADERS` -- standard header name constants
|
|
545
|
+
|
|
546
|
+
**Cache**:
|
|
547
|
+
- `LruCache` -- in-memory LRU cache with TTL
|
|
548
|
+
|
|
549
|
+
**Authorization**:
|
|
550
|
+
- `AuthzEffect` -- rule effect constants (`ALLOW`, `DENY`)
|
|
551
|
+
- `AuthzDeniedError` -- authorization denied error class
|
|
552
|
+
- `matchesMethodPattern` -- method pattern matching utility
|
|
553
|
+
|
|
554
|
+
**Types** (TypeScript only):
|
|
555
|
+
- `AuthContext`
|
|
556
|
+
- `AuthInterceptorOptions`
|
|
557
|
+
- `JwtAuthInterceptorOptions`
|
|
558
|
+
- `GatewayAuthInterceptorOptions`
|
|
559
|
+
- `GatewayHeaderMapping`
|
|
560
|
+
- `SessionAuthInterceptorOptions`
|
|
561
|
+
- `AuthzInterceptorOptions`
|
|
562
|
+
- `AuthzRule`
|
|
563
|
+
- `CacheOptions`
|
|
564
|
+
- `InterceptorFactory`
|
|
565
|
+
- `AuthzDeniedDetails`
|
|
566
|
+
|
|
567
|
+
### Testing export (`@connectum/auth/testing`)
|
|
568
|
+
|
|
569
|
+
- `createMockAuthContext` -- create AuthContext with defaults
|
|
570
|
+
- `createTestJwt` -- create signed HS256 test JWT
|
|
571
|
+
- `withAuthContext` -- run code with injected AuthContext
|
|
572
|
+
- `TEST_JWT_SECRET` -- deterministic test secret
|
|
573
|
+
|
|
574
|
+
## Dependencies
|
|
575
|
+
|
|
576
|
+
- `@connectrpc/connect` -- ConnectRPC core (peer dependency)
|
|
577
|
+
- `jose` -- JWT/JWK/JWS verification
|
|
578
|
+
|
|
579
|
+
## Requirements
|
|
580
|
+
|
|
581
|
+
- **Node.js**: >=18.0.0
|
|
582
|
+
- **TypeScript**: >=5.7.2 (for type checking)
|
|
583
|
+
|
|
584
|
+
## License
|
|
585
|
+
|
|
586
|
+
Apache-2.0
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
**Part of [@connectum](../../README.md)** -- Universal framework for production-ready gRPC/ConnectRPC microservices
|