@vouchington/utils 0.7.5 → 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.
- package/README.md +24 -0
- package/dist/error-tree.d.mts +8 -0
- package/dist/error-tree.mjs +94 -0
- package/dist/job-data-scrub.d.mts +21 -0
- package/dist/job-data-scrub.mjs +83 -0
- package/dist/oauth-pkce.d.mts +6 -0
- package/dist/oauth-pkce.mjs +19 -0
- package/dist/oauth-popup.d.mts +80 -0
- package/dist/oauth-popup.mjs +106 -0
- package/dist/oauth-providers-core.d.mts +44 -0
- package/dist/oauth-providers-core.mjs +88 -0
- package/dist/oauth-providers-facebook.d.mts +9 -0
- package/dist/oauth-providers-facebook.mjs +27 -0
- package/dist/oauth-providers-github.d.mts +7 -0
- package/dist/oauth-providers-github.mjs +46 -0
- package/dist/oauth-providers-google.d.mts +7 -0
- package/dist/oauth-providers-google.mjs +43 -0
- package/dist/oauth-providers-linkedin.d.mts +7 -0
- package/dist/oauth-providers-linkedin.mjs +42 -0
- package/dist/oauth-providers-microsoft.d.mts +14 -0
- package/dist/oauth-providers-microsoft.mjs +43 -0
- package/dist/oauth-providers-x.d.mts +7 -0
- package/dist/oauth-providers-x.mjs +41 -0
- package/dist/oauth-providers.d.mts +8 -0
- package/dist/oauth-providers.mjs +7 -0
- package/dist/oauth-types.d.mts +8 -0
- package/dist/oauth-types.mjs +1 -0
- package/dist/oidc-id-token-profiles.d.mts +21 -0
- package/dist/oidc-id-token-profiles.mjs +28 -0
- package/dist/oidc-id-token-util.d.mts +6 -0
- package/dist/oidc-id-token-util.mjs +18 -0
- package/dist/oidc-id-token.d.mts +31 -0
- package/dist/oidc-id-token.mjs +141 -0
- package/package.json +31 -1
- package/dist/http-signatures-test-helpers.d.mts +0 -30
- package/dist/http-signatures-test-helpers.mjs +0 -62
package/README.md
CHANGED
|
@@ -78,6 +78,21 @@ 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` 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.
|
|
81
96
|
- `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
|
|
82
97
|
and deterministic JSON serialization.
|
|
83
98
|
- `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
|
|
@@ -130,6 +145,15 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
|
|
|
130
145
|
URL-only and credential-only passes are exported separately, and `composeBeforeSend` accepts
|
|
131
146
|
either `ScrubOptions` or a final scrubber function. This package has no product defaults.
|
|
132
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
|
+
|
|
133
157
|
```ts
|
|
134
158
|
const money = createMoneyCatalog([{ code: 'credit', minorUnitExponent: 2 }] as const, 6)
|
|
135
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;
|