@vouchington/utils 0.7.4 → 0.8.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 (38) hide show
  1. package/README.md +28 -0
  2. package/dist/error-tree.d.mts +8 -0
  3. package/dist/error-tree.mjs +94 -0
  4. package/dist/job-data-scrub.d.mts +21 -0
  5. package/dist/job-data-scrub.mjs +83 -0
  6. package/dist/oauth-pkce.d.mts +6 -0
  7. package/dist/oauth-pkce.mjs +19 -0
  8. package/dist/oauth-popup.d.mts +80 -0
  9. package/dist/oauth-popup.mjs +106 -0
  10. package/dist/oauth-providers-core.d.mts +44 -0
  11. package/dist/oauth-providers-core.mjs +88 -0
  12. package/dist/oauth-providers-facebook.d.mts +9 -0
  13. package/dist/oauth-providers-facebook.mjs +27 -0
  14. package/dist/oauth-providers-github.d.mts +7 -0
  15. package/dist/oauth-providers-github.mjs +46 -0
  16. package/dist/oauth-providers-google.d.mts +7 -0
  17. package/dist/oauth-providers-google.mjs +43 -0
  18. package/dist/oauth-providers-linkedin.d.mts +7 -0
  19. package/dist/oauth-providers-linkedin.mjs +42 -0
  20. package/dist/oauth-providers-microsoft.d.mts +14 -0
  21. package/dist/oauth-providers-microsoft.mjs +43 -0
  22. package/dist/oauth-providers-x.d.mts +7 -0
  23. package/dist/oauth-providers-x.mjs +41 -0
  24. package/dist/oauth-providers.d.mts +8 -0
  25. package/dist/oauth-providers.mjs +7 -0
  26. package/dist/oauth-types.d.mts +8 -0
  27. package/dist/oauth-types.mjs +1 -0
  28. package/dist/oidc-id-token-profiles.d.mts +21 -0
  29. package/dist/oidc-id-token-profiles.mjs +28 -0
  30. package/dist/oidc-id-token-util.d.mts +6 -0
  31. package/dist/oidc-id-token-util.mjs +18 -0
  32. package/dist/oidc-id-token.d.mts +31 -0
  33. package/dist/oidc-id-token.mjs +141 -0
  34. package/dist/service-worker-activation.d.mts +26 -0
  35. package/dist/service-worker-activation.mjs +50 -0
  36. package/package.json +36 -1
  37. package/dist/http-signatures-test-helpers.d.mts +0 -30
  38. package/dist/http-signatures-test-helpers.mjs +0 -62
package/README.md CHANGED
@@ -36,6 +36,10 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
36
36
  request/acknowledge round trip with an injected message type and acceptance check, and a named
37
37
  Web Lock wrapper. Request actions, expected state transitions, and the bind/clear/reconcile
38
38
  sequence stay in the application.
39
+ - `service-worker-activation`: `waitForActiveServiceWorker(registration, options)` resolves when
40
+ the registration has an active worker. An installing or waiting worker is preferred over
41
+ `serviceWorker.ready`. `timeoutMs` bounds the wait; omit it to wait without a deadline. The
42
+ caller supplies the timeout and may inject `serviceWorker` (default `navigator.serviceWorker`).
39
43
  - `json-ld`: schema.org JSON-LD builders (organization, website, navigation, collection, thing,
40
44
  comparison, item list, breadcrumb, profile) and script-safe serialization. Callers inject the
41
45
  absolute-URL resolver and site name; product schemas such as posts stay in the application.
@@ -74,6 +78,21 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
74
78
  - `http-signatures`: Node-only (`node:crypto`) Cavage-12 HTTP Signatures. Callers pass signed
75
79
  headers, allowed algorithms, and `maxAgeSeconds` on both sign and verify. The package has no
76
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` and `appleProfileFromClaims` 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 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.
77
96
  - `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
78
97
  and deterministic JSON serialization.
79
98
  - `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
@@ -126,6 +145,15 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
126
145
  URL-only and credential-only passes are exported separately, and `composeBeforeSend` accepts
127
146
  either `ScrubOptions` or a final scrubber function. This package has no product defaults.
128
147
 
148
+ - `error-tree`: `formatErrorTree(error, { maxDepth?, maxNested? })` renders an error with its
149
+ `errors[]` and `cause` chain, marks circular references, and reports how many nested errors the
150
+ depth and count limits omitted (defaults 5 and 20).
151
+ - `job-data-scrub`: `scrubJobData(data, options?)`, a safe-scalar allowlist scrubber for queue job
152
+ payloads. Only booleans, numbers, UUID/ULID ids, and short slug-like strings survive; sensitive
153
+ keys redact regardless of value. The redaction marker (default `'[Filtered]'`), key substring and
154
+ exact-name lists, and depth/array/key limits are options, and the defaults list only generic keys.
155
+ It complements the key-based `observability` scrubbers.
156
+
129
157
  ```ts
130
158
  const money = createMoneyCatalog([{ code: 'credit', minorUnitExponent: 2 }] as const, 6)
131
159
  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
+ }
@@ -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,9 @@
1
+ import { 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;
@@ -0,0 +1,27 @@
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
+ return {
11
+ id: 'facebook',
12
+ 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
+ };
25
+ },
26
+ };
27
+ }
@@ -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;