@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/dist/index.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
// src/auth-interceptor.ts
|
|
2
|
+
import { Code as Code2, ConnectError as ConnectError2 } from "@connectrpc/connect";
|
|
3
|
+
|
|
4
|
+
// src/cache.ts
|
|
5
|
+
var LruCache = class {
|
|
6
|
+
#maxSize;
|
|
7
|
+
#ttl;
|
|
8
|
+
#entries = /* @__PURE__ */ new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
if (typeof options.ttl !== "number" || options.ttl <= 0) {
|
|
11
|
+
throw new RangeError("ttl must be a positive number");
|
|
12
|
+
}
|
|
13
|
+
this.#ttl = options.ttl;
|
|
14
|
+
this.#maxSize = options.maxSize ?? 1e3;
|
|
15
|
+
}
|
|
16
|
+
get(key) {
|
|
17
|
+
const entry = this.#entries.get(key);
|
|
18
|
+
if (!entry) return void 0;
|
|
19
|
+
if (Date.now() >= entry.expiresAt) {
|
|
20
|
+
this.#entries.delete(key);
|
|
21
|
+
return void 0;
|
|
22
|
+
}
|
|
23
|
+
this.#entries.delete(key);
|
|
24
|
+
this.#entries.set(key, entry);
|
|
25
|
+
return entry.value;
|
|
26
|
+
}
|
|
27
|
+
set(key, value) {
|
|
28
|
+
this.#entries.delete(key);
|
|
29
|
+
if (this.#entries.size >= this.#maxSize) {
|
|
30
|
+
const firstKey = this.#entries.keys().next().value;
|
|
31
|
+
if (firstKey !== void 0) {
|
|
32
|
+
this.#entries.delete(firstKey);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
this.#entries.set(key, {
|
|
36
|
+
value,
|
|
37
|
+
expiresAt: Date.now() + this.#ttl
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
clear() {
|
|
41
|
+
this.#entries.clear();
|
|
42
|
+
}
|
|
43
|
+
get size() {
|
|
44
|
+
return this.#entries.size;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/context.ts
|
|
49
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
50
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
51
|
+
var authContextStorage = new AsyncLocalStorage();
|
|
52
|
+
function getAuthContext() {
|
|
53
|
+
return authContextStorage.getStore();
|
|
54
|
+
}
|
|
55
|
+
function requireAuthContext() {
|
|
56
|
+
const context = authContextStorage.getStore();
|
|
57
|
+
if (!context) {
|
|
58
|
+
throw new ConnectError("Authentication required", Code.Unauthenticated);
|
|
59
|
+
}
|
|
60
|
+
return context;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/types.ts
|
|
64
|
+
var AUTH_HEADERS = {
|
|
65
|
+
/** Authenticated subject identifier */
|
|
66
|
+
SUBJECT: "x-auth-subject",
|
|
67
|
+
/** JSON-encoded roles array */
|
|
68
|
+
ROLES: "x-auth-roles",
|
|
69
|
+
/** Space-separated scopes */
|
|
70
|
+
SCOPES: "x-auth-scopes",
|
|
71
|
+
/** JSON-encoded claims object */
|
|
72
|
+
CLAIMS: "x-auth-claims",
|
|
73
|
+
/** Human-readable display name */
|
|
74
|
+
NAME: "x-auth-name",
|
|
75
|
+
/** Credential type (jwt, api-key, mtls, etc.) */
|
|
76
|
+
TYPE: "x-auth-type"
|
|
77
|
+
};
|
|
78
|
+
var AuthzEffect = {
|
|
79
|
+
ALLOW: "allow",
|
|
80
|
+
DENY: "deny"
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/headers.ts
|
|
84
|
+
var MAX_HEADER_BYTES = 8192;
|
|
85
|
+
function sanitizeHeaderValue(value, maxLength) {
|
|
86
|
+
const cleaned = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
87
|
+
return cleaned.slice(0, maxLength);
|
|
88
|
+
}
|
|
89
|
+
function setAuthHeaders(headers, context, propagatedClaims) {
|
|
90
|
+
headers.set(AUTH_HEADERS.SUBJECT, sanitizeHeaderValue(context.subject, 512));
|
|
91
|
+
headers.set(AUTH_HEADERS.TYPE, sanitizeHeaderValue(context.type, 128));
|
|
92
|
+
if (context.name) {
|
|
93
|
+
headers.set(AUTH_HEADERS.NAME, sanitizeHeaderValue(context.name, 256));
|
|
94
|
+
}
|
|
95
|
+
if (context.roles.length > 0) {
|
|
96
|
+
const rolesValue = JSON.stringify(context.roles);
|
|
97
|
+
if (rolesValue.length <= MAX_HEADER_BYTES) {
|
|
98
|
+
headers.set(AUTH_HEADERS.ROLES, rolesValue);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (context.scopes.length > 0) {
|
|
102
|
+
const scopesValue = context.scopes.join(" ");
|
|
103
|
+
if (scopesValue.length <= MAX_HEADER_BYTES) {
|
|
104
|
+
headers.set(AUTH_HEADERS.SCOPES, scopesValue);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const claimKeys = Object.keys(context.claims);
|
|
108
|
+
if (claimKeys.length > 0) {
|
|
109
|
+
const filteredClaims = propagatedClaims ? Object.fromEntries(Object.entries(context.claims).filter(([key]) => propagatedClaims.includes(key))) : context.claims;
|
|
110
|
+
if (Object.keys(filteredClaims).length > 0) {
|
|
111
|
+
const claimsValue = JSON.stringify(filteredClaims);
|
|
112
|
+
if (claimsValue.length <= MAX_HEADER_BYTES) {
|
|
113
|
+
headers.set(AUTH_HEADERS.CLAIMS, claimsValue);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function parseAuthHeaders(headers) {
|
|
119
|
+
const subjectHeader = headers.get(AUTH_HEADERS.SUBJECT);
|
|
120
|
+
if (!subjectHeader) {
|
|
121
|
+
return void 0;
|
|
122
|
+
}
|
|
123
|
+
const subject = sanitizeHeaderValue(subjectHeader, 512);
|
|
124
|
+
const typeHeader = headers.get(AUTH_HEADERS.TYPE);
|
|
125
|
+
const type = typeHeader ? sanitizeHeaderValue(typeHeader, 128) : "unknown";
|
|
126
|
+
const rolesRaw = headers.get(AUTH_HEADERS.ROLES);
|
|
127
|
+
const scopesRaw = headers.get(AUTH_HEADERS.SCOPES);
|
|
128
|
+
const claimsRaw = headers.get(AUTH_HEADERS.CLAIMS);
|
|
129
|
+
let roles = [];
|
|
130
|
+
if (rolesRaw && rolesRaw.length <= MAX_HEADER_BYTES) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(rolesRaw);
|
|
133
|
+
if (Array.isArray(parsed)) {
|
|
134
|
+
roles = parsed.filter((r) => typeof r === "string");
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let scopes = [];
|
|
140
|
+
if (scopesRaw && scopesRaw.length <= MAX_HEADER_BYTES) {
|
|
141
|
+
scopes = scopesRaw.split(" ").filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
const nameRaw = headers.get(AUTH_HEADERS.NAME);
|
|
144
|
+
const name = nameRaw ? sanitizeHeaderValue(nameRaw, 256) : void 0;
|
|
145
|
+
let claims = {};
|
|
146
|
+
if (claimsRaw) {
|
|
147
|
+
if (claimsRaw.length > MAX_HEADER_BYTES) {
|
|
148
|
+
claims = {};
|
|
149
|
+
} else {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(claimsRaw);
|
|
152
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
153
|
+
claims = parsed;
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
subject,
|
|
161
|
+
name,
|
|
162
|
+
type,
|
|
163
|
+
roles,
|
|
164
|
+
scopes,
|
|
165
|
+
claims
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/method-match.ts
|
|
170
|
+
function matchesMethodPattern(serviceName, methodName, patterns) {
|
|
171
|
+
if (patterns.length === 0) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const fullMethod = `${serviceName}/${methodName}`;
|
|
175
|
+
for (const pattern of patterns) {
|
|
176
|
+
if (pattern === "*") {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (pattern === fullMethod) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (pattern.endsWith("/*")) {
|
|
183
|
+
const servicePattern = pattern.slice(0, -2);
|
|
184
|
+
if (serviceName === servicePattern) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/auth-interceptor.ts
|
|
193
|
+
function defaultExtractCredentials(req) {
|
|
194
|
+
const authHeader = req.header.get("authorization");
|
|
195
|
+
if (!authHeader) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const match = /^Bearer\s+(.+)$/i.exec(authHeader);
|
|
199
|
+
return match?.[1] ?? null;
|
|
200
|
+
}
|
|
201
|
+
function createAuthInterceptor(options) {
|
|
202
|
+
const { extractCredentials = defaultExtractCredentials, verifyCredentials, skipMethods = [], propagateHeaders = false, cache: cacheOptions, propagatedClaims } = options;
|
|
203
|
+
const cache = cacheOptions ? new LruCache(cacheOptions) : void 0;
|
|
204
|
+
return (next) => async (req) => {
|
|
205
|
+
const serviceName = req.service.typeName;
|
|
206
|
+
const methodName = req.method.name;
|
|
207
|
+
for (const headerName of Object.values(AUTH_HEADERS)) {
|
|
208
|
+
req.header.delete(headerName);
|
|
209
|
+
}
|
|
210
|
+
if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
|
|
211
|
+
return await next(req);
|
|
212
|
+
}
|
|
213
|
+
const credentials = await extractCredentials(req);
|
|
214
|
+
if (!credentials) {
|
|
215
|
+
throw new ConnectError2("Missing credentials", Code2.Unauthenticated);
|
|
216
|
+
}
|
|
217
|
+
const cached = cache?.get(credentials);
|
|
218
|
+
if (cached && (!cached.expiresAt || cached.expiresAt.getTime() > Date.now())) {
|
|
219
|
+
if (propagateHeaders) {
|
|
220
|
+
setAuthHeaders(req.header, cached, propagatedClaims);
|
|
221
|
+
}
|
|
222
|
+
return await authContextStorage.run(cached, () => next(req));
|
|
223
|
+
}
|
|
224
|
+
let authContext;
|
|
225
|
+
try {
|
|
226
|
+
authContext = await verifyCredentials(credentials);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err instanceof ConnectError2) {
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
throw new ConnectError2("Authentication failed", Code2.Unauthenticated);
|
|
232
|
+
}
|
|
233
|
+
cache?.set(credentials, authContext);
|
|
234
|
+
if (propagateHeaders) {
|
|
235
|
+
setAuthHeaders(req.header, authContext, propagatedClaims);
|
|
236
|
+
}
|
|
237
|
+
return await authContextStorage.run(authContext, () => next(req));
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/authz-interceptor.ts
|
|
242
|
+
import { Code as Code4, ConnectError as ConnectError4 } from "@connectrpc/connect";
|
|
243
|
+
|
|
244
|
+
// src/errors.ts
|
|
245
|
+
import { Code as Code3, ConnectError as ConnectError3 } from "@connectrpc/connect";
|
|
246
|
+
var AuthzDeniedError = class extends ConnectError3 {
|
|
247
|
+
clientMessage = "Access denied";
|
|
248
|
+
ruleName;
|
|
249
|
+
authzDetails;
|
|
250
|
+
get serverDetails() {
|
|
251
|
+
return {
|
|
252
|
+
ruleName: this.authzDetails.ruleName,
|
|
253
|
+
requiredRoles: this.authzDetails.requiredRoles,
|
|
254
|
+
requiredScopes: this.authzDetails.requiredScopes
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
constructor(details) {
|
|
258
|
+
super(`Access denied by rule: ${details.ruleName}`, Code3.PermissionDenied);
|
|
259
|
+
this.name = "AuthzDeniedError";
|
|
260
|
+
this.ruleName = details.ruleName;
|
|
261
|
+
this.authzDetails = details;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// src/authz-interceptor.ts
|
|
266
|
+
function satisfiesRequirements(context, requires) {
|
|
267
|
+
if (requires.roles && requires.roles.length > 0) {
|
|
268
|
+
const hasRole = requires.roles.some((role) => context.roles.includes(role));
|
|
269
|
+
if (!hasRole) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (requires.scopes && requires.scopes.length > 0) {
|
|
274
|
+
const hasAllScopes = requires.scopes.every((scope) => context.scopes.includes(scope));
|
|
275
|
+
if (!hasAllScopes) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
function evaluateRules(rules, context, serviceName, methodName) {
|
|
282
|
+
for (const rule of rules) {
|
|
283
|
+
const matches = matchesMethodPattern(serviceName, methodName, rule.methods);
|
|
284
|
+
if (!matches) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (rule.requires) {
|
|
288
|
+
if (satisfiesRequirements(context, rule.requires)) {
|
|
289
|
+
const result = {
|
|
290
|
+
effect: rule.effect,
|
|
291
|
+
ruleName: rule.name
|
|
292
|
+
};
|
|
293
|
+
if (rule.requires.roles) result.requiredRoles = rule.requires.roles;
|
|
294
|
+
if (rule.requires.scopes) result.requiredScopes = rule.requires.scopes;
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
return { effect: rule.effect, ruleName: rule.name };
|
|
300
|
+
}
|
|
301
|
+
return void 0;
|
|
302
|
+
}
|
|
303
|
+
function createAuthzInterceptor(options = {}) {
|
|
304
|
+
const { defaultPolicy = AuthzEffect.DENY, rules = [], authorize, skipMethods = [] } = options;
|
|
305
|
+
return (next) => async (req) => {
|
|
306
|
+
const serviceName = req.service.typeName;
|
|
307
|
+
const methodName = req.method.name;
|
|
308
|
+
if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
|
|
309
|
+
return await next(req);
|
|
310
|
+
}
|
|
311
|
+
const authContext = getAuthContext();
|
|
312
|
+
if (!authContext) {
|
|
313
|
+
throw new ConnectError4("Authentication required for authorization", Code4.Unauthenticated);
|
|
314
|
+
}
|
|
315
|
+
if (rules.length > 0) {
|
|
316
|
+
const ruleResult = evaluateRules(rules, authContext, serviceName, methodName);
|
|
317
|
+
if (ruleResult) {
|
|
318
|
+
if (ruleResult.effect === AuthzEffect.DENY) {
|
|
319
|
+
const details = {
|
|
320
|
+
ruleName: ruleResult.ruleName,
|
|
321
|
+
...ruleResult.requiredRoles && { requiredRoles: [...ruleResult.requiredRoles] },
|
|
322
|
+
...ruleResult.requiredScopes && { requiredScopes: [...ruleResult.requiredScopes] }
|
|
323
|
+
};
|
|
324
|
+
throw new AuthzDeniedError(details);
|
|
325
|
+
}
|
|
326
|
+
return await next(req);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (authorize) {
|
|
330
|
+
const allowed = await authorize(authContext, { service: serviceName, method: methodName });
|
|
331
|
+
if (!allowed) {
|
|
332
|
+
throw new ConnectError4("Access denied", Code4.PermissionDenied);
|
|
333
|
+
}
|
|
334
|
+
return await next(req);
|
|
335
|
+
}
|
|
336
|
+
if (defaultPolicy === AuthzEffect.DENY) {
|
|
337
|
+
throw new ConnectError4("Access denied by default policy", Code4.PermissionDenied);
|
|
338
|
+
}
|
|
339
|
+
return await next(req);
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/gateway-auth-interceptor.ts
|
|
344
|
+
import { Code as Code5, ConnectError as ConnectError5 } from "@connectrpc/connect";
|
|
345
|
+
function isValidOctet(value) {
|
|
346
|
+
return Number.isInteger(value) && value >= 0 && value <= 255;
|
|
347
|
+
}
|
|
348
|
+
function matchesIp(address, pattern) {
|
|
349
|
+
if (address === pattern) return true;
|
|
350
|
+
if (pattern.includes("/")) {
|
|
351
|
+
const [network, prefixStr] = pattern.split("/");
|
|
352
|
+
if (!network || !prefixStr) return false;
|
|
353
|
+
const prefix = Number.parseInt(prefixStr, 10);
|
|
354
|
+
if (Number.isNaN(prefix) || prefix < 0 || prefix > 32) return false;
|
|
355
|
+
const peerParts = address.split(".").map(Number);
|
|
356
|
+
const networkParts = network.split(".").map(Number);
|
|
357
|
+
if (peerParts.length !== 4 || networkParts.length !== 4) return false;
|
|
358
|
+
if (!peerParts.every(isValidOctet) || !networkParts.every(isValidOctet)) return false;
|
|
359
|
+
const [p0 = 0, p1 = 0, p2 = 0, p3 = 0] = peerParts;
|
|
360
|
+
const [n0 = 0, n1 = 0, n2 = 0, n3 = 0] = networkParts;
|
|
361
|
+
const peerInt = (p0 << 24 | p1 << 16 | p2 << 8 | p3) >>> 0;
|
|
362
|
+
const networkInt = (n0 << 24 | n1 << 16 | n2 << 8 | n3) >>> 0;
|
|
363
|
+
const mask = prefix === 0 ? 0 : ~0 << 32 - prefix >>> 0;
|
|
364
|
+
return (peerInt & mask) === (networkInt & mask);
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
function isTrusted(headerValue, expectedValues) {
|
|
369
|
+
for (const expected of expectedValues) {
|
|
370
|
+
if (headerValue === expected) return true;
|
|
371
|
+
if (expected.includes("/") && matchesIp(headerValue, expected)) return true;
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
function createGatewayAuthInterceptor(options) {
|
|
376
|
+
const { headerMapping, trustSource, stripHeaders = [], skipMethods = [], propagateHeaders = false, defaultType = "gateway" } = options;
|
|
377
|
+
if (!headerMapping.subject) {
|
|
378
|
+
throw new Error("@connectum/auth: Gateway auth requires headerMapping.subject");
|
|
379
|
+
}
|
|
380
|
+
if (trustSource.expectedValues.length === 0) {
|
|
381
|
+
throw new Error("@connectum/auth: Gateway auth requires non-empty trustSource.expectedValues");
|
|
382
|
+
}
|
|
383
|
+
const headersToStrip = [
|
|
384
|
+
headerMapping.subject,
|
|
385
|
+
headerMapping.name,
|
|
386
|
+
headerMapping.roles,
|
|
387
|
+
headerMapping.scopes,
|
|
388
|
+
headerMapping.type,
|
|
389
|
+
headerMapping.claims,
|
|
390
|
+
trustSource.header,
|
|
391
|
+
...stripHeaders
|
|
392
|
+
];
|
|
393
|
+
function stripGatewayHeaders(headers) {
|
|
394
|
+
for (const header of headersToStrip) {
|
|
395
|
+
if (header) headers.delete(header);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return (next) => async (req) => {
|
|
399
|
+
const serviceName = req.service.typeName;
|
|
400
|
+
const methodName = req.method.name;
|
|
401
|
+
if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
|
|
402
|
+
stripGatewayHeaders(req.header);
|
|
403
|
+
return await next(req);
|
|
404
|
+
}
|
|
405
|
+
const trustHeaderValue = req.header.get(trustSource.header);
|
|
406
|
+
if (!trustHeaderValue || !isTrusted(trustHeaderValue, trustSource.expectedValues)) {
|
|
407
|
+
throw new ConnectError5("Untrusted request source", Code5.Unauthenticated);
|
|
408
|
+
}
|
|
409
|
+
const subject = req.header.get(headerMapping.subject);
|
|
410
|
+
if (!subject) {
|
|
411
|
+
throw new ConnectError5("Missing subject header from gateway", Code5.Unauthenticated);
|
|
412
|
+
}
|
|
413
|
+
const name = headerMapping.name ? req.header.get(headerMapping.name) ?? void 0 : void 0;
|
|
414
|
+
const type = headerMapping.type ? req.header.get(headerMapping.type) ?? defaultType : defaultType;
|
|
415
|
+
let roles = [];
|
|
416
|
+
if (headerMapping.roles) {
|
|
417
|
+
const rolesRaw = req.header.get(headerMapping.roles);
|
|
418
|
+
if (rolesRaw) {
|
|
419
|
+
try {
|
|
420
|
+
const parsed = JSON.parse(rolesRaw);
|
|
421
|
+
if (Array.isArray(parsed)) {
|
|
422
|
+
roles = parsed.filter((r) => typeof r === "string");
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
roles = rolesRaw.split(",").map((r) => r.trim()).filter(Boolean);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
let scopes = [];
|
|
430
|
+
if (headerMapping.scopes) {
|
|
431
|
+
const scopesRaw = req.header.get(headerMapping.scopes);
|
|
432
|
+
if (scopesRaw) {
|
|
433
|
+
scopes = scopesRaw.split(" ").filter(Boolean);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
let claims = {};
|
|
437
|
+
if (headerMapping.claims) {
|
|
438
|
+
const claimsRaw = req.header.get(headerMapping.claims);
|
|
439
|
+
if (claimsRaw && claimsRaw.length <= 8192) {
|
|
440
|
+
try {
|
|
441
|
+
const parsed = JSON.parse(claimsRaw);
|
|
442
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
443
|
+
claims = parsed;
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const authContext = { subject, name, roles, scopes, claims, type };
|
|
450
|
+
stripGatewayHeaders(req.header);
|
|
451
|
+
if (propagateHeaders) {
|
|
452
|
+
setAuthHeaders(req.header, authContext);
|
|
453
|
+
}
|
|
454
|
+
return await authContextStorage.run(authContext, () => next(req));
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/jwt-auth-interceptor.ts
|
|
459
|
+
import { Code as Code6, ConnectError as ConnectError6 } from "@connectrpc/connect";
|
|
460
|
+
import * as jose from "jose";
|
|
461
|
+
function getNestedValue(obj, path) {
|
|
462
|
+
let current = obj;
|
|
463
|
+
for (const key of path.split(".")) {
|
|
464
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
465
|
+
return void 0;
|
|
466
|
+
}
|
|
467
|
+
current = current[key];
|
|
468
|
+
}
|
|
469
|
+
return current;
|
|
470
|
+
}
|
|
471
|
+
function getMinHmacKeyBytes(algorithms) {
|
|
472
|
+
if (!algorithms) return 32;
|
|
473
|
+
if (algorithms.includes("HS512")) return 64;
|
|
474
|
+
if (algorithms.includes("HS384")) return 48;
|
|
475
|
+
return 32;
|
|
476
|
+
}
|
|
477
|
+
function buildVerifier(options, verifyOptions) {
|
|
478
|
+
if (options.jwksUri) {
|
|
479
|
+
const jwks = jose.createRemoteJWKSet(new URL(options.jwksUri));
|
|
480
|
+
return (token) => jose.jwtVerify(token, jwks, verifyOptions);
|
|
481
|
+
}
|
|
482
|
+
if (options.secret) {
|
|
483
|
+
const key = new TextEncoder().encode(options.secret);
|
|
484
|
+
const minBytes = getMinHmacKeyBytes(options.algorithms);
|
|
485
|
+
if (key.byteLength < minBytes) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`@connectum/auth: HMAC secret must be at least ${minBytes} bytes (${minBytes * 8} bits) per RFC 7518. Got ${key.byteLength} bytes. Generate with: openssl rand -base64 ${minBytes}`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return (token) => jose.jwtVerify(token, key, verifyOptions);
|
|
491
|
+
}
|
|
492
|
+
if (options.publicKey) {
|
|
493
|
+
const key = options.publicKey;
|
|
494
|
+
return (token) => jose.jwtVerify(token, key, verifyOptions);
|
|
495
|
+
}
|
|
496
|
+
throw new Error("@connectum/auth: JWT interceptor requires one of: jwksUri, secret, or publicKey");
|
|
497
|
+
}
|
|
498
|
+
function mapClaimsToContext(payload, mapping) {
|
|
499
|
+
const result = {};
|
|
500
|
+
const claims = payload;
|
|
501
|
+
if (mapping.subject) {
|
|
502
|
+
const val = getNestedValue(claims, mapping.subject);
|
|
503
|
+
if (typeof val === "string") {
|
|
504
|
+
result.subject = val;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (mapping.name) {
|
|
508
|
+
const val = getNestedValue(claims, mapping.name);
|
|
509
|
+
if (typeof val === "string") {
|
|
510
|
+
result.name = val;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (mapping.roles) {
|
|
514
|
+
const val = getNestedValue(claims, mapping.roles);
|
|
515
|
+
if (Array.isArray(val)) {
|
|
516
|
+
result.roles = val.filter((r) => typeof r === "string");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (mapping.scopes) {
|
|
520
|
+
const val = getNestedValue(claims, mapping.scopes);
|
|
521
|
+
if (typeof val === "string") {
|
|
522
|
+
result.scopes = val.split(" ").filter(Boolean);
|
|
523
|
+
} else if (Array.isArray(val)) {
|
|
524
|
+
result.scopes = val.filter((s) => typeof s === "string");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
function throwMissingSubject() {
|
|
530
|
+
throw new ConnectError6("JWT missing subject claim", Code6.Unauthenticated);
|
|
531
|
+
}
|
|
532
|
+
function createJwtAuthInterceptor(options) {
|
|
533
|
+
const { claimsMapping = {}, skipMethods, propagateHeaders } = options;
|
|
534
|
+
const verifyOptions = {};
|
|
535
|
+
if (options.issuer) {
|
|
536
|
+
verifyOptions.issuer = options.issuer;
|
|
537
|
+
}
|
|
538
|
+
if (options.audience) {
|
|
539
|
+
verifyOptions.audience = options.audience;
|
|
540
|
+
}
|
|
541
|
+
if (options.algorithms) {
|
|
542
|
+
verifyOptions.algorithms = options.algorithms;
|
|
543
|
+
}
|
|
544
|
+
if (options.maxTokenAge) {
|
|
545
|
+
verifyOptions.maxTokenAge = options.maxTokenAge;
|
|
546
|
+
}
|
|
547
|
+
const verify = buildVerifier(options, verifyOptions);
|
|
548
|
+
return createAuthInterceptor({
|
|
549
|
+
skipMethods,
|
|
550
|
+
propagateHeaders,
|
|
551
|
+
verifyCredentials: async (token) => {
|
|
552
|
+
const { payload } = await verify(token);
|
|
553
|
+
const mapped = mapClaimsToContext(payload, claimsMapping);
|
|
554
|
+
const claims = payload;
|
|
555
|
+
return {
|
|
556
|
+
subject: mapped.subject ?? payload.sub ?? throwMissingSubject(),
|
|
557
|
+
name: mapped.name ?? (typeof claims.name === "string" ? claims.name : void 0),
|
|
558
|
+
roles: mapped.roles ?? [],
|
|
559
|
+
scopes: mapped.scopes ?? (typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : []),
|
|
560
|
+
claims,
|
|
561
|
+
type: "jwt",
|
|
562
|
+
expiresAt: payload.exp ? new Date(payload.exp * 1e3) : void 0
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/session-auth-interceptor.ts
|
|
569
|
+
import { Code as Code7, ConnectError as ConnectError7 } from "@connectrpc/connect";
|
|
570
|
+
function defaultExtractToken(req) {
|
|
571
|
+
const authHeader = req.header.get("authorization");
|
|
572
|
+
if (!authHeader) return null;
|
|
573
|
+
const match = /^Bearer\s+(.+)$/i.exec(authHeader);
|
|
574
|
+
return match?.[1] ?? null;
|
|
575
|
+
}
|
|
576
|
+
function createSessionAuthInterceptor(options) {
|
|
577
|
+
const { verifySession, mapSession, extractToken = defaultExtractToken, cache: cacheOptions, skipMethods = [], propagateHeaders = false, propagatedClaims } = options;
|
|
578
|
+
const cache = cacheOptions ? new LruCache(cacheOptions) : void 0;
|
|
579
|
+
return (next) => async (req) => {
|
|
580
|
+
const serviceName = req.service.typeName;
|
|
581
|
+
const methodName = req.method.name;
|
|
582
|
+
for (const headerName of Object.values(AUTH_HEADERS)) {
|
|
583
|
+
req.header.delete(headerName);
|
|
584
|
+
}
|
|
585
|
+
if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
|
|
586
|
+
return await next(req);
|
|
587
|
+
}
|
|
588
|
+
const token = await extractToken(req);
|
|
589
|
+
if (!token) {
|
|
590
|
+
throw new ConnectError7("Missing credentials", Code7.Unauthenticated);
|
|
591
|
+
}
|
|
592
|
+
const cached = cache?.get(token);
|
|
593
|
+
if (cached && (!cached.expiresAt || cached.expiresAt.getTime() > Date.now())) {
|
|
594
|
+
if (propagateHeaders) {
|
|
595
|
+
setAuthHeaders(req.header, cached, propagatedClaims);
|
|
596
|
+
}
|
|
597
|
+
return await authContextStorage.run(cached, () => next(req));
|
|
598
|
+
}
|
|
599
|
+
let session;
|
|
600
|
+
try {
|
|
601
|
+
session = await verifySession(token, req.header);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
if (err instanceof ConnectError7) throw err;
|
|
604
|
+
throw new ConnectError7("Session verification failed", Code7.Unauthenticated);
|
|
605
|
+
}
|
|
606
|
+
let authContext;
|
|
607
|
+
try {
|
|
608
|
+
authContext = await mapSession(session);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
if (err instanceof ConnectError7) throw err;
|
|
611
|
+
throw new ConnectError7("Session mapping failed", Code7.Unauthenticated);
|
|
612
|
+
}
|
|
613
|
+
cache?.set(token, authContext);
|
|
614
|
+
if (propagateHeaders) {
|
|
615
|
+
setAuthHeaders(req.header, authContext, propagatedClaims);
|
|
616
|
+
}
|
|
617
|
+
return await authContextStorage.run(authContext, () => next(req));
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
export {
|
|
621
|
+
AUTH_HEADERS,
|
|
622
|
+
AuthzDeniedError,
|
|
623
|
+
AuthzEffect,
|
|
624
|
+
LruCache,
|
|
625
|
+
authContextStorage,
|
|
626
|
+
createAuthInterceptor,
|
|
627
|
+
createAuthzInterceptor,
|
|
628
|
+
createGatewayAuthInterceptor,
|
|
629
|
+
createJwtAuthInterceptor,
|
|
630
|
+
createSessionAuthInterceptor,
|
|
631
|
+
getAuthContext,
|
|
632
|
+
matchesMethodPattern,
|
|
633
|
+
parseAuthHeaders,
|
|
634
|
+
requireAuthContext,
|
|
635
|
+
setAuthHeaders
|
|
636
|
+
};
|
|
637
|
+
//# sourceMappingURL=index.js.map
|