@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/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@connectum/auth",
3
+ "version": "1.0.0-rc.3",
4
+ "description": "Authentication and authorization interceptors for Connectum",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./testing": {
14
+ "types": "./dist/testing/index.d.ts",
15
+ "default": "./dist/testing/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Connectum-Framework/connectum.git",
25
+ "directory": "packages/auth"
26
+ },
27
+ "scripts": {
28
+ "dev": "node --watch src/index.ts",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "node --test tests/unit/**/*.test.ts tests/integration/**/*.test.ts",
31
+ "test:unit": "node --test tests/unit/**/*.test.ts",
32
+ "test:integration": "node --test tests/integration/**/*.test.ts",
33
+ "test:bun": "exodus-test --engine bun:pure tests/unit/**/*.test.ts tests/integration/**/*.test.ts",
34
+ "test:esbuild": "exodus-test --esbuild tests/unit/**/*.test.ts tests/integration/**/*.test.ts",
35
+ "lint": "biome check src",
36
+ "format": "biome check --write src",
37
+ "build": "tsup",
38
+ "clean": "rm -rf coverage dist"
39
+ },
40
+ "keywords": [
41
+ "auth",
42
+ "authentication",
43
+ "authorization",
44
+ "jwt",
45
+ "connectrpc",
46
+ "grpc",
47
+ "connectum",
48
+ "interceptor"
49
+ ],
50
+ "author": "Highload.Zone",
51
+ "license": "Apache-2.0",
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "dependencies": {
59
+ "@connectrpc/connect": "catalog:",
60
+ "@connectum/core": "workspace:^",
61
+ "jose": "^6.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@biomejs/biome": "catalog:",
65
+ "c8": "catalog:",
66
+ "tsup": "catalog:",
67
+ "typescript": "catalog:"
68
+ }
69
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Generic authentication interceptor
3
+ *
4
+ * Provides pluggable authentication for any credential type.
5
+ * Extracts credentials, verifies them, and stores AuthContext
6
+ * in AsyncLocalStorage for downstream access.
7
+ *
8
+ * @module 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, AuthInterceptorOptions } from "./types.ts";
18
+ import { AUTH_HEADERS } from "./types.ts";
19
+
20
+ /**
21
+ * Default credential extractor.
22
+ * Extracts Bearer token from Authorization header.
23
+ */
24
+ function defaultExtractCredentials(req: { header: Headers }): string | null {
25
+ const authHeader = req.header.get("authorization");
26
+ if (!authHeader) {
27
+ return null;
28
+ }
29
+
30
+ // Support "Bearer <token>" format
31
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader);
32
+ return match?.[1] ?? null;
33
+ }
34
+
35
+ /**
36
+ * Create a generic authentication interceptor.
37
+ *
38
+ * Extracts credentials from request headers, verifies them using
39
+ * a user-provided callback, and stores the resulting AuthContext
40
+ * in AsyncLocalStorage for downstream access.
41
+ *
42
+ * @param options - Authentication options
43
+ * @returns ConnectRPC interceptor
44
+ *
45
+ * @example API key authentication
46
+ * ```typescript
47
+ * import { createAuthInterceptor } from '@connectum/auth';
48
+ *
49
+ * const auth = createAuthInterceptor({
50
+ * extractCredentials: (req) => req.header.get('x-api-key'),
51
+ * verifyCredentials: async (apiKey) => {
52
+ * const user = await db.findByApiKey(apiKey);
53
+ * if (!user) throw new Error('Invalid API key');
54
+ * return {
55
+ * subject: user.id,
56
+ * roles: user.roles,
57
+ * scopes: [],
58
+ * claims: {},
59
+ * type: 'api-key',
60
+ * };
61
+ * },
62
+ * });
63
+ * ```
64
+ *
65
+ * @example Bearer token with default extractor
66
+ * ```typescript
67
+ * const auth = createAuthInterceptor({
68
+ * verifyCredentials: async (token) => {
69
+ * const payload = await verifyToken(token);
70
+ * return {
71
+ * subject: payload.sub,
72
+ * roles: payload.roles ?? [],
73
+ * scopes: payload.scope?.split(' ') ?? [],
74
+ * claims: payload,
75
+ * type: 'jwt',
76
+ * };
77
+ * },
78
+ * });
79
+ * ```
80
+ */
81
+ export function createAuthInterceptor(options: AuthInterceptorOptions): Interceptor {
82
+ const { extractCredentials = defaultExtractCredentials, verifyCredentials, skipMethods = [], propagateHeaders = false, cache: cacheOptions, propagatedClaims } = options;
83
+
84
+ const cache = cacheOptions ? new LruCache<AuthContext>(cacheOptions) : undefined;
85
+
86
+ return (next) => async (req: UnaryRequest | StreamRequest) => {
87
+ const serviceName: string = req.service.typeName;
88
+ const methodName: string = req.method.name;
89
+
90
+ // Strip auth headers to prevent spoofing from external clients
91
+ for (const headerName of Object.values(AUTH_HEADERS)) {
92
+ req.header.delete(headerName);
93
+ }
94
+
95
+ // Skip specified methods
96
+ if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
97
+ return await next(req);
98
+ }
99
+
100
+ // Extract credentials
101
+ const credentials = await extractCredentials(req);
102
+ if (!credentials) {
103
+ throw new ConnectError("Missing credentials", Code.Unauthenticated);
104
+ }
105
+
106
+ // Check cache before verification
107
+ const cached = cache?.get(credentials);
108
+ if (cached && (!cached.expiresAt || cached.expiresAt.getTime() > Date.now())) {
109
+ if (propagateHeaders) {
110
+ setAuthHeaders(req.header, cached, propagatedClaims);
111
+ }
112
+ return await authContextStorage.run(cached, () => next(req));
113
+ }
114
+
115
+ // Verify credentials
116
+ let authContext: AuthContext;
117
+ try {
118
+ authContext = await verifyCredentials(credentials);
119
+ } catch (err) {
120
+ if (err instanceof ConnectError) {
121
+ throw err;
122
+ }
123
+ throw new ConnectError("Authentication failed", Code.Unauthenticated);
124
+ }
125
+
126
+ // Cache the verification result
127
+ cache?.set(credentials, authContext);
128
+
129
+ // Propagate auth context as headers if enabled
130
+ if (propagateHeaders) {
131
+ setAuthHeaders(req.header, authContext, propagatedClaims);
132
+ }
133
+
134
+ // Run downstream with auth context in AsyncLocalStorage
135
+ return await authContextStorage.run(authContext, () => next(req));
136
+ };
137
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Authorization interceptor
3
+ *
4
+ * Declarative rules-based authorization with RBAC support.
5
+ * Evaluates rules against AuthContext from the auth interceptor.
6
+ *
7
+ * @module authz-interceptor
8
+ */
9
+
10
+ import type { Interceptor, StreamRequest, UnaryRequest } from "@connectrpc/connect";
11
+ import { Code, ConnectError } from "@connectrpc/connect";
12
+ import { getAuthContext } from "./context.ts";
13
+ import type { AuthzDeniedDetails } from "./errors.ts";
14
+ import { AuthzDeniedError } from "./errors.ts";
15
+ import { matchesMethodPattern } from "./method-match.ts";
16
+ import type { AuthzInterceptorOptions, AuthzRule } from "./types.ts";
17
+ import { AuthzEffect } from "./types.ts";
18
+
19
+ /**
20
+ * Check if the auth context satisfies a rule's requirements.
21
+ */
22
+ function satisfiesRequirements(context: { roles: ReadonlyArray<string>; scopes: ReadonlyArray<string> }, requires: NonNullable<AuthzRule["requires"]>): boolean {
23
+ // Check roles: user must have at least one of the required roles
24
+ if (requires.roles && requires.roles.length > 0) {
25
+ const hasRole = requires.roles.some((role) => context.roles.includes(role));
26
+ if (!hasRole) {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ // Check scopes: user must have ALL required scopes
32
+ if (requires.scopes && requires.scopes.length > 0) {
33
+ const hasAllScopes = requires.scopes.every((scope) => context.scopes.includes(scope));
34
+ if (!hasAllScopes) {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Evaluate authorization rules against auth context for a specific method.
44
+ *
45
+ * Returns the effect of the first matching rule, or undefined if no rule matches.
46
+ */
47
+ function evaluateRules(
48
+ rules: AuthzRule[],
49
+ context: { roles: ReadonlyArray<string>; scopes: ReadonlyArray<string> },
50
+ serviceName: string,
51
+ methodName: string,
52
+ ): { effect: string; ruleName: string; requiredRoles?: readonly string[]; requiredScopes?: readonly string[] } | undefined {
53
+ for (const rule of rules) {
54
+ // Check if any of the rule's method patterns match
55
+ const matches = matchesMethodPattern(serviceName, methodName, rule.methods);
56
+ if (!matches) {
57
+ continue;
58
+ }
59
+
60
+ // If rule has requirements, check them
61
+ if (rule.requires) {
62
+ if (satisfiesRequirements(context, rule.requires)) {
63
+ const result: { effect: string; ruleName: string; requiredRoles?: readonly string[]; requiredScopes?: readonly string[] } = {
64
+ effect: rule.effect,
65
+ ruleName: rule.name,
66
+ };
67
+ if (rule.requires.roles) result.requiredRoles = rule.requires.roles;
68
+ if (rule.requires.scopes) result.requiredScopes = rule.requires.scopes;
69
+ return result;
70
+ }
71
+ // Requirements not met — this rule doesn't match, continue to next
72
+ continue;
73
+ }
74
+
75
+ // No requirements — rule matches unconditionally
76
+ return { effect: rule.effect, ruleName: rule.name };
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Create an authorization interceptor.
84
+ *
85
+ * Evaluates declarative rules and/or a programmatic callback against
86
+ * the AuthContext established by the authentication interceptor.
87
+ *
88
+ * IMPORTANT: This interceptor MUST run AFTER an authentication interceptor
89
+ * in the chain.
90
+ *
91
+ * @param options - Authorization options
92
+ * @returns ConnectRPC interceptor
93
+ *
94
+ * @example RBAC with declarative rules
95
+ * ```typescript
96
+ * import { createAuthzInterceptor } from '@connectum/auth';
97
+ *
98
+ * const authz = createAuthzInterceptor({
99
+ * defaultPolicy: 'deny',
100
+ * rules: [
101
+ * { name: 'public', methods: ['public.v1.PublicService/*'], effect: 'allow' },
102
+ * { name: 'admin', methods: ['admin.v1.AdminService/*'], requires: { roles: ['admin'] }, effect: 'allow' },
103
+ * ],
104
+ * });
105
+ * ```
106
+ */
107
+ export function createAuthzInterceptor(options: AuthzInterceptorOptions = {}): Interceptor {
108
+ const { defaultPolicy = AuthzEffect.DENY, rules = [], authorize, skipMethods = [] } = options;
109
+
110
+ return (next) => async (req: UnaryRequest | StreamRequest) => {
111
+ const serviceName: string = req.service.typeName;
112
+ const methodName: string = req.method.name;
113
+
114
+ // Skip specified methods
115
+ if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
116
+ return await next(req);
117
+ }
118
+
119
+ // Get auth context from AsyncLocalStorage
120
+ const authContext = getAuthContext();
121
+ if (!authContext) {
122
+ throw new ConnectError("Authentication required for authorization", Code.Unauthenticated);
123
+ }
124
+
125
+ // Evaluate declarative rules first
126
+ if (rules.length > 0) {
127
+ const ruleResult = evaluateRules(rules, authContext, serviceName, methodName);
128
+ if (ruleResult) {
129
+ if (ruleResult.effect === AuthzEffect.DENY) {
130
+ const details: AuthzDeniedDetails = {
131
+ ruleName: ruleResult.ruleName,
132
+ ...(ruleResult.requiredRoles && { requiredRoles: [...ruleResult.requiredRoles] }),
133
+ ...(ruleResult.requiredScopes && { requiredScopes: [...ruleResult.requiredScopes] }),
134
+ };
135
+ throw new AuthzDeniedError(details);
136
+ }
137
+ // ALLOW — continue
138
+ return await next(req);
139
+ }
140
+ }
141
+
142
+ // If no rules matched, try programmatic callback
143
+ if (authorize) {
144
+ const allowed = await authorize(authContext, { service: serviceName, method: methodName });
145
+ if (!allowed) {
146
+ throw new ConnectError("Access denied", Code.PermissionDenied);
147
+ }
148
+ return await next(req);
149
+ }
150
+
151
+ // No rules matched, no callback — apply default policy
152
+ if (defaultPolicy === AuthzEffect.DENY) {
153
+ throw new ConnectError("Access denied by default policy", Code.PermissionDenied);
154
+ }
155
+
156
+ return await next(req);
157
+ };
158
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Minimal in-memory LRU cache with TTL expiration.
3
+ *
4
+ * Uses Map insertion order for LRU eviction.
5
+ * No external dependencies.
6
+ */
7
+
8
+ interface CacheEntry<T> {
9
+ value: T;
10
+ expiresAt: number;
11
+ }
12
+
13
+ export class LruCache<T> {
14
+ readonly #maxSize: number;
15
+ readonly #ttl: number;
16
+ readonly #entries = new Map<string, CacheEntry<T>>();
17
+
18
+ constructor(options: { ttl: number; maxSize?: number | undefined }) {
19
+ if (typeof options.ttl !== "number" || options.ttl <= 0) {
20
+ throw new RangeError("ttl must be a positive number");
21
+ }
22
+ this.#ttl = options.ttl;
23
+ this.#maxSize = options.maxSize ?? 1000;
24
+ }
25
+
26
+ get(key: string): T | undefined {
27
+ const entry = this.#entries.get(key);
28
+ if (!entry) return undefined;
29
+
30
+ if (Date.now() >= entry.expiresAt) {
31
+ this.#entries.delete(key);
32
+ return undefined;
33
+ }
34
+
35
+ // Move to end (most recently used)
36
+ this.#entries.delete(key);
37
+ this.#entries.set(key, entry);
38
+ return entry.value;
39
+ }
40
+
41
+ set(key: string, value: T): void {
42
+ // Delete first to update insertion order
43
+ this.#entries.delete(key);
44
+
45
+ // Evict LRU (first entry) if at capacity
46
+ if (this.#entries.size >= this.#maxSize) {
47
+ const firstKey = this.#entries.keys().next().value;
48
+ if (firstKey !== undefined) {
49
+ this.#entries.delete(firstKey);
50
+ }
51
+ }
52
+
53
+ this.#entries.set(key, {
54
+ value,
55
+ expiresAt: Date.now() + this.#ttl,
56
+ });
57
+ }
58
+
59
+ clear(): void {
60
+ this.#entries.clear();
61
+ }
62
+
63
+ get size(): number {
64
+ return this.#entries.size;
65
+ }
66
+ }
package/src/context.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Authentication context storage
3
+ *
4
+ * Uses AsyncLocalStorage to make auth context available to handlers
5
+ * without passing it through function parameters.
6
+ *
7
+ * @module context
8
+ */
9
+
10
+ import { AsyncLocalStorage } from "node:async_hooks";
11
+ import { Code, ConnectError } from "@connectrpc/connect";
12
+ import type { AuthContext } from "./types.ts";
13
+
14
+ /**
15
+ * Module-level AsyncLocalStorage for auth context.
16
+ *
17
+ * Set by auth interceptors, read by handlers via getAuthContext().
18
+ * Automatically isolated per async context (request).
19
+ */
20
+ export const authContextStorage = new AsyncLocalStorage<AuthContext>();
21
+
22
+ /**
23
+ * Get the current auth context.
24
+ *
25
+ * Returns the AuthContext set by the auth interceptor in the current
26
+ * async context. Returns undefined if no auth interceptor is active
27
+ * or the current method was skipped.
28
+ *
29
+ * @returns Current auth context or undefined
30
+ *
31
+ * @example Usage in a service handler
32
+ * ```typescript
33
+ * import { getAuthContext } from '@connectum/auth';
34
+ *
35
+ * const handler = {
36
+ * async getUser(req) {
37
+ * const auth = getAuthContext();
38
+ * if (!auth) throw new ConnectError('Not authenticated', Code.Unauthenticated);
39
+ * return { user: await db.getUser(auth.subject) };
40
+ * },
41
+ * };
42
+ * ```
43
+ */
44
+ export function getAuthContext(): AuthContext | undefined {
45
+ return authContextStorage.getStore();
46
+ }
47
+
48
+ /**
49
+ * Get the current auth context or throw.
50
+ *
51
+ * Like getAuthContext() but throws ConnectError(Code.Unauthenticated)
52
+ * if no auth context is available. Use when auth is mandatory.
53
+ *
54
+ * @returns Current auth context (never undefined)
55
+ * @throws ConnectError with Code.Unauthenticated if no context
56
+ */
57
+ export function requireAuthContext(): AuthContext {
58
+ const context = authContextStorage.getStore();
59
+ if (!context) {
60
+ throw new ConnectError("Authentication required", Code.Unauthenticated);
61
+ }
62
+ return context;
63
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Auth-specific error types
3
+ *
4
+ * @module errors
5
+ */
6
+
7
+ import { Code, ConnectError } from "@connectrpc/connect";
8
+ // biome-ignore lint/correctness/useImportExtensions: workspace package import
9
+ import type { SanitizableError } from "@connectum/core";
10
+
11
+ /**
12
+ * Details for authorization denied errors.
13
+ */
14
+ export interface AuthzDeniedDetails {
15
+ readonly ruleName: string;
16
+ readonly requiredRoles?: readonly string[];
17
+ readonly requiredScopes?: readonly string[];
18
+ }
19
+
20
+ /**
21
+ * Authorization denied error.
22
+ *
23
+ * Carries server-side details (rule name, required roles/scopes) while
24
+ * exposing only "Access denied" to the client via SanitizableError protocol.
25
+ */
26
+ export class AuthzDeniedError extends ConnectError implements SanitizableError {
27
+ readonly clientMessage = "Access denied";
28
+ readonly ruleName: string;
29
+ readonly authzDetails: AuthzDeniedDetails;
30
+
31
+ get serverDetails(): Readonly<Record<string, unknown>> {
32
+ return {
33
+ ruleName: this.authzDetails.ruleName,
34
+ requiredRoles: this.authzDetails.requiredRoles,
35
+ requiredScopes: this.authzDetails.requiredScopes,
36
+ };
37
+ }
38
+
39
+ constructor(details: AuthzDeniedDetails) {
40
+ super(`Access denied by rule: ${details.ruleName}`, Code.PermissionDenied);
41
+ this.name = "AuthzDeniedError";
42
+ this.ruleName = details.ruleName;
43
+ this.authzDetails = details;
44
+ }
45
+ }