@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway authentication interceptor
|
|
3
|
+
*
|
|
4
|
+
* For services behind an API gateway that has already performed authentication.
|
|
5
|
+
* Extracts auth context from gateway-injected headers after verifying trust.
|
|
6
|
+
*
|
|
7
|
+
* Trust is established via a header (e.g., x-gateway-secret) rather than
|
|
8
|
+
* peerAddress, since ConnectRPC interceptors don't have access to peer info.
|
|
9
|
+
*
|
|
10
|
+
* @module gateway-auth-interceptor
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Interceptor, StreamRequest, UnaryRequest } from "@connectrpc/connect";
|
|
14
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
15
|
+
import { authContextStorage } from "./context.ts";
|
|
16
|
+
import { setAuthHeaders } from "./headers.ts";
|
|
17
|
+
import { matchesMethodPattern } from "./method-match.ts";
|
|
18
|
+
import type { AuthContext, GatewayAuthInterceptorOptions } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Match an IP address against a pattern (exact or CIDR notation).
|
|
22
|
+
*
|
|
23
|
+
* Supports:
|
|
24
|
+
* - Exact match: "10.0.0.1"
|
|
25
|
+
* - CIDR range: "10.0.0.0/8"
|
|
26
|
+
*/
|
|
27
|
+
function isValidOctet(value: number): boolean {
|
|
28
|
+
return Number.isInteger(value) && value >= 0 && value <= 255;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function matchesIp(address: string, pattern: string): boolean {
|
|
32
|
+
if (address === pattern) return true;
|
|
33
|
+
|
|
34
|
+
if (pattern.includes("/")) {
|
|
35
|
+
const [network, prefixStr] = pattern.split("/");
|
|
36
|
+
if (!network || !prefixStr) return false;
|
|
37
|
+
const prefix = Number.parseInt(prefixStr, 10);
|
|
38
|
+
if (Number.isNaN(prefix) || prefix < 0 || prefix > 32) return false;
|
|
39
|
+
const peerParts = address.split(".").map(Number);
|
|
40
|
+
const networkParts = network.split(".").map(Number);
|
|
41
|
+
if (peerParts.length !== 4 || networkParts.length !== 4) return false;
|
|
42
|
+
if (!peerParts.every(isValidOctet) || !networkParts.every(isValidOctet)) return false;
|
|
43
|
+
const [p0 = 0, p1 = 0, p2 = 0, p3 = 0] = peerParts;
|
|
44
|
+
const [n0 = 0, n1 = 0, n2 = 0, n3 = 0] = networkParts;
|
|
45
|
+
const peerInt = ((p0 << 24) | (p1 << 16) | (p2 << 8) | p3) >>> 0;
|
|
46
|
+
const networkInt = ((n0 << 24) | (n1 << 16) | (n2 << 8) | n3) >>> 0;
|
|
47
|
+
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
|
|
48
|
+
return (peerInt & mask) === (networkInt & mask);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a trust header value matches any of the expected values.
|
|
56
|
+
*
|
|
57
|
+
* For each expected value, tries exact match first, then CIDR match.
|
|
58
|
+
*/
|
|
59
|
+
function isTrusted(headerValue: string, expectedValues: readonly string[]): boolean {
|
|
60
|
+
for (const expected of expectedValues) {
|
|
61
|
+
if (headerValue === expected) return true;
|
|
62
|
+
if (expected.includes("/") && matchesIp(headerValue, expected)) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a gateway authentication interceptor.
|
|
69
|
+
*
|
|
70
|
+
* Reads pre-authenticated identity from gateway-injected headers.
|
|
71
|
+
* Trust is established by checking a designated header value against
|
|
72
|
+
* a list of expected values (shared secrets or trusted IP ranges).
|
|
73
|
+
*
|
|
74
|
+
* @param options - Gateway auth configuration
|
|
75
|
+
* @returns ConnectRPC interceptor
|
|
76
|
+
*
|
|
77
|
+
* @example Kong/Envoy gateway with shared secret
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const gatewayAuth = createGatewayAuthInterceptor({
|
|
80
|
+
* headerMapping: {
|
|
81
|
+
* subject: 'x-user-id',
|
|
82
|
+
* name: 'x-user-name',
|
|
83
|
+
* roles: 'x-user-roles',
|
|
84
|
+
* },
|
|
85
|
+
* trustSource: {
|
|
86
|
+
* header: 'x-gateway-secret',
|
|
87
|
+
* expectedValues: [process.env.GATEWAY_SECRET],
|
|
88
|
+
* },
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function createGatewayAuthInterceptor(options: GatewayAuthInterceptorOptions): Interceptor {
|
|
93
|
+
const { headerMapping, trustSource, stripHeaders = [], skipMethods = [], propagateHeaders = false, defaultType = "gateway" } = options;
|
|
94
|
+
|
|
95
|
+
// Fail-closed: require subject mapping and non-empty expectedValues
|
|
96
|
+
if (!headerMapping.subject) {
|
|
97
|
+
throw new Error("@connectum/auth: Gateway auth requires headerMapping.subject");
|
|
98
|
+
}
|
|
99
|
+
if (trustSource.expectedValues.length === 0) {
|
|
100
|
+
throw new Error("@connectum/auth: Gateway auth requires non-empty trustSource.expectedValues");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pre-compute headers to strip (prevents downstream spoofing on all routes)
|
|
104
|
+
const headersToStrip = [
|
|
105
|
+
headerMapping.subject,
|
|
106
|
+
headerMapping.name,
|
|
107
|
+
headerMapping.roles,
|
|
108
|
+
headerMapping.scopes,
|
|
109
|
+
headerMapping.type,
|
|
110
|
+
headerMapping.claims,
|
|
111
|
+
trustSource.header,
|
|
112
|
+
...stripHeaders,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
function stripGatewayHeaders(headers: Headers): void {
|
|
116
|
+
for (const header of headersToStrip) {
|
|
117
|
+
if (header) headers.delete(header);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (next) => async (req: UnaryRequest | StreamRequest) => {
|
|
122
|
+
const serviceName: string = req.service.typeName;
|
|
123
|
+
const methodName: string = req.method.name;
|
|
124
|
+
|
|
125
|
+
if (matchesMethodPattern(serviceName, methodName, skipMethods)) {
|
|
126
|
+
// Strip gateway headers even for skipped methods to prevent spoofing
|
|
127
|
+
stripGatewayHeaders(req.header);
|
|
128
|
+
return await next(req);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Verify trust
|
|
132
|
+
const trustHeaderValue = req.header.get(trustSource.header);
|
|
133
|
+
if (!trustHeaderValue || !isTrusted(trustHeaderValue, trustSource.expectedValues)) {
|
|
134
|
+
throw new ConnectError("Untrusted request source", Code.Unauthenticated);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Extract subject (required)
|
|
138
|
+
const subject = req.header.get(headerMapping.subject);
|
|
139
|
+
if (!subject) {
|
|
140
|
+
throw new ConnectError("Missing subject header from gateway", Code.Unauthenticated);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Extract optional fields
|
|
144
|
+
const name = headerMapping.name ? (req.header.get(headerMapping.name) ?? undefined) : undefined;
|
|
145
|
+
const type = headerMapping.type ? (req.header.get(headerMapping.type) ?? defaultType) : defaultType;
|
|
146
|
+
|
|
147
|
+
// Parse roles: JSON array or comma-separated
|
|
148
|
+
let roles: string[] = [];
|
|
149
|
+
if (headerMapping.roles) {
|
|
150
|
+
const rolesRaw = req.header.get(headerMapping.roles);
|
|
151
|
+
if (rolesRaw) {
|
|
152
|
+
try {
|
|
153
|
+
const parsed: unknown = JSON.parse(rolesRaw);
|
|
154
|
+
if (Array.isArray(parsed)) {
|
|
155
|
+
roles = parsed.filter((r): r is string => typeof r === "string");
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Not JSON — try comma-separated
|
|
159
|
+
roles = rolesRaw
|
|
160
|
+
.split(",")
|
|
161
|
+
.map((r) => r.trim())
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Parse scopes: space-separated
|
|
168
|
+
let scopes: string[] = [];
|
|
169
|
+
if (headerMapping.scopes) {
|
|
170
|
+
const scopesRaw = req.header.get(headerMapping.scopes);
|
|
171
|
+
if (scopesRaw) {
|
|
172
|
+
scopes = scopesRaw.split(" ").filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Parse claims: JSON object
|
|
177
|
+
let claims: Record<string, unknown> = {};
|
|
178
|
+
if (headerMapping.claims) {
|
|
179
|
+
const claimsRaw = req.header.get(headerMapping.claims);
|
|
180
|
+
if (claimsRaw && claimsRaw.length <= 8192) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed: unknown = JSON.parse(claimsRaw);
|
|
183
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
184
|
+
claims = parsed as Record<string, unknown>;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Invalid JSON — ignore
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const authContext: AuthContext = { subject, name, roles, scopes, claims, type };
|
|
193
|
+
|
|
194
|
+
// Strip mapped headers to prevent downstream spoofing
|
|
195
|
+
stripGatewayHeaders(req.header);
|
|
196
|
+
|
|
197
|
+
if (propagateHeaders) {
|
|
198
|
+
setAuthHeaders(req.header, authContext);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return await authContextStorage.run(authContext, () => next(req));
|
|
202
|
+
};
|
|
203
|
+
}
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth header propagation utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles serialization/deserialization of AuthContext to/from
|
|
5
|
+
* HTTP headers for cross-service context propagation.
|
|
6
|
+
*
|
|
7
|
+
* @module headers
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthContext } from "./types.ts";
|
|
11
|
+
import { AUTH_HEADERS } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const MAX_HEADER_BYTES = 8192;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sanitize a header value by removing control characters and enforcing length limits.
|
|
17
|
+
*/
|
|
18
|
+
function sanitizeHeaderValue(value: string, maxLength: number): string {
|
|
19
|
+
// Remove control characters (except tab/LF/CR which are valid in headers)
|
|
20
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control char removal for header sanitization
|
|
21
|
+
const cleaned = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
22
|
+
return cleaned.slice(0, maxLength);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serialize AuthContext to request headers.
|
|
27
|
+
*
|
|
28
|
+
* Sets standard auth headers on the provided Headers object.
|
|
29
|
+
* Used by auth interceptors when propagateHeaders is enabled.
|
|
30
|
+
*
|
|
31
|
+
* @param headers - Headers object to set auth headers on
|
|
32
|
+
* @param context - Auth context to serialize
|
|
33
|
+
* @param propagatedClaims - Optional list of claim keys to propagate (all if undefined)
|
|
34
|
+
*/
|
|
35
|
+
export function setAuthHeaders(headers: Headers, context: AuthContext, propagatedClaims?: string[]): void {
|
|
36
|
+
headers.set(AUTH_HEADERS.SUBJECT, sanitizeHeaderValue(context.subject, 512));
|
|
37
|
+
headers.set(AUTH_HEADERS.TYPE, sanitizeHeaderValue(context.type, 128));
|
|
38
|
+
|
|
39
|
+
if (context.name) {
|
|
40
|
+
headers.set(AUTH_HEADERS.NAME, sanitizeHeaderValue(context.name, 256));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (context.roles.length > 0) {
|
|
44
|
+
const rolesValue = JSON.stringify(context.roles);
|
|
45
|
+
if (rolesValue.length <= MAX_HEADER_BYTES) {
|
|
46
|
+
headers.set(AUTH_HEADERS.ROLES, rolesValue);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (context.scopes.length > 0) {
|
|
51
|
+
const scopesValue = context.scopes.join(" ");
|
|
52
|
+
if (scopesValue.length <= MAX_HEADER_BYTES) {
|
|
53
|
+
headers.set(AUTH_HEADERS.SCOPES, scopesValue);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const claimKeys = Object.keys(context.claims);
|
|
58
|
+
if (claimKeys.length > 0) {
|
|
59
|
+
const filteredClaims = propagatedClaims ? Object.fromEntries(Object.entries(context.claims).filter(([key]) => propagatedClaims.includes(key))) : context.claims;
|
|
60
|
+
if (Object.keys(filteredClaims).length > 0) {
|
|
61
|
+
const claimsValue = JSON.stringify(filteredClaims);
|
|
62
|
+
if (claimsValue.length <= MAX_HEADER_BYTES) {
|
|
63
|
+
headers.set(AUTH_HEADERS.CLAIMS, claimsValue);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse AuthContext from request headers.
|
|
71
|
+
*
|
|
72
|
+
* Deserializes auth context from standard headers set by an upstream
|
|
73
|
+
* service or gateway. Returns undefined if required headers are missing.
|
|
74
|
+
*
|
|
75
|
+
* WARNING: Only use this in trusted environments (behind mTLS, mesh, etc.).
|
|
76
|
+
* For untrusted environments, use createTrustedHeadersReader() instead.
|
|
77
|
+
*
|
|
78
|
+
* @param headers - Request headers to parse
|
|
79
|
+
* @returns Parsed AuthContext or undefined if headers are missing
|
|
80
|
+
*
|
|
81
|
+
* @example Trust upstream auth headers
|
|
82
|
+
* ```typescript
|
|
83
|
+
* import { parseAuthHeaders } from '@connectum/auth';
|
|
84
|
+
*
|
|
85
|
+
* const context = parseAuthHeaders(req.header);
|
|
86
|
+
* if (context) {
|
|
87
|
+
* console.log(`Authenticated as ${context.subject}`);
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function parseAuthHeaders(headers: Headers): AuthContext | undefined {
|
|
92
|
+
const subjectHeader = headers.get(AUTH_HEADERS.SUBJECT);
|
|
93
|
+
if (!subjectHeader) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const subject = sanitizeHeaderValue(subjectHeader, 512);
|
|
98
|
+
const typeHeader = headers.get(AUTH_HEADERS.TYPE);
|
|
99
|
+
const type = typeHeader ? sanitizeHeaderValue(typeHeader, 128) : "unknown";
|
|
100
|
+
const rolesRaw = headers.get(AUTH_HEADERS.ROLES);
|
|
101
|
+
const scopesRaw = headers.get(AUTH_HEADERS.SCOPES);
|
|
102
|
+
const claimsRaw = headers.get(AUTH_HEADERS.CLAIMS);
|
|
103
|
+
|
|
104
|
+
let roles: string[] = [];
|
|
105
|
+
if (rolesRaw && rolesRaw.length <= MAX_HEADER_BYTES) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed: unknown = JSON.parse(rolesRaw);
|
|
108
|
+
if (Array.isArray(parsed)) {
|
|
109
|
+
roles = parsed.filter((r): r is string => typeof r === "string");
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Invalid JSON — ignore malformed header
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let scopes: string[] = [];
|
|
117
|
+
if (scopesRaw && scopesRaw.length <= MAX_HEADER_BYTES) {
|
|
118
|
+
scopes = scopesRaw.split(" ").filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const nameRaw = headers.get(AUTH_HEADERS.NAME);
|
|
122
|
+
const name = nameRaw ? sanitizeHeaderValue(nameRaw, 256) : undefined;
|
|
123
|
+
|
|
124
|
+
let claims: Record<string, unknown> = {};
|
|
125
|
+
if (claimsRaw) {
|
|
126
|
+
if (claimsRaw.length > MAX_HEADER_BYTES) {
|
|
127
|
+
// Claims too large — ignore to prevent abuse
|
|
128
|
+
claims = {};
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
const parsed: unknown = JSON.parse(claimsRaw);
|
|
132
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
133
|
+
claims = parsed as Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Invalid JSON — ignore malformed header
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
subject,
|
|
143
|
+
name,
|
|
144
|
+
type,
|
|
145
|
+
roles,
|
|
146
|
+
scopes,
|
|
147
|
+
claims,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @connectum/auth
|
|
3
|
+
*
|
|
4
|
+
* Authentication and authorization interceptors for Connectum.
|
|
5
|
+
*
|
|
6
|
+
* Provides five interceptor factories:
|
|
7
|
+
* - createAuthInterceptor() — generic, pluggable authentication
|
|
8
|
+
* - createJwtAuthInterceptor() — JWT convenience with jose
|
|
9
|
+
* - createGatewayAuthInterceptor() — gateway-injected headers
|
|
10
|
+
* - createSessionAuthInterceptor() — session-based auth (better-auth, etc.)
|
|
11
|
+
* - createAuthzInterceptor() — declarative rules-based authorization
|
|
12
|
+
*
|
|
13
|
+
* Plus context propagation via AsyncLocalStorage and request headers.
|
|
14
|
+
*
|
|
15
|
+
* @module @connectum/auth
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Interceptor factories
|
|
19
|
+
export { createAuthInterceptor } from "./auth-interceptor.ts";
|
|
20
|
+
export { createAuthzInterceptor } from "./authz-interceptor.ts";
|
|
21
|
+
// Cache
|
|
22
|
+
export { LruCache } from "./cache.ts";
|
|
23
|
+
// Context management
|
|
24
|
+
export { authContextStorage, getAuthContext, requireAuthContext } from "./context.ts";
|
|
25
|
+
export type { AuthzDeniedDetails } from "./errors.ts";
|
|
26
|
+
export { AuthzDeniedError } from "./errors.ts";
|
|
27
|
+
export { createGatewayAuthInterceptor } from "./gateway-auth-interceptor.ts";
|
|
28
|
+
// Header utilities
|
|
29
|
+
export { parseAuthHeaders, setAuthHeaders } from "./headers.ts";
|
|
30
|
+
export { createJwtAuthInterceptor } from "./jwt-auth-interceptor.ts";
|
|
31
|
+
// Method pattern matching
|
|
32
|
+
export { matchesMethodPattern } from "./method-match.ts";
|
|
33
|
+
export { createSessionAuthInterceptor } from "./session-auth-interceptor.ts";
|
|
34
|
+
|
|
35
|
+
// Types and constants
|
|
36
|
+
export type {
|
|
37
|
+
AuthContext,
|
|
38
|
+
AuthInterceptorOptions,
|
|
39
|
+
AuthzInterceptorOptions,
|
|
40
|
+
AuthzRule,
|
|
41
|
+
CacheOptions,
|
|
42
|
+
GatewayAuthInterceptorOptions,
|
|
43
|
+
GatewayHeaderMapping,
|
|
44
|
+
InterceptorFactory,
|
|
45
|
+
JwtAuthInterceptorOptions,
|
|
46
|
+
SessionAuthInterceptorOptions,
|
|
47
|
+
} from "./types.ts";
|
|
48
|
+
|
|
49
|
+
export { AUTH_HEADERS, AuthzEffect } from "./types.ts";
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT authentication interceptor
|
|
3
|
+
*
|
|
4
|
+
* Convenience wrapper for JWT-based authentication using the jose library.
|
|
5
|
+
* Supports JWKS remote key sets, HMAC secrets, and asymmetric public keys.
|
|
6
|
+
*
|
|
7
|
+
* @module jwt-auth-interceptor
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Interceptor } from "@connectrpc/connect";
|
|
11
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
12
|
+
import * as jose from "jose";
|
|
13
|
+
import { createAuthInterceptor } from "./auth-interceptor.ts";
|
|
14
|
+
import type { AuthContext, JwtAuthInterceptorOptions } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a value at a dot-notation path in an object.
|
|
18
|
+
*
|
|
19
|
+
* @example getNestedValue({ a: { b: [1, 2] } }, "a.b") // [1, 2]
|
|
20
|
+
*/
|
|
21
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
22
|
+
let current: unknown = obj;
|
|
23
|
+
for (const key of path.split(".")) {
|
|
24
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
current = (current as Record<string, unknown>)[key];
|
|
28
|
+
}
|
|
29
|
+
return current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get minimum HMAC key size in bytes per RFC 7518.
|
|
34
|
+
* HS256 requires 32 bytes, HS384 requires 48, HS512 requires 64.
|
|
35
|
+
*/
|
|
36
|
+
function getMinHmacKeyBytes(algorithms?: string[]): number {
|
|
37
|
+
if (!algorithms) return 32;
|
|
38
|
+
if (algorithms.includes("HS512")) return 64;
|
|
39
|
+
if (algorithms.includes("HS384")) return 48;
|
|
40
|
+
return 32;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a JWT verification function from options.
|
|
45
|
+
*
|
|
46
|
+
* Separates JWKS (dynamic key resolution) from static keys (HMAC / asymmetric)
|
|
47
|
+
* to satisfy jose's overloaded jwtVerify signatures.
|
|
48
|
+
*
|
|
49
|
+
* Priority: jwksUri > secret > publicKey
|
|
50
|
+
*/
|
|
51
|
+
function buildVerifier(options: JwtAuthInterceptorOptions, verifyOptions: jose.JWTVerifyOptions): (token: string) => Promise<jose.JWTVerifyResult> {
|
|
52
|
+
if (options.jwksUri) {
|
|
53
|
+
const jwks = jose.createRemoteJWKSet(new URL(options.jwksUri));
|
|
54
|
+
return (token) => jose.jwtVerify(token, jwks, verifyOptions);
|
|
55
|
+
}
|
|
56
|
+
if (options.secret) {
|
|
57
|
+
const key = new TextEncoder().encode(options.secret);
|
|
58
|
+
const minBytes = getMinHmacKeyBytes(options.algorithms);
|
|
59
|
+
if (key.byteLength < minBytes) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`@connectum/auth: HMAC secret must be at least ${minBytes} bytes (${minBytes * 8} bits) per RFC 7518. ` +
|
|
62
|
+
`Got ${key.byteLength} bytes. Generate with: openssl rand -base64 ${minBytes}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (token) => jose.jwtVerify(token, key, verifyOptions);
|
|
66
|
+
}
|
|
67
|
+
if (options.publicKey) {
|
|
68
|
+
const key = options.publicKey;
|
|
69
|
+
return (token) => jose.jwtVerify(token, key, verifyOptions);
|
|
70
|
+
}
|
|
71
|
+
throw new Error("@connectum/auth: JWT interceptor requires one of: jwksUri, secret, or publicKey");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mutable intermediate type for claim mapping results.
|
|
76
|
+
*/
|
|
77
|
+
interface MappedClaims {
|
|
78
|
+
subject?: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
roles?: string[];
|
|
81
|
+
scopes?: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map JWT claims to AuthContext using configurable claim paths.
|
|
86
|
+
*/
|
|
87
|
+
function mapClaimsToContext(payload: jose.JWTPayload, mapping: NonNullable<JwtAuthInterceptorOptions["claimsMapping"]>): MappedClaims {
|
|
88
|
+
const result: MappedClaims = {};
|
|
89
|
+
const claims = payload as Record<string, unknown>;
|
|
90
|
+
|
|
91
|
+
// Subject
|
|
92
|
+
if (mapping.subject) {
|
|
93
|
+
const val = getNestedValue(claims, mapping.subject);
|
|
94
|
+
if (typeof val === "string") {
|
|
95
|
+
result.subject = val;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Name
|
|
100
|
+
if (mapping.name) {
|
|
101
|
+
const val = getNestedValue(claims, mapping.name);
|
|
102
|
+
if (typeof val === "string") {
|
|
103
|
+
result.name = val;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Roles
|
|
108
|
+
if (mapping.roles) {
|
|
109
|
+
const val = getNestedValue(claims, mapping.roles);
|
|
110
|
+
if (Array.isArray(val)) {
|
|
111
|
+
result.roles = val.filter((r): r is string => typeof r === "string");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Scopes (can be space-separated string or array)
|
|
116
|
+
if (mapping.scopes) {
|
|
117
|
+
const val = getNestedValue(claims, mapping.scopes);
|
|
118
|
+
if (typeof val === "string") {
|
|
119
|
+
result.scopes = val.split(" ").filter(Boolean);
|
|
120
|
+
} else if (Array.isArray(val)) {
|
|
121
|
+
result.scopes = val.filter((s): s is string => typeof s === "string");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Throw an Unauthenticated error for a missing JWT subject claim.
|
|
130
|
+
*/
|
|
131
|
+
function throwMissingSubject(): never {
|
|
132
|
+
throw new ConnectError("JWT missing subject claim", Code.Unauthenticated);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a JWT authentication interceptor.
|
|
137
|
+
*
|
|
138
|
+
* Convenience wrapper around createAuthInterceptor() that handles
|
|
139
|
+
* JWT extraction from Authorization header, verification via jose,
|
|
140
|
+
* and standard claim mapping to AuthContext.
|
|
141
|
+
*
|
|
142
|
+
* @param options - JWT authentication options
|
|
143
|
+
* @returns ConnectRPC interceptor
|
|
144
|
+
*
|
|
145
|
+
* @example JWKS-based JWT auth (Auth0, Keycloak, etc.)
|
|
146
|
+
* ```typescript
|
|
147
|
+
* import { createJwtAuthInterceptor } from '@connectum/auth';
|
|
148
|
+
*
|
|
149
|
+
* const jwtAuth = createJwtAuthInterceptor({
|
|
150
|
+
* jwksUri: 'https://auth.example.com/.well-known/jwks.json',
|
|
151
|
+
* issuer: 'https://auth.example.com/',
|
|
152
|
+
* audience: 'my-api',
|
|
153
|
+
* claimsMapping: {
|
|
154
|
+
* roles: 'realm_access.roles',
|
|
155
|
+
* scopes: 'scope',
|
|
156
|
+
* },
|
|
157
|
+
* });
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* @example HMAC secret (testing / simple setups)
|
|
161
|
+
* ```typescript
|
|
162
|
+
* const jwtAuth = createJwtAuthInterceptor({
|
|
163
|
+
* secret: process.env.JWT_SECRET,
|
|
164
|
+
* issuer: 'my-service',
|
|
165
|
+
* });
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
export function createJwtAuthInterceptor(options: JwtAuthInterceptorOptions): Interceptor {
|
|
169
|
+
const { claimsMapping = {}, skipMethods, propagateHeaders } = options;
|
|
170
|
+
|
|
171
|
+
const verifyOptions: jose.JWTVerifyOptions = {};
|
|
172
|
+
if (options.issuer) {
|
|
173
|
+
verifyOptions.issuer = options.issuer;
|
|
174
|
+
}
|
|
175
|
+
if (options.audience) {
|
|
176
|
+
verifyOptions.audience = options.audience;
|
|
177
|
+
}
|
|
178
|
+
if (options.algorithms) {
|
|
179
|
+
verifyOptions.algorithms = options.algorithms;
|
|
180
|
+
}
|
|
181
|
+
if (options.maxTokenAge) {
|
|
182
|
+
verifyOptions.maxTokenAge = options.maxTokenAge;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const verify = buildVerifier(options, verifyOptions);
|
|
186
|
+
|
|
187
|
+
return createAuthInterceptor({
|
|
188
|
+
skipMethods,
|
|
189
|
+
propagateHeaders,
|
|
190
|
+
verifyCredentials: async (token: string): Promise<AuthContext> => {
|
|
191
|
+
const { payload } = await verify(token);
|
|
192
|
+
|
|
193
|
+
// Map standard + custom claims
|
|
194
|
+
const mapped = mapClaimsToContext(payload, claimsMapping);
|
|
195
|
+
const claims = payload as Record<string, unknown>;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
subject: mapped.subject ?? payload.sub ?? throwMissingSubject(),
|
|
199
|
+
name: mapped.name ?? (typeof claims.name === "string" ? claims.name : undefined),
|
|
200
|
+
roles: mapped.roles ?? [],
|
|
201
|
+
scopes: mapped.scopes ?? (typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : []),
|
|
202
|
+
claims,
|
|
203
|
+
type: "jwt",
|
|
204
|
+
expiresAt: payload.exp ? new Date(payload.exp * 1000) : undefined,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Method pattern matching utility
|
|
3
|
+
*
|
|
4
|
+
* Shared logic for matching gRPC methods against patterns.
|
|
5
|
+
* Used by both auth and authz interceptors.
|
|
6
|
+
*
|
|
7
|
+
* @module method-match
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a method matches any of the given patterns.
|
|
12
|
+
*
|
|
13
|
+
* Patterns:
|
|
14
|
+
* - "*" — matches all methods
|
|
15
|
+
* - "Service/*" — matches all methods of a service
|
|
16
|
+
* - "Service/Method" — matches exact method
|
|
17
|
+
*
|
|
18
|
+
* @param serviceName - Fully-qualified service name (e.g., "user.v1.UserService")
|
|
19
|
+
* @param methodName - Method name (e.g., "GetUser")
|
|
20
|
+
* @param patterns - Readonly array of match patterns
|
|
21
|
+
* @returns true if the method matches any pattern
|
|
22
|
+
*/
|
|
23
|
+
export function matchesMethodPattern(serviceName: string, methodName: string, patterns: readonly string[]): boolean {
|
|
24
|
+
if (patterns.length === 0) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const fullMethod = `${serviceName}/${methodName}`;
|
|
29
|
+
|
|
30
|
+
for (const pattern of patterns) {
|
|
31
|
+
if (pattern === "*") {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (pattern === fullMethod) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (pattern.endsWith("/*")) {
|
|
38
|
+
const servicePattern = pattern.slice(0, -2);
|
|
39
|
+
if (serviceName === servicePattern) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|