@mi9-identity/auth-middleware-express 1.0.0
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/CHANGELOG.md +35 -0
- package/LICENSE +201 -0
- package/README.md +234 -0
- package/dist/adapter.d.ts +12 -0
- package/dist/adapter.js +89 -0
- package/dist/default-error-mapping.d.ts +11 -0
- package/dist/default-error-mapping.js +124 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +45 -0
- package/dist/event.d.ts +50 -0
- package/dist/event.js +83 -0
- package/dist/failure-reason.d.ts +12 -0
- package/dist/failure-reason.js +44 -0
- package/dist/guards.d.ts +47 -0
- package/dist/guards.js +180 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +5 -0
- package/dist/type-guards.d.ts +93 -0
- package/dist/type-guards.js +102 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +2 -0
- package/package.json +73 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { AlgorithmNotAllowedError, AudienceMismatchError, InvalidTokenError, IssuerNotAllowedError, JwksUnavailableError, MissingRequiredClaimError, TokenExpiredError, } from '@mi9-identity/jwt-verifier';
|
|
2
|
+
import { isEmpty, isUndefined } from './type-guards.js';
|
|
3
|
+
import { InsufficientScopeError, MissingTokenError, RetailerMismatchError } from './errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize an error message so it can be safely embedded in a
|
|
6
|
+
* `WWW-Authenticate` `error_description` parameter. RFC 6750 §3 disallows
|
|
7
|
+
* raw quotes / control chars; collapse them rather than expose
|
|
8
|
+
* implementation-specific text.
|
|
9
|
+
*
|
|
10
|
+
* @param { string } msg The raw error message.
|
|
11
|
+
* @returns { string } The sanitized, length-capped message.
|
|
12
|
+
*/
|
|
13
|
+
const sanitizeReason = (msg) => msg.replaceAll(/[\p{Cc}"\\]/gu, ' ').slice(0, 256);
|
|
14
|
+
/**
|
|
15
|
+
* Build the `WWW-Authenticate` header value for a Bearer challenge. Omits
|
|
16
|
+
* `error=` when `errorCode` is undefined (RFC 6750 §3.1: a request without
|
|
17
|
+
* authentication info must not advertise a specific error code).
|
|
18
|
+
*
|
|
19
|
+
* @param { string | undefined } errorCode The RFC 6750 `error` parameter, or undefined for missing-credentials.
|
|
20
|
+
* @param { string | undefined } reason Optional `error_description` text.
|
|
21
|
+
* @param { readonly string[] | undefined } scope Optional list of required scopes for the `scope` parameter.
|
|
22
|
+
* @returns { string } The fully-formed header value.
|
|
23
|
+
*/
|
|
24
|
+
const wwwAuthenticate = (errorCode, reason, scope) => {
|
|
25
|
+
if (isUndefined(errorCode)) {
|
|
26
|
+
// RFC 6750 §3.1: a request without authentication info MUST receive
|
|
27
|
+
// `WWW-Authenticate: Bearer realm="..."` with no `error=` parameter.
|
|
28
|
+
// Including one mis-signals "request was malformed" to strict clients.
|
|
29
|
+
return 'Bearer realm="mi9"';
|
|
30
|
+
}
|
|
31
|
+
let header = `Bearer realm="mi9", error="${errorCode}"`;
|
|
32
|
+
if (!isEmpty(reason)) {
|
|
33
|
+
header += `, error_description="${sanitizeReason(reason)}"`;
|
|
34
|
+
}
|
|
35
|
+
if (!isEmpty(scope)) {
|
|
36
|
+
// RFC 6750 §3: `scope` lists scopes required for the resource.
|
|
37
|
+
// Space-delimited per RFC 6749 §3.3. MUST NOT appear more than once.
|
|
38
|
+
header += `, scope="${scope.join(' ')}"`;
|
|
39
|
+
}
|
|
40
|
+
return header;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Shared response builder for the RFC 6750 token-error classes (everything
|
|
44
|
+
* except `MissingTokenError`, which omits `error=`).
|
|
45
|
+
*
|
|
46
|
+
* @param { string } errorCode The RFC 6750 `error` parameter.
|
|
47
|
+
* @param { number } status The HTTP status code to return.
|
|
48
|
+
* @param { VerifierError } err The original error (drives `error_description`).
|
|
49
|
+
* @param { readonly string[] | undefined } scope Optional list of required scopes.
|
|
50
|
+
* @returns { ErrorResponse } The wire-shape response.
|
|
51
|
+
*/
|
|
52
|
+
const tokenError = (errorCode, status, err, scope) => ({
|
|
53
|
+
status,
|
|
54
|
+
headers: {
|
|
55
|
+
'WWW-Authenticate': wwwAuthenticate(errorCode, err.message, scope),
|
|
56
|
+
},
|
|
57
|
+
body: {
|
|
58
|
+
error: errorCode,
|
|
59
|
+
error_description: sanitizeReason(err.message),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* RFC 6750 §3 default mapping. Keep deterministic: identical input must
|
|
64
|
+
* always produce identical output for downstream caching / observability.
|
|
65
|
+
*
|
|
66
|
+
* @param { VerifierError } err The error to map onto a wire-shape response.
|
|
67
|
+
* @returns { ErrorResponse } The RFC 6750-compliant response envelope.
|
|
68
|
+
*/
|
|
69
|
+
export const defaultErrorMapping = (err) => {
|
|
70
|
+
if (err instanceof MissingTokenError) {
|
|
71
|
+
// RFC 6750 §3.1: omit `error=` for missing-credentials. The body keeps
|
|
72
|
+
// a JSON envelope for client logs but the `WWW-Authenticate` header
|
|
73
|
+
// does not advertise a specific error code.
|
|
74
|
+
return {
|
|
75
|
+
status: 401,
|
|
76
|
+
headers: {
|
|
77
|
+
'WWW-Authenticate': wwwAuthenticate(undefined),
|
|
78
|
+
},
|
|
79
|
+
body: {
|
|
80
|
+
error_description: sanitizeReason(err.message),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (err instanceof InsufficientScopeError) {
|
|
85
|
+
return tokenError('insufficient_scope', 403, err, err.requiredScopes);
|
|
86
|
+
}
|
|
87
|
+
if (err instanceof RetailerMismatchError) {
|
|
88
|
+
// Mi9-private error code. Token IS valid (signature, exp, aud all
|
|
89
|
+
// pass) — this is an authorization mismatch, not a scope shortage
|
|
90
|
+
// (`insufficient_scope`) or a token defect (`invalid_token`). RFC
|
|
91
|
+
// 6750 §3 does not restrict the `error=` parameter to registered
|
|
92
|
+
// codes; we use a namespaced private code so observability does not
|
|
93
|
+
// conflate this with scope failures.
|
|
94
|
+
return tokenError('mi9_retailer_mismatch', 403, err);
|
|
95
|
+
}
|
|
96
|
+
if (err instanceof JwksUnavailableError) {
|
|
97
|
+
// RFC 6750 §6.2.1 registers only `invalid_request`, `invalid_token`,
|
|
98
|
+
// `insufficient_scope`. `temporarily_unavailable` is from RFC 6749
|
|
99
|
+
// §4.1.2.1 (authorization endpoint), not the resource server. Use
|
|
100
|
+
// `invalid_token` + 503 + Retry-After to convey transience without
|
|
101
|
+
// emitting an unregistered error code.
|
|
102
|
+
return {
|
|
103
|
+
status: 503,
|
|
104
|
+
headers: {
|
|
105
|
+
'WWW-Authenticate': wwwAuthenticate('invalid_token', 'JWKS endpoint unavailable'),
|
|
106
|
+
'Retry-After': '30',
|
|
107
|
+
},
|
|
108
|
+
body: {
|
|
109
|
+
error: 'invalid_token',
|
|
110
|
+
error_description: 'JWKS endpoint unavailable',
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (err instanceof TokenExpiredError ||
|
|
115
|
+
err instanceof IssuerNotAllowedError ||
|
|
116
|
+
err instanceof AudienceMismatchError ||
|
|
117
|
+
err instanceof AlgorithmNotAllowedError ||
|
|
118
|
+
err instanceof MissingRequiredClaimError ||
|
|
119
|
+
err instanceof InvalidTokenError) {
|
|
120
|
+
return tokenError('invalid_token', 401, err);
|
|
121
|
+
}
|
|
122
|
+
return tokenError('invalid_token', 401, err);
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=default-error-mapping.js.map
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AlgorithmNotAllowedError, AudienceMismatchError, InvalidTokenError, IssuerNotAllowedError, JwksUnavailableError, MissingRequiredClaimError, TokenExpiredError } from '@mi9-identity/jwt-verifier';
|
|
2
|
+
/**
|
|
3
|
+
* Distinct from the verifier's `InvalidTokenError` family — raised when the
|
|
4
|
+
* `Authorization` header is absent or not a Bearer token. The default
|
|
5
|
+
* `onError` maps it to `401` with `WWW-Authenticate: Bearer realm="mi9"` and
|
|
6
|
+
* NO `error=` parameter, per RFC 6750 §3.1 (a request lacking any
|
|
7
|
+
* authentication info SHOULD NOT advertise a specific error code).
|
|
8
|
+
*/
|
|
9
|
+
export declare class MissingTokenError extends Error {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Insufficient scopes for the current route. Raised by `requireScope` /
|
|
14
|
+
* `requireAnyScope`. Default mapping: `403 insufficient_scope` (RFC 6750
|
|
15
|
+
* §6.2.1). When `requiredScopes` is supplied the default mapping appends a
|
|
16
|
+
* `scope="..."` parameter to `WWW-Authenticate` listing the required scopes.
|
|
17
|
+
*/
|
|
18
|
+
export declare class InsufficientScopeError extends Error {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly requiredScopes?: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* Construct the error with an optional list of scopes that the request was
|
|
23
|
+
* missing — surfaced in the `WWW-Authenticate: ... scope="..."` parameter.
|
|
24
|
+
*
|
|
25
|
+
* @param { string } message Human-readable error message.
|
|
26
|
+
* @param { readonly string[] | undefined } requiredScopes Scopes required for the route (omitted from the wire when empty).
|
|
27
|
+
*/
|
|
28
|
+
constructor(message: string, requiredScopes?: readonly string[]);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Token authenticated but its `retailerId` claim does not match the
|
|
32
|
+
* resolver value for the current request. Raised by `requireRetailer`.
|
|
33
|
+
* Distinct from `InsufficientScopeError` — this is an authorization mismatch
|
|
34
|
+
* on a verified token, not a scope shortage. See default mapping in
|
|
35
|
+
* `default-error-mapping.ts` for the wire shape.
|
|
36
|
+
*/
|
|
37
|
+
export declare class RetailerMismatchError extends Error {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Discriminated union of every error type the default `onError` understands.
|
|
42
|
+
* Custom handlers can narrow on `instanceof` to override one mapping while
|
|
43
|
+
* delegating the rest to the default.
|
|
44
|
+
*/
|
|
45
|
+
export type VerifierError = MissingTokenError | InsufficientScopeError | RetailerMismatchError | InvalidTokenError | TokenExpiredError | IssuerNotAllowedError | AudienceMismatchError | AlgorithmNotAllowedError | MissingRequiredClaimError | JwksUnavailableError;
|
|
46
|
+
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isEmpty } from './type-guards.js';
|
|
2
|
+
/**
|
|
3
|
+
* Distinct from the verifier's `InvalidTokenError` family — raised when the
|
|
4
|
+
* `Authorization` header is absent or not a Bearer token. The default
|
|
5
|
+
* `onError` maps it to `401` with `WWW-Authenticate: Bearer realm="mi9"` and
|
|
6
|
+
* NO `error=` parameter, per RFC 6750 §3.1 (a request lacking any
|
|
7
|
+
* authentication info SHOULD NOT advertise a specific error code).
|
|
8
|
+
*/
|
|
9
|
+
export class MissingTokenError extends Error {
|
|
10
|
+
name = 'MissingTokenError';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Insufficient scopes for the current route. Raised by `requireScope` /
|
|
14
|
+
* `requireAnyScope`. Default mapping: `403 insufficient_scope` (RFC 6750
|
|
15
|
+
* §6.2.1). When `requiredScopes` is supplied the default mapping appends a
|
|
16
|
+
* `scope="..."` parameter to `WWW-Authenticate` listing the required scopes.
|
|
17
|
+
*/
|
|
18
|
+
export class InsufficientScopeError extends Error {
|
|
19
|
+
name = 'InsufficientScopeError';
|
|
20
|
+
requiredScopes;
|
|
21
|
+
/**
|
|
22
|
+
* Construct the error with an optional list of scopes that the request was
|
|
23
|
+
* missing — surfaced in the `WWW-Authenticate: ... scope="..."` parameter.
|
|
24
|
+
*
|
|
25
|
+
* @param { string } message Human-readable error message.
|
|
26
|
+
* @param { readonly string[] | undefined } requiredScopes Scopes required for the route (omitted from the wire when empty).
|
|
27
|
+
*/
|
|
28
|
+
constructor(message, requiredScopes) {
|
|
29
|
+
super(message);
|
|
30
|
+
if (!isEmpty(requiredScopes)) {
|
|
31
|
+
this.requiredScopes = requiredScopes;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Token authenticated but its `retailerId` claim does not match the
|
|
37
|
+
* resolver value for the current request. Raised by `requireRetailer`.
|
|
38
|
+
* Distinct from `InsufficientScopeError` — this is an authorization mismatch
|
|
39
|
+
* on a verified token, not a scope shortage. See default mapping in
|
|
40
|
+
* `default-error-mapping.ts` for the wire shape.
|
|
41
|
+
*/
|
|
42
|
+
export class RetailerMismatchError extends Error {
|
|
43
|
+
name = 'RetailerMismatchError';
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=errors.js.map
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import type { AuthContext } from '@mi9-identity/jwt-verifier';
|
|
3
|
+
import type { AuthEvent } from './types.js';
|
|
4
|
+
import type { VerifierError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Default inbound request-id header name. Canonical casing per the design doc;
|
|
7
|
+
* lookups lowercase it, so the casing is for log readability only. Single
|
|
8
|
+
* source imported by the adapter and the guards.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_REQUEST_ID_HEADER = "X-Request-ID";
|
|
11
|
+
/**
|
|
12
|
+
* Read a header by case-insensitive name. Picks the first value when Node has
|
|
13
|
+
* arrayed the header (rare on `Authorization`-style names but supported by
|
|
14
|
+
* `IncomingMessage.headers`). Shared by the adapter (Authorization lookup) and
|
|
15
|
+
* the request-id resolver below.
|
|
16
|
+
*
|
|
17
|
+
* @param { Request } req The Express request.
|
|
18
|
+
* @param { string } name Header name (any casing).
|
|
19
|
+
* @returns { string | undefined } The header value, or `undefined` when absent.
|
|
20
|
+
*/
|
|
21
|
+
export declare const headerString: (req: Request, name: string) => string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a stable request id: inbound header → `req.id` (set by id middleware
|
|
24
|
+
* such as `express-request-id`) → UUID. Identical resolution for adapter and
|
|
25
|
+
* guards so the same request never produces two different ids in BigQuery.
|
|
26
|
+
*
|
|
27
|
+
* @param { Request } req The Express request.
|
|
28
|
+
* @param { string } headerName Name of the inbound request-id header.
|
|
29
|
+
* @returns { string } The resolved request id.
|
|
30
|
+
*/
|
|
31
|
+
export declare const resolveRequestId: (req: Request, headerName: string) => string;
|
|
32
|
+
/**
|
|
33
|
+
* Build the `auth_succeeded` audit event from a verified `AuthContext`.
|
|
34
|
+
*
|
|
35
|
+
* @param { AuthContext } ctx The verified auth context.
|
|
36
|
+
* @param { string } requestId The resolved request id.
|
|
37
|
+
* @param { number } latencyMs Verification latency in milliseconds.
|
|
38
|
+
* @returns { AuthEvent } The success-shaped audit event.
|
|
39
|
+
*/
|
|
40
|
+
export declare const buildSuccessEvent: (ctx: AuthContext, requestId: string, latencyMs: number) => AuthEvent;
|
|
41
|
+
/**
|
|
42
|
+
* Build the `auth_failed` audit event from a verifier or guard error.
|
|
43
|
+
*
|
|
44
|
+
* @param { VerifierError } err The error raised by verification or a guard.
|
|
45
|
+
* @param { string } requestId The resolved request id.
|
|
46
|
+
* @param { number } latencyMs Verification latency in milliseconds.
|
|
47
|
+
* @returns { AuthEvent } The failure-shaped audit event.
|
|
48
|
+
*/
|
|
49
|
+
export declare const buildFailureEvent: (err: VerifierError, requestId: string, latencyMs: number) => AuthEvent;
|
|
50
|
+
//# sourceMappingURL=event.d.ts.map
|
package/dist/event.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isEmpty, isString } from './type-guards.js';
|
|
3
|
+
import { failureReasonFor } from './failure-reason.js';
|
|
4
|
+
/**
|
|
5
|
+
* Default inbound request-id header name. Canonical casing per the design doc;
|
|
6
|
+
* lookups lowercase it, so the casing is for log readability only. Single
|
|
7
|
+
* source imported by the adapter and the guards.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_REQUEST_ID_HEADER = 'X-Request-ID';
|
|
10
|
+
/**
|
|
11
|
+
* Read a header by case-insensitive name. Picks the first value when Node has
|
|
12
|
+
* arrayed the header (rare on `Authorization`-style names but supported by
|
|
13
|
+
* `IncomingMessage.headers`). Shared by the adapter (Authorization lookup) and
|
|
14
|
+
* the request-id resolver below.
|
|
15
|
+
*
|
|
16
|
+
* @param { Request } req The Express request.
|
|
17
|
+
* @param { string } name Header name (any casing).
|
|
18
|
+
* @returns { string | undefined } The header value, or `undefined` when absent.
|
|
19
|
+
*/
|
|
20
|
+
export const headerString = (req, name) => {
|
|
21
|
+
const v = req.headers[name.toLowerCase()];
|
|
22
|
+
if (isString(v)) {
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(v) && isString(v[0])) {
|
|
26
|
+
return v[0];
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a stable request id: inbound header → `req.id` (set by id middleware
|
|
32
|
+
* such as `express-request-id`) → UUID. Identical resolution for adapter and
|
|
33
|
+
* guards so the same request never produces two different ids in BigQuery.
|
|
34
|
+
*
|
|
35
|
+
* @param { Request } req The Express request.
|
|
36
|
+
* @param { string } headerName Name of the inbound request-id header.
|
|
37
|
+
* @returns { string } The resolved request id.
|
|
38
|
+
*/
|
|
39
|
+
export const resolveRequestId = (req, headerName) => {
|
|
40
|
+
const fromHeader = headerString(req, headerName);
|
|
41
|
+
if (!isEmpty(fromHeader)) {
|
|
42
|
+
return fromHeader;
|
|
43
|
+
}
|
|
44
|
+
const reqAny = req;
|
|
45
|
+
if (isString(reqAny.id) && !isEmpty(reqAny.id)) {
|
|
46
|
+
return reqAny.id;
|
|
47
|
+
}
|
|
48
|
+
return randomUUID();
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Build the `auth_succeeded` audit event from a verified `AuthContext`.
|
|
52
|
+
*
|
|
53
|
+
* @param { AuthContext } ctx The verified auth context.
|
|
54
|
+
* @param { string } requestId The resolved request id.
|
|
55
|
+
* @param { number } latencyMs Verification latency in milliseconds.
|
|
56
|
+
* @returns { AuthEvent } The success-shaped audit event.
|
|
57
|
+
*/
|
|
58
|
+
export const buildSuccessEvent = (ctx, requestId, latencyMs) => ({
|
|
59
|
+
event: 'auth_succeeded',
|
|
60
|
+
request_id: requestId,
|
|
61
|
+
client_id: ctx.sub,
|
|
62
|
+
retailer_code: ctx.retailerId,
|
|
63
|
+
product: ctx.product,
|
|
64
|
+
success: true,
|
|
65
|
+
latency_ms: latencyMs,
|
|
66
|
+
jti: ctx.jti,
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* Build the `auth_failed` audit event from a verifier or guard error.
|
|
70
|
+
*
|
|
71
|
+
* @param { VerifierError } err The error raised by verification or a guard.
|
|
72
|
+
* @param { string } requestId The resolved request id.
|
|
73
|
+
* @param { number } latencyMs Verification latency in milliseconds.
|
|
74
|
+
* @returns { AuthEvent } The failure-shaped audit event.
|
|
75
|
+
*/
|
|
76
|
+
export const buildFailureEvent = (err, requestId, latencyMs) => ({
|
|
77
|
+
event: 'auth_failed',
|
|
78
|
+
request_id: requestId,
|
|
79
|
+
success: false,
|
|
80
|
+
latency_ms: latencyMs,
|
|
81
|
+
failure_reason: failureReasonFor(err),
|
|
82
|
+
});
|
|
83
|
+
//# sourceMappingURL=event.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AuthEvent } from './types.js';
|
|
2
|
+
import { type VerifierError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Map a verifier or guard error onto its `AuthEvent.failure_reason` string.
|
|
5
|
+
* Single source of truth so adapter and guard rejections never disagree on
|
|
6
|
+
* the audit code for the same error class.
|
|
7
|
+
*
|
|
8
|
+
* @param { VerifierError } err The verifier or guard error to classify.
|
|
9
|
+
* @returns { NonNullable<AuthEvent['failure_reason']> } The matching audit `failure_reason` string.
|
|
10
|
+
*/
|
|
11
|
+
export declare const failureReasonFor: (err: VerifierError) => NonNullable<AuthEvent["failure_reason"]>;
|
|
12
|
+
//# sourceMappingURL=failure-reason.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AlgorithmNotAllowedError, AudienceMismatchError, InvalidTokenError, IssuerNotAllowedError, JwksUnavailableError, MissingRequiredClaimError, TokenExpiredError, } from '@mi9-identity/jwt-verifier';
|
|
2
|
+
import { InsufficientScopeError, MissingTokenError, RetailerMismatchError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Map a verifier or guard error onto its `AuthEvent.failure_reason` string.
|
|
5
|
+
* Single source of truth so adapter and guard rejections never disagree on
|
|
6
|
+
* the audit code for the same error class.
|
|
7
|
+
*
|
|
8
|
+
* @param { VerifierError } err The verifier or guard error to classify.
|
|
9
|
+
* @returns { NonNullable<AuthEvent['failure_reason']> } The matching audit `failure_reason` string.
|
|
10
|
+
*/
|
|
11
|
+
export const failureReasonFor = (err) => {
|
|
12
|
+
if (err instanceof MissingTokenError) {
|
|
13
|
+
return 'missing_token';
|
|
14
|
+
}
|
|
15
|
+
if (err instanceof InsufficientScopeError) {
|
|
16
|
+
return 'insufficient_scope';
|
|
17
|
+
}
|
|
18
|
+
if (err instanceof RetailerMismatchError) {
|
|
19
|
+
return 'retailer_mismatch';
|
|
20
|
+
}
|
|
21
|
+
if (err instanceof TokenExpiredError) {
|
|
22
|
+
return 'token_expired';
|
|
23
|
+
}
|
|
24
|
+
if (err instanceof IssuerNotAllowedError) {
|
|
25
|
+
return 'issuer_not_allowed';
|
|
26
|
+
}
|
|
27
|
+
if (err instanceof AudienceMismatchError) {
|
|
28
|
+
return 'audience_mismatch';
|
|
29
|
+
}
|
|
30
|
+
if (err instanceof AlgorithmNotAllowedError) {
|
|
31
|
+
return 'algorithm_not_allowed';
|
|
32
|
+
}
|
|
33
|
+
if (err instanceof MissingRequiredClaimError) {
|
|
34
|
+
return 'missing_required_claim';
|
|
35
|
+
}
|
|
36
|
+
if (err instanceof JwksUnavailableError) {
|
|
37
|
+
return 'jwks_unavailable';
|
|
38
|
+
}
|
|
39
|
+
if (err instanceof InvalidTokenError) {
|
|
40
|
+
return 'invalid_token';
|
|
41
|
+
}
|
|
42
|
+
return 'invalid_token';
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=failure-reason.js.map
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from 'express';
|
|
2
|
+
import type { AuthEvent } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for `createGuards`. Pass `onAuthEvent` to surface scope and
|
|
5
|
+
* retailer rejections in the same structured-event sink the adapter uses —
|
|
6
|
+
* without it, a guard rejection is honored on the wire but invisible to
|
|
7
|
+
* BigQuery/audit pipelines.
|
|
8
|
+
*/
|
|
9
|
+
export interface GuardOptions {
|
|
10
|
+
/** Defaults to no-op. Wire your structured logger here. Mirror of `Mi9AuthOptions.onAuthEvent`. */
|
|
11
|
+
onAuthEvent?: (e: AuthEvent) => void;
|
|
12
|
+
/** Defaults to `'X-Request-ID'`. Used only when `mi9Auth` did not stash `req.mi9AuthState` (i.e. guard mounted without the adapter). */
|
|
13
|
+
requestIdHeader?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Bundle returned by `createGuards`. Signatures are identical to the legacy
|
|
17
|
+
* top-level exports, so a consumer migrating only changes the construction
|
|
18
|
+
* site.
|
|
19
|
+
*/
|
|
20
|
+
export interface Guards {
|
|
21
|
+
requireScope: (scope: string | readonly string[]) => RequestHandler;
|
|
22
|
+
requireAnyScope: (scopes: readonly string[]) => RequestHandler;
|
|
23
|
+
requireRetailer: (resolveExpected: string | ((req: Request) => string)) => RequestHandler;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build a guard set bound to a structured-event emitter. Guard rejections
|
|
27
|
+
* (`insufficient_scope`, `retailer_mismatch`, `missing_token`) emit
|
|
28
|
+
* `auth_failed` events with the same `request_id` shape as the adapter — so a
|
|
29
|
+
* single request that verifies but fails authorization produces one
|
|
30
|
+
* `auth_succeeded` row followed by one `auth_failed` row, both keyed on the
|
|
31
|
+
* same id. Top-level `requireScope` / `requireAnyScope` / `requireRetailer`
|
|
32
|
+
* exports are equivalent to `createGuards()` with no event sink.
|
|
33
|
+
*
|
|
34
|
+
* @param { GuardOptions } options Guard configuration (`onAuthEvent`, `requestIdHeader`).
|
|
35
|
+
* @returns { Guards } The bundle of `requireScope` / `requireAnyScope` / `requireRetailer` factories.
|
|
36
|
+
*/
|
|
37
|
+
export declare const createGuards: (options?: GuardOptions) => Guards;
|
|
38
|
+
/**
|
|
39
|
+
* AND-semantics convenience export bound to a no-op event emitter. Call
|
|
40
|
+
* `createGuards({ onAuthEvent })` instead when rejections must reach BigQuery.
|
|
41
|
+
*/
|
|
42
|
+
export declare const requireScope: (scope: string | readonly string[]) => RequestHandler;
|
|
43
|
+
/** OR-semantics. See `createGuards` for event wiring. */
|
|
44
|
+
export declare const requireAnyScope: (scopes: readonly string[]) => RequestHandler;
|
|
45
|
+
/** Exact retailer match. See `createGuards` for event wiring. */
|
|
46
|
+
export declare const requireRetailer: (resolveExpected: string | ((req: Request) => string)) => RequestHandler;
|
|
47
|
+
//# sourceMappingURL=guards.d.ts.map
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { defaultErrorMapping } from './default-error-mapping.js';
|
|
2
|
+
import { isEmpty, isFunction, isString, isUndefined } from './type-guards.js';
|
|
3
|
+
import { buildFailureEvent, DEFAULT_REQUEST_ID_HEADER, resolveRequestId } from './event.js';
|
|
4
|
+
import { InsufficientScopeError, MissingTokenError, RetailerMismatchError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Apply a mapped `ErrorResponse` onto the outbound Express response,
|
|
7
|
+
* populating headers (when present) and writing the JSON body at the
|
|
8
|
+
* mapped status.
|
|
9
|
+
*
|
|
10
|
+
* @param { Parameters<RequestHandler>[1] } res The Express response.
|
|
11
|
+
* @param { VerifierError } err The error to map and send.
|
|
12
|
+
* @returns { void } Nothing.
|
|
13
|
+
*/
|
|
14
|
+
const respondWithError = (res, err) => {
|
|
15
|
+
const mapped = defaultErrorMapping(err);
|
|
16
|
+
/* v8 ignore next 5 -- defaultErrorMapping always populates headers for the guard error classes */
|
|
17
|
+
if (!isUndefined(mapped.headers)) {
|
|
18
|
+
for (const [k, v] of Object.entries(mapped.headers)) {
|
|
19
|
+
res.setHeader(k, v);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
res.status(mapped.status).json(mapped.body);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Return the verified `AuthContext` attached to the request, or throw
|
|
26
|
+
* `MissingTokenError` if `mi9Auth` did not run before the guard.
|
|
27
|
+
*
|
|
28
|
+
* @param { Request } req The Express request.
|
|
29
|
+
* @returns { AuthContext } The verified auth context.
|
|
30
|
+
* @throws { MissingTokenError } If no auth context is present on the request.
|
|
31
|
+
*/
|
|
32
|
+
const requireAuth = (req) => {
|
|
33
|
+
if (isUndefined(req.auth)) {
|
|
34
|
+
throw new MissingTokenError('mi9Auth middleware did not run before this guard');
|
|
35
|
+
}
|
|
36
|
+
return req.auth;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Coerce a single-scope string or scope list into a `readonly string[]` for
|
|
40
|
+
* uniform set-membership checks downstream.
|
|
41
|
+
*
|
|
42
|
+
* @param { string | readonly string[] } scope The scope or scope list.
|
|
43
|
+
* @returns { readonly string[] } The normalized scope list.
|
|
44
|
+
*/
|
|
45
|
+
const toScopeList = (scope) => (isString(scope) ? [scope] : scope);
|
|
46
|
+
/**
|
|
47
|
+
* Build a guard set bound to a structured-event emitter. Guard rejections
|
|
48
|
+
* (`insufficient_scope`, `retailer_mismatch`, `missing_token`) emit
|
|
49
|
+
* `auth_failed` events with the same `request_id` shape as the adapter — so a
|
|
50
|
+
* single request that verifies but fails authorization produces one
|
|
51
|
+
* `auth_succeeded` row followed by one `auth_failed` row, both keyed on the
|
|
52
|
+
* same id. Top-level `requireScope` / `requireAnyScope` / `requireRetailer`
|
|
53
|
+
* exports are equivalent to `createGuards()` with no event sink.
|
|
54
|
+
*
|
|
55
|
+
* @param { GuardOptions } options Guard configuration (`onAuthEvent`, `requestIdHeader`).
|
|
56
|
+
* @returns { Guards } The bundle of `requireScope` / `requireAnyScope` / `requireRetailer` factories.
|
|
57
|
+
*/
|
|
58
|
+
export const createGuards = (options = {}) => {
|
|
59
|
+
const emit = options.onAuthEvent ?? (() => undefined);
|
|
60
|
+
const requestIdHeader = options.requestIdHeader ?? DEFAULT_REQUEST_ID_HEADER;
|
|
61
|
+
const fail = (req, res, err) => {
|
|
62
|
+
// mi9AuthState is stashed by `mi9Auth` so guard latency spans the
|
|
63
|
+
// same anchor as the adapter's success event. Absent only when the
|
|
64
|
+
// adapter never ran — fall back to header/`req.id`/UUID and 0ms.
|
|
65
|
+
const stashed = req.mi9AuthState;
|
|
66
|
+
const requestId = stashed?.requestId ?? resolveRequestId(req, requestIdHeader);
|
|
67
|
+
const latencyMs = isUndefined(stashed) ? 0 : Date.now() - stashed.startedAt;
|
|
68
|
+
emit(buildFailureEvent(err, requestId, latencyMs));
|
|
69
|
+
respondWithError(res, err);
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* AND-semantics: the request's `scopes` must contain every entry in
|
|
73
|
+
* `scope`. A single string is treated as a one-element array. Use this
|
|
74
|
+
* when an operation requires multiple scopes simultaneously
|
|
75
|
+
* (e.g. `['orders:read', 'pos:write']`).
|
|
76
|
+
*
|
|
77
|
+
* @param { string | readonly string[] } scope Required scope or scopes.
|
|
78
|
+
* @returns { RequestHandler } Express middleware that enforces AND-scope semantics.
|
|
79
|
+
*/
|
|
80
|
+
const requireScope = (scope) => {
|
|
81
|
+
const required = toScopeList(scope);
|
|
82
|
+
return (req, res, next) => {
|
|
83
|
+
let auth;
|
|
84
|
+
try {
|
|
85
|
+
auth = requireAuth(req);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
fail(req, res, err);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const granted = new Set(auth.scopes);
|
|
92
|
+
const missing = required.filter((s) => !granted.has(s));
|
|
93
|
+
if (missing.length > 0) {
|
|
94
|
+
fail(req, res, new InsufficientScopeError(`Missing required scope(s): ${missing.join(' ')}`, required));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
next();
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* OR-semantics: any one of the listed scopes is sufficient. Use this when
|
|
102
|
+
* an operation can be authorized by alternative scopes (admin OR self).
|
|
103
|
+
*
|
|
104
|
+
* @param { readonly string[] } scopes Candidate scopes (at least one must match).
|
|
105
|
+
* @returns { RequestHandler } Express middleware that enforces OR-scope semantics.
|
|
106
|
+
* @throws { Error } If `scopes` is empty (wiring bug — surfaced at factory call, not request time).
|
|
107
|
+
*/
|
|
108
|
+
const requireAnyScope = (scopes) => {
|
|
109
|
+
const required = Array.from(scopes);
|
|
110
|
+
if (required.length === 0) {
|
|
111
|
+
throw new Error('requireAnyScope: `scopes` must contain at least one entry');
|
|
112
|
+
}
|
|
113
|
+
return (req, res, next) => {
|
|
114
|
+
let auth;
|
|
115
|
+
try {
|
|
116
|
+
auth = requireAuth(req);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
fail(req, res, err);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const granted = new Set(auth.scopes);
|
|
123
|
+
const matched = required.some((s) => granted.has(s));
|
|
124
|
+
if (!matched) {
|
|
125
|
+
fail(req, res, new InsufficientScopeError(`Required at least one scope: ${required.join(' ')}`, required));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
next();
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Exact retailer match — required arg, no defaults. Caller decides whether
|
|
133
|
+
* to read from `req.params`, a literal, or a header. Resolver throws to
|
|
134
|
+
* surface "wiring missing" loudly via Express's error handler.
|
|
135
|
+
*
|
|
136
|
+
* @param { string | ((req: Request) => string) } resolveExpected Literal retailer id or a resolver function.
|
|
137
|
+
* @returns { RequestHandler } Express middleware that enforces exact retailer match.
|
|
138
|
+
*/
|
|
139
|
+
const requireRetailer = (resolveExpected) => {
|
|
140
|
+
return (req, res, next) => {
|
|
141
|
+
let auth;
|
|
142
|
+
try {
|
|
143
|
+
auth = requireAuth(req);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
fail(req, res, err);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let expected;
|
|
150
|
+
try {
|
|
151
|
+
expected = isFunction(resolveExpected) ? resolveExpected(req) : resolveExpected;
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
next(err);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!isString(expected) || isEmpty(expected)) {
|
|
158
|
+
next(new Error('requireRetailer: resolver returned empty value'));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (auth.retailerId !== expected) {
|
|
162
|
+
fail(req, res, new RetailerMismatchError('Retailer mismatch: token retailer does not match resource'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
next();
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
return { requireScope, requireAnyScope, requireRetailer };
|
|
169
|
+
};
|
|
170
|
+
const noEventGuards = createGuards();
|
|
171
|
+
/**
|
|
172
|
+
* AND-semantics convenience export bound to a no-op event emitter. Call
|
|
173
|
+
* `createGuards({ onAuthEvent })` instead when rejections must reach BigQuery.
|
|
174
|
+
*/
|
|
175
|
+
export const requireScope = noEventGuards.requireScope;
|
|
176
|
+
/** OR-semantics. See `createGuards` for event wiring. */
|
|
177
|
+
export const requireAnyScope = noEventGuards.requireAnyScope;
|
|
178
|
+
/** Exact retailer match. See `createGuards` for event wiring. */
|
|
179
|
+
export const requireRetailer = noEventGuards.requireRetailer;
|
|
180
|
+
//# sourceMappingURL=guards.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AuthContext } from '@mi9-identity/jwt-verifier';
|
|
2
|
+
export { mi9Auth } from './adapter.js';
|
|
3
|
+
export { createGuards, requireAnyScope, requireScope, requireRetailer, type GuardOptions, type Guards } from './guards.js';
|
|
4
|
+
export { defaultErrorMapping } from './default-error-mapping.js';
|
|
5
|
+
export { InsufficientScopeError, MissingTokenError, RetailerMismatchError, type VerifierError } from './errors.js';
|
|
6
|
+
export type { AuthEvent, ErrorResponse, Mi9AuthOptions } from './types.js';
|
|
7
|
+
declare global {
|
|
8
|
+
namespace Express {
|
|
9
|
+
interface Request {
|
|
10
|
+
auth?: AuthContext;
|
|
11
|
+
/**
|
|
12
|
+
* Set by `mi9Auth` once the request enters the verifier pipeline.
|
|
13
|
+
* Read by guards built via `createGuards` so guard-rejection
|
|
14
|
+
* `auth_failed` rows keep the adapter's `request_id` and measure
|
|
15
|
+
* latency from the same anchor. Absent when `mi9Auth` did not run.
|
|
16
|
+
*/
|
|
17
|
+
mi9AuthState?: {
|
|
18
|
+
startedAt: number;
|
|
19
|
+
requestId: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { mi9Auth } from './adapter.js';
|
|
2
|
+
export { createGuards, requireAnyScope, requireScope, requireRetailer } from './guards.js';
|
|
3
|
+
export { defaultErrorMapping } from './default-error-mapping.js';
|
|
4
|
+
export { InsufficientScopeError, MissingTokenError, RetailerMismatchError } from './errors.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|