@vouchington/utils 0.8.0 → 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.
- package/README.md +11 -5
- package/dist/feature-flags.d.mts +1 -1
- package/dist/oauth-providers-facebook.d.mts +4 -2
- package/dist/oauth-providers-facebook.mjs +19 -12
- package/dist/oauth-providers-microsoft.d.mts +3 -6
- package/dist/oauth-providers-microsoft.mjs +38 -5
- package/dist/oauth-providers.d.mts +1 -1
- package/dist/oauth-providers.mjs +1 -1
- package/dist/oidc-id-token-profiles.d.mts +2 -0
- package/dist/oidc-id-token-profiles.mjs +14 -0
- package/dist/oidc-id-token.d.mts +3 -1
- package/dist/oidc-id-token.mjs +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,14 +85,20 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
|
|
|
85
85
|
- `oidc-id-token`: Node-only (`node:crypto`) RS256 ID-token verifier with an injected `fetch`, JWKS
|
|
86
86
|
URI, issuers, and audiences. It caches the JWKS with a TTL, refreshes once on a `kid` miss, checks
|
|
87
87
|
`iat` skew and an optional (hashed) nonce, and throws `OidcVerificationError` with a `code`.
|
|
88
|
-
`googleProfileFromClaims` and `
|
|
88
|
+
`googleProfileFromClaims`, `appleProfileFromClaims`, and `microsoftProfileFromClaims` map claims to a profile; account policy
|
|
89
89
|
and HTTP error mapping stay in the application.
|
|
90
90
|
- `oauth-providers`: authorization-code redemption for GitHub, X, LinkedIn, Microsoft, Google, and Facebook. Each
|
|
91
91
|
provider's `redeem(input, { fetch, signal, now })` returns `{ tokens, profile }`; `verifiedEmail`
|
|
92
|
-
follows each provider's own rule. Microsoft
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
follows each provider's own rule. Microsoft verifies an ID token with the tenant JWKS and uses only an `email` claim with boolean
|
|
93
|
+
`xms_edov: true` for `verifiedEmail`; Graph `mail` stays in profile data without verification. The
|
|
94
|
+
authorization URL must request `microsoftAuthorizationScopes` (`openid email User.Read`) so Graph
|
|
95
|
+
`/me` is authorized.
|
|
96
|
+
Facebook keeps Graph email as unverified data and supports native tokens through
|
|
97
|
+
`redeemAccessToken(token, ctx)`, which upgrades the token before fetching the profile.
|
|
98
|
+
Credentials, tenant, and the egress-guarded `fetch` are injected; provider and HTTP failures
|
|
99
|
+
throw `OAuthProviderError` (`provider`, `kind`, `status`), while Microsoft ID-token
|
|
100
|
+
verification failures throw `OidcVerificationError` (`code`). Persistence and HTTP error
|
|
101
|
+
mapping stay in the application.
|
|
96
102
|
- `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
|
|
97
103
|
and deterministic JSON serialization.
|
|
98
104
|
- `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
|
package/dist/feature-flags.d.mts
CHANGED
|
@@ -8,7 +8,7 @@ export type FeatureFlagCookieOptions = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const DEFAULT_FEATURE_FLAG_COOKIE_MAX_LENGTH = 4096;
|
|
10
10
|
export declare function extractBooleanFeatureFlags(fields: Record<string, unknown>): FeatureFlags;
|
|
11
|
-
export declare function parseFeatureFlagCookie(cookieValue: string, codec: FeatureFlagCookieCodec, options?: FeatureFlagCookieOptions): FeatureFlags;
|
|
11
|
+
export declare function parseFeatureFlagCookie(cookieValue: string, codec: Partial<FeatureFlagCookieCodec> & Pick<FeatureFlagCookieCodec, 'decodeBase64'>, options?: FeatureFlagCookieOptions): FeatureFlags;
|
|
12
12
|
export declare function encodeFeatureFlagCookie(overrides: FeatureFlags, codec: FeatureFlagCookieCodec): string;
|
|
13
13
|
export declare function parseFeatureFlagCookieMaxLength(value: string | undefined, fallback?: number): number;
|
|
14
14
|
export declare function safeFeatureFlagCookiePart(cookieName: string, cookieValue: string | undefined, options?: FeatureFlagCookieOptions): string | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type OAuthProvider } from './oauth-providers-core.mts';
|
|
1
|
+
import { type OAuthCallContext, type OAuthProvider } from './oauth-providers-core.mts';
|
|
2
2
|
export interface FacebookProviderConfig {
|
|
3
3
|
appId: string;
|
|
4
4
|
appSecret: string;
|
|
@@ -6,4 +6,6 @@ export interface FacebookProviderConfig {
|
|
|
6
6
|
graphVersion: string;
|
|
7
7
|
}
|
|
8
8
|
/** Code exchange, then `fb_exchange_token` for a long-lived token, then `/me`. */
|
|
9
|
-
export declare function facebookProvider(config: FacebookProviderConfig): OAuthProvider
|
|
9
|
+
export declare function facebookProvider(config: FacebookProviderConfig): OAuthProvider & {
|
|
10
|
+
redeemAccessToken(token: string, ctx: OAuthCallContext): ReturnType<OAuthProvider['redeem']>;
|
|
11
|
+
};
|
|
@@ -7,21 +7,28 @@ export function facebookProvider(config) {
|
|
|
7
7
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
8
8
|
body: formBody({ client_id: config.appId, client_secret: config.appSecret, ...values }),
|
|
9
9
|
});
|
|
10
|
+
async function redeemAccessToken(token, ctx) {
|
|
11
|
+
if (!token) {
|
|
12
|
+
throw new OAuthProviderError('facebook', 'malformed', 'An access token is required');
|
|
13
|
+
}
|
|
14
|
+
const now = nowOf(ctx);
|
|
15
|
+
const tokens = parseTokens('facebook', await requestJson('facebook', 'Facebook token exchange', ctx, `${graph}/oauth/access_token`, form({ grant_type: 'fb_exchange_token', fb_exchange_token: token })), now);
|
|
16
|
+
const mePayload = await requestJson('facebook', 'Facebook /me request', ctx, `${graph}/me?fields=name,email`, { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
|
|
17
|
+
const me = asRecord('facebook', mePayload, 'Facebook /me response');
|
|
18
|
+
const id = str(me.id);
|
|
19
|
+
if (!id)
|
|
20
|
+
throw new OAuthProviderError('facebook', 'malformed', 'Facebook user is missing id');
|
|
21
|
+
return {
|
|
22
|
+
tokens,
|
|
23
|
+
profile: { providerUserId: id, verifiedEmail: null, data: me },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
10
26
|
return {
|
|
11
27
|
id: 'facebook',
|
|
12
28
|
async redeem(input, ctx) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const tokens = parseTokens('facebook', await requestJson('facebook', 'Facebook token exchange', ctx, `${graph}/oauth/access_token`, form({ grant_type: 'fb_exchange_token', fb_exchange_token: shortLived.accessToken })), now);
|
|
16
|
-
const mePayload = await requestJson('facebook', 'Facebook /me request', ctx, `${graph}/me?fields=name,email`, { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
|
|
17
|
-
const me = asRecord('facebook', mePayload, 'Facebook /me response');
|
|
18
|
-
const id = str(me.id);
|
|
19
|
-
if (!id)
|
|
20
|
-
throw new OAuthProviderError('facebook', 'malformed', 'Facebook user is missing id');
|
|
21
|
-
return {
|
|
22
|
-
tokens,
|
|
23
|
-
profile: { providerUserId: id, verifiedEmail: str(me.email) ?? null, data: me },
|
|
24
|
-
};
|
|
29
|
+
const shortLived = parseTokens('facebook', await requestJson('facebook', 'Facebook authorization code exchange', ctx, `${graph}/oauth/access_token`, form({ redirect_uri: input.redirectUri, code: input.code })), nowOf(ctx));
|
|
30
|
+
return redeemAccessToken(shortLived.accessToken, ctx);
|
|
25
31
|
},
|
|
32
|
+
redeemAccessToken,
|
|
26
33
|
};
|
|
27
34
|
}
|
|
@@ -4,11 +4,8 @@ export interface MicrosoftProviderConfig {
|
|
|
4
4
|
clientSecret: string;
|
|
5
5
|
/** Defaults to `common`. */
|
|
6
6
|
tenantId?: string;
|
|
7
|
-
/**
|
|
8
|
-
* Treat Graph `mail` as verified. Off by default: a tenant admin can set `mail` without proving
|
|
9
|
-
* ownership, so only enable it when every accepted tenant is trusted.
|
|
10
|
-
*/
|
|
11
|
-
trustMailAsVerified?: boolean;
|
|
12
7
|
}
|
|
13
|
-
/**
|
|
8
|
+
/** Request these scopes in the authorization URL for ID-token email and Graph /me. */
|
|
9
|
+
export declare const microsoftAuthorizationScopes = "openid email User.Read";
|
|
10
|
+
/** v2.0 code exchange with PKCE, signed ID-token email proof, and Graph profile data. */
|
|
14
11
|
export declare function microsoftProvider(config: MicrosoftProviderConfig): OAuthProvider;
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { createOidcIdTokenVerifier } from './oidc-id-token.mjs';
|
|
2
|
+
import { microsoftProfileFromClaims } from './oidc-id-token-profiles.mjs';
|
|
1
3
|
import { OAuthProviderError, asRecord, compact, formBody, nowOf, parseTokens, requestJson, requireCodeVerifier, str, } from './oauth-providers-core.mjs';
|
|
2
|
-
|
|
4
|
+
const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
/** Request these scopes in the authorization URL for ID-token email and Graph /me. */
|
|
6
|
+
export const microsoftAuthorizationScopes = 'openid email User.Read';
|
|
7
|
+
/** v2.0 code exchange with PKCE, signed ID-token email proof, and Graph profile data. */
|
|
3
8
|
export function microsoftProvider(config) {
|
|
4
9
|
return {
|
|
5
10
|
id: 'microsoft',
|
|
6
11
|
async redeem(input, ctx) {
|
|
7
12
|
const codeVerifier = requireCodeVerifier('microsoft', input);
|
|
8
13
|
const tenant = config.tenantId || 'common';
|
|
9
|
-
const
|
|
14
|
+
const login = `https://login.microsoftonline.com/${tenant}`;
|
|
15
|
+
const tokenPayload = await requestJson('microsoft', 'Microsoft token exchange', ctx, `${login}/oauth2/v2.0/token`, {
|
|
10
16
|
method: 'POST',
|
|
11
17
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
12
18
|
body: formBody({
|
|
@@ -16,23 +22,50 @@ export function microsoftProvider(config) {
|
|
|
16
22
|
client_id: config.clientId,
|
|
17
23
|
client_secret: config.clientSecret,
|
|
18
24
|
code_verifier: codeVerifier,
|
|
25
|
+
scope: microsoftAuthorizationScopes,
|
|
19
26
|
}),
|
|
20
27
|
});
|
|
21
28
|
const tokens = parseTokens('microsoft', tokenPayload, nowOf(ctx));
|
|
29
|
+
const body = asRecord('microsoft', tokenPayload, 'Microsoft token response');
|
|
30
|
+
const idToken = str(body.id_token);
|
|
31
|
+
if (!idToken) {
|
|
32
|
+
throw new OAuthProviderError('microsoft', 'malformed', 'Token response is missing id_token');
|
|
33
|
+
}
|
|
34
|
+
const alias = tenant === 'common' || tenant === 'organizations';
|
|
35
|
+
const verifier = createOidcIdTokenVerifier({
|
|
36
|
+
jwksUri: `${login}/discovery/v2.0/keys`,
|
|
37
|
+
issuers: [],
|
|
38
|
+
issuerMatches: (claims, keyIssuer) => {
|
|
39
|
+
const tid = claims.tid;
|
|
40
|
+
if (typeof tid !== 'string' || !TENANT_ID.test(tid))
|
|
41
|
+
return false;
|
|
42
|
+
const issuer = `https://login.microsoftonline.com/${tid}/v2.0`;
|
|
43
|
+
return ((alias || tid === tenant) &&
|
|
44
|
+
claims.iss === issuer &&
|
|
45
|
+
(keyIssuer === undefined || keyIssuer.replace('{tenantid}', tid) === issuer));
|
|
46
|
+
},
|
|
47
|
+
audiences: () => [config.clientId],
|
|
48
|
+
fetch: ctx.fetch,
|
|
49
|
+
...(ctx.now ? { now: ctx.now } : {}),
|
|
50
|
+
});
|
|
51
|
+
const claims = await verifier.verify(idToken, ctx.signal ? { signal: ctx.signal } : {});
|
|
22
52
|
const mePayload = await requestJson('microsoft', 'Microsoft Graph /me request', ctx, 'https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
|
|
23
53
|
const me = asRecord('microsoft', mePayload, 'Microsoft Graph /me response');
|
|
24
54
|
const id = str(me.id);
|
|
25
55
|
if (!id)
|
|
26
56
|
throw new OAuthProviderError('microsoft', 'malformed', 'Microsoft user is missing id');
|
|
27
|
-
|
|
57
|
+
if (claims.oid !== id) {
|
|
58
|
+
throw new OAuthProviderError('microsoft', 'malformed', 'ID token user does not match Graph user');
|
|
59
|
+
}
|
|
60
|
+
const signedProfile = microsoftProfileFromClaims(claims);
|
|
28
61
|
return {
|
|
29
62
|
tokens,
|
|
30
63
|
profile: {
|
|
31
64
|
providerUserId: id,
|
|
32
|
-
verifiedEmail:
|
|
65
|
+
verifiedEmail: signedProfile.verifiedEmail,
|
|
33
66
|
data: compact({
|
|
34
67
|
name: str(me.displayName),
|
|
35
|
-
email,
|
|
68
|
+
email: str(me.mail),
|
|
36
69
|
given_name: str(me.givenName),
|
|
37
70
|
family_name: str(me.surname),
|
|
38
71
|
}),
|
|
@@ -4,5 +4,5 @@ export { facebookProvider, type FacebookProviderConfig } from './oauth-providers
|
|
|
4
4
|
export { githubProvider, type GithubProviderConfig } from './oauth-providers-github.mts';
|
|
5
5
|
export { googleProvider, type GoogleProviderConfig } from './oauth-providers-google.mts';
|
|
6
6
|
export { linkedinProvider, type LinkedinProviderConfig } from './oauth-providers-linkedin.mts';
|
|
7
|
-
export { microsoftProvider, type MicrosoftProviderConfig } from './oauth-providers-microsoft.mts';
|
|
7
|
+
export { microsoftAuthorizationScopes, microsoftProvider, type MicrosoftProviderConfig, } from './oauth-providers-microsoft.mts';
|
|
8
8
|
export { xProvider, type XProviderConfig } from './oauth-providers-x.mts';
|
package/dist/oauth-providers.mjs
CHANGED
|
@@ -3,5 +3,5 @@ export { facebookProvider } from './oauth-providers-facebook.mjs';
|
|
|
3
3
|
export { githubProvider } from './oauth-providers-github.mjs';
|
|
4
4
|
export { googleProvider } from './oauth-providers-google.mjs';
|
|
5
5
|
export { linkedinProvider } from './oauth-providers-linkedin.mjs';
|
|
6
|
-
export { microsoftProvider } from './oauth-providers-microsoft.mjs';
|
|
6
|
+
export { microsoftAuthorizationScopes, microsoftProvider, } from './oauth-providers-microsoft.mjs';
|
|
7
7
|
export { xProvider } from './oauth-providers-x.mjs';
|
|
@@ -15,6 +15,8 @@ export interface OidcClaims {
|
|
|
15
15
|
[claim: string]: unknown;
|
|
16
16
|
}
|
|
17
17
|
export declare function googleProfileFromClaims(claims: OidcClaims): OAuthProfile;
|
|
18
|
+
/** Microsoft verifies an email only when the signed domain-owner claim is boolean true. */
|
|
19
|
+
export declare function microsoftProfileFromClaims(claims: OidcClaims): OAuthProfile;
|
|
18
20
|
/** Apple sends `email_verified` as a boolean or the string `'true'`. */
|
|
19
21
|
export declare function appleProfileFromClaims(claims: OidcClaims, userData?: {
|
|
20
22
|
name?: string;
|
|
@@ -13,6 +13,20 @@ export function googleProfileFromClaims(claims) {
|
|
|
13
13
|
data,
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
/** Microsoft verifies an email only when the signed domain-owner claim is boolean true. */
|
|
17
|
+
export function microsoftProfileFromClaims(claims) {
|
|
18
|
+
const data = {};
|
|
19
|
+
for (const key of ['name', 'email', 'given_name', 'family_name']) {
|
|
20
|
+
if (claims[key])
|
|
21
|
+
data[key] = claims[key];
|
|
22
|
+
}
|
|
23
|
+
const oid = claims.oid;
|
|
24
|
+
return {
|
|
25
|
+
providerUserId: typeof oid === 'string' && oid.length > 0 ? oid : claims.sub,
|
|
26
|
+
verifiedEmail: claims.xms_edov === true ? (claims.email ?? null) : null,
|
|
27
|
+
data,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
16
30
|
/** Apple sends `email_verified` as a boolean or the string `'true'`. */
|
|
17
31
|
export function appleProfileFromClaims(claims, userData) {
|
|
18
32
|
const data = {};
|
package/dist/oidc-id-token.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OAuthFetch } from './oauth-types.mts';
|
|
2
2
|
import type { OidcClaims } from './oidc-id-token-profiles.mts';
|
|
3
|
-
export { appleProfileFromClaims, googleProfileFromClaims, type OidcClaims, } from './oidc-id-token-profiles.mts';
|
|
3
|
+
export { appleProfileFromClaims, googleProfileFromClaims, microsoftProfileFromClaims, type OidcClaims, } from './oidc-id-token-profiles.mts';
|
|
4
4
|
export type OidcVerificationCode = 'format' | 'alg' | 'key_not_found' | 'signature' | 'expired' | 'future_iat' | 'not_yet_valid' | 'issuer' | 'audience' | 'nonce' | 'nonce_missing' | 'jwks_fetch';
|
|
5
5
|
export declare class OidcVerificationError extends Error {
|
|
6
6
|
readonly code: OidcVerificationCode;
|
|
@@ -9,6 +9,8 @@ export declare class OidcVerificationError extends Error {
|
|
|
9
9
|
export interface OidcIdTokenVerifierOptions {
|
|
10
10
|
jwksUri: string;
|
|
11
11
|
issuers: readonly string[];
|
|
12
|
+
/** Additional issuer rule evaluated only after the token signature is verified. */
|
|
13
|
+
issuerMatches?: (claims: OidcClaims, keyIssuer?: string) => boolean;
|
|
12
14
|
/** Read on every verification so configuration can change at runtime. */
|
|
13
15
|
audiences: () => readonly string[];
|
|
14
16
|
fetch: OAuthFetch;
|
package/dist/oidc-id-token.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, createPublicKey, createVerify } from 'node:crypto';
|
|
2
2
|
import { abortable } from './oidc-id-token-util.mjs';
|
|
3
|
-
export { appleProfileFromClaims, googleProfileFromClaims, } from './oidc-id-token-profiles.mjs';
|
|
3
|
+
export { appleProfileFromClaims, googleProfileFromClaims, microsoftProfileFromClaims, } from './oidc-id-token-profiles.mjs';
|
|
4
4
|
export class OidcVerificationError extends Error {
|
|
5
5
|
code;
|
|
6
6
|
constructor(code, message) {
|
|
@@ -84,7 +84,7 @@ export function createOidcIdTokenVerifier(options) {
|
|
|
84
84
|
if (!valid)
|
|
85
85
|
throw new OidcVerificationError('signature', 'ID token signature is invalid');
|
|
86
86
|
}
|
|
87
|
-
function checkClaims(claims, verify) {
|
|
87
|
+
function checkClaims(claims, verify, key) {
|
|
88
88
|
if (typeof claims.exp !== 'number' || typeof claims.iat !== 'number') {
|
|
89
89
|
throw new OidcVerificationError('format', 'ID token is missing exp or iat');
|
|
90
90
|
}
|
|
@@ -103,7 +103,7 @@ export function createOidcIdTokenVerifier(options) {
|
|
|
103
103
|
if (claims.nbf !== undefined && claims.nbf > seconds + skew) {
|
|
104
104
|
throw new OidcVerificationError('not_yet_valid', 'ID token is not valid yet');
|
|
105
105
|
}
|
|
106
|
-
if (!options.issuers.includes(claims.iss)) {
|
|
106
|
+
if (!options.issuers.includes(claims.iss) && !options.issuerMatches?.(claims, key.issuer)) {
|
|
107
107
|
throw new OidcVerificationError('issuer', 'ID token issuer is invalid');
|
|
108
108
|
}
|
|
109
109
|
const accepted = options.audiences();
|
|
@@ -134,7 +134,7 @@ export function createOidcIdTokenVerifier(options) {
|
|
|
134
134
|
const key = await findKey(kid, verify.signal);
|
|
135
135
|
checkSignature(`${header}.${payload}`, signature, key);
|
|
136
136
|
const claims = decodePart(payload);
|
|
137
|
-
checkClaims(claims, verify);
|
|
137
|
+
checkClaims(claims, verify, key);
|
|
138
138
|
return claims;
|
|
139
139
|
},
|
|
140
140
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.
|
|
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": {
|