@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 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 `appleProfileFromClaims` map claims to a profile; account policy
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 Graph `mail` is not proof of ownership, so it is
93
- only reported as verified with `trustMailAsVerified: true`. Credentials, tenant, and the egress-guarded `fetch` are
94
- injected; failures throw `OAuthProviderError` (`provider`, `kind`, `status`). Persistence and
95
- HTTP error mapping stay in the application.
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. This package has no product defaults.
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
@@ -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 now = nowOf(ctx);
14
- const shortLived = parseTokens('facebook', await requestJson('facebook', 'Facebook authorization code exchange', ctx, `${graph}/oauth/access_token`, form({ redirect_uri: input.redirectUri, code: input.code })), now);
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
- /** v2.0 token exchange with required PKCE; Graph `mail` is unverified unless trusted. */
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
- /** v2.0 token exchange with required PKCE; Graph `mail` is unverified unless trusted. */
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 tokenPayload = await requestJson('microsoft', 'Microsoft token exchange', ctx, `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
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
- const email = str(me.mail);
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: config.trustMailAsVerified ? (email ?? null) : null,
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';
@@ -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 an event's request and breadcrumbs; the input event is returned when nothing changed. */
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 an event's request and breadcrumbs; the input event is returned when nothing changed. */
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
- if (request === event.request && breadcrumbs === event.breadcrumbs)
35
- return event;
36
- return {
37
- ...event,
38
- ...(request === event.request ? {} : { request }),
39
- ...(breadcrumbs === event.breadcrumbs ? {} : { breadcrumbs }),
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'))
@@ -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 { composeBeforeSend, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, type ObservableEvent, type ScrubOptions, } from './observability-event.mts';
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';
@@ -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 { composeBeforeSend, scrubBreadcrumbs, scrubEvent, scrubHeaders, scrubRequest, scrubSpanAttributes, } from './observability-event.mjs';
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 = {};
@@ -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;
@@ -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.8.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": {