@open-mercato/enterprise 0.6.3-develop.3797.1.9d6338d7cd → 0.6.3-develop.3805.1.357827108c
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.
|
@@ -39,12 +39,12 @@ class OidcProvider {
|
|
|
39
39
|
if (!email) {
|
|
40
40
|
throw new Error("IdP did not return an email claim (checked: email, upn, unique_name)");
|
|
41
41
|
}
|
|
42
|
-
const emailVerified = mergedClaims.email_verified
|
|
42
|
+
const emailVerified = normalizeEmailVerifiedClaim(mergedClaims.email_verified);
|
|
43
43
|
const groups = extractIdentityGroups(mergedClaims);
|
|
44
44
|
return {
|
|
45
45
|
subject,
|
|
46
46
|
email,
|
|
47
|
-
emailVerified,
|
|
47
|
+
...emailVerified === void 0 ? {} : { emailVerified },
|
|
48
48
|
name: mergedClaims.name ?? void 0,
|
|
49
49
|
groups
|
|
50
50
|
};
|
|
@@ -88,6 +88,9 @@ async function mergeWithUserInfoClaims(oidcConfig, tokens, claims) {
|
|
|
88
88
|
return claims;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
function normalizeEmailVerifiedClaim(value) {
|
|
92
|
+
return typeof value === "boolean" ? value : void 0;
|
|
93
|
+
}
|
|
91
94
|
function extractIdentityGroups(claims) {
|
|
92
95
|
const groups = /* @__PURE__ */ new Set();
|
|
93
96
|
const add = (value) => {
|
|
@@ -135,6 +138,7 @@ function coerceClaimValues(value) {
|
|
|
135
138
|
export {
|
|
136
139
|
OidcProvider,
|
|
137
140
|
coerceClaimValues,
|
|
138
|
-
extractIdentityGroups
|
|
141
|
+
extractIdentityGroups,
|
|
142
|
+
normalizeEmailVerifiedClaim
|
|
139
143
|
};
|
|
140
144
|
//# sourceMappingURL=oidc-provider.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sso/lib/oidc-provider.ts"],
|
|
4
|
-
"sourcesContent": ["import * as client from 'openid-client'\nimport type { SsoConfig } from '../data/entities'\nimport type { SsoIdentityPayload, SsoProtocolProvider } from './types'\n\nexport class OidcProvider implements SsoProtocolProvider {\n readonly protocol = 'oidc' as const\n\n async buildAuthUrl(\n config: SsoConfig,\n params: {\n state: string\n nonce: string\n redirectUri: string\n codeVerifier?: string\n clientSecret?: string\n },\n ): Promise<string> {\n const oidcConfig = await this.discover(config, params.clientSecret)\n\n const codeChallenge = params.codeVerifier\n ? await client.calculatePKCECodeChallenge(params.codeVerifier)\n : undefined\n\n const authUrl = client.buildAuthorizationUrl(oidcConfig, {\n redirect_uri: params.redirectUri,\n scope: 'openid email profile',\n state: params.state,\n nonce: params.nonce,\n ...(codeChallenge\n ? { code_challenge: codeChallenge, code_challenge_method: 'S256' }\n : {}),\n })\n\n return authUrl.href\n }\n\n async handleCallback(\n config: SsoConfig,\n params: {\n callbackParams: Record<string, string>\n redirectUri: string\n expectedState: string\n expectedNonce: string\n codeVerifier?: string\n clientSecret?: string\n },\n ): Promise<SsoIdentityPayload> {\n const oidcConfig = await this.discover(config, params.clientSecret)\n\n const callbackUrl = new URL(params.redirectUri)\n for (const [key, value] of Object.entries(params.callbackParams)) {\n callbackUrl.searchParams.set(key, value)\n }\n\n const tokens = await client.authorizationCodeGrant(oidcConfig, callbackUrl, {\n pkceCodeVerifier: params.codeVerifier,\n expectedState: params.expectedState,\n expectedNonce: params.expectedNonce,\n })\n\n const claims = tokens.claims()\n if (!claims) {\n throw new Error('No ID token claims received from IdP')\n }\n\n const mergedClaims = await mergeWithUserInfoClaims(oidcConfig, tokens, claims)\n\n const subject = String(mergedClaims.sub ?? claims.sub ?? '')\n const rawEmail = mergedClaims.email as string | undefined\n const upnCandidate = (mergedClaims.upn ?? mergedClaims.unique_name) as string | undefined\n const upnAsEmail = typeof upnCandidate === 'string' && upnCandidate.includes('@') ? upnCandidate : undefined\n const email = rawEmail ?? upnAsEmail\n if (!email) {\n throw new Error('IdP did not return an email claim (checked: email, upn, unique_name)')\n }\n\n const emailVerified = mergedClaims.email_verified
|
|
5
|
-
"mappings": "AAAA,YAAY,YAAY;AAIjB,MAAM,aAA4C;AAAA,EAAlD;AACL,SAAS,WAAW;AAAA;AAAA,EAEpB,MAAM,aACJ,QACA,QAOiB;AACjB,UAAM,aAAa,MAAM,KAAK,SAAS,QAAQ,OAAO,YAAY;AAElE,UAAM,gBAAgB,OAAO,eACzB,MAAM,OAAO,2BAA2B,OAAO,YAAY,IAC3D;AAEJ,UAAM,UAAU,OAAO,sBAAsB,YAAY;AAAA,MACvD,cAAc,OAAO;AAAA,MACrB,OAAO;AAAA,MACP,OAAO,OAAO;AAAA,MACd,OAAO,OAAO;AAAA,MACd,GAAI,gBACA,EAAE,gBAAgB,eAAe,uBAAuB,OAAO,IAC/D,CAAC;AAAA,IACP,CAAC;AAED,WAAO,QAAQ;AAAA,EACjB;AAAA,EAEA,MAAM,eACJ,QACA,QAQ6B;AAC7B,UAAM,aAAa,MAAM,KAAK,SAAS,QAAQ,OAAO,YAAY;AAElE,UAAM,cAAc,IAAI,IAAI,OAAO,WAAW;AAC9C,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,cAAc,GAAG;AAChE,kBAAY,aAAa,IAAI,KAAK,KAAK;AAAA,IACzC;AAEA,UAAM,SAAS,MAAM,OAAO,uBAAuB,YAAY,aAAa;AAAA,MAC1E,kBAAkB,OAAO;AAAA,MACzB,eAAe,OAAO;AAAA,MACtB,eAAe,OAAO;AAAA,IACxB,CAAC;AAED,UAAM,SAAS,OAAO,OAAO;AAC7B,QAAI,CAAC,QAAQ;AACX,YAAM,IAAI,MAAM,sCAAsC;AAAA,IACxD;AAEA,UAAM,eAAe,MAAM,wBAAwB,YAAY,QAAQ,MAAM;AAE7E,UAAM,UAAU,OAAO,aAAa,OAAO,OAAO,OAAO,EAAE;AAC3D,UAAM,WAAW,aAAa;AAC9B,UAAM,eAAgB,aAAa,OAAO,aAAa;AACvD,UAAM,aAAa,OAAO,iBAAiB,YAAY,aAAa,SAAS,GAAG,IAAI,eAAe;AACnG,UAAM,QAAQ,YAAY;AAC1B,QAAI,CAAC,OAAO;AACV,YAAM,IAAI,MAAM,sEAAsE;AAAA,IACxF;AAEA,UAAM,gBAAgB,aAAa,
|
|
4
|
+
"sourcesContent": ["import * as client from 'openid-client'\nimport type { SsoConfig } from '../data/entities'\nimport type { SsoIdentityPayload, SsoProtocolProvider } from './types'\n\nexport class OidcProvider implements SsoProtocolProvider {\n readonly protocol = 'oidc' as const\n\n async buildAuthUrl(\n config: SsoConfig,\n params: {\n state: string\n nonce: string\n redirectUri: string\n codeVerifier?: string\n clientSecret?: string\n },\n ): Promise<string> {\n const oidcConfig = await this.discover(config, params.clientSecret)\n\n const codeChallenge = params.codeVerifier\n ? await client.calculatePKCECodeChallenge(params.codeVerifier)\n : undefined\n\n const authUrl = client.buildAuthorizationUrl(oidcConfig, {\n redirect_uri: params.redirectUri,\n scope: 'openid email profile',\n state: params.state,\n nonce: params.nonce,\n ...(codeChallenge\n ? { code_challenge: codeChallenge, code_challenge_method: 'S256' }\n : {}),\n })\n\n return authUrl.href\n }\n\n async handleCallback(\n config: SsoConfig,\n params: {\n callbackParams: Record<string, string>\n redirectUri: string\n expectedState: string\n expectedNonce: string\n codeVerifier?: string\n clientSecret?: string\n },\n ): Promise<SsoIdentityPayload> {\n const oidcConfig = await this.discover(config, params.clientSecret)\n\n const callbackUrl = new URL(params.redirectUri)\n for (const [key, value] of Object.entries(params.callbackParams)) {\n callbackUrl.searchParams.set(key, value)\n }\n\n const tokens = await client.authorizationCodeGrant(oidcConfig, callbackUrl, {\n pkceCodeVerifier: params.codeVerifier,\n expectedState: params.expectedState,\n expectedNonce: params.expectedNonce,\n })\n\n const claims = tokens.claims()\n if (!claims) {\n throw new Error('No ID token claims received from IdP')\n }\n\n const mergedClaims = await mergeWithUserInfoClaims(oidcConfig, tokens, claims)\n\n const subject = String(mergedClaims.sub ?? claims.sub ?? '')\n const rawEmail = mergedClaims.email as string | undefined\n const upnCandidate = (mergedClaims.upn ?? mergedClaims.unique_name) as string | undefined\n const upnAsEmail = typeof upnCandidate === 'string' && upnCandidate.includes('@') ? upnCandidate : undefined\n const email = rawEmail ?? upnAsEmail\n if (!email) {\n throw new Error('IdP did not return an email claim (checked: email, upn, unique_name)')\n }\n\n const emailVerified = normalizeEmailVerifiedClaim(mergedClaims.email_verified)\n const groups = extractIdentityGroups(mergedClaims)\n\n return {\n subject,\n email,\n ...(emailVerified === undefined ? {} : { emailVerified }),\n name: (mergedClaims.name as string) ?? undefined,\n groups,\n }\n }\n\n async validateConfig(\n config: SsoConfig,\n params?: { clientSecret?: string },\n ): Promise<{ ok: boolean; error?: string }> {\n try {\n await this.discover(config, params?.clientSecret)\n return { ok: true }\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message : 'Discovery failed',\n }\n }\n }\n\n private async discover(\n config: SsoConfig,\n clientSecret?: string,\n ): Promise<client.Configuration> {\n if (!config.issuer) {\n throw new Error('SSO config is missing issuer URL')\n }\n if (!config.clientId) {\n throw new Error('SSO config is missing client ID')\n }\n\n return client.discovery(\n new URL(config.issuer),\n config.clientId,\n clientSecret ?? undefined,\n )\n }\n}\n\nasync function mergeWithUserInfoClaims(\n oidcConfig: client.Configuration,\n tokens: client.TokenEndpointResponse,\n claims: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n const accessToken = tokens.access_token\n if (!accessToken) return claims\n\n try {\n const userInfo = await client.fetchUserInfo(\n oidcConfig,\n accessToken,\n client.skipSubjectCheck,\n )\n return { ...(userInfo as Record<string, unknown>), ...claims }\n } catch {\n return claims\n }\n}\n\nexport function normalizeEmailVerifiedClaim(value: unknown): boolean | undefined {\n return typeof value === 'boolean' ? value : undefined\n}\n\nexport function extractIdentityGroups(claims: Record<string, unknown>): string[] | undefined {\n const groups = new Set<string>()\n\n const add = (value: unknown) => {\n for (const group of coerceClaimValues(value)) {\n groups.add(group)\n }\n }\n\n add(claims.groups)\n add(claims.roles)\n add(claims.role)\n\n for (const [key, value] of Object.entries(claims)) {\n if (!key.endsWith(':roles')) continue\n add(value)\n }\n\n return groups.size > 0 ? Array.from(groups) : undefined\n}\n\nexport function coerceClaimValues(value: unknown): string[] {\n if (typeof value === 'string') {\n const normalized = value.trim()\n return normalized ? [normalized] : []\n }\n\n if (Array.isArray(value)) {\n return value.flatMap((entry) => coerceClaimValues(entry))\n }\n\n if (value && typeof value === 'object') {\n const entries = Object.entries(value as Record<string, unknown>)\n const out = new Set<string>()\n for (const [key, nested] of entries) {\n const normalizedKey = key.trim()\n if (normalizedKey) out.add(normalizedKey)\n if (typeof nested === 'string') {\n const normalizedNested = nested.trim()\n if (normalizedNested) out.add(normalizedNested)\n } else if (nested && typeof nested === 'object') {\n const nestedName = (nested as Record<string, unknown>).name\n if (typeof nestedName === 'string' && nestedName.trim()) {\n out.add(nestedName.trim())\n }\n }\n }\n return Array.from(out)\n }\n\n return []\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/enterprise",
|
|
3
|
-
"version": "0.6.3-develop.
|
|
3
|
+
"version": "0.6.3-develop.3805.1.357827108c",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@open-mercato/core": "0.6.3-develop.
|
|
68
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
67
|
+
"@open-mercato/core": "0.6.3-develop.3805.1.357827108c",
|
|
68
|
+
"@open-mercato/ui": "0.6.3-develop.3805.1.357827108c",
|
|
69
69
|
"@simplewebauthn/browser": "^13.3.0",
|
|
70
70
|
"@simplewebauthn/server": "^13.3.0",
|
|
71
71
|
"@simplewebauthn/types": "^12.0.0",
|
|
@@ -75,12 +75,12 @@
|
|
|
75
75
|
"qrcode": "^1.5.4"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
78
|
+
"@open-mercato/shared": "0.6.3-develop.3805.1.357827108c",
|
|
79
79
|
"react": "^19.0.0",
|
|
80
80
|
"react-dom": "^19.0.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
83
|
+
"@open-mercato/shared": "0.6.3-develop.3805.1.357827108c",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.15",
|
|
86
86
|
"@types/react-dom": "^19.2.3",
|
|
@@ -74,15 +74,13 @@ export class OidcProvider implements SsoProtocolProvider {
|
|
|
74
74
|
throw new Error('IdP did not return an email claim (checked: email, upn, unique_name)')
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
const emailVerified = mergedClaims.email_verified
|
|
77
|
+
const emailVerified = normalizeEmailVerifiedClaim(mergedClaims.email_verified)
|
|
78
78
|
const groups = extractIdentityGroups(mergedClaims)
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
80
|
return {
|
|
83
81
|
subject,
|
|
84
82
|
email,
|
|
85
|
-
emailVerified,
|
|
83
|
+
...(emailVerified === undefined ? {} : { emailVerified }),
|
|
86
84
|
name: (mergedClaims.name as string) ?? undefined,
|
|
87
85
|
groups,
|
|
88
86
|
}
|
|
@@ -142,6 +140,10 @@ async function mergeWithUserInfoClaims(
|
|
|
142
140
|
}
|
|
143
141
|
}
|
|
144
142
|
|
|
143
|
+
export function normalizeEmailVerifiedClaim(value: unknown): boolean | undefined {
|
|
144
|
+
return typeof value === 'boolean' ? value : undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
145
147
|
export function extractIdentityGroups(claims: Record<string, unknown>): string[] | undefined {
|
|
146
148
|
const groups = new Set<string>()
|
|
147
149
|
|