@connectum/auth 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }