@vouchington/utils 0.7.5 → 0.9.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.
Files changed (37) hide show
  1. package/README.md +30 -0
  2. package/dist/error-tree.d.mts +8 -0
  3. package/dist/error-tree.mjs +94 -0
  4. package/dist/feature-flags.d.mts +1 -1
  5. package/dist/job-data-scrub.d.mts +21 -0
  6. package/dist/job-data-scrub.mjs +83 -0
  7. package/dist/oauth-pkce.d.mts +6 -0
  8. package/dist/oauth-pkce.mjs +19 -0
  9. package/dist/oauth-popup.d.mts +80 -0
  10. package/dist/oauth-popup.mjs +106 -0
  11. package/dist/oauth-providers-core.d.mts +44 -0
  12. package/dist/oauth-providers-core.mjs +88 -0
  13. package/dist/oauth-providers-facebook.d.mts +11 -0
  14. package/dist/oauth-providers-facebook.mjs +34 -0
  15. package/dist/oauth-providers-github.d.mts +7 -0
  16. package/dist/oauth-providers-github.mjs +46 -0
  17. package/dist/oauth-providers-google.d.mts +7 -0
  18. package/dist/oauth-providers-google.mjs +43 -0
  19. package/dist/oauth-providers-linkedin.d.mts +7 -0
  20. package/dist/oauth-providers-linkedin.mjs +42 -0
  21. package/dist/oauth-providers-microsoft.d.mts +11 -0
  22. package/dist/oauth-providers-microsoft.mjs +76 -0
  23. package/dist/oauth-providers-x.d.mts +7 -0
  24. package/dist/oauth-providers-x.mjs +41 -0
  25. package/dist/oauth-providers.d.mts +8 -0
  26. package/dist/oauth-providers.mjs +7 -0
  27. package/dist/oauth-types.d.mts +8 -0
  28. package/dist/oauth-types.mjs +1 -0
  29. package/dist/oidc-id-token-profiles.d.mts +23 -0
  30. package/dist/oidc-id-token-profiles.mjs +42 -0
  31. package/dist/oidc-id-token-util.d.mts +6 -0
  32. package/dist/oidc-id-token-util.mjs +18 -0
  33. package/dist/oidc-id-token.d.mts +33 -0
  34. package/dist/oidc-id-token.mjs +141 -0
  35. package/package.json +31 -1
  36. package/dist/http-signatures-test-helpers.d.mts +0 -30
  37. package/dist/http-signatures-test-helpers.mjs +0 -62
package/README.md CHANGED
@@ -78,6 +78,27 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
78
78
  - `http-signatures`: Node-only (`node:crypto`) Cavage-12 HTTP Signatures. Callers pass signed
79
79
  headers, allowed algorithms, and `maxAgeSeconds` on both sign and verify. The package has no
80
80
  default header set or product keyId policy.
81
+ - `oauth-pkce`: WebCrypto `generateCodeVerifier`, `generateState`, and S256 `generateCodeChallenge`.
82
+ - `oauth-popup` (browser): `openOAuthPopup`, `buildAuthorizationUrl`, `runPopupOAuthLogin`, and
83
+ `OAuthCancelledError`. The flow takes an injectable `window`; callers keep their own in-flight
84
+ guard and map the code, PKCE verifier, and redirect URI to their token shape.
85
+ - `oidc-id-token`: Node-only (`node:crypto`) RS256 ID-token verifier with an injected `fetch`, JWKS
86
+ URI, issuers, and audiences. It caches the JWKS with a TTL, refreshes once on a `kid` miss, checks
87
+ `iat` skew and an optional (hashed) nonce, and throws `OidcVerificationError` with a `code`.
88
+ `googleProfileFromClaims`, `appleProfileFromClaims`, and `microsoftProfileFromClaims` map claims to a profile; account policy
89
+ and HTTP error mapping stay in the application.
90
+ - `oauth-providers`: authorization-code redemption for GitHub, X, LinkedIn, Microsoft, Google, and Facebook. Each
91
+ provider's `redeem(input, { fetch, signal, now })` returns `{ tokens, profile }`; `verifiedEmail`
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.
81
102
  - `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
82
103
  and deterministic JSON serialization.
83
104
  - `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
@@ -130,6 +151,15 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
130
151
  URL-only and credential-only passes are exported separately, and `composeBeforeSend` accepts
131
152
  either `ScrubOptions` or a final scrubber function. This package has no product defaults.
132
153
 
154
+ - `error-tree`: `formatErrorTree(error, { maxDepth?, maxNested? })` renders an error with its
155
+ `errors[]` and `cause` chain, marks circular references, and reports how many nested errors the
156
+ depth and count limits omitted (defaults 5 and 20).
157
+ - `job-data-scrub`: `scrubJobData(data, options?)`, a safe-scalar allowlist scrubber for queue job
158
+ payloads. Only booleans, numbers, UUID/ULID ids, and short slug-like strings survive; sensitive
159
+ keys redact regardless of value. The redaction marker (default `'[Filtered]'`), key substring and
160
+ exact-name lists, and depth/array/key limits are options, and the defaults list only generic keys.
161
+ It complements the key-based `observability` scrubbers.
162
+
133
163
  ```ts
134
164
  const money = createMoneyCatalog([{ code: 'credit', minorUnitExponent: 2 }] as const, 6)
135
165
  money.parseMajorUnitsToMoney('12.34', 'credit')
@@ -0,0 +1,8 @@
1
+ export interface FormatErrorTreeOptions {
2
+ /** Deepest nesting level rendered; deeper errors are counted as omitted. Default 5. */
3
+ maxDepth?: number;
4
+ /** Total nested errors rendered across the tree. Default 20. */
5
+ maxNested?: number;
6
+ }
7
+ /** Formats an error and its nested `errors[]` and `cause` chain without util.format's shallow output. */
8
+ export declare function formatErrorTree(error: unknown, options?: FormatErrorTreeOptions): string;
@@ -0,0 +1,94 @@
1
+ import { inspect } from 'node:util';
2
+ /** Formats an error and its nested `errors[]` and `cause` chain without util.format's shallow output. */
3
+ export function formatErrorTree(error, options = {}) {
4
+ const maxDepth = options.maxDepth ?? 5;
5
+ const maxNested = options.maxNested ?? 20;
6
+ const seen = new Set();
7
+ // Nodes visited only while counting omitted subtrees; kept apart from `seen` so a hidden
8
+ // occurrence never turns a later visible occurrence into `[circular]`.
9
+ const omittedSeen = new Set();
10
+ let nestedCount = 0;
11
+ let omittedCount = 0;
12
+ const render = (value, depth, label) => {
13
+ if (isObject(value)) {
14
+ if (seen.has(value))
15
+ return indentLines('[circular]', depth, label);
16
+ seen.add(value);
17
+ }
18
+ const lines = indentLines(formatValue(value), depth, label);
19
+ for (const child of getChildren(value)) {
20
+ if (depth >= maxDepth || nestedCount >= maxNested) {
21
+ omittedCount += countOmitted(child.value, seen, omittedSeen);
22
+ }
23
+ else {
24
+ nestedCount += 1;
25
+ lines.push(...render(child.value, depth + 1, child.label));
26
+ }
27
+ }
28
+ return lines;
29
+ };
30
+ const lines = render(error, 0);
31
+ if (omittedCount > 0)
32
+ lines.push(`… ${omittedCount} more nested errors omitted`);
33
+ return lines.join('\n');
34
+ }
35
+ function formatValue(value) {
36
+ if (!(value instanceof Error))
37
+ return inspect(withoutChildren(value), { depth: 2 });
38
+ if (typeof value.stack === 'string' && value.stack.length > 0)
39
+ return value.stack;
40
+ return `${value.name || 'Error'}: ${value.message}`;
41
+ }
42
+ // Child errors are traversed separately under the limits, so inspect must not print them too.
43
+ function withoutChildren(value) {
44
+ if (!isObject(value) || Array.isArray(value) || getChildren(value).length === 0)
45
+ return value;
46
+ const copy = { ...value };
47
+ if (Array.isArray(copy.errors))
48
+ delete copy.errors;
49
+ if (copy.cause !== undefined)
50
+ delete copy.cause;
51
+ return copy;
52
+ }
53
+ function getChildren(value) {
54
+ if (!isObject(value))
55
+ return [];
56
+ const children = [];
57
+ const properties = value;
58
+ const errors = properties.errors;
59
+ if (Array.isArray(errors)) {
60
+ errors.forEach((nested, index) => children.push({ label: `errors[${index}]:`, value: nested }));
61
+ }
62
+ if (properties.cause !== undefined)
63
+ children.push({ label: 'cause:', value: properties.cause });
64
+ return children;
65
+ }
66
+ function countOmitted(value, seen, omittedSeen) {
67
+ const pending = [value];
68
+ let count = 0;
69
+ while (pending.length > 0) {
70
+ const current = pending.pop();
71
+ if (isObject(current)) {
72
+ if (seen.has(current) || omittedSeen.has(current)) {
73
+ count += 1;
74
+ continue;
75
+ }
76
+ omittedSeen.add(current);
77
+ }
78
+ count += 1;
79
+ const children = getChildren(current);
80
+ for (let index = children.length - 1; index >= 0; index -= 1) {
81
+ pending.push(children[index].value);
82
+ }
83
+ }
84
+ return count;
85
+ }
86
+ function indentLines(value, depth, label) {
87
+ const indentation = ' '.repeat(depth);
88
+ return value
89
+ .split(/\r?\n/)
90
+ .map((line, index) => `${indentation}${index === 0 && label ? `${label} ` : ''}${line}`);
91
+ }
92
+ function isObject(value) {
93
+ return (typeof value === 'object' && value !== null) || typeof value === 'function';
94
+ }
@@ -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;
@@ -0,0 +1,21 @@
1
+ export interface JobDataScrubOptions {
2
+ /** Replacement for redacted values. Default `'[Filtered]'`. */
3
+ redacted?: string;
4
+ /** Normalized (lowercase alphanumeric) key substrings that always redact. Default: generic PII/secret terms. */
5
+ sensitiveSubstrings?: readonly string[];
6
+ /** Normalized key names that redact only on an exact match. Default: none. */
7
+ sensitiveNames?: readonly string[];
8
+ /** Nesting depth at which containers are redacted. Default 4. */
9
+ maxDepth?: number;
10
+ /** Array items kept before a `...N more` marker. Default 10. */
11
+ maxArrayItems?: number;
12
+ /** Sorted object keys kept. Default 32. */
13
+ maxKeys?: number;
14
+ }
15
+ export declare const DEFAULT_SENSITIVE_SUBSTRINGS: readonly string[];
16
+ /**
17
+ * Safe-scalar allowlist scrubber for queue job payloads. Values default to redacted: only booleans,
18
+ * numbers, UUID/ULID ids, and short slug-like strings survive, and sensitive key names redact
19
+ * regardless of value. Plain non-empty objects produce output; anything else yields undefined.
20
+ */
21
+ export declare function scrubJobData(data: unknown, options?: JobDataScrubOptions): Record<string, unknown> | undefined;
@@ -0,0 +1,83 @@
1
+ export const DEFAULT_SENSITIVE_SUBSTRINGS = [
2
+ 'email',
3
+ 'phone',
4
+ 'address',
5
+ 'token',
6
+ 'secret',
7
+ 'password',
8
+ 'passwd',
9
+ 'credential',
10
+ 'sessionid',
11
+ 'ssn',
12
+ 'creditcard',
13
+ 'cardnumber',
14
+ 'cvv',
15
+ 'dob',
16
+ 'birthdate',
17
+ ];
18
+ const SAFE_SCALAR_STRING = /^[a-z][a-z0-9_-]{0,63}$/;
19
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/;
21
+ /**
22
+ * Safe-scalar allowlist scrubber for queue job payloads. Values default to redacted: only booleans,
23
+ * numbers, UUID/ULID ids, and short slug-like strings survive, and sensitive key names redact
24
+ * regardless of value. Plain non-empty objects produce output; anything else yields undefined.
25
+ */
26
+ export function scrubJobData(data, options = {}) {
27
+ if (data == null || typeof data !== 'object' || Array.isArray(data))
28
+ return undefined;
29
+ if (Object.keys(data).length === 0)
30
+ return undefined;
31
+ const resolved = {
32
+ redacted: options.redacted ?? '[Filtered]',
33
+ sensitiveSubstrings: options.sensitiveSubstrings ?? DEFAULT_SENSITIVE_SUBSTRINGS,
34
+ sensitiveNames: options.sensitiveNames ?? [],
35
+ maxDepth: options.maxDepth ?? 4,
36
+ maxArrayItems: options.maxArrayItems ?? 10,
37
+ maxKeys: options.maxKeys ?? 32,
38
+ };
39
+ return scrubObject(data, 0, resolved);
40
+ }
41
+ function scrubValue(value, depth, o) {
42
+ if (value === null || value === undefined)
43
+ return value;
44
+ if (typeof value === 'boolean' || typeof value === 'number')
45
+ return value;
46
+ if (typeof value === 'string')
47
+ return scrubString(value, o);
48
+ if (Array.isArray(value))
49
+ return scrubArray(value, depth, o);
50
+ if (typeof value === 'object')
51
+ return scrubObject(value, depth, o);
52
+ return o.redacted;
53
+ }
54
+ function scrubString(value, o) {
55
+ if (UUID_PATTERN.test(value) || ULID_PATTERN.test(value))
56
+ return value;
57
+ return SAFE_SCALAR_STRING.test(value) ? value : o.redacted;
58
+ }
59
+ function scrubArray(value, depth, o) {
60
+ if (depth >= o.maxDepth)
61
+ return o.redacted;
62
+ const items = value.slice(0, o.maxArrayItems).map((item) => scrubValue(item, depth + 1, o));
63
+ if (value.length > o.maxArrayItems)
64
+ items.push(`...${value.length - o.maxArrayItems} more`);
65
+ return items;
66
+ }
67
+ function scrubObject(value, depth, o) {
68
+ if (depth >= o.maxDepth)
69
+ return o.redacted;
70
+ // fromEntries defines own properties, so a JSON `__proto__` key cannot hit the prototype setter.
71
+ return Object.fromEntries(Object.keys(value)
72
+ .toSorted()
73
+ .slice(0, o.maxKeys)
74
+ .map((key) => [
75
+ key,
76
+ isSensitiveKey(key, o) ? o.redacted : scrubValue(value[key], depth + 1, o),
77
+ ]));
78
+ }
79
+ function isSensitiveKey(key, o) {
80
+ const normalized = key.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
81
+ return (o.sensitiveNames.includes(normalized) ||
82
+ o.sensitiveSubstrings.some((substring) => normalized.includes(substring)));
83
+ }
@@ -0,0 +1,6 @@
1
+ /** 32 random bytes, base64url without padding (43 characters). */
2
+ export declare function generateCodeVerifier(): string;
3
+ /** 16 random bytes as 32 lowercase hex characters. */
4
+ export declare function generateState(): string;
5
+ /** RFC 7636 S256 code challenge for a verifier. */
6
+ export declare function generateCodeChallenge(verifier: string): Promise<string>;
@@ -0,0 +1,19 @@
1
+ function base64Url(bytes) {
2
+ return btoa(String.fromCodePoint(...bytes))
3
+ .replaceAll('+', '-')
4
+ .replaceAll('/', '_')
5
+ .replaceAll('=', '');
6
+ }
7
+ /** 32 random bytes, base64url without padding (43 characters). */
8
+ export function generateCodeVerifier() {
9
+ return base64Url(crypto.getRandomValues(new Uint8Array(32)));
10
+ }
11
+ /** 16 random bytes as 32 lowercase hex characters. */
12
+ export function generateState() {
13
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join('');
14
+ }
15
+ /** RFC 7636 S256 code challenge for a verifier. */
16
+ export async function generateCodeChallenge(verifier) {
17
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
18
+ return base64Url(new Uint8Array(hash));
19
+ }
@@ -0,0 +1,80 @@
1
+ export declare class OAuthCancelledError extends Error {
2
+ constructor(providerLabel: string, reason?: string);
3
+ }
4
+ export interface OAuthPopupHandle {
5
+ readonly closed: boolean;
6
+ location: {
7
+ href: string;
8
+ };
9
+ close(): void;
10
+ }
11
+ export interface OAuthPopupMessageEvent {
12
+ readonly origin: string;
13
+ readonly source: unknown;
14
+ readonly data?: {
15
+ provider?: string;
16
+ code?: string;
17
+ state?: string;
18
+ } | null;
19
+ }
20
+ /** The slice of `window` the popup flow uses; injectable for tests and non-DOM hosts. */
21
+ export interface OAuthPopupWindow {
22
+ readonly location: {
23
+ readonly origin: string;
24
+ };
25
+ open(url: string, name: string, features: string): OAuthPopupHandle | null;
26
+ addEventListener(type: 'message', listener: (event: OAuthPopupMessageEvent) => void): void;
27
+ removeEventListener(type: 'message', listener: (event: OAuthPopupMessageEvent) => void): void;
28
+ setInterval(callback: () => void, ms: number): number;
29
+ clearInterval(id: number): void;
30
+ }
31
+ export interface OpenOAuthPopupOptions {
32
+ authUrl: URL | string;
33
+ popupName: string;
34
+ provider: string;
35
+ providerLabel: string;
36
+ state: string;
37
+ /** Defaults to `width=600,height=700`. */
38
+ features?: string;
39
+ /** Popup-closed poll interval; defaults to 500. */
40
+ pollMs?: number;
41
+ /** Defaults to `globalThis.window`. */
42
+ window?: OAuthPopupWindow;
43
+ }
44
+ /** Resolves with the authorization code posted back by the same-origin popup. */
45
+ export declare function openOAuthPopup(options: OpenOAuthPopupOptions): Promise<{
46
+ code: string;
47
+ }>;
48
+ export interface BuildAuthorizationUrlOptions {
49
+ authUrl: string | URL;
50
+ clientId: string;
51
+ redirectUri: string;
52
+ scope: string;
53
+ state: string;
54
+ responseType?: string;
55
+ responseMode?: string;
56
+ /** Adds `code_challenge` and `code_challenge_method=S256` when set. */
57
+ codeChallenge?: string;
58
+ }
59
+ export declare function buildAuthorizationUrl(options: BuildAuthorizationUrlOptions): URL;
60
+ export interface PopupOAuthLoginConfig<T> {
61
+ clientId: string;
62
+ authUrl: string | (() => string);
63
+ callbackPath: string;
64
+ popupName: string;
65
+ provider: string;
66
+ providerLabel: string;
67
+ scope: string;
68
+ responseType?: string;
69
+ responseMode?: string;
70
+ pkce?: boolean;
71
+ mapResult(result: {
72
+ code: string;
73
+ codeVerifier?: string;
74
+ redirectUri: string;
75
+ }): T;
76
+ /** Defaults to `globalThis.window`. */
77
+ window?: OAuthPopupWindow;
78
+ }
79
+ /** Generates state (and PKCE), opens the popup, and maps the redeemed code. No in-flight guard. */
80
+ export declare function runPopupOAuthLogin<T>(config: PopupOAuthLoginConfig<T>): Promise<T>;
@@ -0,0 +1,106 @@
1
+ import { generateCodeChallenge, generateCodeVerifier, generateState } from './oauth-pkce.mjs';
2
+ export class OAuthCancelledError extends Error {
3
+ constructor(providerLabel, reason) {
4
+ super(reason ?? `${providerLabel} login cancelled`);
5
+ this.name = 'OAuthCancelledError';
6
+ }
7
+ }
8
+ function defaultWindow() {
9
+ return globalThis.window;
10
+ }
11
+ const DEFAULT_FEATURES = 'width=600,height=700';
12
+ function openPopup(win, url, name, features, providerLabel) {
13
+ const popup = win.open(url, name, features);
14
+ if (!popup)
15
+ throw new OAuthCancelledError(providerLabel, 'Popup blocked');
16
+ return popup;
17
+ }
18
+ /** Resolves with the authorization code posted back by the same-origin popup. */
19
+ export function openOAuthPopup(options) {
20
+ const win = options.window ?? defaultWindow();
21
+ try {
22
+ const popup = openPopup(win, options.authUrl.toString(), options.popupName, options.features ?? DEFAULT_FEATURES, options.providerLabel);
23
+ return awaitPopupCode(win, popup, options);
24
+ }
25
+ catch (error) {
26
+ return Promise.reject(error);
27
+ }
28
+ }
29
+ function awaitPopupCode(win, popup, options) {
30
+ const { provider, providerLabel, state } = options;
31
+ return new Promise((resolve, reject) => {
32
+ const finish = (callback) => {
33
+ win.clearInterval(checkClosed);
34
+ win.removeEventListener('message', handleMessage);
35
+ callback();
36
+ };
37
+ const handleMessage = (event) => {
38
+ if (event.origin !== win.location.origin)
39
+ return;
40
+ if (event.source !== popup)
41
+ return;
42
+ if (event.data?.provider !== provider)
43
+ return;
44
+ const { code } = event.data;
45
+ if (code && event.data.state === state) {
46
+ finish(() => resolve({ code }));
47
+ return;
48
+ }
49
+ finish(() => reject(new OAuthCancelledError(providerLabel)));
50
+ };
51
+ win.addEventListener('message', handleMessage);
52
+ const checkClosed = win.setInterval(() => {
53
+ if (popup.closed)
54
+ finish(() => reject(new OAuthCancelledError(providerLabel)));
55
+ }, options.pollMs ?? 500);
56
+ });
57
+ }
58
+ export function buildAuthorizationUrl(options) {
59
+ const url = new URL(options.authUrl);
60
+ const params = url.searchParams;
61
+ if (options.responseType)
62
+ params.set('response_type', options.responseType);
63
+ params.set('client_id', options.clientId);
64
+ params.set('redirect_uri', options.redirectUri);
65
+ params.set('scope', options.scope);
66
+ params.set('state', options.state);
67
+ if (options.responseMode)
68
+ params.set('response_mode', options.responseMode);
69
+ if (options.codeChallenge) {
70
+ params.set('code_challenge', options.codeChallenge);
71
+ params.set('code_challenge_method', 'S256');
72
+ }
73
+ return url;
74
+ }
75
+ /** Generates state (and PKCE), opens the popup, and maps the redeemed code. No in-flight guard. */
76
+ export async function runPopupOAuthLogin(config) {
77
+ const win = config.window ?? defaultWindow();
78
+ // Open before any await: browsers only allow window.open inside the user-gesture call stack.
79
+ const popup = openPopup(win, 'about:blank', config.popupName, DEFAULT_FEATURES, config.providerLabel);
80
+ const state = generateState();
81
+ const redirectUri = `${win.location.origin}${config.callbackPath}`;
82
+ const codeVerifier = config.pkce ? generateCodeVerifier() : undefined;
83
+ try {
84
+ const codeChallenge = codeVerifier ? await generateCodeChallenge(codeVerifier) : undefined;
85
+ popup.location.href = buildAuthorizationUrl({
86
+ authUrl: typeof config.authUrl === 'function' ? config.authUrl() : config.authUrl,
87
+ clientId: config.clientId,
88
+ redirectUri,
89
+ scope: config.scope,
90
+ state,
91
+ ...(config.responseType ? { responseType: config.responseType } : {}),
92
+ ...(config.responseMode ? { responseMode: config.responseMode } : {}),
93
+ ...(codeChallenge ? { codeChallenge } : {}),
94
+ }).toString();
95
+ }
96
+ catch (error) {
97
+ popup.close();
98
+ throw error;
99
+ }
100
+ const { code } = await awaitPopupCode(win, popup, {
101
+ provider: config.provider,
102
+ providerLabel: config.providerLabel,
103
+ state,
104
+ });
105
+ return config.mapResult({ code, ...(codeVerifier ? { codeVerifier } : {}), redirectUri });
106
+ }
@@ -0,0 +1,44 @@
1
+ import type { OAuthFetch, OAuthProfile } from './oauth-types.mts';
2
+ export interface OAuthCallContext {
3
+ fetch: OAuthFetch;
4
+ signal?: AbortSignal;
5
+ /** Epoch milliseconds; defaults to `Date.now`. */
6
+ now?: () => number;
7
+ }
8
+ export interface OAuthTokens {
9
+ accessToken: string;
10
+ refreshToken?: string;
11
+ accessTokenExpiresAt?: Date;
12
+ }
13
+ export interface AuthorizationCodeInput {
14
+ code: string;
15
+ redirectUri: string;
16
+ codeVerifier?: string;
17
+ }
18
+ export type OAuthProviderErrorKind = 'http' | 'provider_error' | 'malformed';
19
+ export declare class OAuthProviderError extends Error {
20
+ readonly provider: string;
21
+ readonly kind: OAuthProviderErrorKind;
22
+ readonly status?: number;
23
+ constructor(provider: string, kind: OAuthProviderErrorKind, message: string, status?: number);
24
+ }
25
+ export interface OAuthProvider {
26
+ id: string;
27
+ redeem(input: AuthorizationCodeInput, ctx: OAuthCallContext): Promise<{
28
+ tokens: OAuthTokens;
29
+ profile: OAuthProfile;
30
+ }>;
31
+ }
32
+ export declare function nowOf(ctx: OAuthCallContext): () => number;
33
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
34
+ /** A non-empty string, otherwise undefined. */
35
+ export declare function str(value: unknown): string | undefined;
36
+ /** Drops undefined values so profile data only holds what the provider returned. */
37
+ export declare function compact(values: Record<string, unknown>): Record<string, unknown>;
38
+ export declare function asRecord(provider: string, value: unknown, label: string): Record<string, unknown>;
39
+ export declare function requireCodeVerifier(provider: string, input: AuthorizationCodeInput): string;
40
+ export declare function formBody(values: Record<string, string | undefined>): string;
41
+ /** Fetches JSON through the injected transport; non-2xx and unparsable bodies throw. */
42
+ export declare function requestJson(provider: string, label: string, ctx: OAuthCallContext, url: string, init?: RequestInit): Promise<unknown>;
43
+ /** Validates a token endpoint payload: `error` bodies and a missing access token throw. */
44
+ export declare function parseTokens(provider: string, payload: unknown, now: () => number): OAuthTokens;
@@ -0,0 +1,88 @@
1
+ export class OAuthProviderError extends Error {
2
+ provider;
3
+ kind;
4
+ status;
5
+ constructor(provider, kind, message, status) {
6
+ super(message);
7
+ this.name = 'OAuthProviderError';
8
+ this.provider = provider;
9
+ this.kind = kind;
10
+ if (status !== undefined)
11
+ this.status = status;
12
+ }
13
+ }
14
+ export function nowOf(ctx) {
15
+ return ctx.now ?? Date.now;
16
+ }
17
+ export function isRecord(value) {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+ /** A non-empty string, otherwise undefined. */
21
+ export function str(value) {
22
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
23
+ }
24
+ /** Drops undefined values so profile data only holds what the provider returned. */
25
+ export function compact(values) {
26
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
27
+ }
28
+ export function asRecord(provider, value, label) {
29
+ if (isRecord(value))
30
+ return value;
31
+ throw new OAuthProviderError(provider, 'malformed', `${label} is not an object`);
32
+ }
33
+ export function requireCodeVerifier(provider, input) {
34
+ if (input.codeVerifier)
35
+ return input.codeVerifier;
36
+ throw new OAuthProviderError(provider, 'malformed', 'A PKCE code verifier is required');
37
+ }
38
+ export function formBody(values) {
39
+ return new URLSearchParams(compact(values)).toString();
40
+ }
41
+ /** Fetches JSON through the injected transport; non-2xx and unparsable bodies throw. */
42
+ export async function requestJson(provider, label, ctx, url, init = {}) {
43
+ // Token and profile requests carry credentials; never follow a redirect with them.
44
+ const response = await ctx.fetch(url, {
45
+ ...init,
46
+ redirect: 'error',
47
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
48
+ });
49
+ if (!response.ok) {
50
+ await response.body?.cancel().catch(() => undefined);
51
+ throw new OAuthProviderError(provider, 'http', `${label} failed: ${response.status}`, response.status);
52
+ }
53
+ try {
54
+ return await response.json();
55
+ }
56
+ catch {
57
+ throw new OAuthProviderError(provider, 'malformed', `${label} returned invalid JSON`);
58
+ }
59
+ }
60
+ function tokenErrorMessage(body) {
61
+ const { error } = body;
62
+ if (!error)
63
+ return undefined;
64
+ if (typeof error === 'string')
65
+ return str(body.error_description) ?? error;
66
+ return isRecord(error) ? (str(error.message) ?? 'Unknown error') : 'Unknown error';
67
+ }
68
+ /** Validates a token endpoint payload: `error` bodies and a missing access token throw. */
69
+ export function parseTokens(provider, payload, now) {
70
+ const body = asRecord(provider, payload, 'Token response');
71
+ const message = tokenErrorMessage(body);
72
+ if (message) {
73
+ throw new OAuthProviderError(provider, 'provider_error', `Token exchange failed: ${message}`);
74
+ }
75
+ const accessToken = str(body.access_token);
76
+ if (!accessToken) {
77
+ throw new OAuthProviderError(provider, 'malformed', 'Token response is missing access_token');
78
+ }
79
+ const refreshToken = str(body.refresh_token);
80
+ const expiresIn = body.expires_in;
81
+ return {
82
+ accessToken,
83
+ ...(refreshToken ? { refreshToken } : {}),
84
+ ...(typeof expiresIn === 'number' && expiresIn > 0
85
+ ? { accessTokenExpiresAt: new Date(now() + expiresIn * 1000) }
86
+ : {}),
87
+ };
88
+ }
@@ -0,0 +1,11 @@
1
+ import { type OAuthCallContext, type OAuthProvider } from './oauth-providers-core.mts';
2
+ export interface FacebookProviderConfig {
3
+ appId: string;
4
+ appSecret: string;
5
+ /** Graph API version path segment, for example `v1.0`. */
6
+ graphVersion: string;
7
+ }
8
+ /** Code exchange, then `fb_exchange_token` for a long-lived token, then `/me`. */
9
+ export declare function facebookProvider(config: FacebookProviderConfig): OAuthProvider & {
10
+ redeemAccessToken(token: string, ctx: OAuthCallContext): ReturnType<OAuthProvider['redeem']>;
11
+ };
@@ -0,0 +1,34 @@
1
+ import { OAuthProviderError, asRecord, formBody, nowOf, parseTokens, requestJson, str, } from './oauth-providers-core.mjs';
2
+ /** Code exchange, then `fb_exchange_token` for a long-lived token, then `/me`. */
3
+ export function facebookProvider(config) {
4
+ const graph = `https://graph.facebook.com/${config.graphVersion}`;
5
+ const form = (values) => ({
6
+ method: 'POST',
7
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
8
+ body: formBody({ client_id: config.appId, client_secret: config.appSecret, ...values }),
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
+ }
26
+ return {
27
+ id: 'facebook',
28
+ async redeem(input, ctx) {
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);
31
+ },
32
+ redeemAccessToken,
33
+ };
34
+ }
@@ -0,0 +1,7 @@
1
+ import { type OAuthProvider } from './oauth-providers-core.mts';
2
+ export interface GithubProviderConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ }
6
+ /** JSON token exchange; the verified email is the primary verified address from `/user/emails`. */
7
+ export declare function githubProvider(config: GithubProviderConfig): OAuthProvider;