@vouchington/utils 0.8.0 → 0.10.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/README.md +20 -6
- package/dist/feature-flags.d.mts +1 -1
- package/dist/oauth-providers-facebook.d.mts +4 -2
- package/dist/oauth-providers-facebook.mjs +19 -12
- package/dist/oauth-providers-microsoft.d.mts +3 -6
- package/dist/oauth-providers-microsoft.mjs +38 -5
- package/dist/oauth-providers.d.mts +1 -1
- package/dist/oauth-providers.mjs +1 -1
- package/dist/observability-deep.d.mts +26 -0
- package/dist/observability-deep.mjs +79 -0
- package/dist/observability-email.d.mts +6 -0
- package/dist/observability-email.mjs +51 -0
- package/dist/observability-event.d.mts +10 -2
- package/dist/observability-event.mjs +18 -8
- package/dist/observability.d.mts +2 -1
- package/dist/observability.mjs +2 -1
- package/dist/oidc-id-token-profiles.d.mts +2 -0
- package/dist/oidc-id-token-profiles.mjs +14 -0
- package/dist/oidc-id-token.d.mts +3 -1
- package/dist/oidc-id-token.mjs +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,14 +85,20 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
|
|
|
85
85
|
- `oidc-id-token`: Node-only (`node:crypto`) RS256 ID-token verifier with an injected `fetch`, JWKS
|
|
86
86
|
URI, issuers, and audiences. It caches the JWKS with a TTL, refreshes once on a `kid` miss, checks
|
|
87
87
|
`iat` skew and an optional (hashed) nonce, and throws `OidcVerificationError` with a `code`.
|
|
88
|
-
`googleProfileFromClaims` and `
|
|
88
|
+
`googleProfileFromClaims`, `appleProfileFromClaims`, and `microsoftProfileFromClaims` map claims to a profile; account policy
|
|
89
89
|
and HTTP error mapping stay in the application.
|
|
90
90
|
- `oauth-providers`: authorization-code redemption for GitHub, X, LinkedIn, Microsoft, Google, and Facebook. Each
|
|
91
91
|
provider's `redeem(input, { fetch, signal, now })` returns `{ tokens, profile }`; `verifiedEmail`
|
|
92
|
-
follows each provider's own rule. Microsoft
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
follows each provider's own rule. Microsoft verifies an ID token with the tenant JWKS and uses only an `email` claim with boolean
|
|
93
|
+
`xms_edov: true` for `verifiedEmail`; Graph `mail` stays in profile data without verification. The
|
|
94
|
+
authorization URL must request `microsoftAuthorizationScopes` (`openid email User.Read`) so Graph
|
|
95
|
+
`/me` is authorized.
|
|
96
|
+
Facebook keeps Graph email as unverified data and supports native tokens through
|
|
97
|
+
`redeemAccessToken(token, ctx)`, which upgrades the token before fetching the profile.
|
|
98
|
+
Credentials, tenant, and the egress-guarded `fetch` are injected; provider and HTTP failures
|
|
99
|
+
throw `OAuthProviderError` (`provider`, `kind`, `status`), while Microsoft ID-token
|
|
100
|
+
verification failures throw `OidcVerificationError` (`code`). Persistence and HTTP error
|
|
101
|
+
mapping stay in the application.
|
|
96
102
|
- `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
|
|
97
103
|
and deterministic JSON serialization.
|
|
98
104
|
- `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
|
|
@@ -143,7 +149,15 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
|
|
|
143
149
|
returned by identity. Callers provide `credentialHeaders`, an optional `redactUrl` hook (applied
|
|
144
150
|
after query and fragment stripping, for path-borne credentials), and an opt-in `dropRequestBody`.
|
|
145
151
|
URL-only and credential-only passes are exported separately, and `composeBeforeSend` accepts
|
|
146
|
-
either `ScrubOptions` or a final scrubber function.
|
|
152
|
+
either `ScrubOptions` or a final scrubber function. `scrubEvent` also makes a recursive pass over
|
|
153
|
+
the whole event (`user`, `extra`, `contexts`, `tags`, `message`, exception values, breadcrumb
|
|
154
|
+
messages and data): values under sensitive keys (authorization, cookie, password, secret, token,
|
|
155
|
+
api/access key, email, dsn, credential) are replaced with `[Filtered]` (a plain object keeps its
|
|
156
|
+
keys), and every string has bearer/basic credentials, `token=` style assignments, and email
|
|
157
|
+
addresses redacted. `ScrubOptions` accepts additive `sensitiveKeyPatterns`, `textRedactors`, and
|
|
158
|
+
`maxDepth` (default 12, minimum `MIN_MAX_DEPTH` = 8; deeper containers become `[Filtered]`, cycles become `[Circular]`). Dates,
|
|
159
|
+
Errors, and other class instances are left as-is. `scrubDeep(value, options?)`, `redactText`, and
|
|
160
|
+
`scrubBreadcrumb(breadcrumb, options)` are exported for direct use.
|
|
147
161
|
|
|
148
162
|
- `error-tree`: `formatErrorTree(error, { maxDepth?, maxNested? })` renders an error with its
|
|
149
163
|
`errors[]` and `cause` chain, marks circular references, and reports how many nested errors the
|
package/dist/feature-flags.d.mts
CHANGED
|
@@ -8,7 +8,7 @@ export type FeatureFlagCookieOptions = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const DEFAULT_FEATURE_FLAG_COOKIE_MAX_LENGTH = 4096;
|
|
10
10
|
export declare function extractBooleanFeatureFlags(fields: Record<string, unknown>): FeatureFlags;
|
|
11
|
-
export declare function parseFeatureFlagCookie(cookieValue: string, codec: FeatureFlagCookieCodec, options?: FeatureFlagCookieOptions): FeatureFlags;
|
|
11
|
+
export declare function parseFeatureFlagCookie(cookieValue: string, codec: Partial<FeatureFlagCookieCodec> & Pick<FeatureFlagCookieCodec, 'decodeBase64'>, options?: FeatureFlagCookieOptions): FeatureFlags;
|
|
12
12
|
export declare function encodeFeatureFlagCookie(overrides: FeatureFlags, codec: FeatureFlagCookieCodec): string;
|
|
13
13
|
export declare function parseFeatureFlagCookieMaxLength(value: string | undefined, fallback?: number): number;
|
|
14
14
|
export declare function safeFeatureFlagCookiePart(cookieName: string, cookieValue: string | undefined, options?: FeatureFlagCookieOptions): string | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type OAuthProvider } from './oauth-providers-core.mts';
|
|
1
|
+
import { type OAuthCallContext, type OAuthProvider } from './oauth-providers-core.mts';
|
|
2
2
|
export interface FacebookProviderConfig {
|
|
3
3
|
appId: string;
|
|
4
4
|
appSecret: string;
|
|
@@ -6,4 +6,6 @@ export interface FacebookProviderConfig {
|
|
|
6
6
|
graphVersion: string;
|
|
7
7
|
}
|
|
8
8
|
/** Code exchange, then `fb_exchange_token` for a long-lived token, then `/me`. */
|
|
9
|
-
export declare function facebookProvider(config: FacebookProviderConfig): OAuthProvider
|
|
9
|
+
export declare function facebookProvider(config: FacebookProviderConfig): OAuthProvider & {
|
|
10
|
+
redeemAccessToken(token: string, ctx: OAuthCallContext): ReturnType<OAuthProvider['redeem']>;
|
|
11
|
+
};
|
|
@@ -7,21 +7,28 @@ export function facebookProvider(config) {
|
|
|
7
7
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
8
8
|
body: formBody({ client_id: config.appId, client_secret: config.appSecret, ...values }),
|
|
9
9
|
});
|
|
10
|
+
async function redeemAccessToken(token, ctx) {
|
|
11
|
+
if (!token) {
|
|
12
|
+
throw new OAuthProviderError('facebook', 'malformed', 'An access token is required');
|
|
13
|
+
}
|
|
14
|
+
const now = nowOf(ctx);
|
|
15
|
+
const tokens = parseTokens('facebook', await requestJson('facebook', 'Facebook token exchange', ctx, `${graph}/oauth/access_token`, form({ grant_type: 'fb_exchange_token', fb_exchange_token: token })), now);
|
|
16
|
+
const mePayload = await requestJson('facebook', 'Facebook /me request', ctx, `${graph}/me?fields=name,email`, { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
|
|
17
|
+
const me = asRecord('facebook', mePayload, 'Facebook /me response');
|
|
18
|
+
const id = str(me.id);
|
|
19
|
+
if (!id)
|
|
20
|
+
throw new OAuthProviderError('facebook', 'malformed', 'Facebook user is missing id');
|
|
21
|
+
return {
|
|
22
|
+
tokens,
|
|
23
|
+
profile: { providerUserId: id, verifiedEmail: null, data: me },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
10
26
|
return {
|
|
11
27
|
id: 'facebook',
|
|
12
28
|
async redeem(input, ctx) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const tokens = parseTokens('facebook', await requestJson('facebook', 'Facebook token exchange', ctx, `${graph}/oauth/access_token`, form({ grant_type: 'fb_exchange_token', fb_exchange_token: shortLived.accessToken })), now);
|
|
16
|
-
const mePayload = await requestJson('facebook', 'Facebook /me request', ctx, `${graph}/me?fields=name,email`, { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
|
|
17
|
-
const me = asRecord('facebook', mePayload, 'Facebook /me response');
|
|
18
|
-
const id = str(me.id);
|
|
19
|
-
if (!id)
|
|
20
|
-
throw new OAuthProviderError('facebook', 'malformed', 'Facebook user is missing id');
|
|
21
|
-
return {
|
|
22
|
-
tokens,
|
|
23
|
-
profile: { providerUserId: id, verifiedEmail: str(me.email) ?? null, data: me },
|
|
24
|
-
};
|
|
29
|
+
const shortLived = parseTokens('facebook', await requestJson('facebook', 'Facebook authorization code exchange', ctx, `${graph}/oauth/access_token`, form({ redirect_uri: input.redirectUri, code: input.code })), nowOf(ctx));
|
|
30
|
+
return redeemAccessToken(shortLived.accessToken, ctx);
|
|
25
31
|
},
|
|
32
|
+
redeemAccessToken,
|
|
26
33
|
};
|
|
27
34
|
}
|
|
@@ -4,11 +4,8 @@ export interface MicrosoftProviderConfig {
|
|
|
4
4
|
clientSecret: string;
|
|
5
5
|
/** Defaults to `common`. */
|
|
6
6
|
tenantId?: string;
|
|
7
|
-
/**
|
|
8
|
-
* Treat Graph `mail` as verified. Off by default: a tenant admin can set `mail` without proving
|
|
9
|
-
* ownership, so only enable it when every accepted tenant is trusted.
|
|
10
|
-
*/
|
|
11
|
-
trustMailAsVerified?: boolean;
|
|
12
7
|
}
|
|
13
|
-
/**
|
|
8
|
+
/** Request these scopes in the authorization URL for ID-token email and Graph /me. */
|
|
9
|
+
export declare const microsoftAuthorizationScopes = "openid email User.Read";
|
|
10
|
+
/** v2.0 code exchange with PKCE, signed ID-token email proof, and Graph profile data. */
|
|
14
11
|
export declare function microsoftProvider(config: MicrosoftProviderConfig): OAuthProvider;
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { createOidcIdTokenVerifier } from './oidc-id-token.mjs';
|
|
2
|
+
import { microsoftProfileFromClaims } from './oidc-id-token-profiles.mjs';
|
|
1
3
|
import { OAuthProviderError, asRecord, compact, formBody, nowOf, parseTokens, requestJson, requireCodeVerifier, str, } from './oauth-providers-core.mjs';
|
|
2
|
-
|
|
4
|
+
const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
/** Request these scopes in the authorization URL for ID-token email and Graph /me. */
|
|
6
|
+
export const microsoftAuthorizationScopes = 'openid email User.Read';
|
|
7
|
+
/** v2.0 code exchange with PKCE, signed ID-token email proof, and Graph profile data. */
|
|
3
8
|
export function microsoftProvider(config) {
|
|
4
9
|
return {
|
|
5
10
|
id: 'microsoft',
|
|
6
11
|
async redeem(input, ctx) {
|
|
7
12
|
const codeVerifier = requireCodeVerifier('microsoft', input);
|
|
8
13
|
const tenant = config.tenantId || 'common';
|
|
9
|
-
const
|
|
14
|
+
const login = `https://login.microsoftonline.com/${tenant}`;
|
|
15
|
+
const tokenPayload = await requestJson('microsoft', 'Microsoft token exchange', ctx, `${login}/oauth2/v2.0/token`, {
|
|
10
16
|
method: 'POST',
|
|
11
17
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
12
18
|
body: formBody({
|
|
@@ -16,23 +22,50 @@ export function microsoftProvider(config) {
|
|
|
16
22
|
client_id: config.clientId,
|
|
17
23
|
client_secret: config.clientSecret,
|
|
18
24
|
code_verifier: codeVerifier,
|
|
25
|
+
scope: microsoftAuthorizationScopes,
|
|
19
26
|
}),
|
|
20
27
|
});
|
|
21
28
|
const tokens = parseTokens('microsoft', tokenPayload, nowOf(ctx));
|
|
29
|
+
const body = asRecord('microsoft', tokenPayload, 'Microsoft token response');
|
|
30
|
+
const idToken = str(body.id_token);
|
|
31
|
+
if (!idToken) {
|
|
32
|
+
throw new OAuthProviderError('microsoft', 'malformed', 'Token response is missing id_token');
|
|
33
|
+
}
|
|
34
|
+
const alias = tenant === 'common' || tenant === 'organizations';
|
|
35
|
+
const verifier = createOidcIdTokenVerifier({
|
|
36
|
+
jwksUri: `${login}/discovery/v2.0/keys`,
|
|
37
|
+
issuers: [],
|
|
38
|
+
issuerMatches: (claims, keyIssuer) => {
|
|
39
|
+
const tid = claims.tid;
|
|
40
|
+
if (typeof tid !== 'string' || !TENANT_ID.test(tid))
|
|
41
|
+
return false;
|
|
42
|
+
const issuer = `https://login.microsoftonline.com/${tid}/v2.0`;
|
|
43
|
+
return ((alias || tid === tenant) &&
|
|
44
|
+
claims.iss === issuer &&
|
|
45
|
+
(keyIssuer === undefined || keyIssuer.replace('{tenantid}', tid) === issuer));
|
|
46
|
+
},
|
|
47
|
+
audiences: () => [config.clientId],
|
|
48
|
+
fetch: ctx.fetch,
|
|
49
|
+
...(ctx.now ? { now: ctx.now } : {}),
|
|
50
|
+
});
|
|
51
|
+
const claims = await verifier.verify(idToken, ctx.signal ? { signal: ctx.signal } : {});
|
|
22
52
|
const mePayload = await requestJson('microsoft', 'Microsoft Graph /me request', ctx, 'https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
|
|
23
53
|
const me = asRecord('microsoft', mePayload, 'Microsoft Graph /me response');
|
|
24
54
|
const id = str(me.id);
|
|
25
55
|
if (!id)
|
|
26
56
|
throw new OAuthProviderError('microsoft', 'malformed', 'Microsoft user is missing id');
|
|
27
|
-
|
|
57
|
+
if (claims.oid !== id) {
|
|
58
|
+
throw new OAuthProviderError('microsoft', 'malformed', 'ID token user does not match Graph user');
|
|
59
|
+
}
|
|
60
|
+
const signedProfile = microsoftProfileFromClaims(claims);
|
|
28
61
|
return {
|
|
29
62
|
tokens,
|
|
30
63
|
profile: {
|
|
31
64
|
providerUserId: id,
|
|
32
|
-
verifiedEmail:
|
|
65
|
+
verifiedEmail: signedProfile.verifiedEmail,
|
|
33
66
|
data: compact({
|
|
34
67
|
name: str(me.displayName),
|
|
35
|
-
email,
|
|
68
|
+
email: str(me.mail),
|
|
36
69
|
given_name: str(me.givenName),
|
|
37
70
|
family_name: str(me.surname),
|
|
38
71
|
}),
|
|
@@ -4,5 +4,5 @@ export { facebookProvider, type FacebookProviderConfig } from './oauth-providers
|
|
|
4
4
|
export { githubProvider, type GithubProviderConfig } from './oauth-providers-github.mts';
|
|
5
5
|
export { googleProvider, type GoogleProviderConfig } from './oauth-providers-google.mts';
|
|
6
6
|
export { linkedinProvider, type LinkedinProviderConfig } from './oauth-providers-linkedin.mts';
|
|
7
|
-
export { microsoftProvider, type MicrosoftProviderConfig } from './oauth-providers-microsoft.mts';
|
|
7
|
+
export { microsoftAuthorizationScopes, microsoftProvider, type MicrosoftProviderConfig, } from './oauth-providers-microsoft.mts';
|
|
8
8
|
export { xProvider, type XProviderConfig } from './oauth-providers-x.mts';
|
package/dist/oauth-providers.mjs
CHANGED
|
@@ -3,5 +3,5 @@ export { facebookProvider } from './oauth-providers-facebook.mjs';
|
|
|
3
3
|
export { githubProvider } from './oauth-providers-github.mjs';
|
|
4
4
|
export { googleProvider } from './oauth-providers-google.mjs';
|
|
5
5
|
export { linkedinProvider } from './oauth-providers-linkedin.mjs';
|
|
6
|
-
export { microsoftProvider } from './oauth-providers-microsoft.mjs';
|
|
6
|
+
export { microsoftAuthorizationScopes, microsoftProvider, } from './oauth-providers-microsoft.mjs';
|
|
7
7
|
export { xProvider } from './oauth-providers-x.mjs';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const EMAIL_REDACTED = "[Filtered email]";
|
|
2
|
+
export declare const TOKEN_REDACTED = "[Filtered token]";
|
|
3
|
+
export declare const CIRCULAR_VALUE = "[Circular]";
|
|
4
|
+
export declare const DEFAULT_MAX_DEPTH = 12;
|
|
5
|
+
/** Deep enough to keep `event.exception.values[].stacktrace.frames[]` objects intact. */
|
|
6
|
+
export declare const MIN_MAX_DEPTH = 8;
|
|
7
|
+
export interface DeepScrubOptions {
|
|
8
|
+
/** Extra key patterns, added to the defaults, whose values are replaced with the filtered marker. */
|
|
9
|
+
sensitiveKeyPatterns?: readonly RegExp[];
|
|
10
|
+
/** Extra text redactors, run after the defaults, applied to every string in the event. */
|
|
11
|
+
textRedactors?: ReadonlyArray<(text: string) => string>;
|
|
12
|
+
/**
|
|
13
|
+
* Containers nested deeper than this are replaced with the filtered marker. Default 12; at least
|
|
14
|
+
* `MIN_MAX_DEPTH` so event structure (fingerprint, exception frames) is never truncated.
|
|
15
|
+
*/
|
|
16
|
+
maxDepth?: number;
|
|
17
|
+
}
|
|
18
|
+
/** Default text redactor: bearer/basic credentials, token assignments, then email addresses. */
|
|
19
|
+
export declare function redactText(text: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Recursively redact sensitive keys and free-text secrets. Plain objects and arrays are walked;
|
|
22
|
+
* Date, Error, and other class instances are returned as-is. Cycles become a marker, containers
|
|
23
|
+
* beyond `maxDepth` are replaced (fail closed), and unchanged input is returned by identity.
|
|
24
|
+
* Shared subtrees are scrubbed once per depth they are first reached at, not once per path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function scrubDeep<T>(value: T, options?: DeepScrubOptions): T;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { redactEmails } from './observability-email.mjs';
|
|
2
|
+
import { SENSITIVE_VALUE, isRecord, transformRecord } from './observability-records.mjs';
|
|
3
|
+
export const EMAIL_REDACTED = '[Filtered email]';
|
|
4
|
+
export const TOKEN_REDACTED = '[Filtered token]';
|
|
5
|
+
export const CIRCULAR_VALUE = '[Circular]';
|
|
6
|
+
export const DEFAULT_MAX_DEPTH = 12;
|
|
7
|
+
/** Deep enough to keep `event.exception.values[].stacktrace.frames[]` objects intact. */
|
|
8
|
+
export const MIN_MAX_DEPTH = 8;
|
|
9
|
+
const DEFAULT_KEY_PATTERN = /authorization|cookie|password|passwd|secret|token|api[_-]?key|access[_-]?key|email|dsn|credential/i;
|
|
10
|
+
const AUTH_TOKEN_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
11
|
+
const TOKEN_ASSIGNMENT_PATTERN = /\b((?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|secret|password|passwd|api[_-]?key)=)(?!\[Filtered token\])[^&\s]+/gi;
|
|
12
|
+
/** Default text redactor: bearer/basic credentials, token assignments, then email addresses. */
|
|
13
|
+
export function redactText(text) {
|
|
14
|
+
return redactEmails(text
|
|
15
|
+
.replace(AUTH_TOKEN_PATTERN, `$1 ${TOKEN_REDACTED}`)
|
|
16
|
+
.replace(TOKEN_ASSIGNMENT_PATTERN, `$1${TOKEN_REDACTED}`), EMAIL_REDACTED);
|
|
17
|
+
}
|
|
18
|
+
/** Stateless copy of a pattern, so `g` and `y` flags cannot make `test` order-dependent. */
|
|
19
|
+
function stateless(pattern) {
|
|
20
|
+
return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
|
|
21
|
+
}
|
|
22
|
+
/** Redact a sensitive value; a plain object keeps its keys with every value filtered. */
|
|
23
|
+
function redactValue(value) {
|
|
24
|
+
return isRecord(value) && isPlain(value)
|
|
25
|
+
? transformRecord(value, () => SENSITIVE_VALUE)
|
|
26
|
+
: SENSITIVE_VALUE;
|
|
27
|
+
}
|
|
28
|
+
function isPlain(value) {
|
|
29
|
+
const prototype = Object.getPrototypeOf(value);
|
|
30
|
+
return prototype === Object.prototype || prototype === null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Recursively redact sensitive keys and free-text secrets. Plain objects and arrays are walked;
|
|
34
|
+
* Date, Error, and other class instances are returned as-is. Cycles become a marker, containers
|
|
35
|
+
* beyond `maxDepth` are replaced (fail closed), and unchanged input is returned by identity.
|
|
36
|
+
* Shared subtrees are scrubbed once per depth they are first reached at, not once per path.
|
|
37
|
+
*/
|
|
38
|
+
export function scrubDeep(value, options = {}) {
|
|
39
|
+
const keyPatterns = [DEFAULT_KEY_PATTERN, ...(options.sensitiveKeyPatterns ?? []).map(stateless)];
|
|
40
|
+
const redactors = [redactText, ...(options.textRedactors ?? [])];
|
|
41
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
42
|
+
if (!Number.isInteger(maxDepth) || maxDepth < MIN_MAX_DEPTH) {
|
|
43
|
+
throw new RangeError(`maxDepth must be an integer of at least ${MIN_MAX_DEPTH}`);
|
|
44
|
+
}
|
|
45
|
+
const ancestors = new WeakSet();
|
|
46
|
+
// A result computed at a shallower depth had more budget, so it is reusable at deeper depths.
|
|
47
|
+
const done = new WeakMap();
|
|
48
|
+
const isSensitiveKey = (key) => keyPatterns.some((pattern) => pattern.test(key));
|
|
49
|
+
function walkArray(input, depth) {
|
|
50
|
+
const mapped = input.map((child) => walk(child, depth + 1));
|
|
51
|
+
return mapped.some((child, index) => !Object.is(child, input[index])) ? mapped : input;
|
|
52
|
+
}
|
|
53
|
+
function walk(input, depth) {
|
|
54
|
+
if (typeof input === 'string')
|
|
55
|
+
return redactors.reduce((text, redact) => redact(text), input);
|
|
56
|
+
const isArray = Array.isArray(input);
|
|
57
|
+
if (!isArray && !(isRecord(input) && isPlain(input)))
|
|
58
|
+
return input;
|
|
59
|
+
if (ancestors.has(input))
|
|
60
|
+
return CIRCULAR_VALUE;
|
|
61
|
+
if (depth >= maxDepth)
|
|
62
|
+
return SENSITIVE_VALUE;
|
|
63
|
+
const cached = done.get(input);
|
|
64
|
+
if (cached && cached.depth <= depth)
|
|
65
|
+
return cached.result;
|
|
66
|
+
ancestors.add(input);
|
|
67
|
+
try {
|
|
68
|
+
const result = isArray
|
|
69
|
+
? walkArray(input, depth)
|
|
70
|
+
: transformRecord(input, (key, child) => isSensitiveKey(key) ? redactValue(child) : walk(child, depth + 1));
|
|
71
|
+
done.set(input, { depth, result });
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
ancestors.delete(input);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return walk(value, 0);
|
|
79
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace email addresses with `replacement` in linear time. Matching starts from each `@` and
|
|
3
|
+
* expands over local-part and domain characters, which never include `@`, so no character is
|
|
4
|
+
* scanned more than a constant number of times (unlike a backtracking regular expression).
|
|
5
|
+
*/
|
|
6
|
+
export declare function redactEmails(text: string, replacement: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
|
|
2
|
+
const DOMAIN_CHAR = /[A-Za-z0-9.-]/;
|
|
3
|
+
const LETTER = /[A-Za-z]/;
|
|
4
|
+
const WORD_CHAR = /\w/;
|
|
5
|
+
/** End of the longest `label.tld` domain in `text[from, end)`, or -1; one forward pass. */
|
|
6
|
+
function domainEnd(text, from, end) {
|
|
7
|
+
let best = -1;
|
|
8
|
+
let letterRunStart = from;
|
|
9
|
+
for (let index = from; index < end; index++) {
|
|
10
|
+
if (!LETTER.test(text[index])) {
|
|
11
|
+
letterRunStart = index + 1;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const after = index + 1;
|
|
15
|
+
if (after - letterRunStart >= 2 &&
|
|
16
|
+
letterRunStart - 1 > from &&
|
|
17
|
+
text[letterRunStart - 1] === '.' &&
|
|
18
|
+
!WORD_CHAR.test(text[after] ?? '')) {
|
|
19
|
+
best = after;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return best;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Replace email addresses with `replacement` in linear time. Matching starts from each `@` and
|
|
26
|
+
* expands over local-part and domain characters, which never include `@`, so no character is
|
|
27
|
+
* scanned more than a constant number of times (unlike a backtracking regular expression).
|
|
28
|
+
*/
|
|
29
|
+
export function redactEmails(text, replacement) {
|
|
30
|
+
let output = '';
|
|
31
|
+
let copied = 0;
|
|
32
|
+
let at = text.indexOf('@');
|
|
33
|
+
while (at !== -1) {
|
|
34
|
+
let start = at;
|
|
35
|
+
while (start > copied && LOCAL_CHAR.test(text[start - 1]))
|
|
36
|
+
start--;
|
|
37
|
+
let end = at + 1;
|
|
38
|
+
while (end < text.length && DOMAIN_CHAR.test(text[end]))
|
|
39
|
+
end++;
|
|
40
|
+
const domain = domainEnd(text, at + 1, end);
|
|
41
|
+
if (start < at && domain !== -1) {
|
|
42
|
+
output += text.slice(copied, start) + replacement;
|
|
43
|
+
copied = domain;
|
|
44
|
+
at = text.indexOf('@', domain);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
at = text.indexOf('@', at + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return output + text.slice(copied);
|
|
51
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type DeepScrubOptions } from './observability-deep.mts';
|
|
1
2
|
import { type UrlScrubOptions } from './observability-url.mts';
|
|
2
|
-
export interface ScrubOptions extends UrlScrubOptions {
|
|
3
|
+
export interface ScrubOptions extends UrlScrubOptions, DeepScrubOptions {
|
|
3
4
|
/** Header names (case-insensitive) whose values are replaced with the filtered marker. */
|
|
4
5
|
credentialHeaders: readonly string[];
|
|
5
6
|
/** Also drop `request.data` and the `http.request.body.data` attribute. Defaults to false. */
|
|
@@ -20,7 +21,14 @@ export declare function scrubRequest<T extends object>(request: T | undefined, o
|
|
|
20
21
|
export declare function scrubBreadcrumbs<T extends {
|
|
21
22
|
data?: Record<string, unknown>;
|
|
22
23
|
}>(breadcrumbs: T[] | undefined, options: ScrubOptions): T[] | undefined;
|
|
23
|
-
/** Scrub
|
|
24
|
+
/** Scrub one breadcrumb: its `data` like span attributes, then every key and string recursively. */
|
|
25
|
+
export declare function scrubBreadcrumb<T extends {
|
|
26
|
+
data?: Record<string, unknown>;
|
|
27
|
+
}>(breadcrumb: T, options: ScrubOptions): T;
|
|
28
|
+
/**
|
|
29
|
+
* Scrub an event: request and breadcrumb data first, then a recursive pass over the whole event
|
|
30
|
+
* (`user`, `extra`, `contexts`, `tags`, messages, exception values). Unchanged input is returned.
|
|
31
|
+
*/
|
|
24
32
|
export declare function scrubEvent<T extends ObservableEvent>(event: T, options: ScrubOptions): T;
|
|
25
33
|
type BeforeSendResult<T> = T | null | PromiseLike<T | null>;
|
|
26
34
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dropRequestBody, dropSpanRequestBody } from './observability-body.mjs';
|
|
2
|
+
import { scrubDeep } from './observability-deep.mjs';
|
|
2
3
|
import { scrubCredentialHeaders, scrubRequestCredentialFields, scrubSpanCredentialAttributes, } from './observability-credentials.mjs';
|
|
3
4
|
import { scrubHeaderUrls, scrubRequestUrlFields, scrubSpanUrlAttributes, } from './observability-url.mjs';
|
|
4
5
|
export function scrubHeaders(headers, credentialHeaders, redactUrl) {
|
|
@@ -27,17 +28,26 @@ export function scrubBreadcrumbs(breadcrumbs, options) {
|
|
|
27
28
|
? result
|
|
28
29
|
: breadcrumbs;
|
|
29
30
|
}
|
|
30
|
-
/** Scrub
|
|
31
|
+
/** Scrub one breadcrumb: its `data` like span attributes, then every key and string recursively. */
|
|
32
|
+
export function scrubBreadcrumb(breadcrumb, options) {
|
|
33
|
+
const [scrubbed] = scrubBreadcrumbs([breadcrumb], options);
|
|
34
|
+
return scrubDeep(scrubbed, options);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Scrub an event: request and breadcrumb data first, then a recursive pass over the whole event
|
|
38
|
+
* (`user`, `extra`, `contexts`, `tags`, messages, exception values). Unchanged input is returned.
|
|
39
|
+
*/
|
|
31
40
|
export function scrubEvent(event, options) {
|
|
32
41
|
const request = scrubRequest(event.request, options);
|
|
33
42
|
const breadcrumbs = scrubBreadcrumbs(event.breadcrumbs, options);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const base = request === event.request && breadcrumbs === event.breadcrumbs
|
|
44
|
+
? event
|
|
45
|
+
: {
|
|
46
|
+
...event,
|
|
47
|
+
...(request === event.request ? {} : { request }),
|
|
48
|
+
...(breadcrumbs === event.breadcrumbs ? {} : { breadcrumbs }),
|
|
49
|
+
};
|
|
50
|
+
return scrubDeep(base, options);
|
|
41
51
|
}
|
|
42
52
|
function isPromiseLike(value) {
|
|
43
53
|
if (value === null || (typeof value !== 'object' && typeof value !== 'function'))
|
package/dist/observability.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { SENSITIVE_VALUE } from './observability-records.mts';
|
|
2
2
|
export { createSpikeWindowTracker, DEFAULT_MAX_SPIKE_FINGERPRINTS, type SpikeWindowTracker, } from './spike-window.mts';
|
|
3
|
-
export {
|
|
3
|
+
export { CIRCULAR_VALUE, DEFAULT_MAX_DEPTH, EMAIL_REDACTED, MIN_MAX_DEPTH, TOKEN_REDACTED, redactText, scrubDeep, type DeepScrubOptions, } from './observability-deep.mts';
|
|
4
|
+
export { composeBeforeSend, scrubBreadcrumb, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, type ObservableEvent, type ScrubOptions, } from './observability-event.mts';
|
|
4
5
|
export { scrubCredentialHeaders, scrubRequestCredentialFields, scrubSpanCredentialAttributes, } from './observability-credentials.mts';
|
|
5
6
|
export { SCRUBBED_URL_ATTRIBUTE_KEYS, URL_ATTRIBUTE_KEYS, URL_COMPONENT_ATTRIBUTE_KEYS, scrubHeaderUrls, scrubRequestUrlFields, scrubSpanUrlAttributes, scrubUrlValue, stripUrlQueryAndFragment, type UrlScrubOptions, } from './observability-url.mts';
|
|
6
7
|
export { REQUEST_BODY_SPAN_ATTRIBUTE, dropRequestBody, dropSpanRequestBody, } from './observability-body.mts';
|
package/dist/observability.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { SENSITIVE_VALUE } from './observability-records.mjs';
|
|
2
2
|
export { createSpikeWindowTracker, DEFAULT_MAX_SPIKE_FINGERPRINTS, } from './spike-window.mjs';
|
|
3
|
-
export {
|
|
3
|
+
export { CIRCULAR_VALUE, DEFAULT_MAX_DEPTH, EMAIL_REDACTED, MIN_MAX_DEPTH, TOKEN_REDACTED, redactText, scrubDeep, } from './observability-deep.mjs';
|
|
4
|
+
export { composeBeforeSend, scrubBreadcrumb, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, } from './observability-event.mjs';
|
|
4
5
|
export { scrubCredentialHeaders, scrubRequestCredentialFields, scrubSpanCredentialAttributes, } from './observability-credentials.mjs';
|
|
5
6
|
export { SCRUBBED_URL_ATTRIBUTE_KEYS, URL_ATTRIBUTE_KEYS, URL_COMPONENT_ATTRIBUTE_KEYS, scrubHeaderUrls, scrubRequestUrlFields, scrubSpanUrlAttributes, scrubUrlValue, stripUrlQueryAndFragment, } from './observability-url.mjs';
|
|
6
7
|
export { REQUEST_BODY_SPAN_ATTRIBUTE, dropRequestBody, dropSpanRequestBody, } from './observability-body.mjs';
|
|
@@ -15,6 +15,8 @@ export interface OidcClaims {
|
|
|
15
15
|
[claim: string]: unknown;
|
|
16
16
|
}
|
|
17
17
|
export declare function googleProfileFromClaims(claims: OidcClaims): OAuthProfile;
|
|
18
|
+
/** Microsoft verifies an email only when the signed domain-owner claim is boolean true. */
|
|
19
|
+
export declare function microsoftProfileFromClaims(claims: OidcClaims): OAuthProfile;
|
|
18
20
|
/** Apple sends `email_verified` as a boolean or the string `'true'`. */
|
|
19
21
|
export declare function appleProfileFromClaims(claims: OidcClaims, userData?: {
|
|
20
22
|
name?: string;
|
|
@@ -13,6 +13,20 @@ export function googleProfileFromClaims(claims) {
|
|
|
13
13
|
data,
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
/** Microsoft verifies an email only when the signed domain-owner claim is boolean true. */
|
|
17
|
+
export function microsoftProfileFromClaims(claims) {
|
|
18
|
+
const data = {};
|
|
19
|
+
for (const key of ['name', 'email', 'given_name', 'family_name']) {
|
|
20
|
+
if (claims[key])
|
|
21
|
+
data[key] = claims[key];
|
|
22
|
+
}
|
|
23
|
+
const oid = claims.oid;
|
|
24
|
+
return {
|
|
25
|
+
providerUserId: typeof oid === 'string' && oid.length > 0 ? oid : claims.sub,
|
|
26
|
+
verifiedEmail: claims.xms_edov === true ? (claims.email ?? null) : null,
|
|
27
|
+
data,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
16
30
|
/** Apple sends `email_verified` as a boolean or the string `'true'`. */
|
|
17
31
|
export function appleProfileFromClaims(claims, userData) {
|
|
18
32
|
const data = {};
|
package/dist/oidc-id-token.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OAuthFetch } from './oauth-types.mts';
|
|
2
2
|
import type { OidcClaims } from './oidc-id-token-profiles.mts';
|
|
3
|
-
export { appleProfileFromClaims, googleProfileFromClaims, type OidcClaims, } from './oidc-id-token-profiles.mts';
|
|
3
|
+
export { appleProfileFromClaims, googleProfileFromClaims, microsoftProfileFromClaims, type OidcClaims, } from './oidc-id-token-profiles.mts';
|
|
4
4
|
export type OidcVerificationCode = 'format' | 'alg' | 'key_not_found' | 'signature' | 'expired' | 'future_iat' | 'not_yet_valid' | 'issuer' | 'audience' | 'nonce' | 'nonce_missing' | 'jwks_fetch';
|
|
5
5
|
export declare class OidcVerificationError extends Error {
|
|
6
6
|
readonly code: OidcVerificationCode;
|
|
@@ -9,6 +9,8 @@ export declare class OidcVerificationError extends Error {
|
|
|
9
9
|
export interface OidcIdTokenVerifierOptions {
|
|
10
10
|
jwksUri: string;
|
|
11
11
|
issuers: readonly string[];
|
|
12
|
+
/** Additional issuer rule evaluated only after the token signature is verified. */
|
|
13
|
+
issuerMatches?: (claims: OidcClaims, keyIssuer?: string) => boolean;
|
|
12
14
|
/** Read on every verification so configuration can change at runtime. */
|
|
13
15
|
audiences: () => readonly string[];
|
|
14
16
|
fetch: OAuthFetch;
|
package/dist/oidc-id-token.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, createPublicKey, createVerify } from 'node:crypto';
|
|
2
2
|
import { abortable } from './oidc-id-token-util.mjs';
|
|
3
|
-
export { appleProfileFromClaims, googleProfileFromClaims, } from './oidc-id-token-profiles.mjs';
|
|
3
|
+
export { appleProfileFromClaims, googleProfileFromClaims, microsoftProfileFromClaims, } from './oidc-id-token-profiles.mjs';
|
|
4
4
|
export class OidcVerificationError extends Error {
|
|
5
5
|
code;
|
|
6
6
|
constructor(code, message) {
|
|
@@ -84,7 +84,7 @@ export function createOidcIdTokenVerifier(options) {
|
|
|
84
84
|
if (!valid)
|
|
85
85
|
throw new OidcVerificationError('signature', 'ID token signature is invalid');
|
|
86
86
|
}
|
|
87
|
-
function checkClaims(claims, verify) {
|
|
87
|
+
function checkClaims(claims, verify, key) {
|
|
88
88
|
if (typeof claims.exp !== 'number' || typeof claims.iat !== 'number') {
|
|
89
89
|
throw new OidcVerificationError('format', 'ID token is missing exp or iat');
|
|
90
90
|
}
|
|
@@ -103,7 +103,7 @@ export function createOidcIdTokenVerifier(options) {
|
|
|
103
103
|
if (claims.nbf !== undefined && claims.nbf > seconds + skew) {
|
|
104
104
|
throw new OidcVerificationError('not_yet_valid', 'ID token is not valid yet');
|
|
105
105
|
}
|
|
106
|
-
if (!options.issuers.includes(claims.iss)) {
|
|
106
|
+
if (!options.issuers.includes(claims.iss) && !options.issuerMatches?.(claims, key.issuer)) {
|
|
107
107
|
throw new OidcVerificationError('issuer', 'ID token issuer is invalid');
|
|
108
108
|
}
|
|
109
109
|
const accepted = options.audiences();
|
|
@@ -134,7 +134,7 @@ export function createOidcIdTokenVerifier(options) {
|
|
|
134
134
|
const key = await findKey(kid, verify.signal);
|
|
135
135
|
checkSignature(`${header}.${payload}`, signature, key);
|
|
136
136
|
const claims = decodePart(payload);
|
|
137
|
-
checkClaims(claims, verify);
|
|
137
|
+
checkClaims(claims, verify, key);
|
|
138
138
|
return claims;
|
|
139
139
|
},
|
|
140
140
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Dependency-free runtime utilities for Vouchington-style applications.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
|
|
6
6
|
"bugs": {
|