@open-mercato/enterprise 0.6.5-develop.5048.1.fd82f4ae17 → 0.6.5-develop.5116.1.f0af9e5080
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/sso/api/callback/oidc/route.js +2 -2
- package/dist/modules/sso/api/callback/oidc/route.js.map +2 -2
- package/dist/modules/sso/lib/errors.js +21 -0
- package/dist/modules/sso/lib/errors.js.map +7 -0
- package/dist/modules/sso/services/accountLinkingService.js +2 -1
- package/dist/modules/sso/services/accountLinkingService.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/sso/api/callback/oidc/route.ts +2 -2
- package/src/modules/sso/lib/errors.ts +35 -0
- package/src/modules/sso/services/accountLinkingService.ts +2 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:enterprise] found
|
|
1
|
+
[build:enterprise] found 293 entry points
|
|
2
2
|
[build:enterprise] built successfully
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { toAbsoluteUrl } from "@open-mercato/shared/lib/url";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
4
|
import { emitSsoEvent } from "../../../events.js";
|
|
5
|
+
import { resolveSsoCallbackErrorCode } from "../../../lib/errors.js";
|
|
5
6
|
const metadata = {
|
|
6
7
|
GET: { requireAuth: false },
|
|
7
8
|
POST: { requireAuth: false }
|
|
@@ -66,8 +67,7 @@ async function handleCallback(req) {
|
|
|
66
67
|
void emitSsoEvent("sso.login.failed", {
|
|
67
68
|
reason: err instanceof Error ? err.message : "callback_failed"
|
|
68
69
|
}).catch((e) => console.error("[SSO Event]", e));
|
|
69
|
-
const
|
|
70
|
-
const errorCode = message.includes("email is not verified") ? "sso_email_not_verified" : "sso_failed";
|
|
70
|
+
const errorCode = resolveSsoCallbackErrorCode(err);
|
|
71
71
|
return NextResponse.redirect(toAbsoluteUrl(req, `/login?error=${errorCode}`));
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/sso/api/callback/oidc/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { SsoService } from '../../../services/ssoService'\nimport { emitSsoEvent } from '../../../events'\n\nexport const metadata = {\n GET: { requireAuth: false },\n POST: { requireAuth: false },\n}\n\nfunction parseCookie(req: Request, name: string): string | null {\n const cookie = req.headers.get('cookie') || ''\n const m = cookie.match(new RegExp('(?:^|;\\\\s*)' + name + '=([^;]+)'))\n return m ? decodeURIComponent(m[1]) : null\n}\n\nasync function handleCallback(req: Request): Promise<NextResponse> {\n try {\n const url = new URL(req.url)\n const callbackParams: Record<string, string> = {}\n url.searchParams.forEach((value, key) => {\n callbackParams[key] = value\n })\n\n if (req.method === 'POST') {\n const contentType = req.headers.get('content-type') || ''\n if (contentType.includes('application/x-www-form-urlencoded')) {\n const form = await req.formData()\n form.forEach((value, key) => {\n callbackParams[key] = String(value)\n })\n }\n }\n\n const stateCookie = parseCookie(req, 'sso_state')\n if (!stateCookie) {\n return NextResponse.redirect(toAbsoluteUrl(req, '/login?error=sso_state_missing'))\n }\n\n if (callbackParams.error) {\n void emitSsoEvent('sso.login.failed', {\n reason: callbackParams.error,\n }).catch((e) => console.error('[SSO Event]', e))\n return NextResponse.redirect(toAbsoluteUrl(req, '/login?error=sso_idp_error'))\n }\n\n if (!callbackParams.code || !callbackParams.state) {\n return NextResponse.redirect(toAbsoluteUrl(req, '/login?error=sso_missing_params'))\n }\n\n const redirectUri = toAbsoluteUrl(req, '/api/sso/callback/oidc')\n const container = await createRequestContainer()\n const ssoService = container.resolve<SsoService>('ssoService')\n\n const result = await ssoService.handleOidcCallback(callbackParams, stateCookie, redirectUri)\n\n const res = NextResponse.redirect(toAbsoluteUrl(req, result.redirectUrl))\n\n res.cookies.set('auth_token', result.token, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV !== 'development',\n maxAge: 60 * 60 * 8,\n })\n\n res.cookies.set('session_token', result.sessionToken, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV !== 'development',\n expires: result.sessionExpiresAt,\n })\n\n res.cookies.set('sso_state', '', { path: '/', maxAge: 0 })\n\n return res\n } catch (err) {\n console.error('[SSO Callback] Error:', err)\n void emitSsoEvent('sso.login.failed', {\n reason: err instanceof Error ? err.message : 'callback_failed',\n }).catch((e) => console.error('[SSO Event]', e))\n const
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,qBAAqB;AAC9B,SAAS,8BAA8B;AAEvC,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { SsoService } from '../../../services/ssoService'\nimport { emitSsoEvent } from '../../../events'\nimport { resolveSsoCallbackErrorCode } from '../../../lib/errors'\n\nexport const metadata = {\n GET: { requireAuth: false },\n POST: { requireAuth: false },\n}\n\nfunction parseCookie(req: Request, name: string): string | null {\n const cookie = req.headers.get('cookie') || ''\n const m = cookie.match(new RegExp('(?:^|;\\\\s*)' + name + '=([^;]+)'))\n return m ? decodeURIComponent(m[1]) : null\n}\n\nasync function handleCallback(req: Request): Promise<NextResponse> {\n try {\n const url = new URL(req.url)\n const callbackParams: Record<string, string> = {}\n url.searchParams.forEach((value, key) => {\n callbackParams[key] = value\n })\n\n if (req.method === 'POST') {\n const contentType = req.headers.get('content-type') || ''\n if (contentType.includes('application/x-www-form-urlencoded')) {\n const form = await req.formData()\n form.forEach((value, key) => {\n callbackParams[key] = String(value)\n })\n }\n }\n\n const stateCookie = parseCookie(req, 'sso_state')\n if (!stateCookie) {\n return NextResponse.redirect(toAbsoluteUrl(req, '/login?error=sso_state_missing'))\n }\n\n if (callbackParams.error) {\n void emitSsoEvent('sso.login.failed', {\n reason: callbackParams.error,\n }).catch((e) => console.error('[SSO Event]', e))\n return NextResponse.redirect(toAbsoluteUrl(req, '/login?error=sso_idp_error'))\n }\n\n if (!callbackParams.code || !callbackParams.state) {\n return NextResponse.redirect(toAbsoluteUrl(req, '/login?error=sso_missing_params'))\n }\n\n const redirectUri = toAbsoluteUrl(req, '/api/sso/callback/oidc')\n const container = await createRequestContainer()\n const ssoService = container.resolve<SsoService>('ssoService')\n\n const result = await ssoService.handleOidcCallback(callbackParams, stateCookie, redirectUri)\n\n const res = NextResponse.redirect(toAbsoluteUrl(req, result.redirectUrl))\n\n res.cookies.set('auth_token', result.token, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV !== 'development',\n maxAge: 60 * 60 * 8,\n })\n\n res.cookies.set('session_token', result.sessionToken, {\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secure: process.env.NODE_ENV !== 'development',\n expires: result.sessionExpiresAt,\n })\n\n res.cookies.set('sso_state', '', { path: '/', maxAge: 0 })\n\n return res\n } catch (err) {\n console.error('[SSO Callback] Error:', err)\n void emitSsoEvent('sso.login.failed', {\n reason: err instanceof Error ? err.message : 'callback_failed',\n }).catch((e) => console.error('[SSO Event]', e))\n const errorCode = resolveSsoCallbackErrorCode(err)\n return NextResponse.redirect(toAbsoluteUrl(req, `/login?error=${errorCode}`))\n }\n}\n\nexport async function GET(req: Request) {\n return handleCallback(req)\n}\n\nexport async function POST(req: Request) {\n return handleCallback(req)\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'SSO',\n summary: 'OIDC callback',\n methods: {\n GET: {\n summary: 'Handle OIDC callback (GET)',\n description: 'Receives the authorization code from the IdP, exchanges it for tokens, resolves the user, and issues auth cookies.',\n tags: ['SSO'],\n responses: [\n { status: 302, description: 'Redirect to application with auth cookies set', mediaType: 'text/html' },\n ],\n },\n POST: {\n summary: 'Handle OIDC callback (POST)',\n description: 'Some IdPs send the callback as a POST (form_post response mode). Handles the same flow as the GET variant.',\n tags: ['SSO'],\n responses: [\n { status: 302, description: 'Redirect to application with auth cookies set', mediaType: 'text/html' },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
var _a, _b;
|
|
2
|
+
const EMAIL_NOT_VERIFIED_ERROR_MARKER = /* @__PURE__ */ Symbol.for("@open-mercato/sso/EmailNotVerifiedError");
|
|
3
|
+
class EmailNotVerifiedError extends (_b = Error, _a = EMAIL_NOT_VERIFIED_ERROR_MARKER, _b) {
|
|
4
|
+
constructor(message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this[_a] = true;
|
|
7
|
+
this.name = "EmailNotVerifiedError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function isEmailNotVerifiedError(err) {
|
|
11
|
+
return !!err && typeof err === "object" && err[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true;
|
|
12
|
+
}
|
|
13
|
+
function resolveSsoCallbackErrorCode(err) {
|
|
14
|
+
return isEmailNotVerifiedError(err) ? "sso_email_not_verified" : "sso_failed";
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
EmailNotVerifiedError,
|
|
18
|
+
isEmailNotVerifiedError,
|
|
19
|
+
resolveSsoCallbackErrorCode
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/sso/lib/errors.ts"],
|
|
4
|
+
"sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle\n// boundaries: the OIDC callback route and the account-linking service can be\n// bundled into separate chunks where `instanceof` silently returns false\n// (same rationale as isCrudHttpError in @open-mercato/shared).\nconst EMAIL_NOT_VERIFIED_ERROR_MARKER = Symbol.for('@open-mercato/sso/EmailNotVerifiedError')\n\nexport class EmailNotVerifiedError extends Error {\n readonly [EMAIL_NOT_VERIFIED_ERROR_MARKER] = true\n\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'EmailNotVerifiedError'\n }\n}\n\n/**\n * Type-safe check that works across module/bundle boundaries. Prefer this over\n * `instanceof EmailNotVerifiedError` because the SSO callback route may be\n * bundled separately from the service that throws the error.\n */\nexport function isEmailNotVerifiedError(err: unknown): err is EmailNotVerifiedError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true\n}\n\nexport type SsoCallbackErrorCode = 'sso_email_not_verified' | 'sso_failed'\n\n/**\n * Maps an error thrown during the OIDC callback to the login-page UX error code.\n * Keyed off the error type rather than a substring of the human-readable message,\n * which previously drifted out of sync and left `sso_email_not_verified`\n * unreachable (#2741).\n */\nexport function resolveSsoCallbackErrorCode(err: unknown): SsoCallbackErrorCode {\n return isEmailNotVerifiedError(err) ? 'sso_email_not_verified' : 'sso_failed'\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAIA,MAAM,kCAAkC,uBAAO,IAAI,yCAAyC;AAErF,MAAM,+BAA8B,YAC/B,sCAD+B,IAAM;AAAA,EAG/C,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AAHxB,SAAU,MAAmC;AAI3C,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,wBAAwB,KAA4C;AAClF,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,+BAA+B,MAAM;AACnH;AAUO,SAAS,4BAA4B,KAAoC;AAC9E,SAAO,wBAAwB,GAAG,IAAI,2BAA2B;AACnE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -3,6 +3,7 @@ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find"
|
|
|
3
3
|
import { computeEmailHash } from "@open-mercato/core/modules/auth/lib/emailHash";
|
|
4
4
|
import { SsoIdentity, SsoRoleGrant, ScimToken } from "../data/entities.js";
|
|
5
5
|
import { emitSsoEvent } from "../events.js";
|
|
6
|
+
import { EmailNotVerifiedError } from "../lib/errors.js";
|
|
6
7
|
class AccountLinkingService {
|
|
7
8
|
constructor(em) {
|
|
8
9
|
this.em = em;
|
|
@@ -14,7 +15,7 @@ class AccountLinkingService {
|
|
|
14
15
|
return existing;
|
|
15
16
|
}
|
|
16
17
|
if (idpPayload.emailVerified === false) {
|
|
17
|
-
throw new
|
|
18
|
+
throw new EmailNotVerifiedError("IdP explicitly reported email as unverified \u2014 cannot link or provision account");
|
|
18
19
|
}
|
|
19
20
|
const emailDomain = idpPayload.email.split("@")[1]?.toLowerCase();
|
|
20
21
|
if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sso/services/accountLinkingService.ts"],
|
|
4
|
-
"sourcesContent": ["import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'\nimport { emitSsoEvent } from '../events'\nimport type { SsoIdentityPayload } from '../lib/types'\n\nexport class AccountLinkingService {\n constructor(private em: EntityManager) {}\n\n async resolveUser(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId)\n if (existing) {\n await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups)\n return existing\n }\n\n if (idpPayload.emailVerified === false) {\n throw new Error('IdP explicitly reported email as unverified \u2014 cannot link or provision account')\n }\n\n const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()\n if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {\n throw new Error('Email domain is not in the allowed domains for this SSO configuration')\n }\n\n const emailLinked = config.autoLinkByEmail\n ? await this.linkByEmail(config, idpPayload, tenantId)\n : null\n if (emailLinked) {\n await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups)\n return emailLinked\n }\n\n if (config.jitEnabled) {\n const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0\n if (scimActive) {\n throw new Error('JIT provisioning is disabled because SCIM directory sync is active')\n }\n return this.jitProvision(config, idpPayload, tenantId)\n }\n\n throw new Error('No matching user found and JIT provisioning is disabled')\n }\n\n private async findExistingLink(\n ssoConfigId: string,\n idpSubject: string,\n tenantId: string,\n organizationId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const identity = await findOneWithDecryption(\n this.em,\n SsoIdentity,\n { ssoConfigId, idpSubject, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!identity) return null\n\n const user = await findOneWithDecryption(\n this.em,\n User,\n { id: identity.userId, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!user) {\n identity.deletedAt = new Date()\n await this.em.flush()\n return null\n }\n\n identity.lastLoginAt = new Date()\n await this.em.flush()\n\n return { user, identity }\n }\n\n private async linkByEmail(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const emailHash = computeEmailHash(idpPayload.email)\n const user = await findOneWithDecryption(\n this.em,\n User,\n {\n organizationId: config.organizationId,\n deletedAt: null,\n $or: [\n { email: idpPayload.email },\n { emailHash },\n ],\n } as FilterQuery<User>,\n {},\n { tenantId, organizationId: config.organizationId },\n )\n if (!user) return null\n\n const now = new Date()\n const identity = this.em.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'manual',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await this.em.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.linked', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n }\n\n private async jitProvision(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n return this.em.transactional(async (txEm) => {\n const user = txEm.create(User, {\n tenantId,\n organizationId: config.organizationId,\n email: idpPayload.email,\n emailHash: computeEmailHash(idpPayload.email),\n name: idpPayload.name ?? null,\n passwordHash: null,\n isConfirmed: true,\n createdAt: new Date(),\n })\n await txEm.persist(user).flush()\n\n await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups)\n\n const now = new Date()\n const identity = txEm.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'jit',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await txEm.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.created', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n })\n }\n\n private async assignRolesFromSso(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0\n if (!hasMappings) return\n\n await this.syncMappedRoles(em, user, config, tenantId, idpGroups)\n\n const hasAnySsoRole = await em.findOne(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n if (!hasAnySsoRole) {\n throw new Error('No roles could be resolved from IdP groups \u2014 login denied. Configure role mappings or ensure the IdP sends matching group claims.')\n }\n }\n\n /**\n * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced\n * with what the IdP sends, while manually-assigned roles are preserved.\n */\n private async syncMappedRoles(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const resolvedTenantId = tenantId || user.tenantId || ''\n if (!resolvedTenantId) return\n\n const allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null } as FilterQuery<Role>)\n const roleByNormalizedName = new Map<string, Role>()\n for (const role of allRoles) {\n const normalized = normalizeToken(role.name)\n if (normalized) roleByNormalizedName.set(normalized, role)\n }\n\n // Resolve desired role IDs from IdP groups using merged mappings\n const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings)\n const desiredRoleIds = new Set<string>()\n for (const roleName of desiredRoleNames) {\n const role = roleByNormalizedName.get(roleName)\n if (role) desiredRoleIds.add(role.id)\n }\n\n // Query current SSO grants for this user+config\n const existingGrants = await em.find(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId))\n\n // Compute diff\n const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id))\n const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId))\n\n // Add new roles\n for (const roleId of toAdd) {\n const role = allRoles.find((r) => r.id === roleId)\n if (!role) continue\n await this.ensureUserRole(em, user, role)\n const grant = em.create(SsoRoleGrant, {\n tenantId: resolvedTenantId,\n organizationId: config.organizationId,\n userId: user.id,\n roleId,\n ssoConfigId: config.id,\n } as RequiredEntityData<SsoRoleGrant>)\n em.persist(grant)\n }\n\n // Remove stale SSO-sourced roles\n for (const grant of toRemove) {\n const userRole = await em.findOne(UserRole, {\n user: user.id,\n role: grant.roleId,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (userRole) {\n em.remove(userRole)\n }\n em.remove(grant)\n }\n\n // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic)\n const allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery<UserRole>)\n for (const ur of allUserRoles) {\n if (ur.deletedAt) {\n em.remove(ur)\n }\n }\n\n if (toAdd.length > 0 || toRemove.length > 0 || allUserRoles.some((ur) => ur.deletedAt)) {\n await em.flush()\n }\n }\n\n private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise<void> {\n const existingLink = await em.findOne(UserRole, {\n user: user.id,\n role: role.id,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (existingLink) return\n\n const userRole = em.create(UserRole, { user, role, createdAt: new Date() })\n await em.persist(userRole).flush()\n }\n}\n\nfunction resolveRoleNamesFromIdpGroups(\n idpGroups?: string[],\n configMappings?: Record<string, string>,\n): string[] {\n if (!Array.isArray(idpGroups) || idpGroups.length === 0) return []\n\n const normalizedGroups = idpGroups\n .map((group) => normalizeToken(group))\n .filter((group): group is string => group !== null)\n if (normalizedGroups.length === 0) return []\n\n const mergedMappings = loadMergedMappings(configMappings)\n const roleNames = new Set<string>()\n\n for (const group of normalizedGroups) {\n const mapped = mergedMappings.get(group)\n if (mapped?.length) {\n for (const role of mapped) roleNames.add(role)\n continue\n }\n\n roleNames.add(group)\n const segmented = group.split(/[\\\\/:]/).map((part) => normalizeToken(part)).filter((part): part is string => part !== null)\n for (const candidate of segmented) {\n roleNames.add(candidate)\n }\n }\n\n return Array.from(roleNames)\n}\n\nfunction loadMergedMappings(configMappings?: Record<string, string>): Map<string, string[]> {\n const envMappings = loadGroupRoleMappingsFromEnv()\n\n // Per-config mappings take precedence over env var\n if (configMappings && Object.keys(configMappings).length > 0) {\n for (const [group, roleName] of Object.entries(configMappings)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const normalizedRole = normalizeToken(roleName)\n if (!normalizedRole) continue\n envMappings.set(normalizedGroup, [normalizedRole])\n }\n }\n\n return envMappings\n}\n\nfunction loadGroupRoleMappingsFromEnv(): Map<string, string[]> {\n const raw = process.env.SSO_GROUP_ROLE_MAP\n if (!raw) return new Map()\n\n try {\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const out = new Map<string, string[]>()\n for (const [group, roleValue] of Object.entries(parsed)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const roles = normalizeRoleList(roleValue)\n if (roles.length > 0) out.set(normalizedGroup, roles)\n }\n return out\n } catch {\n return new Map()\n }\n}\n\nfunction normalizeRoleList(value: unknown): string[] {\n if (typeof value === 'string') {\n const token = normalizeToken(value)\n return token ? [token] : []\n }\n\n if (Array.isArray(value)) {\n const out = new Set<string>()\n for (const entry of value) {\n const token = normalizeToken(entry)\n if (token) out.add(token)\n }\n return Array.from(out)\n }\n\n return []\n}\n\nfunction normalizeToken(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n return normalized.length > 0 ? normalized : null\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,MAAM,UAAU,YAAY;AACrC,SAAS,6BAA6B;AACtC,SAAS,wBAAwB;AACjC,SAAoB,aAAa,cAAc,iBAAiB;AAChE,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'\nimport { emitSsoEvent } from '../events'\nimport { EmailNotVerifiedError } from '../lib/errors'\nimport type { SsoIdentityPayload } from '../lib/types'\n\nexport class AccountLinkingService {\n constructor(private em: EntityManager) {}\n\n async resolveUser(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId)\n if (existing) {\n await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups)\n return existing\n }\n\n if (idpPayload.emailVerified === false) {\n throw new EmailNotVerifiedError('IdP explicitly reported email as unverified \u2014 cannot link or provision account')\n }\n\n const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()\n if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {\n throw new Error('Email domain is not in the allowed domains for this SSO configuration')\n }\n\n const emailLinked = config.autoLinkByEmail\n ? await this.linkByEmail(config, idpPayload, tenantId)\n : null\n if (emailLinked) {\n await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups)\n return emailLinked\n }\n\n if (config.jitEnabled) {\n const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0\n if (scimActive) {\n throw new Error('JIT provisioning is disabled because SCIM directory sync is active')\n }\n return this.jitProvision(config, idpPayload, tenantId)\n }\n\n throw new Error('No matching user found and JIT provisioning is disabled')\n }\n\n private async findExistingLink(\n ssoConfigId: string,\n idpSubject: string,\n tenantId: string,\n organizationId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const identity = await findOneWithDecryption(\n this.em,\n SsoIdentity,\n { ssoConfigId, idpSubject, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!identity) return null\n\n const user = await findOneWithDecryption(\n this.em,\n User,\n { id: identity.userId, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!user) {\n identity.deletedAt = new Date()\n await this.em.flush()\n return null\n }\n\n identity.lastLoginAt = new Date()\n await this.em.flush()\n\n return { user, identity }\n }\n\n private async linkByEmail(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const emailHash = computeEmailHash(idpPayload.email)\n const user = await findOneWithDecryption(\n this.em,\n User,\n {\n organizationId: config.organizationId,\n deletedAt: null,\n $or: [\n { email: idpPayload.email },\n { emailHash },\n ],\n } as FilterQuery<User>,\n {},\n { tenantId, organizationId: config.organizationId },\n )\n if (!user) return null\n\n const now = new Date()\n const identity = this.em.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'manual',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await this.em.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.linked', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n }\n\n private async jitProvision(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n return this.em.transactional(async (txEm) => {\n const user = txEm.create(User, {\n tenantId,\n organizationId: config.organizationId,\n email: idpPayload.email,\n emailHash: computeEmailHash(idpPayload.email),\n name: idpPayload.name ?? null,\n passwordHash: null,\n isConfirmed: true,\n createdAt: new Date(),\n })\n await txEm.persist(user).flush()\n\n await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups)\n\n const now = new Date()\n const identity = txEm.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'jit',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await txEm.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.created', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n })\n }\n\n private async assignRolesFromSso(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0\n if (!hasMappings) return\n\n await this.syncMappedRoles(em, user, config, tenantId, idpGroups)\n\n const hasAnySsoRole = await em.findOne(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n if (!hasAnySsoRole) {\n throw new Error('No roles could be resolved from IdP groups \u2014 login denied. Configure role mappings or ensure the IdP sends matching group claims.')\n }\n }\n\n /**\n * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced\n * with what the IdP sends, while manually-assigned roles are preserved.\n */\n private async syncMappedRoles(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const resolvedTenantId = tenantId || user.tenantId || ''\n if (!resolvedTenantId) return\n\n const allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null } as FilterQuery<Role>)\n const roleByNormalizedName = new Map<string, Role>()\n for (const role of allRoles) {\n const normalized = normalizeToken(role.name)\n if (normalized) roleByNormalizedName.set(normalized, role)\n }\n\n // Resolve desired role IDs from IdP groups using merged mappings\n const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings)\n const desiredRoleIds = new Set<string>()\n for (const roleName of desiredRoleNames) {\n const role = roleByNormalizedName.get(roleName)\n if (role) desiredRoleIds.add(role.id)\n }\n\n // Query current SSO grants for this user+config\n const existingGrants = await em.find(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId))\n\n // Compute diff\n const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id))\n const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId))\n\n // Add new roles\n for (const roleId of toAdd) {\n const role = allRoles.find((r) => r.id === roleId)\n if (!role) continue\n await this.ensureUserRole(em, user, role)\n const grant = em.create(SsoRoleGrant, {\n tenantId: resolvedTenantId,\n organizationId: config.organizationId,\n userId: user.id,\n roleId,\n ssoConfigId: config.id,\n } as RequiredEntityData<SsoRoleGrant>)\n em.persist(grant)\n }\n\n // Remove stale SSO-sourced roles\n for (const grant of toRemove) {\n const userRole = await em.findOne(UserRole, {\n user: user.id,\n role: grant.roleId,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (userRole) {\n em.remove(userRole)\n }\n em.remove(grant)\n }\n\n // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic)\n const allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery<UserRole>)\n for (const ur of allUserRoles) {\n if (ur.deletedAt) {\n em.remove(ur)\n }\n }\n\n if (toAdd.length > 0 || toRemove.length > 0 || allUserRoles.some((ur) => ur.deletedAt)) {\n await em.flush()\n }\n }\n\n private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise<void> {\n const existingLink = await em.findOne(UserRole, {\n user: user.id,\n role: role.id,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (existingLink) return\n\n const userRole = em.create(UserRole, { user, role, createdAt: new Date() })\n await em.persist(userRole).flush()\n }\n}\n\nfunction resolveRoleNamesFromIdpGroups(\n idpGroups?: string[],\n configMappings?: Record<string, string>,\n): string[] {\n if (!Array.isArray(idpGroups) || idpGroups.length === 0) return []\n\n const normalizedGroups = idpGroups\n .map((group) => normalizeToken(group))\n .filter((group): group is string => group !== null)\n if (normalizedGroups.length === 0) return []\n\n const mergedMappings = loadMergedMappings(configMappings)\n const roleNames = new Set<string>()\n\n for (const group of normalizedGroups) {\n const mapped = mergedMappings.get(group)\n if (mapped?.length) {\n for (const role of mapped) roleNames.add(role)\n continue\n }\n\n roleNames.add(group)\n const segmented = group.split(/[\\\\/:]/).map((part) => normalizeToken(part)).filter((part): part is string => part !== null)\n for (const candidate of segmented) {\n roleNames.add(candidate)\n }\n }\n\n return Array.from(roleNames)\n}\n\nfunction loadMergedMappings(configMappings?: Record<string, string>): Map<string, string[]> {\n const envMappings = loadGroupRoleMappingsFromEnv()\n\n // Per-config mappings take precedence over env var\n if (configMappings && Object.keys(configMappings).length > 0) {\n for (const [group, roleName] of Object.entries(configMappings)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const normalizedRole = normalizeToken(roleName)\n if (!normalizedRole) continue\n envMappings.set(normalizedGroup, [normalizedRole])\n }\n }\n\n return envMappings\n}\n\nfunction loadGroupRoleMappingsFromEnv(): Map<string, string[]> {\n const raw = process.env.SSO_GROUP_ROLE_MAP\n if (!raw) return new Map()\n\n try {\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const out = new Map<string, string[]>()\n for (const [group, roleValue] of Object.entries(parsed)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const roles = normalizeRoleList(roleValue)\n if (roles.length > 0) out.set(normalizedGroup, roles)\n }\n return out\n } catch {\n return new Map()\n }\n}\n\nfunction normalizeRoleList(value: unknown): string[] {\n if (typeof value === 'string') {\n const token = normalizeToken(value)\n return token ? [token] : []\n }\n\n if (Array.isArray(value)) {\n const out = new Set<string>()\n for (const entry of value) {\n const token = normalizeToken(entry)\n if (token) out.add(token)\n }\n return Array.from(out)\n }\n\n return []\n}\n\nfunction normalizeToken(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n return normalized.length > 0 ? normalized : null\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.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5116.1.f0af9e5080",
|
|
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.5-develop.
|
|
68
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
67
|
+
"@open-mercato/core": "0.6.5-develop.5116.1.f0af9e5080",
|
|
68
|
+
"@open-mercato/ui": "0.6.5-develop.5116.1.f0af9e5080",
|
|
69
69
|
"@simplewebauthn/browser": "^13.3.0",
|
|
70
70
|
"@simplewebauthn/server": "^13.3.1",
|
|
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.5-develop.
|
|
78
|
+
"@open-mercato/shared": "0.6.5-develop.5116.1.f0af9e5080",
|
|
79
79
|
"react": "^19.0.0",
|
|
80
80
|
"react-dom": "^19.0.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
83
|
+
"@open-mercato/shared": "0.6.5-develop.5116.1.f0af9e5080",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.16",
|
|
86
86
|
"@types/react-dom": "^19.2.3",
|
|
@@ -4,6 +4,7 @@ import { toAbsoluteUrl } from '@open-mercato/shared/lib/url'
|
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { SsoService } from '../../../services/ssoService'
|
|
6
6
|
import { emitSsoEvent } from '../../../events'
|
|
7
|
+
import { resolveSsoCallbackErrorCode } from '../../../lib/errors'
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
9
10
|
GET: { requireAuth: false },
|
|
@@ -82,8 +83,7 @@ async function handleCallback(req: Request): Promise<NextResponse> {
|
|
|
82
83
|
void emitSsoEvent('sso.login.failed', {
|
|
83
84
|
reason: err instanceof Error ? err.message : 'callback_failed',
|
|
84
85
|
}).catch((e) => console.error('[SSO Event]', e))
|
|
85
|
-
const
|
|
86
|
-
const errorCode = message.includes('email is not verified') ? 'sso_email_not_verified' : 'sso_failed'
|
|
86
|
+
const errorCode = resolveSsoCallbackErrorCode(err)
|
|
87
87
|
return NextResponse.redirect(toAbsoluteUrl(req, `/login?error=${errorCode}`))
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Use Symbol.for so the marker survives module duplication across bundle
|
|
2
|
+
// boundaries: the OIDC callback route and the account-linking service can be
|
|
3
|
+
// bundled into separate chunks where `instanceof` silently returns false
|
|
4
|
+
// (same rationale as isCrudHttpError in @open-mercato/shared).
|
|
5
|
+
const EMAIL_NOT_VERIFIED_ERROR_MARKER = Symbol.for('@open-mercato/sso/EmailNotVerifiedError')
|
|
6
|
+
|
|
7
|
+
export class EmailNotVerifiedError extends Error {
|
|
8
|
+
readonly [EMAIL_NOT_VERIFIED_ERROR_MARKER] = true
|
|
9
|
+
|
|
10
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
11
|
+
super(message, options)
|
|
12
|
+
this.name = 'EmailNotVerifiedError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Type-safe check that works across module/bundle boundaries. Prefer this over
|
|
18
|
+
* `instanceof EmailNotVerifiedError` because the SSO callback route may be
|
|
19
|
+
* bundled separately from the service that throws the error.
|
|
20
|
+
*/
|
|
21
|
+
export function isEmailNotVerifiedError(err: unknown): err is EmailNotVerifiedError {
|
|
22
|
+
return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SsoCallbackErrorCode = 'sso_email_not_verified' | 'sso_failed'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps an error thrown during the OIDC callback to the login-page UX error code.
|
|
29
|
+
* Keyed off the error type rather than a substring of the human-readable message,
|
|
30
|
+
* which previously drifted out of sync and left `sso_email_not_verified`
|
|
31
|
+
* unreachable (#2741).
|
|
32
|
+
*/
|
|
33
|
+
export function resolveSsoCallbackErrorCode(err: unknown): SsoCallbackErrorCode {
|
|
34
|
+
return isEmailNotVerifiedError(err) ? 'sso_email_not_verified' : 'sso_failed'
|
|
35
|
+
}
|
|
@@ -4,6 +4,7 @@ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
4
4
|
import { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'
|
|
5
5
|
import { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'
|
|
6
6
|
import { emitSsoEvent } from '../events'
|
|
7
|
+
import { EmailNotVerifiedError } from '../lib/errors'
|
|
7
8
|
import type { SsoIdentityPayload } from '../lib/types'
|
|
8
9
|
|
|
9
10
|
export class AccountLinkingService {
|
|
@@ -21,7 +22,7 @@ export class AccountLinkingService {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
if (idpPayload.emailVerified === false) {
|
|
24
|
-
throw new
|
|
25
|
+
throw new EmailNotVerifiedError('IdP explicitly reported email as unverified — cannot link or provision account')
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()
|