@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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Session-based authentication interceptor
3
+ *
4
+ * Convenience wrapper for session-based auth systems (e.g., better-auth).
5
+ * Implements interceptor directly (not via createAuthInterceptor) to pass
6
+ * full request headers to verifySession for cookie-based auth support.
7
+ *
8
+ * @module session-auth-interceptor
9
+ */
10
+
11
+ import type { Interceptor, StreamRequest, UnaryRequest } from "@connectrpc/connect";
12
+ import { Code, ConnectError } from "@connectrpc/connect";
13
+ import { LruCache } from "./cache.ts";
14
+ import { authContextStorage } from "./context.ts";
15
+ import { setAuthHeaders } from "./headers.ts";
16
+ import { matchesMethodPattern } from "./method-match.ts";
17
+ import type { AuthContext, SessionAuthInterceptorOptions } from "./types.ts";
18
+ import { AUTH_HEADERS } from "./types.ts";
19
+
20
+ /**
21
+ * Default token extractor.
22
+ * Extracts Bearer token from Authorization header.
23
+ */
24
+ function defaultExtractToken(req: { header: Headers }): string | null {
25
+ const authHeader = req.header.get("authorization");
26
+ if (!authHeader) return null;
27
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader);
28
+ return match?.[1] ?? null;
29
+ }
30
+
31
+ /**
32
+ * Create a session-based authentication interceptor.
33
+ *
34
+ * Two-step authentication:
35
+ * 1. Extract token from request
36
+ * 2. Verify session via user-provided callback (receives full headers for cookie support)
37
+ * 3. Map session data to AuthContext via user-provided mapper
38
+ *
39
+ * @param options - Session auth configuration
40
+ * @returns ConnectRPC interceptor
41
+ *
42
+ * @example better-auth integration
43
+ * ```typescript
44
+ * import { createSessionAuthInterceptor } from '@connectum/auth';
45
+ *
46
+ * const sessionAuth = createSessionAuthInterceptor({
47
+ * verifySession: (token, headers) => auth.api.getSession({ headers }),
48
+ * mapSession: (s) => ({
49
+ * subject: s.user.id,
50
+ * name: s.user.name,
51
+ * roles: [],
52
+ * scopes: [],
53
+ * claims: s.user,
54
+ * type: 'session',
55
+ * }),
56
+ * cache: { ttl: 60_000 },
57
+ * });
58
+ * ```
59
+ */
60
+ export function createSessionAuthInterceptor(options: SessionAuthInterceptorOptions): Interceptor {
61
+ const { verifySession, mapSession, extractToken = defaultExtractToken, cache: cacheOptions, skipMethods = [], propagateHeaders = false, propagatedClaims } = options;
62
+
63
+ const cache = cacheOptions ? new LruCache<AuthContext>(cacheOptions) : undefined;
64
+
65
+ return (next) => async (req: UnaryRequest | StreamRequest) => {
66
+ const serviceName: string = req.service.typeName;
67
+ const methodName: string = req.method.name;
68
+
69
+ // Strip auth headers to prevent spoofing
70
+ for (const headerName of Object.values(AUTH_HEADERS)) {
71
+ req.header.delete(headerName);
72
+ }
73
+
74
+ if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
75
+ return await next(req);
76
+ }
77
+
78
+ // Extract token
79
+ const token = await extractToken(req);
80
+ if (!token) {
81
+ throw new ConnectError("Missing credentials", Code.Unauthenticated);
82
+ }
83
+
84
+ // Check cache
85
+ const cached = cache?.get(token);
86
+ if (cached && (!cached.expiresAt || cached.expiresAt.getTime() > Date.now())) {
87
+ if (propagateHeaders) {
88
+ setAuthHeaders(req.header, cached, propagatedClaims);
89
+ }
90
+ return await authContextStorage.run(cached, () => next(req));
91
+ }
92
+
93
+ // Verify session — pass full headers for cookie-based auth
94
+ let session: unknown;
95
+ try {
96
+ session = await verifySession(token, req.header);
97
+ } catch (err) {
98
+ if (err instanceof ConnectError) throw err;
99
+ throw new ConnectError("Session verification failed", Code.Unauthenticated);
100
+ }
101
+
102
+ // Map session to AuthContext
103
+ let authContext: AuthContext;
104
+ try {
105
+ authContext = await mapSession(session);
106
+ } catch (err) {
107
+ if (err instanceof ConnectError) throw err;
108
+ throw new ConnectError("Session mapping failed", Code.Unauthenticated);
109
+ }
110
+
111
+ // Cache result
112
+ cache?.set(token, authContext);
113
+
114
+ if (propagateHeaders) {
115
+ setAuthHeaders(req.header, authContext, propagatedClaims);
116
+ }
117
+
118
+ return await authContextStorage.run(authContext, () => next(req));
119
+ };
120
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @connectum/auth/testing
3
+ *
4
+ * Test utilities for authentication and authorization.
5
+ *
6
+ * @module @connectum/auth/testing
7
+ */
8
+
9
+ export { createMockAuthContext } from "./mock-context.ts";
10
+ export { createTestJwt, TEST_JWT_SECRET } from "./test-jwt.ts";
11
+ export { withAuthContext } from "./with-context.ts";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Mock auth context for testing
3
+ *
4
+ * @module testing/mock-context
5
+ */
6
+
7
+ import type { AuthContext } from "../types.ts";
8
+
9
+ /**
10
+ * Default mock auth context values.
11
+ */
12
+ const DEFAULT_MOCK_CONTEXT: AuthContext = {
13
+ subject: "test-user",
14
+ name: "Test User",
15
+ roles: ["user"],
16
+ scopes: ["read"],
17
+ claims: {},
18
+ type: "test",
19
+ };
20
+
21
+ /**
22
+ * Create a mock AuthContext for testing.
23
+ *
24
+ * Merges provided overrides with sensible test defaults.
25
+ *
26
+ * @param overrides - Partial AuthContext to override defaults
27
+ * @returns Complete AuthContext
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * import { createMockAuthContext } from '@connectum/auth/testing';
32
+ *
33
+ * const ctx = createMockAuthContext({ subject: 'admin-1', roles: ['admin'] });
34
+ * assert.strictEqual(ctx.subject, 'admin-1');
35
+ * assert.deepStrictEqual(ctx.roles, ['admin']);
36
+ * assert.strictEqual(ctx.type, 'test'); // default preserved
37
+ * ```
38
+ */
39
+ export function createMockAuthContext(overrides?: Partial<AuthContext>): AuthContext {
40
+ return {
41
+ ...DEFAULT_MOCK_CONTEXT,
42
+ ...overrides,
43
+ };
44
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Test JWT utilities
3
+ *
4
+ * Provides deterministic JWT creation for testing.
5
+ * NOT for production use.
6
+ *
7
+ * @module testing/test-jwt
8
+ */
9
+
10
+ import * as jose from "jose";
11
+
12
+ /**
13
+ * Deterministic test secret for HS256 JWTs.
14
+ *
15
+ * WARNING: This is a well-known secret for testing only.
16
+ * NEVER use in production.
17
+ */
18
+ export const TEST_JWT_SECRET = "connectum-test-secret-do-not-use-in-production";
19
+
20
+ /**
21
+ * Encoded test secret for jose.
22
+ */
23
+ const encodedSecret = new TextEncoder().encode(TEST_JWT_SECRET);
24
+
25
+ /**
26
+ * Create a signed test JWT for integration testing.
27
+ *
28
+ * Uses HS256 algorithm with a deterministic test key.
29
+ * NOT for production use.
30
+ *
31
+ * @param payload - JWT claims
32
+ * @param options - Signing options
33
+ * @returns Signed JWT string
34
+ *
35
+ * @example Create a test token
36
+ * ```typescript
37
+ * import { createTestJwt, TEST_JWT_SECRET } from '@connectum/auth/testing';
38
+ *
39
+ * const token = await createTestJwt({
40
+ * sub: 'user-123',
41
+ * roles: ['admin'],
42
+ * scope: 'read write',
43
+ * });
44
+ *
45
+ * // Use with createJwtAuthInterceptor in tests
46
+ * const auth = createJwtAuthInterceptor({ secret: TEST_JWT_SECRET });
47
+ * ```
48
+ */
49
+ export async function createTestJwt(
50
+ payload: Record<string, unknown>,
51
+ options?: {
52
+ expiresIn?: string;
53
+ issuer?: string;
54
+ audience?: string;
55
+ },
56
+ ): Promise<string> {
57
+ let builder = new jose.SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setIssuedAt();
58
+
59
+ if (options?.expiresIn) {
60
+ builder = builder.setExpirationTime(options.expiresIn);
61
+ } else {
62
+ // Default: 1 hour expiry
63
+ builder = builder.setExpirationTime("1h");
64
+ }
65
+
66
+ if (options?.issuer) {
67
+ builder = builder.setIssuer(options.issuer);
68
+ }
69
+
70
+ if (options?.audience) {
71
+ builder = builder.setAudience(options.audience);
72
+ }
73
+
74
+ return await builder.sign(encodedSecret);
75
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Auth context test helper
3
+ *
4
+ * @module testing/with-context
5
+ */
6
+
7
+ import { authContextStorage } from "../context.ts";
8
+ import type { AuthContext } from "../types.ts";
9
+
10
+ /**
11
+ * Run a function with a pre-set AuthContext.
12
+ *
13
+ * Sets the provided AuthContext in AsyncLocalStorage for the duration
14
+ * of the callback. Useful for testing handlers that call getAuthContext().
15
+ *
16
+ * @param context - Auth context to set
17
+ * @param fn - Function to execute within the context
18
+ * @returns Return value of fn
19
+ *
20
+ * @example Test a handler that reads auth context
21
+ * ```typescript
22
+ * import { withAuthContext, createMockAuthContext } from '@connectum/auth/testing';
23
+ * import { getAuthContext } from '@connectum/auth';
24
+ *
25
+ * await withAuthContext(createMockAuthContext({ subject: 'test-user' }), async () => {
26
+ * const ctx = getAuthContext();
27
+ * assert.strictEqual(ctx?.subject, 'test-user');
28
+ * });
29
+ * ```
30
+ */
31
+ export async function withAuthContext<T>(context: AuthContext, fn: () => T | Promise<T>): Promise<T> {
32
+ return await authContextStorage.run(context, fn);
33
+ }
package/src/types.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Shared types for @connectum/auth
3
+ *
4
+ * @module types
5
+ */
6
+
7
+ import type { Interceptor } from "@connectrpc/connect";
8
+
9
+ /**
10
+ * Interceptor factory function type
11
+ *
12
+ * @template TOptions - Options type for the interceptor
13
+ */
14
+ export type InterceptorFactory<TOptions = void> = TOptions extends void ? () => Interceptor : (options: TOptions) => Interceptor;
15
+
16
+ /**
17
+ * Authenticated user context
18
+ *
19
+ * Represents the result of authentication. Set by auth interceptor,
20
+ * accessible via getAuthContext() in handlers and downstream interceptors.
21
+ */
22
+ export interface AuthContext {
23
+ /** Authenticated subject identifier (user ID, service account, etc.) */
24
+ readonly subject: string;
25
+ /** Human-readable display name */
26
+ readonly name?: string | undefined;
27
+ /** Assigned roles (e.g., ["admin", "user"]) */
28
+ readonly roles: ReadonlyArray<string>;
29
+ /** Granted scopes (e.g., ["read", "write"]) */
30
+ readonly scopes: ReadonlyArray<string>;
31
+ /** Raw claims from the credential (JWT claims, API key metadata, etc.) */
32
+ readonly claims: Readonly<Record<string, unknown>>;
33
+ /** Credential type identifier (e.g., "jwt", "api-key", "mtls") */
34
+ readonly type: string;
35
+ /** Credential expiration time */
36
+ readonly expiresAt?: Date | undefined;
37
+ }
38
+
39
+ /**
40
+ * Standard header names for auth context propagation.
41
+ *
42
+ * Used for cross-service context propagation (similar to Envoy credential injection).
43
+ * The auth interceptor sets these headers when propagateHeaders is true.
44
+ *
45
+ * WARNING: These headers are trusted ONLY in service-to-service communication
46
+ * where transport security (mTLS) is established. Never trust these headers
47
+ * from external clients without using createGatewayAuthInterceptor().
48
+ */
49
+ export const AUTH_HEADERS = {
50
+ /** Authenticated subject identifier */
51
+ SUBJECT: "x-auth-subject",
52
+ /** JSON-encoded roles array */
53
+ ROLES: "x-auth-roles",
54
+ /** Space-separated scopes */
55
+ SCOPES: "x-auth-scopes",
56
+ /** JSON-encoded claims object */
57
+ CLAIMS: "x-auth-claims",
58
+ /** Human-readable display name */
59
+ NAME: "x-auth-name",
60
+ /** Credential type (jwt, api-key, mtls, etc.) */
61
+ TYPE: "x-auth-type",
62
+ } as const;
63
+
64
+ /**
65
+ * Authorization rule effect
66
+ */
67
+ export const AuthzEffect = {
68
+ ALLOW: "allow",
69
+ DENY: "deny",
70
+ } as const;
71
+
72
+ export type AuthzEffect = (typeof AuthzEffect)[keyof typeof AuthzEffect];
73
+
74
+ /**
75
+ * Authorization rule definition.
76
+ *
77
+ * When a rule has `requires`, the match semantics are:
78
+ * - **roles**: "any-of" -- the user must have **at least one** of the listed roles.
79
+ * - **scopes**: "all-of" -- the user must have **every** listed scope.
80
+ */
81
+ export interface AuthzRule {
82
+ /** Rule name for logging/debugging */
83
+ readonly name: string;
84
+ /** Method patterns to match (e.g., "admin.v1.AdminService/*", "user.v1.UserService/DeleteUser") */
85
+ readonly methods: ReadonlyArray<string>;
86
+ /** Effect when rule matches */
87
+ readonly effect: AuthzEffect;
88
+ /**
89
+ * Required roles/scopes for this rule.
90
+ *
91
+ * - `roles` uses "any-of" semantics: user needs at least one of the listed roles.
92
+ * - `scopes` uses "all-of" semantics: user needs every listed scope.
93
+ */
94
+ readonly requires?:
95
+ | {
96
+ readonly roles?: ReadonlyArray<string>;
97
+ readonly scopes?: ReadonlyArray<string>;
98
+ }
99
+ | undefined;
100
+ }
101
+
102
+ /**
103
+ * LRU cache configuration for credentials verification
104
+ */
105
+ export interface CacheOptions {
106
+ /** Cache entry time-to-live in milliseconds */
107
+ readonly ttl: number;
108
+ /** Maximum number of cached entries */
109
+ readonly maxSize?: number | undefined;
110
+ }
111
+
112
+ /**
113
+ * Generic auth interceptor options
114
+ */
115
+ export interface AuthInterceptorOptions {
116
+ /**
117
+ * Extract credentials from request.
118
+ * Default: extracts Bearer token from Authorization header.
119
+ *
120
+ * @param req - Request with headers
121
+ * @returns Credential string or null if no credentials found
122
+ */
123
+ extractCredentials?: (req: { header: Headers }) => string | null | Promise<string | null>;
124
+
125
+ /**
126
+ * Verify credentials and return auth context.
127
+ * REQUIRED. Must throw on invalid credentials.
128
+ *
129
+ * @param credentials - Extracted credential string
130
+ * @returns AuthContext for valid credentials
131
+ */
132
+ verifyCredentials: (credentials: string) => AuthContext | Promise<AuthContext>;
133
+
134
+ /**
135
+ * Methods to skip authentication for.
136
+ * Patterns: "Service/Method" or "Service/*"
137
+ * @default [] (health and reflection methods are NOT auto-skipped)
138
+ */
139
+ skipMethods?: string[] | undefined;
140
+
141
+ /**
142
+ * Propagate auth context as headers for downstream services.
143
+ * @default false
144
+ */
145
+ propagateHeaders?: boolean | undefined;
146
+
147
+ /**
148
+ * LRU cache for credentials verification results.
149
+ * Caches AuthContext by credential string to reduce verification overhead.
150
+ */
151
+ cache?: CacheOptions | undefined;
152
+
153
+ /**
154
+ * Filter which claims are propagated in headers (SEC-001).
155
+ * When set, only listed claim keys are included in x-auth-claims header.
156
+ * When not set, all claims are propagated.
157
+ */
158
+ propagatedClaims?: string[] | undefined;
159
+ }
160
+
161
+ /**
162
+ * JWT auth interceptor options
163
+ */
164
+ export interface JwtAuthInterceptorOptions {
165
+ /** JWKS endpoint URL for remote key set */
166
+ jwksUri?: string | undefined;
167
+ /** HMAC symmetric secret (for HS256/HS384/HS512) */
168
+ secret?: string | undefined;
169
+ /** Asymmetric public key */
170
+ publicKey?: CryptoKey | undefined;
171
+ /** Expected issuer(s) */
172
+ issuer?: string | string[] | undefined;
173
+ /** Expected audience(s) */
174
+ audience?: string | string[] | undefined;
175
+ /** Allowed algorithms */
176
+ algorithms?: string[] | undefined;
177
+ /**
178
+ * Mapping from JWT claims to AuthContext fields.
179
+ * Supports dot-notation paths (e.g., "realm_access.roles").
180
+ */
181
+ claimsMapping?:
182
+ | {
183
+ subject?: string | undefined;
184
+ name?: string | undefined;
185
+ roles?: string | undefined;
186
+ scopes?: string | undefined;
187
+ }
188
+ | undefined;
189
+ /**
190
+ * Maximum token age.
191
+ * Passed to jose jwtVerify options.
192
+ * Number (seconds) or string (e.g., "2h", "7d").
193
+ */
194
+ maxTokenAge?: number | string | undefined;
195
+ /**
196
+ * Methods to skip authentication for.
197
+ * @default []
198
+ */
199
+ skipMethods?: string[] | undefined;
200
+ /**
201
+ * Propagate auth context as headers for downstream services.
202
+ * @default false
203
+ */
204
+ propagateHeaders?: boolean | undefined;
205
+ }
206
+
207
+ /**
208
+ * Authorization interceptor options
209
+ */
210
+ export interface AuthzInterceptorOptions {
211
+ /**
212
+ * Default policy when no rule matches.
213
+ * @default "deny"
214
+ */
215
+ defaultPolicy?: AuthzEffect | undefined;
216
+
217
+ /**
218
+ * Declarative authorization rules.
219
+ * Evaluated in order; first matching rule wins.
220
+ */
221
+ rules?: AuthzRule[] | undefined;
222
+
223
+ /**
224
+ * Programmatic authorization callback.
225
+ * Called after rule evaluation if no rule matched,
226
+ * or always if no rules are defined.
227
+ *
228
+ * @param context - Authenticated user context
229
+ * @param req - Request info (service and method names)
230
+ * @returns true if authorized, false otherwise
231
+ */
232
+ authorize?: (context: AuthContext, req: { service: string; method: string }) => boolean | Promise<boolean>;
233
+
234
+ /**
235
+ * Methods to skip authorization for.
236
+ * @default []
237
+ */
238
+ skipMethods?: string[] | undefined;
239
+ }
240
+
241
+ /**
242
+ * Header name mapping for gateway auth context extraction.
243
+ *
244
+ * Maps AuthContext fields to custom header names used by the API gateway.
245
+ */
246
+ export interface GatewayHeaderMapping {
247
+ /** Header containing the authenticated subject */
248
+ readonly subject: string;
249
+ /** Header containing the display name */
250
+ readonly name?: string | undefined;
251
+ /** Header containing JSON-encoded roles array */
252
+ readonly roles?: string | undefined;
253
+ /** Header containing space-separated scopes */
254
+ readonly scopes?: string | undefined;
255
+ /** Header containing credential type */
256
+ readonly type?: string | undefined;
257
+ /** Header containing JSON-encoded claims */
258
+ readonly claims?: string | undefined;
259
+ }
260
+
261
+ /**
262
+ * Gateway auth interceptor options.
263
+ *
264
+ * For services behind an API gateway that has already performed authentication.
265
+ * Extracts auth context from gateway-injected headers.
266
+ */
267
+ export interface GatewayAuthInterceptorOptions {
268
+ /** Mapping from AuthContext fields to gateway header names */
269
+ readonly headerMapping: GatewayHeaderMapping;
270
+ /** Trust verification: check that request came from a trusted gateway */
271
+ readonly trustSource: {
272
+ /** Header set by the gateway to prove trust */
273
+ readonly header: string;
274
+ /** Accepted values for the trust header */
275
+ readonly expectedValues: string[];
276
+ };
277
+ /** Headers to strip from the request after extraction (prevent spoofing) */
278
+ readonly stripHeaders?: string[] | undefined;
279
+ /** Methods to skip authentication for */
280
+ readonly skipMethods?: string[] | undefined;
281
+ /** Propagate auth context as headers for downstream services */
282
+ readonly propagateHeaders?: boolean | undefined;
283
+ /** Default credential type when not provided by gateway */
284
+ readonly defaultType?: string | undefined;
285
+ }
286
+
287
+ /**
288
+ * Session-based auth interceptor options.
289
+ *
290
+ * Two-step authentication: verify session token, then map session data to AuthContext.
291
+ */
292
+ export interface SessionAuthInterceptorOptions {
293
+ /**
294
+ * Verify session token and return raw session data.
295
+ * Must throw on invalid/expired sessions.
296
+ *
297
+ * @param token - Session token string
298
+ * @param headers - Request headers (for additional context)
299
+ * @returns Raw session data
300
+ */
301
+ readonly verifySession: (token: string, headers: Headers) => unknown | Promise<unknown>;
302
+ /**
303
+ * Map raw session data to AuthContext.
304
+ *
305
+ * @param session - Raw session data from verifySession
306
+ * @returns Normalized auth context
307
+ */
308
+ readonly mapSession: (session: unknown) => AuthContext | Promise<AuthContext>;
309
+ /**
310
+ * Custom token extraction.
311
+ * Default: extracts Bearer token from Authorization header.
312
+ */
313
+ readonly extractToken?: ((req: { header: Headers }) => string | null | Promise<string | null>) | undefined;
314
+ /** LRU cache for session verification results */
315
+ readonly cache?: CacheOptions | undefined;
316
+ /** Methods to skip authentication for */
317
+ readonly skipMethods?: string[] | undefined;
318
+ /** Propagate auth context as headers for downstream services */
319
+ readonly propagateHeaders?: boolean | undefined;
320
+ /**
321
+ * Filter which claims are propagated in headers.
322
+ * When set, only listed claim keys are included in x-auth-claims header.
323
+ * When not set, all claims are propagated.
324
+ */
325
+ readonly propagatedClaims?: string[] | undefined;
326
+ }