@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
@@ -0,0 +1,46 @@
1
+ import { OAuthProviderError, asRecord, compact, isRecord, nowOf, parseTokens, requestJson, str, } from './oauth-providers-core.mjs';
2
+ /** JSON token exchange; the verified email is the primary verified address from `/user/emails`. */
3
+ export function githubProvider(config) {
4
+ return {
5
+ id: 'github',
6
+ async redeem(input, ctx) {
7
+ const tokenPayload = await requestJson('github', 'GitHub token exchange', ctx, 'https://github.com/login/oauth/access_token', {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
10
+ body: JSON.stringify({
11
+ client_id: config.clientId,
12
+ client_secret: config.clientSecret,
13
+ code: input.code,
14
+ redirect_uri: input.redirectUri,
15
+ ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
16
+ }),
17
+ });
18
+ const tokens = parseTokens('github', tokenPayload, nowOf(ctx));
19
+ const { accessToken } = tokens;
20
+ const init = {
21
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' },
22
+ };
23
+ const [userPayload, emailsPayload] = await Promise.all([
24
+ requestJson('github', 'GitHub /user request', ctx, 'https://api.github.com/user', init),
25
+ requestJson('github', 'GitHub /user/emails request', ctx, 'https://api.github.com/user/emails', init),
26
+ ]);
27
+ const user = asRecord('github', userPayload, 'GitHub /user response');
28
+ if (typeof user.id !== 'number' && !str(user.id)) {
29
+ throw new OAuthProviderError('github', 'malformed', 'GitHub /user response is missing id');
30
+ }
31
+ if (!Array.isArray(emailsPayload)) {
32
+ throw new OAuthProviderError('github', 'malformed', 'GitHub emails response is not a list');
33
+ }
34
+ const primary = emailsPayload.find((entry) => isRecord(entry) && entry.primary === true && entry.verified === true);
35
+ const login = str(user.login);
36
+ return {
37
+ tokens,
38
+ profile: {
39
+ providerUserId: String(user.id),
40
+ verifiedEmail: str(primary?.email) ?? null,
41
+ data: compact({ login, name: str(user.name) ?? login, avatar_url: str(user.avatar_url) }),
42
+ },
43
+ };
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,7 @@
1
+ import { type OAuthProvider } from './oauth-providers-core.mts';
2
+ export interface GoogleProviderConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ }
6
+ /** Confidential code exchange with required PKCE, then OIDC userinfo keyed by `sub`. */
7
+ export declare function googleProvider(config: GoogleProviderConfig): OAuthProvider;
@@ -0,0 +1,43 @@
1
+ import { OAuthProviderError, asRecord, compact, formBody, nowOf, parseTokens, requestJson, requireCodeVerifier, str, } from './oauth-providers-core.mjs';
2
+ /** Confidential code exchange with required PKCE, then OIDC userinfo keyed by `sub`. */
3
+ export function googleProvider(config) {
4
+ return {
5
+ id: 'google',
6
+ async redeem(input, ctx) {
7
+ const codeVerifier = requireCodeVerifier('google', input);
8
+ const tokenPayload = await requestJson('google', 'Google token exchange', ctx, 'https://oauth2.googleapis.com/token', {
9
+ method: 'POST',
10
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
11
+ body: formBody({
12
+ grant_type: 'authorization_code',
13
+ code: input.code,
14
+ redirect_uri: input.redirectUri,
15
+ client_id: config.clientId,
16
+ client_secret: config.clientSecret,
17
+ code_verifier: codeVerifier,
18
+ }),
19
+ });
20
+ const tokens = parseTokens('google', tokenPayload, nowOf(ctx));
21
+ const infoPayload = await requestJson('google', 'Google UserInfo request', ctx, 'https://openidconnect.googleapis.com/v1/userinfo', { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
22
+ const info = asRecord('google', infoPayload, 'Google UserInfo response');
23
+ const sub = str(info.sub);
24
+ if (!sub)
25
+ throw new OAuthProviderError('google', 'malformed', 'Google UserInfo is missing sub');
26
+ const email = str(info.email);
27
+ return {
28
+ tokens,
29
+ profile: {
30
+ providerUserId: sub,
31
+ verifiedEmail: info.email_verified === true ? (email ?? null) : null,
32
+ data: compact({
33
+ name: str(info.name),
34
+ email,
35
+ picture: str(info.picture),
36
+ given_name: str(info.given_name),
37
+ family_name: str(info.family_name),
38
+ }),
39
+ },
40
+ };
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,7 @@
1
+ import { type OAuthProvider } from './oauth-providers-core.mts';
2
+ export interface LinkedinProviderConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ }
6
+ /** Form token exchange (optional PKCE) and OIDC userinfo gated on `email_verified`. */
7
+ export declare function linkedinProvider(config: LinkedinProviderConfig): OAuthProvider;
@@ -0,0 +1,42 @@
1
+ import { OAuthProviderError, asRecord, compact, formBody, nowOf, parseTokens, requestJson, str, } from './oauth-providers-core.mjs';
2
+ /** Form token exchange (optional PKCE) and OIDC userinfo gated on `email_verified`. */
3
+ export function linkedinProvider(config) {
4
+ return {
5
+ id: 'linkedin',
6
+ async redeem(input, ctx) {
7
+ const tokenPayload = await requestJson('linkedin', 'LinkedIn token exchange', ctx, 'https://www.linkedin.com/oauth/v2/accessToken', {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
10
+ body: formBody({
11
+ grant_type: 'authorization_code',
12
+ code: input.code,
13
+ redirect_uri: input.redirectUri,
14
+ client_id: config.clientId,
15
+ client_secret: config.clientSecret,
16
+ code_verifier: input.codeVerifier,
17
+ }),
18
+ });
19
+ const tokens = parseTokens('linkedin', tokenPayload, nowOf(ctx));
20
+ const infoPayload = await requestJson('linkedin', 'LinkedIn userinfo request', ctx, 'https://api.linkedin.com/v2/userinfo', { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
21
+ const info = asRecord('linkedin', infoPayload, 'LinkedIn userinfo response');
22
+ const sub = str(info.sub);
23
+ if (!sub)
24
+ throw new OAuthProviderError('linkedin', 'malformed', 'LinkedIn userinfo is missing sub');
25
+ const email = str(info.email);
26
+ return {
27
+ tokens,
28
+ profile: {
29
+ providerUserId: sub,
30
+ verifiedEmail: info.email_verified === true ? (email ?? null) : null,
31
+ data: compact({
32
+ name: str(info.name),
33
+ email,
34
+ picture: str(info.picture),
35
+ given_name: str(info.given_name),
36
+ family_name: str(info.family_name),
37
+ }),
38
+ },
39
+ };
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,11 @@
1
+ import { type OAuthProvider } from './oauth-providers-core.mts';
2
+ export interface MicrosoftProviderConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ /** Defaults to `common`. */
6
+ tenantId?: string;
7
+ }
8
+ /** Request these scopes in the authorization URL for ID-token email and Graph /me. */
9
+ export declare const microsoftAuthorizationScopes = "openid email User.Read";
10
+ /** v2.0 code exchange with PKCE, signed ID-token email proof, and Graph profile data. */
11
+ export declare function microsoftProvider(config: MicrosoftProviderConfig): OAuthProvider;
@@ -0,0 +1,76 @@
1
+ import { createOidcIdTokenVerifier } from './oidc-id-token.mjs';
2
+ import { microsoftProfileFromClaims } from './oidc-id-token-profiles.mjs';
3
+ import { OAuthProviderError, asRecord, compact, formBody, nowOf, parseTokens, requestJson, requireCodeVerifier, str, } from './oauth-providers-core.mjs';
4
+ const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ /** Request these scopes in the authorization URL for ID-token email and Graph /me. */
6
+ export const microsoftAuthorizationScopes = 'openid email User.Read';
7
+ /** v2.0 code exchange with PKCE, signed ID-token email proof, and Graph profile data. */
8
+ export function microsoftProvider(config) {
9
+ return {
10
+ id: 'microsoft',
11
+ async redeem(input, ctx) {
12
+ const codeVerifier = requireCodeVerifier('microsoft', input);
13
+ const tenant = config.tenantId || 'common';
14
+ const login = `https://login.microsoftonline.com/${tenant}`;
15
+ const tokenPayload = await requestJson('microsoft', 'Microsoft token exchange', ctx, `${login}/oauth2/v2.0/token`, {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
18
+ body: formBody({
19
+ grant_type: 'authorization_code',
20
+ code: input.code,
21
+ redirect_uri: input.redirectUri,
22
+ client_id: config.clientId,
23
+ client_secret: config.clientSecret,
24
+ code_verifier: codeVerifier,
25
+ scope: microsoftAuthorizationScopes,
26
+ }),
27
+ });
28
+ const tokens = parseTokens('microsoft', tokenPayload, nowOf(ctx));
29
+ const body = asRecord('microsoft', tokenPayload, 'Microsoft token response');
30
+ const idToken = str(body.id_token);
31
+ if (!idToken) {
32
+ throw new OAuthProviderError('microsoft', 'malformed', 'Token response is missing id_token');
33
+ }
34
+ const alias = tenant === 'common' || tenant === 'organizations';
35
+ const verifier = createOidcIdTokenVerifier({
36
+ jwksUri: `${login}/discovery/v2.0/keys`,
37
+ issuers: [],
38
+ issuerMatches: (claims, keyIssuer) => {
39
+ const tid = claims.tid;
40
+ if (typeof tid !== 'string' || !TENANT_ID.test(tid))
41
+ return false;
42
+ const issuer = `https://login.microsoftonline.com/${tid}/v2.0`;
43
+ return ((alias || tid === tenant) &&
44
+ claims.iss === issuer &&
45
+ (keyIssuer === undefined || keyIssuer.replace('{tenantid}', tid) === issuer));
46
+ },
47
+ audiences: () => [config.clientId],
48
+ fetch: ctx.fetch,
49
+ ...(ctx.now ? { now: ctx.now } : {}),
50
+ });
51
+ const claims = await verifier.verify(idToken, ctx.signal ? { signal: ctx.signal } : {});
52
+ const mePayload = await requestJson('microsoft', 'Microsoft Graph /me request', ctx, 'https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
53
+ const me = asRecord('microsoft', mePayload, 'Microsoft Graph /me response');
54
+ const id = str(me.id);
55
+ if (!id)
56
+ throw new OAuthProviderError('microsoft', 'malformed', 'Microsoft user is missing id');
57
+ if (claims.oid !== id) {
58
+ throw new OAuthProviderError('microsoft', 'malformed', 'ID token user does not match Graph user');
59
+ }
60
+ const signedProfile = microsoftProfileFromClaims(claims);
61
+ return {
62
+ tokens,
63
+ profile: {
64
+ providerUserId: id,
65
+ verifiedEmail: signedProfile.verifiedEmail,
66
+ data: compact({
67
+ name: str(me.displayName),
68
+ email: str(me.mail),
69
+ given_name: str(me.givenName),
70
+ family_name: str(me.surname),
71
+ }),
72
+ },
73
+ };
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,7 @@
1
+ import { type OAuthProvider } from './oauth-providers-core.mts';
2
+ export interface XProviderConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ }
6
+ /** Basic-auth token exchange; PKCE is required and X exposes no verified email. */
7
+ export declare function xProvider(config: XProviderConfig): OAuthProvider;
@@ -0,0 +1,41 @@
1
+ import { OAuthProviderError, asRecord, compact, formBody, nowOf, parseTokens, requestJson, requireCodeVerifier, str, } from './oauth-providers-core.mjs';
2
+ /** Basic-auth token exchange; PKCE is required and X exposes no verified email. */
3
+ export function xProvider(config) {
4
+ return {
5
+ id: 'x',
6
+ async redeem(input, ctx) {
7
+ const codeVerifier = requireCodeVerifier('x', input);
8
+ const tokenPayload = await requestJson('x', 'X token exchange', ctx, 'https://api.x.com/2/oauth2/token', {
9
+ method: 'POST',
10
+ headers: {
11
+ 'Content-Type': 'application/x-www-form-urlencoded',
12
+ Authorization: `Basic ${btoa(`${config.clientId}:${config.clientSecret}`)}`,
13
+ },
14
+ body: formBody({
15
+ grant_type: 'authorization_code',
16
+ code: input.code,
17
+ redirect_uri: input.redirectUri,
18
+ code_verifier: codeVerifier,
19
+ }),
20
+ });
21
+ const tokens = parseTokens('x', tokenPayload, nowOf(ctx));
22
+ const userPayload = await requestJson('x', 'X /users/me request', ctx, 'https://api.x.com/2/users/me?user.fields=profile_image_url', { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
23
+ const user = asRecord('x', asRecord('x', userPayload, 'X /users/me response').data, 'X user');
24
+ const id = str(user.id);
25
+ if (!id)
26
+ throw new OAuthProviderError('x', 'malformed', 'X user is missing id');
27
+ return {
28
+ tokens,
29
+ profile: {
30
+ providerUserId: id,
31
+ verifiedEmail: null,
32
+ data: compact({
33
+ name: user.name,
34
+ username: user.username,
35
+ profile_image_url: str(user.profile_image_url),
36
+ }),
37
+ },
38
+ };
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,8 @@
1
+ export type { OAuthFetch, OAuthProfile } from './oauth-types.mts';
2
+ export { OAuthProviderError, type AuthorizationCodeInput, type OAuthCallContext, type OAuthProvider, type OAuthProviderErrorKind, type OAuthTokens, } from './oauth-providers-core.mts';
3
+ export { facebookProvider, type FacebookProviderConfig } from './oauth-providers-facebook.mts';
4
+ export { githubProvider, type GithubProviderConfig } from './oauth-providers-github.mts';
5
+ export { googleProvider, type GoogleProviderConfig } from './oauth-providers-google.mts';
6
+ export { linkedinProvider, type LinkedinProviderConfig } from './oauth-providers-linkedin.mts';
7
+ export { microsoftAuthorizationScopes, microsoftProvider, type MicrosoftProviderConfig, } from './oauth-providers-microsoft.mts';
8
+ export { xProvider, type XProviderConfig } from './oauth-providers-x.mts';
@@ -0,0 +1,7 @@
1
+ export { OAuthProviderError, } from './oauth-providers-core.mjs';
2
+ export { facebookProvider } from './oauth-providers-facebook.mjs';
3
+ export { githubProvider } from './oauth-providers-github.mjs';
4
+ export { googleProvider } from './oauth-providers-google.mjs';
5
+ export { linkedinProvider } from './oauth-providers-linkedin.mjs';
6
+ export { microsoftAuthorizationScopes, microsoftProvider, } from './oauth-providers-microsoft.mjs';
7
+ export { xProvider } from './oauth-providers-x.mjs';
@@ -0,0 +1,8 @@
1
+ /** Injected transport. Egress guards, dispatchers and timeouts belong to the caller. */
2
+ export type OAuthFetch = (input: string, init?: RequestInit) => Promise<Response>;
3
+ export interface OAuthProfile {
4
+ providerUserId: string;
5
+ /** Only set when the provider asserts the address is verified. */
6
+ verifiedEmail: string | null;
7
+ data: Record<string, unknown>;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { OAuthProfile } from './oauth-types.mts';
2
+ export interface OidcClaims {
3
+ iss: string;
4
+ aud: string | string[];
5
+ sub: string;
6
+ exp: number;
7
+ iat: number;
8
+ nonce?: string;
9
+ email?: string;
10
+ email_verified?: boolean | string;
11
+ name?: string;
12
+ picture?: string;
13
+ given_name?: string;
14
+ family_name?: string;
15
+ [claim: string]: unknown;
16
+ }
17
+ export declare function googleProfileFromClaims(claims: OidcClaims): OAuthProfile;
18
+ /** Microsoft verifies an email only when the signed domain-owner claim is boolean true. */
19
+ export declare function microsoftProfileFromClaims(claims: OidcClaims): OAuthProfile;
20
+ /** Apple sends `email_verified` as a boolean or the string `'true'`. */
21
+ export declare function appleProfileFromClaims(claims: OidcClaims, userData?: {
22
+ name?: string;
23
+ }): OAuthProfile;
@@ -0,0 +1,42 @@
1
+ function isTrue(value) {
2
+ return value === true || value === 'true';
3
+ }
4
+ export function googleProfileFromClaims(claims) {
5
+ const data = {};
6
+ for (const key of ['name', 'email', 'picture', 'given_name', 'family_name']) {
7
+ if (claims[key])
8
+ data[key] = claims[key];
9
+ }
10
+ return {
11
+ providerUserId: claims.sub,
12
+ verifiedEmail: claims.email_verified === true ? (claims.email ?? null) : null,
13
+ data,
14
+ };
15
+ }
16
+ /** Microsoft verifies an email only when the signed domain-owner claim is boolean true. */
17
+ export function microsoftProfileFromClaims(claims) {
18
+ const data = {};
19
+ for (const key of ['name', 'email', 'given_name', 'family_name']) {
20
+ if (claims[key])
21
+ data[key] = claims[key];
22
+ }
23
+ const oid = claims.oid;
24
+ return {
25
+ providerUserId: typeof oid === 'string' && oid.length > 0 ? oid : claims.sub,
26
+ verifiedEmail: claims.xms_edov === true ? (claims.email ?? null) : null,
27
+ data,
28
+ };
29
+ }
30
+ /** Apple sends `email_verified` as a boolean or the string `'true'`. */
31
+ export function appleProfileFromClaims(claims, userData) {
32
+ const data = {};
33
+ if (userData?.name)
34
+ data.name = userData.name;
35
+ if (claims.email)
36
+ data.email = claims.email;
37
+ return {
38
+ providerUserId: claims.sub,
39
+ verifiedEmail: isTrue(claims.email_verified) ? (claims.email ?? null) : null,
40
+ data,
41
+ };
42
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Rejects with the signal's reason when it aborts; the underlying promise keeps running.
3
+ * An already-aborted signal rejects at once, and the abandoned promise gets a no-op handler so
4
+ * a later failure is not an unhandled rejection.
5
+ */
6
+ export declare function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Rejects with the signal's reason when it aborts; the underlying promise keeps running.
3
+ * An already-aborted signal rejects at once, and the abandoned promise gets a no-op handler so
4
+ * a later failure is not an unhandled rejection.
5
+ */
6
+ export function abortable(promise, signal) {
7
+ if (!signal)
8
+ return promise;
9
+ if (signal.aborted) {
10
+ promise.catch(() => undefined);
11
+ return Promise.reject(signal.reason);
12
+ }
13
+ return new Promise((resolve, reject) => {
14
+ const onAbort = () => reject(signal.reason);
15
+ signal.addEventListener('abort', onAbort, { once: true });
16
+ promise.finally(() => signal.removeEventListener('abort', onAbort)).then(resolve, reject);
17
+ });
18
+ }
@@ -0,0 +1,33 @@
1
+ import type { OAuthFetch } from './oauth-types.mts';
2
+ import type { OidcClaims } from './oidc-id-token-profiles.mts';
3
+ export { appleProfileFromClaims, googleProfileFromClaims, microsoftProfileFromClaims, type OidcClaims, } from './oidc-id-token-profiles.mts';
4
+ export type OidcVerificationCode = 'format' | 'alg' | 'key_not_found' | 'signature' | 'expired' | 'future_iat' | 'not_yet_valid' | 'issuer' | 'audience' | 'nonce' | 'nonce_missing' | 'jwks_fetch';
5
+ export declare class OidcVerificationError extends Error {
6
+ readonly code: OidcVerificationCode;
7
+ constructor(code: OidcVerificationCode, message: string);
8
+ }
9
+ export interface OidcIdTokenVerifierOptions {
10
+ jwksUri: string;
11
+ issuers: readonly string[];
12
+ /** Additional issuer rule evaluated only after the token signature is verified. */
13
+ issuerMatches?: (claims: OidcClaims, keyIssuer?: string) => boolean;
14
+ /** Read on every verification so configuration can change at runtime. */
15
+ audiences: () => readonly string[];
16
+ fetch: OAuthFetch;
17
+ /** JWKS cache lifetime; defaults to one hour. */
18
+ cacheTtlMs?: number;
19
+ /** Allowed `iat` skew in seconds; defaults to 300. */
20
+ maxIatSkewSeconds?: number;
21
+ /** Minimum time between JWKS fetches triggered by an unknown `kid`; defaults to one minute. */
22
+ refreshCooldownMs?: number;
23
+ now?: () => number;
24
+ }
25
+ export interface OidcVerifyOptions {
26
+ /** Raw nonce; compared with the `nonce` claim (hashed first when `nonceHash` is set). */
27
+ nonce?: string;
28
+ nonceHash?: 'sha256-hex';
29
+ signal?: AbortSignal;
30
+ }
31
+ export declare function createOidcIdTokenVerifier(options: OidcIdTokenVerifierOptions): {
32
+ verify(idToken: string, verify?: OidcVerifyOptions): Promise<OidcClaims>;
33
+ };
@@ -0,0 +1,141 @@
1
+ import { createHash, createPublicKey, createVerify } from 'node:crypto';
2
+ import { abortable } from './oidc-id-token-util.mjs';
3
+ export { appleProfileFromClaims, googleProfileFromClaims, microsoftProfileFromClaims, } from './oidc-id-token-profiles.mjs';
4
+ export class OidcVerificationError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = 'OidcVerificationError';
9
+ this.code = code;
10
+ }
11
+ }
12
+ function decodePart(part) {
13
+ try {
14
+ const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
15
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
16
+ return value;
17
+ }
18
+ }
19
+ catch {
20
+ // Reported below as a format error.
21
+ }
22
+ throw new OidcVerificationError('format', 'ID token is malformed');
23
+ }
24
+ export function createOidcIdTokenVerifier(options) {
25
+ const now = options.now ?? Date.now;
26
+ const ttl = options.cacheTtlMs ?? 60 * 60 * 1000;
27
+ const skew = options.maxIatSkewSeconds ?? 300;
28
+ const cooldown = options.refreshCooldownMs ?? 60 * 1000;
29
+ let keys = null;
30
+ let cachedAt = 0;
31
+ function loadKeys(forceRefresh) {
32
+ const age = now() - cachedAt;
33
+ // A forced refresh is skipped inside the cooldown, which also joins concurrent callers.
34
+ if (keys && age < ttl && (!forceRefresh || age < cooldown))
35
+ return keys;
36
+ // The shared fetch ignores any caller's signal; callers cancel only their own wait.
37
+ const pending = options
38
+ .fetch(options.jwksUri)
39
+ .then(async (response) => {
40
+ if (!response.ok) {
41
+ throw new OidcVerificationError('jwks_fetch', `JWKS request failed: ${response.status}`);
42
+ }
43
+ const body = (await response.json());
44
+ if (!Array.isArray(body.keys)) {
45
+ throw new OidcVerificationError('jwks_fetch', 'JWKS response has no keys');
46
+ }
47
+ return body.keys;
48
+ })
49
+ .catch((error) => {
50
+ keys = null;
51
+ throw error;
52
+ });
53
+ cachedAt = now();
54
+ keys = pending;
55
+ return pending;
56
+ }
57
+ async function findKey(kid, signal) {
58
+ const match = (list) => list.find((key) => key.kid === kid);
59
+ // Never start a load for a caller that is already cancelled: nobody would observe its failure.
60
+ signal?.throwIfAborted();
61
+ const found = match(await abortable(loadKeys(false), signal));
62
+ if (found)
63
+ return found;
64
+ // The key may have rotated inside the cache window: refresh, at most once per cooldown.
65
+ const refreshed = match(await abortable(loadKeys(true), signal));
66
+ if (!refreshed)
67
+ throw new OidcVerificationError('key_not_found', 'Signing key not found');
68
+ return refreshed;
69
+ }
70
+ function checkSignature(signed, signature, key) {
71
+ let valid = false;
72
+ try {
73
+ const publicKey = createPublicKey({
74
+ key: { kty: key.kty, n: key.n, e: key.e },
75
+ format: 'jwk',
76
+ });
77
+ valid = createVerify('SHA256')
78
+ .update(signed)
79
+ .verify(publicKey, Buffer.from(signature, 'base64url'));
80
+ }
81
+ catch {
82
+ // An unusable key is reported as an invalid signature.
83
+ }
84
+ if (!valid)
85
+ throw new OidcVerificationError('signature', 'ID token signature is invalid');
86
+ }
87
+ function checkClaims(claims, verify, key) {
88
+ if (typeof claims.exp !== 'number' || typeof claims.iat !== 'number') {
89
+ throw new OidcVerificationError('format', 'ID token is missing exp or iat');
90
+ }
91
+ if (claims.nbf !== undefined && typeof claims.nbf !== 'number') {
92
+ throw new OidcVerificationError('format', 'ID token nbf is not a number');
93
+ }
94
+ if (typeof claims.sub !== 'string' || claims.sub.length === 0) {
95
+ throw new OidcVerificationError('format', 'ID token is missing sub');
96
+ }
97
+ const seconds = Math.floor(now() / 1000);
98
+ if (claims.exp < seconds)
99
+ throw new OidcVerificationError('expired', 'ID token has expired');
100
+ if (claims.iat > seconds + skew) {
101
+ throw new OidcVerificationError('future_iat', 'ID token was issued in the future');
102
+ }
103
+ if (claims.nbf !== undefined && claims.nbf > seconds + skew) {
104
+ throw new OidcVerificationError('not_yet_valid', 'ID token is not valid yet');
105
+ }
106
+ if (!options.issuers.includes(claims.iss) && !options.issuerMatches?.(claims, key.issuer)) {
107
+ throw new OidcVerificationError('issuer', 'ID token issuer is invalid');
108
+ }
109
+ const accepted = options.audiences();
110
+ const presented = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
111
+ if (!presented.some((audience) => accepted.includes(audience))) {
112
+ throw new OidcVerificationError('audience', 'ID token audience is invalid');
113
+ }
114
+ if (verify.nonce === undefined)
115
+ return;
116
+ if (!claims.nonce)
117
+ throw new OidcVerificationError('nonce_missing', 'ID token has no nonce');
118
+ const expected = verify.nonceHash === 'sha256-hex'
119
+ ? createHash('sha256').update(verify.nonce).digest('hex')
120
+ : verify.nonce;
121
+ if (claims.nonce !== expected) {
122
+ throw new OidcVerificationError('nonce', 'ID token nonce is invalid');
123
+ }
124
+ }
125
+ return {
126
+ async verify(idToken, verify = {}) {
127
+ const [header, payload, signature, ...rest] = idToken.split('.');
128
+ if (!header || !payload || !signature || rest.length > 0) {
129
+ throw new OidcVerificationError('format', 'ID token is malformed');
130
+ }
131
+ const { alg, kid } = decodePart(header);
132
+ if (alg !== 'RS256')
133
+ throw new OidcVerificationError('alg', 'Unsupported ID token algorithm');
134
+ const key = await findKey(kid, verify.signal);
135
+ checkSignature(`${header}.${payload}`, signature, key);
136
+ const claims = decodePart(payload);
137
+ checkClaims(claims, verify, key);
138
+ return claims;
139
+ },
140
+ };
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchington/utils",
3
- "version": "0.7.5",
3
+ "version": "0.9.0",
4
4
  "description": "Dependency-free runtime utilities for Vouchington-style applications.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
6
6
  "bugs": {
@@ -235,6 +235,16 @@
235
235
  "import": "./dist/observability.mjs",
236
236
  "default": "./dist/observability.mjs"
237
237
  },
238
+ "./error-tree": {
239
+ "types": "./dist/error-tree.d.mts",
240
+ "import": "./dist/error-tree.mjs",
241
+ "default": "./dist/error-tree.mjs"
242
+ },
243
+ "./job-data-scrub": {
244
+ "types": "./dist/job-data-scrub.d.mts",
245
+ "import": "./dist/job-data-scrub.mjs",
246
+ "default": "./dist/job-data-scrub.mjs"
247
+ },
238
248
  "./hashtags": {
239
249
  "types": "./dist/hashtags.d.mts",
240
250
  "import": "./dist/hashtags.mjs",
@@ -260,6 +270,26 @@
260
270
  "import": "./dist/http-link-pagination.mjs",
261
271
  "default": "./dist/http-link-pagination.mjs"
262
272
  },
273
+ "./oauth-pkce": {
274
+ "types": "./dist/oauth-pkce.d.mts",
275
+ "import": "./dist/oauth-pkce.mjs",
276
+ "default": "./dist/oauth-pkce.mjs"
277
+ },
278
+ "./oauth-popup": {
279
+ "types": "./dist/oauth-popup.d.mts",
280
+ "import": "./dist/oauth-popup.mjs",
281
+ "default": "./dist/oauth-popup.mjs"
282
+ },
283
+ "./oauth-providers": {
284
+ "types": "./dist/oauth-providers.d.mts",
285
+ "import": "./dist/oauth-providers.mjs",
286
+ "default": "./dist/oauth-providers.mjs"
287
+ },
288
+ "./oidc-id-token": {
289
+ "types": "./dist/oidc-id-token.d.mts",
290
+ "import": "./dist/oidc-id-token.mjs",
291
+ "default": "./dist/oidc-id-token.mjs"
292
+ },
263
293
  "./http-signatures": {
264
294
  "types": "./dist/http-signatures.d.mts",
265
295
  "import": "./dist/http-signatures.mjs",