@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/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
|
+
}
|