mcpField
S
setup-auth

setup-auth

github.com/microsoft安全1.1k241 次安装今天更新
代码仓库

关于 setup-auth

setup-auth 是为 Power Pages SPA 配置身份验证和基于角色的授权的技能。它为多种身份提供方和站点框架生成身份验证服务、授权工具、界面及角色控制模式。

主要功能

  1. 01支持多种身份提供方
  2. 02创建登录与登出服务
  3. 03添加角色检查工具和界面
  4. 04提供本地测试用模拟数据
  5. 05适配站点框架的开发模式

适用场景

  1. 01为 Power Pages SPA 添加登录
  2. 02根据 Web 角色控制界面显示
  3. 03在本地测试身份验证流程

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/microsoft/power-platform-skills --skill setup-auth

安装后在 Claude Code 中输入/setup-auth确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

assets/
auth-report.html
references/
authentication-reference.md
authorization-reference.md
idp-provisioning-reference.md
scripts/
validate-auth.js

详细介绍

插件检查:运行 node "${PLUGIN_ROOT}/scripts/check-version.js" — 如果输出消息,请先向用户展示,然后再继续。

设置身份验证与授权

为 Power Pages SPA 网站配置身份验证(登录/注销)和基于角色的授权。此技能支持多种身份提供商——Microsoft Entra ID、Entra External ID(适用于支持用户自助注册的面向客户应用)、OpenID Connect(Okta、Auth0 等)、SAML2、WS-Federation、本地身份验证(用户名/密码)、Microsoft Account、Facebook 和 Google。它还支持邀请注册和接受条款与条件等可选功能。Power Pages 内置的双重身份验证不会通过脚手架生成,因为 SendCode/VerifyCode 页面由服务器呈现,无法集成到 SPA 体验中——请改用 IdP 级别的 MFA。此技能会根据网站的框架和所选身份提供商创建身份验证服务、类型声明、授权实用工具、身份验证 UI 组件,以及基于角色的访问控制模式。

核心原则

  • 客户端身份验证仅改善用户体验 — Power Pages 身份验证在服务器端进行(会话 Cookie)。客户端角色检查控制用户能看到什么,而不是他们能访问什么。实际安全性由服务器端表权限强制执行。
  • 符合框架惯用模式 — 每项身份验证产物(钩子、组合式函数、服务、指令、守卫)都必须符合检测到的框架的惯用模式和约定。
  • 保持开发环境一致性 — 包含供本地开发使用的模拟数据,以便开发者无需部署到 Power Pages 即可测试身份验证流程和基于角色的 UI。
  • 维护网站完整性 — 更改导航、身份验证页面、对话框或基于角色的 UI 之前,请先阅读 ${PLUGIN_ROOT}/references/site-modification-integrity.md。在已配置的所有区域设置资源中同步所有可见的身份验证状态,并确保其支持双向同步且可安全扩展。

初始请求: $ARGUMENTS

前置条件:

  • 已通过 /create-site 创建现有的 Power Pages SPA 网站
  • 网站至少部署过一次(必须存在 .powerpages-site 文件夹)
  • 必须通过 /create-webroles 创建 Web 角色

工作流

  1. 阶段 1:检查前置条件 — 验证网站是否存在、检测框架、检查 Web 角色
  2. 阶段 2:制定计划 — 收集身份验证需求(可选择先了解 IDP 应用注册信息——默认采用引导模式,或在支持的情况下通过提供商自己的 CLI 代为配置:Okta/Auth0/Entra External ID),并提交计划以供批准
  3. 阶段 3:创建身份验证服务 — 创建包含登录/注销功能和类型声明的身份验证服务
  4. 阶段 4:创建授权实用工具 — 创建角色检查函数和包装组件
  5. 阶段 5:创建身份验证 UI — 创建集成到导航中的登录/注销按钮
  6. 阶段 6:实现基于角色的 UI — 将基于角色的模式应用于网站组件
  7. 阶段 7:验证身份验证设置 — 验证所有身份验证文件是否存在、构建是否成功以及身份验证 UI 是否正常渲染
  8. 阶段 8:审核并部署 — 提供摘要并提示部署

阶段 1:检查前置条件

目标: 确认项目存在、识别框架、验证部署状态和 Web 角色,并检查是否已有身份验证代码。

操作
1.1 定位项目

在当前目录或其直接子目录中查找 powerpages.config.json:

**/powerpages.config.json

如果未找到:告知用户先通过 /create-site 创建网站。

1.2 检测框架

读取 package.json 以确定所使用的框架(React、Vue、Angular 或 Astro)。完整的框架检测映射请参阅 ${PLUGIN_ROOT}/references/framework-conventions.md。

1.3 检查部署状态

查找 .powerpages-site 文件夹:

**/.powerpages-site

如果未找到:告知用户必须先部署站点:

"未找到 .powerpages-site 文件夹。必须至少部署一次站点,才能配置身份验证。"

🚦 **Gate(plan · setup-auth:1.3.deploy-first):**缺少 .powerpages-site — 身份验证设置会写入该文件夹中的站点设置。请先部署,或停止。

**触发条件:**阶段 1.3 未检测到 .powerpages-site 文件夹。 **询问原因:**自动部署会选错环境;跳过会导致身份验证配置失效。 **取消后保留:**无 — 尚未写入任何身份验证文件。

使用 AskUserQuestion:

问题选项
必须先部署站点。是否现在部署?是,现在部署(推荐),否,我稍后部署

如果选择“是,现在部署”:调用 /deploy-site,然后继续。

如果选择“否”:停止 — 必须先部署站点。

1.4 检查 Web 角色

查找 .powerpages-site/web-roles/ 中的 Web 角色 YAML 文件:

**/.powerpages-site/web-roles/*.yml

读取每个文件,并整理现有 Web 角色列表(名称、ID、标志)。

🚦 **Gate(plan · setup-auth:1.4.create-webroles):**未找到 Web 角色 — 基于角色的授权至少需要一个角色。请先创建角色,或跳过并稍后添加。

**触发条件:**阶段 1.4 未在 .powerpages-site/web-roles/ 中找到 YAML 文件。 **询问原因:**自动调用 /create-webroles 会运行另一个完整技能;自动跳过会导致针对空角色集进行 RBAC 检查。 **取消后保留:**无 — 尚未写入任何身份验证文件。

如果不存在 Web 角色:告知用户授权需要 Web 角色。通过 AskUserQuestion 询问是否先创建:

问题选项
未找到 Web 角色。基于角色的授权需要 Web 角色。是否现在创建?是,先创建 Web 角色(推荐),跳过 — 我稍后添加角色

如果选择“是”:调用 /create-webroles,然后继续。

如果选择“跳过”:继续 — 身份验证服务和登录/注销仍可使用,但之后需要创建角色才能进行基于角色的授权。

1.5 发现现有身份验证配置

始终执行此发现步骤,即使是首次调用 — 站点可能已包含之前运行遗留的站点设置,或手动编辑 YAML 文件添加的设置,即便尚无 SPA 身份验证代码。目标是确保绝不悄然丢弃已在服务端配置的提供商。

步骤 1 — 扫描 .powerpages-site/site-settings/ 中已配置的提供商。

通过将站点设置文件名与以下模式匹配,检测现有提供商:

模式对应的提供商类型
Authentication-OpenIdConnect-{Name}-AuthenticationType.sitesetting.ymlOIDC(Entra External ID、Okta、Auth0、通用 OIDC、B2C — 均使用 OIDC 路径)
Authentication-SAML2-{Name}-AuthenticationType.sitesetting.ymlSAML2
Authentication-WsFederation-{Name}-AuthenticationType.sitesetting.ymlWS-Federation
Authentication-OpenAuth-{Microsoft|Facebook|Google}-{ClientId|AppId}.sitesetting.yml社交 OAuth
Authentication-Registration-LocalLoginEnabled.sitesetting.yml,值为 true本地身份验证

对于检测到的每个提供商,读取其完整的 .sitesetting.yml 文件集,以提取:Authority / MetadataAddress、ClientId / AppId、AuthenticationType(providerIdentifier)、Caption 或显示名称(如果存在),以及键中使用的 {Name} 标识(例如 OpenIdConnect_1、EntraExternalId)。

区分 Entra ID 变体与 OIDC — 根据 Authority URL 模式:

权限模式提供方类型备注
https://login.windows.net/{guid}/(不含 /v2.0/)— 站点的父租户Microsoft Entra ID(员工) — type: 'entra-id'创建站点时由 Power Pages 自动填充。{Name} 通常为 AzureAD。在 AUTH_PROVIDERS 中将 providerIdentifier 设为 undefined — 运行时解析器会从 Portal.tenant 中推导该值。
https://{subdomain}.ciamlogin.com/{tenantId}(末尾不带 /v2.0/)Entra External ID — type: 'oidc'客户租户。必须包含与 Authority 匹配的显式 providerIdentifier。
https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/v2.0/{policy}Azure AD B2C(旧版)— type: 'oidc'较早的 B2C 产品。必须包含显式 providerIdentifier。
任何其他 OIDC Authority(Okta、Auth0 等)OIDC — type: 'oidc'必须包含显式 providerIdentifier。

Entra ID(员工)情况比较特殊 — 当第 1.5 阶段的发现过程检测到站点上的 Authentication/OpenIdConnect/AzureAD/* 设置(Power Pages 会为父租户自动创建这些设置)时,向 EXISTING_PROVIDERS 添加一个条目:

{
  id: 'entra-id',
  type: 'entra-id',
  displayName: existingCaption || 'Sign in with Microsoft',
  // NO providerIdentifier — resolveProviderIdentifier() derives it from Portal.tenant
}

不要从现有的 Authority 站点设置中提取租户 ID,只为将其硬编码回 AUTH_PROVIDERS — 运行时解析器会处理它。如果站点之后克隆到其他租户,这样可以确保 SPA 代码仍可移植。

第 2 步 — 扫描现有的 SPA 身份验证代码。

检查以下文件并读取关键标记:

  • src/services/authService.ts 或 .js — 查找 AUTH_PROVIDERS 数组(当前模式)与单个 AUTH_PROVIDER 常量(旧版模式)
  • src/types/powerPages.d.ts — 检查是否存在
  • src/utils/authorization.ts — 检查是否存在
  • 身份验证组件(AuthButton.*、Login.*、Registration.*、RedeemInvitation.* 等)— 列出存在的组件
  • src/pages/Login.tsx — 提取当前呈现的提供方(通过 AUTH_PROVIDERS 导入项或内联代码)

第 3 步 — 向用户展示发现结果。

如果从站点设置中检测到了提供方,请连同其配置一起展示:

I found these existing auth providers on your site:

  ✓ Entra External ID
    - ProviderName: OpenIdConnect_1
    - Tenant: ba275000-98c8-404d-a6f0-c5450f2aa668
    - ClientId: e728d63e-1190-495a-ae29-663e9cc10877
    - Configured in site settings: yes
    - Surfaced in SPA UI: NO (authService.ts has no entry for this provider)

  ✓ Local Authentication
    - LoginByEmail: true
    - Surfaced in SPA UI: yes

使用 AskUserQuestion:

问题标题选项
我在你的站点上发现了现有的身份验证提供方。你想怎么做?现有身份验证保留所有现有提供方并添加一个新提供方(推荐)— 保留现有配置,并添加你接下来要求的新配置;保留所有现有提供方(本次运行不添加新提供方)— 重新生成 SPA 代码,以显示站点设置中已有的提供方;用新配置替换所有内容 — 清除现有站点设置和 SPA 代码,重新开始

“保留所有现有提供方并添加一个新提供方”(默认路径):

  • 将发现的提供方存储为 EXISTING_PROVIDERS — 这些提供方会与第 3.2 阶段生成的 AUTH_PROVIDERS 数组合并
  • 第 2.1 阶段会询问要添加的新提供方;现有提供方保持不变
  • 尤其是本地身份验证 — 如果 EXISTING_PROVIDERS 中包含 Local Authentication,始终根据用户在第 2.1 阶段的回答重新生成本地身份验证 SPA 代码(登录流程、注册页面、忘记/重置密码、兑换邀请)。不要尝试保留手动编辑过的本地身份验证代码 — 本地流程足够复杂,局部更新带来的错误比避免的错误更多。

“保留所有现有提供方(本次运行不添加新提供方)”:

  • 完全跳过第 2.1 阶段的提供方选择问题
  • 仅从 EXISTING_PROVIDERS 重新推导 AUTH_PROVIDERS
  • 适用于:修复 SPA UI 中缺少站点设置已有提供方的情况(此分支正是为修复这一问题而创建)

“用新配置替换所有内容”:

  • 将 EXISTING_PROVIDERS = []
  • 在第 8.1 阶段删除现有的 OIDC/SAML2/WsFed/OpenAuth 站点设置 YAML
  • 按照没有现有提供方的情况运行第 2.1 阶段

请勿提供“跳过 / 不做更改”选项。用户调用 setup-auth 就是想配置身份验证 — 默默地什么也不做,比询问用户更糟。

输出
  • 已确认项目根目录路径
  • 已识别框架(React、Vue、Angular 或 Astro)
  • 已验证部署状态
  • 已整理 Web 角色清单
  • 已根据站点设置整理 EXISTING_PROVIDERS 列表,其中包含每个提供商的类型、ProviderName slug、ClientId/Authority 等信息
  • 已选择 MERGE_MODE:keep-and-add(默认)| keep-only | replace-all
  • 已记录 SPA 身份验证文件清单(存在哪些文件,以及这些文件使用 AUTH_PROVIDERS 数组还是旧版单一提供商模式)

阶段 2:规划

目标: 收集用户的身份验证需求,并提交实施计划供其审批。

操作
2.0 智能推断身份验证设置(提问前)

在询问用户希望使用哪些提供商之前,根据阶段 1 收集的站点上下文(站点名称、用途、受众类型)进行分析,并尝试自动推断合适的身份验证设置:

推断规则:

站点类型推断的身份验证设置原因
内部/员工门户(HR、仪表板、管理员)Entra ID + 仅限邀请注册(OpenRegistrationEnabled=false,InvitationEnabled=true)内部站点应限制为仅受邀员工访问
面向客户的门户(支持、自助服务)Entra External ID + 开放注册客户门户需要支持客户自助注册
合作伙伴门户(B2B、供应商)Entra ID + 仅限邀请注册合作伙伴已预先审核;开放注册存在安全风险
具有受保护功能的公共站点(电子商务、社区)Entra External ID + 开放注册 + 可选的 Google/Facebook公共站点可通过社交登录实现无障碍注册
贷款/金融/银行门户Entra External ID + 仅限邀请注册金融站点必须通过受控访问满足合规要求

如果能够有把握地推断,请说明推荐设置及原因:

“根据您的站点用途({purpose}),我推荐:

  • 使用 {provider} 进行身份验证
  • 采用 {registration mode},因为 {rationale}

您想按此配置继续,还是选择其他提供商?”

问题选项
您想按此推荐配置继续吗?是,按推荐配置继续;否,我要选择提供商

如果选择“是”:跳过阶段 2.1 的提供商选择,直接收集推荐提供商所需的详细信息(ClientId、租户名称等)。

如果选择“否”,或者无法有把握地推断:转到下方的阶段 2.1。

2.1 收集需求

🚦 关卡(plan · setup-auth:2.1.requirements): 选择要构建的身份验证功能(登录+登出 / RBAC / 两者)。同一步骤中还会询问条件性后续问题“哪些角色可以访问”。

触发条件: 进入阶段 2.1。 提问原因: 生成错误的功能集,例如用户只需要登录功能,却生成了 RBAC 文件。 取消后的状态: 无 — 尚未写入任何身份验证文件。

重新运行时的处理 — 阶段 1.5 检测到现有提供商时:

处理方式取决于阶段 1.5 中选择的 MERGE_MODE:

  • keep-only(用户选择“保留所有现有配置,本次不添加新提供方”)→ 完全跳过新提供方选择问题。仅在检测到本地认证时,继续询问“本地认证”后续问题。Phase 3.2 将仅根据 EXISTING_PROVIDERS 生成 AUTH_PROVIDERS。
  • keep-and-add(默认值——用户想再添加一个)→ 询问用户要添加什么。下方的提供方选择问题仍应为多选(用户可能一次添加多个新提供方),但列表中不包含现有提供方(它们已经配置好——此问题询问的是有哪些新提供方)。常见情况:
    • 用户已有 Entra External ID,想添加本地认证 → 用户选择“本地认证”→ 询问本地认证的后续问题 → Phase 3.2 合并配置
    • 用户已有 Entra External ID + 本地认证,想再添加一个 Entra External ID 租户 → 用户选择“Entra External ID”→ 收集 Authority/ClientId 后,询问:"You already have an Entra External ID provider configured for tenant {existing-tenant}. This new one is a separate instance — give it a distinct ProviderName slug (used in site setting keys like Authentication/OpenIdConnect/{ProviderName}/* and in code as the provider id)." 让用户选择一个 slug(默认为下一个递增编号,例如 OpenIdConnect_2),或选择自定义名称(例如 EntraExternalId_Employee)。
  • replace-all(用户选择清除所有配置)→ 像首次运行一样执行提供方选择问题。

不要主动在开始时询问“是否要配置多个实例?” 逐个引导用户配置一个提供方。完成一个提供方的配置后,如果用户还想添加另一个,可以重新运行 setup-auth → Phase 1.5 检测现有配置 → Phase 2.1 在 keep-and-add 模式下询问“现在要添加什么?”。这样既能减少常见场景(配置一个提供方)中的提问数量,又支持高级场景(多个租户)。

重要:支持多个提供方。 用户可能想要多个身份提供方(例如 Entra External ID + Google)。如果用户的初始提示中提到了特定提供方,则跳过提供方选择问题,直接收集提到的每个提供方的详细信息。

重要——本地认证: 默认情况下绝不要设置本地认证。不要将其列入提供方选择列表,不要在智能推断中推荐,也不要在用户明确且具体地提出要求之前进行配置(例如“我想要用户名/密码登录”“设置本地登录”“添加本地认证”)。应始终优先选择外部身份提供方(Entra External ID、Entra ID、OIDC 等)。如果用户的说法含糊不清,例如“添加登录”,默认选择外部提供方,绝不要选择本地认证。

如果用户尚未指定想要使用哪些提供方,请使用 AskUserQuestion 确定身份提供方。这是一个多选问题——用户可以选择一个或多个:

问题选项
你想使用哪些身份提供方?(选择所有适用项)Entra External ID(推荐)— 提供自助注册功能的客户身份(CIAM),Microsoft Entra ID — 面向内部/员工站点的 Azure AD / Entra ID,OpenID Connect — Okta、Auth0 或任何符合 OIDC 标准的提供方,SAML2 — SAML 2.0 身份提供方(ADFS、Shibboleth、Login.gov 等),WS-Federation — WS-Federation 身份提供方,Microsoft Account — 使用 Microsoft 个人/工作帐户登录,Facebook — 使用 Facebook 登录,Google — 使用 Google 登录

然后,针对每个选定的提供方,询问下方必需的后续问题。 不要跳过任何提供方——继续操作前,必须收集每个选定提供方的配置。

对于每个提供方,还应分享相关的 Microsoft Learn 文档链接,让用户知道如何获取这些值:

对于“Microsoft Account”:

问题选项
你的 Microsoft 应用注册中的客户端 ID 是什么?(例如,a1b2c3d4-e5f6-7890-abcd-ef1234567890)(自由填写)

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/openid-settings

对于“Facebook”:

问题选项
Facebook Developer Console 中的应用 ID 是什么?(例如,1234567890123456)(自由填写)

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/facebook-settings

对于“Google”:

问题选项
Google Cloud Console 中的客户端 ID 是什么?(例如,123456789-abc.apps.googleusercontent.com)(自由填写)

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/oauth2-google

对于“OpenID Connect”(Okta、Auth0 等):

首先,确定具体的 OIDC 提供商——不同供应商的设置步骤和需要阅读的文档各不相同:

问题选项
你使用的是哪个 OpenID Connect 提供商?Okta、Auth0、Other(任何符合 OIDC 标准的提供商)

在下面的问题和 Phase 2.1.2 Step A(阅读提供商的文档)中,将答案用作 {provider}。对于 Other,请记录用户提供的供应商名称。

先向用户说明情况,不要直接开始询问输入信息。 用户选择了 {provider} 后,说明你将为此站点在 {provider} 处设置应用注册。你要做的第一件事是阅读 {provider} 自己的文档——设置方式取决于该提供商。然后,根据 {provider} 支持的功能,你可以引导用户在 {provider} 控制台中完成设置,或者在 {provider} 支持的情况下代用户进行配置。无论采用哪种方式,你都会自动获取元数据、Redirect URI 和声明,并且应用使用无需密钥的 code id_token 流程——无需管理客户端密钥。

询问 {provider} 租户——即登录域名(Okta:your-tenant.okta.com,Auth0:your-tenant.us.auth0.com):

问题选项
你的 {provider} 租户是什么?(自由填写)
登录按钮应显示什么名称?(默认值:Sign in)(自由填写,默认为指定值)
你是否已在 {provider} 处为此站点注册应用?否——在 Phase 2.1.2 中设置(推荐);是——我已经设置好了

如果用户回答 “是——我已经设置好了”,现在就询问现有的客户端 ID 和 Metadata Address(Phase 8.1 需要这些信息),并跳过 Phase 2.1.2 的应用注册设置。否则,不要提前询问客户端 ID——它会在 Phase 2.1.2 中获取:届时你会设置应用(引导用户完成,或在提供商支持时代为配置),并从提供商的发现文档中获取元数据。

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/openid-settings

对于“Entra External ID”——使用下面的 4 步引导流程。不要一开始就向用户询问 Authority/ClientId/Metadata——这些值需要通过租户、应用注册和用户流获取,而用户可能尚未设置这些内容。请先引导用户完成每项前置设置,再询问相应的值。

参考文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/entra-external-id 另请参阅 ${PLUGIN_ROOT}/skills/setup-auth/references/authentication-reference.md,了解以下步骤交叉引用的完整 Entra External ID 前置条件部分。 此站点的预计算值 — 开始操作指南前,请计算:

  • SITE_URL = 已部署站点的 URL(例如 https://site-597pv.powerappsportals.com)。从 pac env who 加站点名称中读取,或从站点现有设置中读取。
  • PROVIDER_NAME = 如果是首次添加,默认为 OpenIdConnect_1(或根据第 8.1 阶段的 CallbackPath 唯一性逻辑,使用下一个可用的 OpenIdConnect_N slug)。在多实例设置中,用户可以改用自定义 slug,例如 EntraExternalId_Customer。
  • REDIRECT_URI = {SITE_URL}/signin-{PROVIDER_NAME-lowercased} — 例如 https://site-597pv.powerappsportals.com/signin-openidconnect_1。用户将此值原样粘贴到 Entra 应用注册中。
  • APP_NAME_SUGGESTION = power-pages-{site-shortname} — 例如 power-pages-savoria
  • USER_FLOW_NAME_SUGGESTION = {site-shortname}-signupsignin — 例如 savoria-signupsignin

在步骤 1 之前向用户显示这些值,方便他们随时查看。

步骤 1 — 租户
问题标题选项
你是否已有 Microsoft Entra External ID 租户?(这与常规员工 Entra ID 租户是不同的租户类型,有时称为 CIAM。)租户有 — 我有 External ID 租户,没有 — 帮我创建一个(免费试用 30 天),我不确定

如果选择“没有”,显示:

创建 Entra External ID 租户的步骤:

  1. 打开 https://entra.microsoft.com/
  2. 使用应拥有该租户的账户登录
  3. 在顶部,点击 管理租户 → 创建
  4. 选择 External(面向客户) — 不要选择 Workforce
  5. 选择域名前缀(即租户子域名)— 例如,contoso 会变成 contoso.ciamlogin.com。它会出现在每个登录 URL 中。
  6. 免费试用 30 天:无需信用卡。之后可以关联付费 Azure 订阅。

详细指南:https://learn.microsoft.com/en-us/entra/external-id/customers/quickstart-tenant-setup

创建租户后,切换到该租户(在 entra.microsoft.com 右上角的租户选择器中),然后返回此处。

如果选择“我不确定”,显示:“在 https://entra.microsoft.com/ → 右上角的租户选择器中查看。面向客户的租户标记为 External。Workforce 租户无法使用 — 那是另一款产品。”

然后收集租户标识符:

问题选项
租户的子域名是什么?(即 .ciamlogin.com 之前的部分,例如 contoso。在 External ID 租户的“概览”页中找到“主要域名”,并去掉 .onmicrosoft.com。)(自由文本)
租户的 ID(GUID)是什么?(在 External ID 租户的“概览”页中找到“租户 ID” — 格式类似 a1b2c3d4-e5f6-7890-abcd-ef1234567890。)(自由文本)

验证:子域名需匹配 ^[a-z0-9-]+$(不能有点号、大写字母或 .ciamlogin.com 后缀);租户 ID 需匹配 UUID 正则表达式。如果任一项不符合,显示预期格式并重新提示输入。

存储为 EXTERNAL_ID_TENANT_SUBDOMAIN 和 EXTERNAL_ID_TENANT_ID。

步骤 2 — 应用注册

先确认 Redirect URI。 该技能会根据站点 URL 和 PROVIDER_NAME 预计算默认值,但用户也可以选择其他 URI:

Power Pages 站点需要在应用注册中注册 Redirect URI。根据站点 URL 和提供程序名称,默认值为:

{REDIRECT_URI}

可以保留此默认值,也可以使用其他 URI — 例如 {SITE_URL}/signin-entra-customer 或 {SITE_URL}/auth/external-id。主机必须是你的 Power Pages 站点;只有路径可以更改。

问题标题选项
使用此 Redirect URI?Redirect URI使用默认值(推荐)— {REDIRECT_URI},使用其他 URI

如果选择“使用其他 URI”,询问:

问题选项
输入 Redirect URI(必须位于 {SITE_URL} 上,必须以 {SITE_URL}/ 开头,不能包含空格或查询字符串)。示例:{SITE_URL}/signin-entra-customer。(自由文本)
验证自定义 URI:
  • 必须以 {SITE_URL}/ 开头
  • 路径部分必须匹配 ^/[a-zA-Z0-9_\-/]+$(字母数字、连字符、下划线,可包含额外斜杠)
  • 路径不得与 .powerpages-site/site-settings/ 中已有的任何 Authentication/OpenIdConnect/*/CallbackPath 冲突(来自第 1.5 阶段的发现)
  • 路径不得是 Power Pages 保留的服务器路径(/Account/...、/SignIn、/Register、/_layout/...、/api/...)

输入无效时重新提示。然后将该值存储为 REDIRECT_URI,供本演练后续部分和第 8.1 阶段使用。

注意:该技能会根据此单一 REDIRECT_URI 写入两个站点设置:面向用户的 RedirectUri(完整 URI,将发送给 IdP)和内部的 CallbackPath(仅路径部分,供 OWIN 中间件确定要处理哪个传入请求)。制作者无需单独考虑 CallbackPath——该技能会自动从 REDIRECT_URI 中提取路径部分并生成它。

问题标题选项
你是否已在 Entra External ID 租户中为此 Power Pages 站点注册应用?应用注册否 — 请引导我完成注册(首次使用时推荐),是 — 我已有 Application (client) ID

如果选择“否”,请逐步展示以下步骤,并原样提供已确认的 Redirect URI:

注册应用的步骤:

  1. 访问 https://entra.microsoft.com/,确保你位于 External ID 租户中(使用右上角的租户选择器)

  2. Applications → App registrations → New registration

  3. 名称:{APP_NAME_SUGGESTION}(或使用你自己的名称)

  4. 支持的帐户类型:选择 Accounts in this organizational directory only (single tenant) — Power Pages 推荐使用此选项。出于安全考虑,多租户配置会强制禁用通过电子邮件映射联系人。

  5. 重定向 URI:选择 Web,然后准确粘贴:

    {REDIRECT_URI}
    

    (请原样复制此值。此值与 RedirectUri 站点设置不一致时,登录将失败,并显示错误 AADSTS50011: The reply URL specified in the request does not match。)

  6. 点击 Register

  7. 打开 Authentication 选项卡 → 在“Implicit grant and hybrid flows”下,勾选 Access tokens 和 ID tokens → 点击 Save

  8. 打开 API permissions 选项卡 → 点击 Grant admin consent for {your tenant} → 确认

  9. 返回 Overview 选项卡,复制 Application (client) ID(这是一个 GUID)

详细指南:https://learn.microsoft.com/en-us/entra/external-id/customers/quickstart-register-app

如果选择“是”(已有应用),在询问 Client ID 之前,还要确认用户已注册匹配的 Redirect URI:

继续之前,请确认现有应用注册已注册以下 Redirect URI(在 Entra 管理中心的 Authentication → Web 下):

{REDIRECT_URI}

如果缺失或不一致,请立即添加。一个应用注册可以注册多个 Web Redirect URI——添加我们的 URI 不会影响任何现有集成。如果 Power Pages 中的值与已注册的 URI 不完全匹配,登录将失败。

然后询问该值:

问题选项
粘贴 Overview 选项卡中的 Application (client) ID。(自由文本)

验证:必须符合 UUID v4 格式(^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)。不匹配时重新提示。

存储为 EXTERNAL_ID_CLIENT_ID。

不要询问 client secret。 Entra External ID 应用注册是使用 PKCE 的公共客户端——无需密钥。该技能将创建不含 ClientSecret 的站点设置,并为此提供程序跳过第 8.1.1 阶段(Key Vault)。如果用户遇到需要密钥的机密客户端场景,可以在部署后通过 Power Pages 管理中心手动添加——请在第 8.5 阶段的部署后说明中将其记录为高级覆盖选项。

第 3 步 — 用户流

用户流定义要从用户收集哪些属性,以及 ID 令牌中包含哪些声明。没有用户流,登录会在 IdP 重定向后失败。

问题标题选项
你是否已在 Entra External ID 租户中创建注册/登录用户流,并将其关联到应用?用户流否 — 请引导我完成(首次使用时推荐),是 — 我知道用户流名称

如果选择“否”,引导流程中的用户流属性选择必须与稍后收集的 PROFILE_MAPPING_CHOICE(Track B 的资料映射问题)一致。由于这一步在该问题之前执行,因此现在就询问(仅针对 Entra External ID):

需要告知用户流要从用户收集哪些属性,以及要在令牌中返回哪些声明。该技能会自动将声明映射到 Dataverse 联系人字段 — 你在此处选择的属性决定了可用内容。

问题标题选项
注册表单应收集哪些用户资料信息,并将其作为声明返回?资料属性标准(推荐)— 电子邮件、名字、姓氏;标准 + 电话 — 还包括电话号码;仅电子邮件 — 最简注册表单

保存为 PROFILE_ATTRIBUTES_CHOICE(这也会决定 Track B 中的 PROFILE_MAPPING_CHOICE — 两者应保持一致;除非用户明确选择不同选项,否则两者都默认为“标准”)。

然后显示:

创建用户流的步骤:

  1. 在 https://entra.microsoft.com/ 中,进入你的 External ID 租户
  2. 外部标识 → 用户流 → 新建用户流
  3. 名称:{USER_FLOW_NAME_SUGGESTION}(或使用你自己的名称 — 只能包含字母、数字、连字符和下划线)
  4. 用于登录的身份提供程序:选择电子邮件和密码(推荐 — 客户最熟悉的方式)或电子邮件一次性密码(无密码)
  5. 要收集的用户属性(注册表单字段):根据你上方的选择,勾选:
    • 标准 / 标准 + 电话:☑ 电子邮件地址、☑ 名字、☑ 姓氏{, ☑ 电话号码(如果选择标准 + 电话)}
    • 仅电子邮件:☑ 电子邮件地址
  6. 作为声明返回的用户属性(在 ID 令牌中):与上方选择相同 — 这些属性用于将资料映射到 Dataverse 联系人字段
  7. 点击创建
  8. 打开刚创建的用户流 → 应用程序选项卡 → 添加应用程序 → 选择在第 2 步注册的应用 → 选择

详细指南:https://learn.microsoft.com/en-us/entra/external-id/customers/how-to-user-flow-sign-up-sign-in-customers

然后询问:

问题选项
粘贴你创建的用户流名称(例如,{USER_FLOW_NAME_SUGGESTION})。(自由文本)

验证:必须匹配 ^[a-zA-Z0-9_-]+$(字母、数字、连字符和下划线)。不匹配时重新提示。

保存为 EXTERNAL_ID_USER_FLOW。

第 4 步 — 显示名称 + 确认
问题选项
登录按钮应显示什么文本?默认值:Sign in with Entra External ID(从“Sign in with Microsoft Entra External ID”缩短而来,以适配横向行布局的登录页面中的单行显示 — 见下方备注)。请勿使用“Sign in with Microsoft” — 这会与 Microsoft Account 社交提供程序冲突。(自由文本,带默认值)

显示名称长度建议:标签长度尽量控制在 28 个字符以内,以便在水平行布局的登录页面(默认布局)中显示为单行。较长的标签仍可使用——按钮会垂直增高,并将文本换行为两行——但单行按钮看起来更精致。参考示例:

  • “使用 Entra External ID 登录”——30 个字符(窄卡片上会换行,在较宽的卡片上可以显示为一行)
  • “使用 Microsoft Entra External ID 登录”——40 个字符(在默认的水平布局中会换为两行)
  • “客户登录”——16 个字符(始终显示为一行,但描述性较弱)

如果用户配置了多个外部提供商(例如 Entra External ID + Google),较短的标签就更重要,因为每个按钮可用的宽度会更小。对于只有一个提供商的网站,较长的标签也没问题(按钮会占满整行宽度)。

保存为 EXTERNAL_ID_DISPLAY_NAME。

现在推导配置并汇总,以供确认:

  • Authority:https://{EXTERNAL_ID_TENANT_SUBDOMAIN}.ciamlogin.com/{EXTERNAL_ID_TENANT_ID}(末尾不要添加 /v2.0/——Entra External ID 使用纯租户路径,而不是 B2C 风格的 URL)
  • MetadataAddress:https://{EXTERNAL_ID_TENANT_SUBDOMAIN}.ciamlogin.com/{EXTERNAL_ID_TENANT_ID}/v2.0/.well-known/openid-configuration
  • AuthenticationType(AUTH_PROVIDERS 数组和 ExternalLogin POST 中的提供商标识符):与 Authority 的值相同
  • RedirectUri:{REDIRECT_URI}(之前计算得出)
  • ClientId:{EXTERNAL_ID_CLIENT_ID}

请以内嵌表格的形式展示此汇总:

即将配置:

字段值
提供商Microsoft Entra External ID
租户{subdomain}.ciamlogin.com({tenantId})
应用(客户端)ID{clientId}
用户流{userFlowName}
重定向 URI{REDIRECT_URI}(必须已在应用中注册)
Authority{authority}(推导得出)
Metadata{metadataAddress}(推导得出)
显示名称{displayName}
登录按钮“{displayName}”
客户端密钥无(公共客户端 / PKCE)

是否继续写入这些站点设置?

问题选项
继续?是——写入站点设置;否——让我调整

如果选择“否”,请重新提示用户指定要更改的具体值。

**实现说明:**Power Pages 服务器会将 Entra External ID 视为通用 OpenID Connect 提供商(没有专门的 CIAM 处理逻辑)。所有设置都放在 Authentication/OpenIdConnect/{ProviderName}/ 下。POST 到 /Account/Login/ExternalLogin 的 provider 值必须与 AuthenticationType 站点设置相匹配;默认情况下,该值等于 authority URL。

对于 “SAML2”:

问题选项
SAML2 身份提供商的元数据终结点 URL 是什么?(例如,https://adfs.contoso.com/FederationMetadata/2007-06/FederationMetadata.xml)(自由文本)
登录按钮应显示什么名称?(例如,Sign in with ADFS、Sign in with Login.gov)(自由文本)

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/saml2-settings Login.gov(美国政府的 SAML IdP)——从 SAML 开发者指南获取其元数据终结点:https://developers.login.gov/saml/getting-started/

对于 “WS-Federation”:

问题选项
WS-Federation 提供商的元数据终结点 URL 是什么?(例如,https://adfs.contoso.com/federationmetadata/2007-06/federationmetadata.xml)(自由文本)
提供商的 realm 或标识符是什么?(例如,https://adfs.contoso.com/adfs/services/trust)(自由文本)
登录按钮应显示什么名称?(例如,Sign in with ADFS)(自由文本)

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/ws-federation-settings

配置文件映射(适用于每个外部提供商——OIDC、Entra External ID、SAML2、WS-Federation、社交) 收集提供方的基本信息后,询问用户希望将哪些个人资料信息从 IdP 同步到 Dataverse 联系人。不要跳过此步骤——否则,联系人记录中的 firstname/lastname 会为空,SPA 会在所有位置都退而显示电子邮件地址或用户名。

问题标题选项
要将哪些个人资料信息从身份提供方复制到 Dataverse 联系人记录?个人资料映射标准(推荐)——首次登录时复制名字、姓氏和电子邮件,标准 + 电话——同时复制手机号码,自定义——让我选择要映射的联系人字段和声明,不映射——联系人字段留空(服务器仍会根据电子邮件声明填充 emailaddress1)

存储为 PROFILE_MAPPING_CHOICE。然后询问:

问题标题选项
应在每次登录时更新个人资料信息,还是仅在首次登录时更新?同步频率仅首次登录时(推荐)——创建联系人时复制一次声明;之后允许用户编辑自己的个人资料,不覆盖其更改,每次登录时同步——在首次登录时和每次登录时都同步(仅当 IdP 是权威数据源,且不希望用户在 Power Pages 中编辑自己的个人资料时使用)

存储为 PROFILE_SYNC_FREQUENCY。这决定了是否在 RegistrationClaimsMapping(仅首次登录时)之外,还写入 LoginClaimsMapping(每次登录时)。

为何现在默认选择“仅首次登录时”:此技能可选择搭建 SPA 个人资料页面(/user-profile),供已登录用户编辑自己的联系人信息。如果设置了 LoginClaimsMapping,服务器会在用户下次登录时用 IdP 声明覆盖其更改——这会令人困惑,也会悄无声息地撤销用户的修改。“仅首次登录时”允许用户在创建联系人后自行管理其个人资料。仅当 IdP 是这些字段的唯一权威数据源(例如,由 HR 管理的员工目录),且终端用户的编辑不应保留时,才切换为“每次登录时同步”。

声明类型值——映射格式为逗号分隔的 contactfield=claimtype(不是 JSON)。对于 Entra External ID 等 OIDC 提供方,请使用 OIDC 短名称:

选项生成的映射
标准firstname=given_name,lastname=family_name,emailaddress1=email
标准 + 电话firstname=given_name,lastname=family_name,emailaddress1=email,mobilephone=phone_number
自定义循环询问用户每个 contactfield=claimtype 对,直到用户表示完成。建议使用 OIDC 短名称(given_name、family_name、email、phone_number、preferred_username、自定义声明名称)。验证 contactfield 是否为已知的 Dataverse 联系人列。
不映射不写入 RegistrationClaimsMapping 或 LoginClaimsMapping 设置。

对于 SAML2 / WS-Federation,声明类型为 URI(例如 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname)。相应调整生成的“标准”映射。对于社交提供方,声明类型因提供方而异(Google:given_name,Facebook:name)。

联系人关联(适用于每个外部提供方)

询问是否根据电子邮件将外部登录自动关联到现有联系人。

问题标题选项
如果用户通过外部提供方登录,且其电子邮件与现有 Dataverse 联系人匹配,应如何处理?联系人关联关联到现有联系人(推荐)——根据电子邮件匹配自动关联,避免管理员预先创建记录时用户出现重复联系人(仅适用于单租户提供方——参见下方警告),创建新联系人——始终创建新联系人,绝不自动关联(当 IdP 不验证电子邮件时更安全的选择)

存储为 CONTACT_LINKING_CHOICE。此选项决定 AllowContactMappingWithEmail 的值(选择“关联”时为 true,选择“创建新联系人”时为 false)。

为何默认选择“链接到现有联系人”:常见流程是管理员在 Dataverse 中预先创建联系人记录(通常通过邀请或导入),然后希望用户首次通过配置的 IdP 登录时能关联到这些联系人。如果不进行关联,服务器就会创建一个全新的联系人,而预先创建的记录会被闲置——这会让用户感到困惑,也很容易被误诊。通过已验证的电子邮件地址进行关联,是将 IdP 身份与现有 CRM 记录匹配的常见做法。

⚠ 自动关联适用于迁移/预先配置联系人:当 Dataverse 中已有联系人(通过数据迁移、管理员导入或邀请创建),且必须按电子邮件地址匹配时,请启用关联。在没有预先存在联系人的全新站点上,首次登录时照常创建联系人——关联功能不会找到任何可匹配的记录。

⚠ 多租户安全性:对于多租户 Entra External ID(Authority 使用 /organizations/ 或 /common/,或 IssuerFilter 为通配符),无论站点设置如何,Power Pages 服务器都会强制禁用 AllowContactMappingWithEmail(LoginController.cs:2578-2587 中的 BlockContactMappingSettingForMultitenantApp 功能标志)。原因:不能信任跨租户的电子邮件声明。如果用户选择“链接到现有联系人”,但 Authority 为多租户,请提醒他们关联功能将无法使用,并建议改用单租户 Authority。

⚠ 安全性:当 AllowContactMappingWithEmail = true 时,能够使用受害者电子邮件地址登录所配置 IdP 的攻击者可以接管受害者的联系人。仅当 IdP 会验证电子邮件地址时才启用此功能(Entra External ID 的单租户会进行验证;任意 OIDC 服务则未必)。如果你配置的 IdP 的电子邮件验证策略不受你控制(例如通用 OIDC 端点),请切换到“创建新联系人”。

对于“本地身份验证”(仅当用户明确要求时):询问用户希望用户登录时如何标识自己:

问题选项
用户应如何使用本地账户登录?通过电子邮件登录(推荐)——用户使用电子邮件地址登录,通过用户名登录——用户使用自己选择的用户名登录

此选项决定 Authentication/Registration/LocalLoginByEmail 站点设置(电子邮件对应 true,用户名对应 false),并影响登录、注册及身份验证服务代码中的所有表单字段。选择电子邮件时,登录和注册表单会显示 Email 字段(类型为 email)。选择用户名时,表单会显示 Username 字段(类型为 text),而 Email 会成为注册表单中单独的必填字段(服务器需要它来创建联系人记录)。请保存此选项——它将在阶段 3(身份验证服务)、阶段 5(登录和注册页面)以及阶段 8.1(站点设置)中使用。

对于“本地身份验证”——还要询问站点应采用哪种注册模式:

问题选项
用户应如何在站点上注册?仅开放注册(推荐)——任何人都可以使用用户名/密码自由注册,仅限邀请——只有持有效邀请代码的用户才能注册;禁止直接注册,同时开放两种方式——用户可以自行注册,也可以兑换邀请链接,禁用注册——无法创建新账户(只有现有用户可以登录)

为何这很重要——服务器会在 RegistrationManager 中强制执行以下准入规则(参见 crm.solutions.portal/Samples/MasterPortal/Areas/Account/Models/RegistrationManager.cs):

模式EnabledOpenRegistrationEnabledInvitationEnabled行为
仅开放注册truetruefalse直接访问 /registration 可正常使用。邀请链接返回 404。
仅限邀请truefalsetrue直接访问 /registration 返回 404。用户必须通过邀请链接 → /redeem-invitation → /registration?invitationCode=... 进入。
两者都启用truetruetrue两种方式都可使用。邀请会预填电子邮件;直接注册完全开放。
禁用注册false(不适用)(不适用)所有注册端点均返回 404。现有用户仍可登录。

注意: Authentication/Registration/RequireInvitationCode 设置并不是真正的服务端设置——服务端不会读取它。“需要邀请”的行为仅由 OpenRegistrationEnabled = false + InvitationEnabled = true 强制实现。请勿创建该设置。

将此选项存储为 REGISTRATION_MODE——它决定:

  • 是否创建 /registration 页面(除非选择 Registration disabled,否则始终创建)
  • 是否创建 /redeem-invitation 页面(仅当 InvitationEnabled 为 true 时创建,即 Invitation-only 或 Both)
  • /registration 页面是否调用 fetchInvitationDetails() 来预填电子邮件(仅当 InvitationEnabled 为 true 时调用)
  • Phase 8.1 中写入的确定性站点设置集合
  • CAPTCHA 默认开启(开放注册 / 两者都启用)还是关闭(仅限邀请——邀请已筛选用户)

对于“Microsoft Entra ID”(员工 / 企业租户):无需租户或客户端信息。创建站点时,Power Pages 会自动为该站点的父租户配置 OIDC 站点设置(Authentication/OpenIdConnect/AzureAD/*)。SPA 会在运行时从 window.Microsoft.Dynamic365.Portal.tenant 推导 providerIdentifier(https://login.windows.net/{tenantId}/)——无需硬编码值。

声明映射也会自动静默配置——Phase 8.1 始终会为员工 Entra 提供程序写入 Authentication/OpenIdConnect/AzureAD/RegistrationClaimsMapping 和 LoginClaimsMapping,其值为 firstname=given_name,lastname=family_name,emailaddress1=upn。不会就此提问——答案是确定的。员工 Entra ID 默认签发 v1.0 令牌(颁发者为 sts.windows.net/{tid}/),其中不包含 email 声明,因此 upn 是填充 emailaddress1 的唯一可靠声明。没有此映射,首次登录时创建的联系人会关联 oid,但 firstname/lastname/email 均为空(User 对象会显示 contactId,但个人资料字段为空)。

只询问一个可选问题——按钮显示名称。提供一个用户可以直接接受的合理默认值:

问题选项
登录按钮应显示什么标签?(默认值:Sign in with Microsoft)(自由文本,提供默认值)

存储为 ENTRA_ID_DISPLAY_NAME(默认值为 "Sign in with Microsoft")。Phase 3.2 会在 AUTH_PROVIDERS 中添加一个条目,包含 type: 'entra-id'、此显示名称,且不包含 providerIdentifier(运行时解析)。

为什么不需要租户 ID? 租户 ID 基本上用于 SPA 按钮接线(SPA 向 /Account/Login/ExternalLogin 发送的值)。Power Pages 会在运行时通过 window.Microsoft.Dynamic365.Portal.tenant 暴露站点的父租户 ID,因此 SPA 无需询问用户即可构造 providerIdentifier(https://login.windows.net/{tenantId}/)。站点创建时,服务端 OIDC 设置已配置完成。与此相比,Entra External ID 使用的是与站点父租户无关的独立客户租户——在这种情况下,我们需要用户提供租户 ID + 子域名,因为无法从 Portal.tenant 推导这些信息。

文档:https://learn.microsoft.com/en-us/power-pages/security/authentication/openid-settings 登录页布局 — 当配置了多个身份验证提供方(包括本地提供方 + 1 个外部提供方,或 2 个及以上提供方)时,登录页会显示所有提供方。询问用户希望如何排列各提供方:

问题标题选项
登录页上的登录选项应如何排列?布局水平排列(推荐)——提供方按钮并排显示,空间不足时换行;本地表单显示在分隔线下方,垂直堆叠——提供方按钮全宽堆叠显示;本地表单显示在分隔线下方,主选项突出显示——将一个提供方设为主要 CTA,其他选项放在“更多登录选项”切换项下方;本地表单显示在下方,选项卡式——通过选项卡切换提供方模式(适用于 3 个及以上提供方;只有 2 个时显得笨重)

将此选项存储为 LOGIN_LAYOUT — Phase 5.1.1 将据此呈现登录页。如果只配置了一个提供方(例如,仅配置 Entra External ID,没有本地提供方),则无需设置 LOGIN_LAYOUT:AuthButton 的“登录”会直接调用 login(),不需要登录页。

对于主选项突出显示布局,请追问:

问题标题选项
应将哪个提供方设为主要登录选项?主要提供方(将已配置的提供方列为选项。第一个外部提供方是合理的默认值。)

存储为 PRIMARY_PROVIDER_ID。

然后确定范围:

问题选项
您需要哪些身份验证功能?登录和注销 + 基于角色的访问控制(推荐),仅登录和注销,仅基于角色的访问控制(身份验证服务已存在)

然后询问可选功能:

问题选项
您是否要启用以下任何可选功能?无(推荐),条款与条件 — 要求用户在访问网站前接受条款

**注意:**如果用户选择条款与条件,请遵循下方的条款流程。

邀请注册不在此列表中 — 它由上方的注册模式问题控制。将注册模式设为 Invitation-only 或 Both 即可启用邀请。

**此处有意不提供双重身份验证(2FA)。**Power Pages 内置的 2FA 流程由服务器渲染(/Account/Login/SendCode → /Account/Login/VerifyCode),无法从 SPA 拦截 — SPA 中没有用于输入验证码的对应界面,而且在登录过程中将用户跳转到服务器页面会破坏 SPA 体验。如果用户明确要求 2FA,请告知:“Power Pages 内置 2FA 需要使用传统的服务器渲染 SendCode/VerifyCode 页面,目前我们不支持基于 SPA 的 SPA 站点使用这些页面。对于外部提供方(Entra ID、Entra External ID、OIDC),请在身份提供方处启用 MFA — 这对 Power Pages 是透明的,且用户始终处于 IdP 的品牌化体验中。本地账户目前不支持 SPA 站点上的 2FA。”请勿创建 TwoFactorEnabled、RememberMeEnabled 或 RememberBrowserEnabled 站点设置。

个人资料页 — 询问是否要搭建一个 SPA 个人资料页,让已登录用户通过 Power Pages Web API 编辑自己的联系人信息。这是一个独立问题,因为它涉及自身的基础设施配置(contact 实体的 Web API 站点设置 + Self 范围的表权限)。

问题标题选项
是否添加个人资料页,让已登录用户通过 Power Pages Web API 编辑其联系人信息(姓名、手机号码、地址)?电子邮件将以只读形式显示。个人资料页否(默认)——不添加个人资料页;用户无法从 SPA 编辑自己的信息,是——创建一个带编辑表单的 /user-profile SPA 页面,可通过页眉中的用户菜单访问

将其存储为 INCLUDE_PROFILE_PAGE(布尔值)。默认值为 false。

⚠ 必须使用路由名称:/user-profile(而不是 /profile)。路径 /profile 已被 Power Pages 服务器保留,用于旧版服务器渲染的个人资料页;将其用作 SPA 路由会产生冲突,导致页面无法正常工作。SPA 路由始终使用 /user-profile。技能执行器不得重命名此路由。文件也必须使用 src/pages/UserProfile.tsx(而不是 Profile.tsx)。

设为 true 时,Phase 5.1.9 会生成 src/pages/UserProfile.tsx(文件名为必选项),在 authService.ts 中添加 getMyProfile / updateMyProfile(函数名为必选项),将 AuthButton 从内联的 [Avatar Name Sign Out] 改为包含“My Profile”和“Sign Out”的下拉菜单,并在 App.tsx 中添加 /user-profile 路由(路径为必选项)。Phase 8.1 会写入 Web API 站点设置(Webapi/contact/enabled = true 和包含 Phase 8.1 中记录的完整默认字段列表的 Webapi/contact/fields,不能只包含部分字段),并为 contact 表设置适用于 Authenticated Users 角色的 Self 范围表权限。无论 INCLUDE_PROFILE_PAGE 的值如何,AuthButton 的下拉菜单都会成为新的默认样式,以便组件为将来的菜单项做好准备。

个人资料页设计——有意保持简单:

该页面包含两个部分:

  1. Account Details(顶部只读信息)——仅显示用户的全名(firstname 和 lastname 合并)和电子邮件。不要显示 contactId、角色、登录方式、提供方名称、上次登录时间戳或任何其他账户元数据。此部分应保持简洁。
  2. 编辑表单(下方)——仅包含以下可编辑字段。电子邮件不可编辑,这是有意为之(通过 Web API 更改电子邮件会与身份验证提供方的声明映射行为冲突,也会让外部身份验证用户感到意外)。

默认可编辑字段集(表单必须恰好包含以下 8 个字段,不要添加电子邮件、middlename 或“Change Password”链接):

  • First name(firstname)
  • Last name(lastname)
  • Mobile phone(mobilephone)
  • Address line 1(address1_line1)
  • City(address1_city)
  • State / Province(address1_stateorprovince)
  • Postal code(address1_postalcode)
  • Country(address1_country)

提交时所有字段均为可选。不要包含:

  • ❌ 将 emailaddress1 作为可编辑字段(仅在 Account Details 中只读显示)
  • ❌ middlename(保持表单简单)
  • ❌ “Change password”链接或按钮(密码重置由现有的 /forgot-password 流程处理,而非个人资料页)
  • ❌ 个人资料页上的“Sign out”按钮(仅在页眉的 AuthButton 下拉菜单中提供退出登录功能)
  • ❌ contactId、userRoles 或姓名和电子邮件之外的任何账户元数据

Web API 的 fields 站点设置必须包含 contactid 以及上述 8 个可编辑字段(共 9 项,均为小写)。Phase 8.1 会逐字指定确切值。

选择 Yes 的前提条件:必须存在一个“Authenticated Users”Web 角色(或任何带有 authenticatedusersrole: true 标记的角色)。Phase 1.4 已盘点 Web 角色;如果没有符合条件的角色,请提醒用户:在分配角色之前,个人资料编辑功能无法使用,并询问是否先调用 /create-webroles。

跨身份验证提供方兼容性:无论身份验证提供方为何(local、Entra ID、Entra External ID、OIDC、social),个人资料页的工作方式都相同,因为用户登录后,它操作的是 contact 记录,而非特定于 IdP 的会话状态。页面上的电子邮件为只读,因此无需说明任何特定于提供方的注意事项(IdP 仍是电子邮件的唯一事实来源)。

如果选择了“Terms and Conditions”,请先说明 GDPR 前置条件,然后再收集内容——只有安装了底层解决方案,条款才能生效:

GDPR 前提条件:若要让服务端实际强制执行条款,必须满足以下全部三项:

  1. Authentication/Registration/TermsAgreementEnabled = true(我们将创建的站点设置)
  2. 必须在 Dataverse 环境中安装 msdynce_PortalPrivacyExtensions 解决方案(IsGdprEnabled 属于门户级设置)
  3. Account/Signin/TermsAndConditionsCopy 内容片段必须包含非空文本(我们将创建此内容)

如果未安装 Privacy Extensions 解决方案,服务端会静默忽略 TermsAgreementEnabled。setup-auth 技能仍会写入以上三项内容,但除非在 Dataverse 中安装该解决方案,否则服务端不会强制执行条款验证。

在询问前自动检测 Privacy Extensions 解决方案。 运行:

node "${PLUGIN_ROOT}/scripts/check-solution-installed.js" --solutionName "msdynce_PortalPrivacyExtensions"

脚本会将 JSON 输出到 stdout:如果找到解决方案,则输出 { "installed": true, "version": "..." };如果环境中没有该解决方案,则输出 { "installed": false }。若基础设施出现故障(没有 PAC 环境、Azure CLI 令牌过期、缺少对 solutions 表的读取权限或网络错误),脚本会以非零状态退出,并向 stderr 写入易于理解的原因。

根据结果执行相应操作:

脚本结果操作
installed: true完全跳过前提条件问题,继续收集条款内容(下一节)。简要告知用户:“已确认你的环境中安装了 msdynce_PortalPrivacyExtensions v{version},条款强制执行将正常生效。”
installed: false明确告知用户条款与条件将不会生效:“你的 Dataverse 环境中未安装 msdynce_PortalPrivacyExtensions 解决方案。在安装该解决方案之前,服务端不会强制执行条款与条件功能;没有该解决方案时,Authentication/Registration/TermsAgreementEnabled 会被静默忽略。站点设置、条款页面和内容片段仍会创建,但在安装该解决方案之前,验证门控不会执行任何操作。” 然后通过 AskUserQuestion 提问:标题“Privacy solution”,问题“你仍想现在设置条款与条件吗(安装该解决方案后即可启用),还是跳过?”,选项:“仍然继续——创建条款基础设施;我稍后会安装该解决方案”、“取消——为此站点跳过条款与条件”。
脚本以非零状态退出(基础设施故障)告知用户我们无法自动检测该解决方案(简要说明 stderr 中的信息,让用户了解原因,例如“无法连接 Dataverse”或“缺少 solutions 表的权限”)。改为手动询问:问题“我们无法自动检测是否已安装 msdynce_PortalPrivacyExtensions。你是否已安装 GDPR/Privacy Extensions 解决方案?”,选项:“是——已安装该解决方案(或我会安装)”、“仍然继续——设置条款;我知道在安装该解决方案之前条款不会生效”、“取消——我不需要条款”。

如果用户选择“取消”(无论是在未安装还是回退流程中):完全跳过条款分支,不设置 TermsAgreementEnabled,也不创建条款页面或内容片段。

否则,收集条款内容。服务端使用 4 个内容片段——该技能会将这些值硬编码到 SPA 条款页面组件中。询问用户:

问题标题选项
要向用户显示什么条款文本?你可以提供 HTML 或纯文本。Terms Content使用默认条款(推荐)——涵盖数据使用、账户责任和可接受使用的通用条款;我会提供自己的条款文本

如果用户提供自定义文本,则使用该文本。否则,使用默认条款模板(默认内容见 authentication-reference.md)。

还要收集可选的自定义项:

问题标题选项
是否要自定义条款页面标签?标签使用默认值(推荐)——标题:“条款和条件”,复选框:“我同意这些条款和条件。”,按钮:“确认”,我将自定义标签

保存这 4 个值——它们将在阶段 5 中硬编码到条款页面组件中,并在阶段 8.1 中创建为内容片段:

  • TERMS_HEADING(默认值:“条款和条件”)
  • TERMS_CONTENT(默认值:通用条款 HTML)
  • TERMS_AGREEMENT_TEXT(默认值:“我同意这些条款和条件。”)
  • TERMS_BUTTON_TEXT(默认值:“确认”)

还可以询问 TermsPublicationDate:

问题标题选项
何时应再次提示用户接受条款?重新同意每次登录(不设置发布日期)——用户每次登录时都要接受条款,设置发布日期——只有在条款更新日期晚于此日期时,才要求用户重新接受条款

如果选择“设置发布日期”,请收集日期。格式应为 ISO:YYYY-MM-DD(例如:2026-01-01)。如果选择“每次登录”,则不设置 TermsPublicationDate。

如果在阶段 1.4 中发现了 Web 角色,也要询问:

问题选项
哪些 Web 角色应有权访问网站的受保护区域?(将发现的 Web 角色名称列为选项)
2.1.1 可选高级设置

收集所需的提供商详细信息后,询问用户是否要配置高级设置:

问题选项
是否要配置高级身份验证设置?(注销模式、声明映射、会话超时、范围等)否,使用默认值(推荐),是,显示选项

如果选择“是,显示选项”,请展示与所选提供商相关的可选设置表。仅显示适用于其提供商类型的设置。对于用户想要配置的每项设置,请收集其值。

注销模式(仅适用于外部提供商——OIDC、Entra External ID、SAML2、WS-Fed、社交登录)

配置外部提供商时,始终向用户提供此问题(这是最常见的高级设置,而且会明显影响用户体验)。对于仅使用本地身份验证的网站,请跳过此问题。

两种注销模式:

  • 本地注销(服务器默认值,更简单)——Power Pages 清除其会话 Cookie,并将用户重定向到 returnUrl(默认为 /)。用户仍在 IdP 中保持登录状态。下次点击外部提供商按钮时,IdP 的 SSO Cookie 仍然有效,用户无需重新输入凭据即可静默登录。这是大多数面向消费者/客户的网站的默认体验。
  • 联合注销(由 RP 发起)——Power Pages 还会使用 id_token_hint 和 post_logout_redirect_uri 调用 IdP 的 end_session_endpoint。IdP 也会将用户从其会话中注销,然后将浏览器重定向回网站。用户将在所有系统中完全注销。以下情况需要使用此模式:共享设备场景、对强制注销有要求的受监管行业,以及明确希望用户每次都重新输入凭据的网站。
问题标题选项
用户注销时,IdP 应执行什么操作?注销模式仅本地注销(推荐,服务器默认值)——清除 Power Pages 会话;用户仍在 IdP 中保持登录状态,联合注销——同时将用户从 IdP 中注销,使其必须重新进行身份验证

如果选择“仅本地注销”:无需进一步操作。跳过在阶段 8.1 中写入 RPInitiatedLogout 和 PostLogoutRedirectUri 网站设置——服务器默认值会正确处理(两者默认均为 false/未设置)。

如果选择“联合注销”:这需要两项必须同时配置的设置。仅设置 RPInitiatedLogout=true 而不设置 PostLogoutRedirectUri,会导致用户停留在 IdP 的“您已注销”页面上无法继续——已通过对某个实际运行的 Entra External ID 网站进行 HAR 分析确认。 步骤 1 — 收集注销后的重定向 URI(默认为网站根目录):

问题选项
用户在 IdP 注销后应转到哪里?(默认为网站首页。如果有专门的“已注销”页面,请使用其他 URL。)(自由填写,默认为 {SITE_URL}/)

验证该值:必须是与 SITE_URL 主机相同的完整 URL。保存为 POST_LOGOUT_REDIRECT_URI。

步骤 2 — 专门针对 Entra External ID,指导用户在其应用注册中注册该 URL:

必需的应用注册步骤(必须在 Microsoft Entra 管理中心完成):

  1. 转到 应用注册 → {your app} → 身份验证
  2. 滚动到 前通道注销 URL 字段(位于“高级设置”下方、“隐式授权和混合流”上方)
  3. 输入:{POST_LOGOUT_REDIRECT_URI} — 必须与上方的值完全一致
  4. 保存

为何需要此步骤:Entra External ID 会拒绝任何未预先注册的 post_logout_redirect_uri 值(其安全模型与用于登录的重定向 URI 相同)。如果未注册,IdP 会静默丢弃该参数,导致用户注销后无处可去 — 即使 Power Pages 已正确发送该参数。

对于其他 OIDC 提供商(Okta、Auth0 等),请查阅提供商文档,了解相应的注册方式。大多数提供商会在应用设置中将其称为“注销 URL”、“注销后重定向 URI”或“允许的注销重定向 URL”。

选择此选项后,阶段 8.1 将写入 RPInitiatedLogout=true 和 PostLogoutRedirectUri={POST_LOGOUT_REDIRECT_URI}。

OpenID Connect / Entra External ID 可选设置:

设置描述默认值
MetadataAddress显式指定的 OIDC 元数据端点 URL(可替代 Authority;当提供程序需要特定元数据 URL 时使用)根据 Authority 推导
Scope以空格分隔的 OAuth 范围(例如,openid profile email)openid
ResponseTypeOAuth 响应类型(code、id_token、code id_token)code id_token
ResponseModeIdP 返回响应的方式(form_post、query、fragment)代码流为 form_post
RedirectUri覆盖回调 URL{site-url}/signin-{ProviderName-lowercased}
PostLogoutRedirectUri联合登出在 IdP 完成后要重定向到的 URL。当 RPInitiatedLogout=true 时为必填项——服务器有一个回退机制,可根据 RedirectUri 的 authority 推导该值,但只有在存在显式站点设置时,单独的标志(PostLogoutRedirectUriEnabled)才会允许使用此回退机制。若未显式设置该值,IdP 登出 URL 会省略此参数,导致用户无法继续操作。未设置(服务器默认值——但请使用上方的“登出模式”问题正确设置)
RPInitiatedLogout通过 end_session_endpoint 和 id_token_hint 使用 RP 发起的登出。与 ExternalLogoutEnabled 互斥——当值为 true 时,无论该设置的值如何,服务器都会将 ExternalLogoutEnabled 强制设为 false。建议使用上方的“登出模式”问题,而不是直接设置此项——该流程会同时设置 PostLogoutRedirectUri(必填)并执行 Entra 应用注册步骤。false
RegistrationClaimsMapping以逗号分隔的 contactfield=claimtype 键值对(不是 JSON)。在首次登录时、创建联系人之前应用。Entra External ID 示例:firstname=given_name,lastname=family_name,emailaddress1=email。服务器会静默跳过格式错误的键值对——如果声明未填充,请在 Application Insights 中验证。无
LoginClaimsMapping格式与 RegistrationClaimsMapping 相同。每次登录时应用(覆盖联系人字段)。请谨慎使用——此项会覆盖用户对其个人资料所做的手动修改。无
ExternalLogoutEnabled用户登出时同时从 IdP 登出(旧版 OWIN 登出方式;对于 OIDC,建议使用 RPInitiatedLogout)。当 RPInitiatedLogout=true 时会强制设为 false。false(服务器默认值)
RegistrationEnabled允许新用户通过此提供程序注册true
AllowContactMappingWithEmail通过将 email 声明与 emailaddress1 匹配,自动将外部登录关联到现有 Dataverse 联系人。 默认值为 false(始终创建新联系人)。⚠ 多租户 Entra External ID:服务器会强制禁用此功能(LoginController.cs:2578-2587 中的 BlockContactMappingSettingForMultitenantApp 功能标志)——不能信任跨租户的电子邮件声明。如需关联联系人,请使用单租户 Authority。⚠ 安全性:当值为 true 时,任何能够使用受害者电子邮件地址登录此提供程序的人,都能访问该受害者的联系人。仅当提供程序可信且能够验证电子邮件时才启用。false
RequireUniqueEmail注册时强制要求电子邮件地址唯一false
UseTokenLifetime使用 IdP 令牌的有效期作为会话 Cookie 的有效期未设置
BackchannelTimeout对 IdP 进行带外 HTTP 调用的超时时间(例如,00:01:00)00:01:00
RefreshOnIssuerKeyNotFound找不到颁发者密钥时刷新提供程序元数据默认值
NonceEnabled启用 OIDC 令牌的 nonce 验证true
NonceLifetimeOIDC nonce 的有效期(例如,00:10:00)00:10:00
AcrValues要求 IdP 返回的身份验证上下文类引用值无
PromptOIDC 提示参数(login、consent、none)。使用 login 可在会话过期时强制重新进行身份验证。无
Resource令牌请求的资源参数无
EmailClaimIdentifier用作用户电子邮件的自定义声明类型标准电子邮件声明
IssuerFilter用于匹配跨租户颁发者的通配符模式(例如,https://login.microsoftonline.com/*/v2.0)。多租户应用必填——若未设置此项,非主租户的颁发者验证会失败。无
UseUserInfoEndpointforClaims从 UserInfo 端点获取其他声明false
UserInfoEndpoint自定义 UserInfo 端点 URL(如果元数据中未提供)来自元数据
PasswordResetPolicyIdB2C/External ID 密码重置用户流策略名称无
ProfileEditPolicyIdB2C/External ID 个人资料编辑用户流策略名称无
DefaultPolicyIdB2C/External ID 默认注册/登录策略名称无
TokenEndPointAuthenticatedMethod令牌端点身份验证方法(client_secret_post、client_secret_basic、private_key_jwt)。在主权云中使用基于证书的身份验证时,请使用 private_key_jwt。client_secret_post
AllowedDynamicAuthorizationParameters允许动态传递的 OIDC 参数,以逗号分隔无
SAML2 可选设置:
设置描述默认值
AssertionConsumerServiceUrlACS URL(通常为 {site-url}/signin-{ProviderName-lowercased})根据站点 URL 推导
RegistrationClaimsMapping以逗号分隔的 contactfield=claimtype 对。SAML 断言类型是 URI(例如,firstname=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname,lastname=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname)。仅在首次登录时应用。无
LoginClaimsMapping格式相同。每次登录时应用(会覆盖联系人字段)。无
ExternalLogoutEnabled启用 SAML 单点注销(SLO)true
RegistrationEnabled允许新用户通过此提供程序注册true
AllowContactMappingWithEmail通过将 email 声明与 emailaddress1 匹配,自动将外部登录关联到现有 Dataverse 联系人。 默认值为 false(始终创建新联系人)。⚠ 多租户 Entra External ID:服务器会强制禁用此设置(LoginController.cs:2578-2587 中的 BlockContactMappingSettingForMultitenantApp 功能标志)——跨租户的电子邮件声明不可信。如果要关联联系人,请使用单租户 Authority。⚠ 安全性:设为 true 时,任何能够使用受害者电子邮件登录此提供程序的人都可以访问受害者的联系人。只有在信任该提供程序能够验证电子邮件时才启用。false
AllowCreateNameIdPolicy在 NameIdPolicy 中包含 AllowCreatetrue
DefaultSignatureAlgorithmSAML 请求的签名算法提供程序默认值
SigningCertificateFindType用于签署请求的 X509 证书查找类型无
SigningCertificateFindValue证书查找值(例如,指纹)无
ExternalLogoutCertThumbprint用于签署 SLO 响应的证书指纹无
SingleLogoutServiceRequestPathSLO 请求的自定义路径默认值
SingleLogoutServiceResponsePathSLO 响应的自定义路径默认值
ComparisonAuthnContextComparison 类型(exact、minimum、maximum、better)无
BackchannelTimeout获取元数据的超时时间00:01:00
UseTokenLifetime使用 IdP 令牌生命周期作为会话时长未设置
EmailClaimIdentifier用户电子邮件的自定义声明类型标准电子邮件声明
IssuerFilter用于匹配多租户颁发者的通配符模式无

WS-Federation 可选设置:

设置说明默认值
WreplyWS-Fed 响应的回复 URL与 Wtrealm 相同
Whr主领域发现提示(例如,域名)无
SignOutWreply注销后的重定向 URL站点根目录
RegistrationClaimsMapping以逗号分隔的 contactfield=claimtype 对。WS-Fed 声明类型通常是 SAML URI(例如,firstname=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname)。仅在首次登录时应用。无
LoginClaimsMapping格式相同。每次登录时应用(覆盖联系人字段)。无
ExternalLogoutEnabled启用联合注销true
RegistrationEnabled允许新用户通过此提供程序注册true
AllowContactMappingWithEmail通过将 email 声明与 emailaddress1 匹配,自动将外部登录关联到现有 Dataverse 联系人。默认值为 false(始终创建新联系人)。⚠ 多租户 Entra External ID:服务器会强制禁用此功能(LoginController.cs:2578-2587 中的 BlockContactMappingSettingForMultitenantApp 功能标志)——不同租户间的电子邮件声明不可信。如果要关联联系人,请使用单租户 Authority。⚠ 安全性:设为 true 时,任何能够使用受害者电子邮件登录此提供程序的人都能访问受害者的联系人。仅当提供程序可信且会验证电子邮件时才启用。false
BackchannelTimeout获取元数据的超时时间00:01:00
UseTokenLifetime使用 IdP 令牌生命周期作为会话时长未设置
IssuerFilter用于多租户颁发者匹配的通配符模式无

社交 OAuth 可选设置(Microsoft Account、Facebook、Google):

设置说明默认值
Scope要请求的 OAuth 范围(以空格分隔)提供程序默认值
RegistrationClaimsMapping以逗号分隔的 contactfield=claimtype 对。社交提供程序的声明类型各不相同——Facebook 使用 name/email,Google 使用 given_name/family_name/email。示例:firstname=given_name,emailaddress1=email。仅在首次登录时应用。无
LoginClaimsMapping格式相同。每次登录时应用(覆盖联系人字段)。无
ExternalLogoutEnabled注销时同时退出社交提供程序true
RegistrationEnabled允许新用户通过此提供程序注册true
AllowContactMappingWithEmail通过将 email 声明与 emailaddress1 匹配,自动将外部登录关联到现有 Dataverse 联系人。默认值为 false(始终创建新联系人)。⚠ 多租户 Entra External ID:服务器会强制禁用此功能(LoginController.cs:2578-2587 中的 BlockContactMappingSettingForMultitenantApp 功能标志)——不同租户间的电子邮件声明不可信。如果要关联联系人,请使用单租户 Authority。⚠ 安全性:设为 true 时,任何能够使用受害者电子邮件登录此提供程序的人都能访问受害者的联系人。仅当提供程序可信且会验证电子邮件时才启用。false
BackchannelTimeoutOAuth 令牌交换的超时时间00:01:00

本地身份验证可选设置:

设置说明默认值
Authentication/Registration/OpenRegistrationEnabled允许用户自行注册true
Authentication/Registration/EmailConfirmationEnabled注册时要求确认电子邮件false
Authentication/Registration/RememberMeEnabled在登录表单上显示“记住我”复选框false
Authentication/Registration/ResetPasswordEnabled启用忘记密码流程true
Authentication/Registration/ResetPasswordRequiresConfirmedEmail允许重置密码前要求电子邮件已确认false
Authentication/Registration/RequireUniqueEmail强制电子邮件地址唯一false
Authentication/Registration/TermsAgreementEnabled注册时要求同意条款和条件。服务器会在完成注册前将用户重定向到条款页面。false
Authentication/Registration/IsCaptchaEnabledForRegistration在注册表单上显示 CAPTCHAfalse
Authentication/Registration/TriggerLockoutOnFailedPassword登录失败次数过多后锁定帐户true
Authentication/Registration/DenyMinors拒绝被识别为未成年人的用户注册false
Authentication/Registration/DenyMinorsWithoutParentalConsent拒绝未经父母同意的未成年人(需要启用 GDPR)false

会话 / Cookie 设置(所有提供程序):

设置说明默认值
Authentication/ApplicationCookie/ExpireTimeSpan会话超时时长(例如,01:00:00 表示 1 小时)01:00:00
Authentication/ApplicationCookie/SlidingExpiration在每次请求时续期 Cookietrue
Authentication/ApplicationCookie/AbsoluteSlidingExpireTimeSpan无论活动情况如何,会话的绝对最长生命周期无
Authentication/ApplicationCookie/CookieName自定义会话 Cookie 名称Power Pages 默认值
Authentication/ApplicationCookie/CookieDomainCookie 域范围当前域
Authentication/ApplicationCookie/CookiePathCookie 路径范围/
Authentication/ApplicationCookie/CookieHttpOnly禁止 JavaScript 访问会话 Cookietrue
Authentication/ApplicationCookie/CookieSecure要求会话 Cookie 使用 HTTPStrue
Authentication/ApplicationCookie/LoginPath自定义登录页面路径/Account/Login/Login
Authentication/ApplicationCookie/SecurityStampValidator/ValidateInterval验证用户安全戳的间隔(例如,00:30:00)默认值

全局身份验证开关(所有提供程序):

设置说明默认值
Authentication/Registration/LoginButtonAuthenticationType登录按钮的默认提供程序无(显示全部)
Authentication/Registration/AzureADLoginEnabled启用/禁用 Azure AD(Entra ID)登录true
Authentication/Registration/ExternalLoginEnabled启用/禁用所有外部身份提供程序登录true
Authentication/Registration/SignOutEverywhereEnabled注销时,通过更新用户的安全戳,使所有设备上的所有会话失效false

对于用户希望配置的每项设置,请在阶段 8.1 使用 create-site-setting.js 创建站点设置,并同时创建所需设置。

2.1.2 设置 IDP 应用注册(请先查看提供程序的文档)

适用于 OIDC 提供程序(Okta、Auth0、Entra External ID 和其他 OIDC 提供程序)。对于 SAML2、WS-Federation、社交登录或本地身份验证,请使用阶段 2.1 的分步指南。

Power Pages 需要在 IDP 注册 OIDC 应用,并将其连接到此站点。具体设置方式完全取决于身份提供程序,因此请先阅读该提供程序当前的官方文档。

请先阅读:${PLUGIN_ROOT}/skills/setup-auth/references/idp-provisioning-reference.md — Power Pages ↔ IDP 合约(Redirect URI、无密钥的 code id_token 流程)以及各 IDP 的官方文档链接。

步骤 A — 查看提供商的文档(始终是第一步)。 确认用户想使用哪个 IDP 后,立即阅读该提供商的官方文档(通过参考文档中的链接;对于 Microsoft IDP,使用 Learn MCP microsoft_docs_search/microsoft_docs_fetch),以确定两件事:**(1)注册和配置 OIDC 应用的确切步骤;(2)**该提供商用于配置应用的 CLI(Okta、Auth0 和 Entra External ID 都提供 CLI)。在此之前不要进行任何其他步骤,也绝不要编造控制台路径或端点。

步骤 B — 选择设置方式。 有两种方式 — 请同时提供:

  • 引导式(默认,推荐)— 你阅读提供商的文档,并引导用户在提供商自己的控制台中完成操作。始终可用。
  • 替用户配置 — 适用于提供应用配置 CLI 的提供商。Okta、Auth0 和 Entra External ID 都提供 CLI,因此始终向这些服务的用户提供此选项。 CLI 尚未安装并不是不提供此选项的理由 — 安装 CLI 并登录是此流程的一部分。

不提供此类 CLI 的提供商,请使用引导式方式。

提供商提供此类 CLI 时(Okta、Auth0 和 Entra External ID 始终提供),请询问(否则跳过此问题并使用引导式方式):

问题标题选项
你想如何设置 {provider} 应用注册?设置引导式(推荐)— 我会引导你在 {provider} 控制台中完成操作,替我配置 — 我会使用 {provider} 的 CLI 为你设置

步骤 C — Power Pages 所需的应用配置(先说明,再征得同意)。 Power Pages 默认使用无密钥的 code id_token 流程,因此该应用必须是没有客户端密钥的 Web 应用程序。请说明目标配置,并让用户确认或调整:

  • 应用类型: Web 应用程序 — 无客户端密钥
  • 登录流程: code id_token(应用必须从 IDP 获取 ID 令牌)
  • Redirect URI: <REDIRECT_URI>(必须完全一致 — 大小写 + signin- 前缀)
  • 范围: openid profile email

使用引导式方式时,这是用户在控制台中操作的检查清单。替用户配置时,这是你要应用的规范。

步骤 D — 按顺序执行并逐步验证。切勿一次性完成。 每次只执行一个步骤,验证后再继续:

  1. 注册应用 → 获取/验证 Client ID。
  2. 设置 Redirect URI → 验证其与 <REDIRECT_URI> 完全一致。
  3. 启用 ID 令牌签发(code id_token)→ 验证。
  • 引导式: 指示用户在控制台中执行每一步,然后确认结果再继续。
  • 替用户配置: 为每一步运行提供商的 CLI/API,然后读取对象并验证,再执行下一步。

Okta: 启用 Implicit (Hybrid) 和 Allow ID Token,将应用分配给用户(否则会出现 access_denied「Policy evaluation failed」),并优先使用 Org 授权服务器(https://{domain})。请参阅参考文档中的 Okta 部分。

🚦 关卡(同意 · setup-auth:2.1.2.provision-idp): 在代表用户于其身份提供商中配置真实应用注册之前,必须明确确认是否继续。

触发条件: 用户选择了替我配置,且提供商支持此方式;即将代表用户在 IDP 中进行实际更改。 询问原因: 这会在用户的 IDP 租户中创建/配置真实对象,因此绝不能在未获得明确同意的情况下执行。引导式设置不会进入此关卡 — 更改由用户自行完成。 取消后结果: 无 — 不会配置任何应用;用户可以改用引导式设置。 | 问题 | 选项 | |----------|---------| | 现在要继续在你的 {provider} 中配置 {provider} 应用注册吗? | 是 — 帮我配置,不 — 切换到引导式设置 |

如果选择“不”:切换到引导式流程。

步骤 E — 开放注册。 外部登录遵循站点的注册限制。确认推荐的默认设置 — 面向客户的提供方(Auth0 / Okta / Entra External ID)选择 开放,面向员工的 Entra ID 选择 受控:

问题标题选项
通过 {provider} 登录的任何人都可以自动创建账户,还是仅限预先配置的用户?注册开放 — 自助注册(客户站点推荐)— 为任何通过身份验证的用户自动创建联系人,受控 — 仅限预先配置的用户 — 仅允许电子邮件与现有联系人匹配的用户

存储为 OPEN_REGISTRATION_CHOICE。阶段 8.1 会写入站点设置 Authentication/Registration/OpenRegistrationEnabled(开放时为 true,受控时为 false)。将此设置与阶段 2.1 中已收集的联系人关联(AllowContactMappingWithEmail)选项配合使用。

步骤 F — 记录阶段 8.1 所需的结果。 无论如何设置应用,都要记录阶段 8.1 写入站点设置的值(两种流程相同):ClientId、Authority(以及 AuthenticationType = Authority)、MetadataAddress(提供方的发现端点 — 从 IDP 读取)、RedirectUri。不需要 ClientSecret — 无密钥流程不需要它,因此跳过阶段 8.1.1(Key Vault),与 Entra External ID 相同。请参阅参考文档中的“最终站点设置”表格。

2.2 提交计划以供批准

以内联方式提交实施计划:

  • 将创建哪些文件(身份验证服务、类型、授权工具、组件)
  • 身份验证 UI 将如何集成到站点导航中
  • 哪些路由/组件将受到保护,以及所需的角色
  • 需要配置的站点设置(Authentication/Registration/ProfileRedirectEnabled = false)

🚦 关卡(计划 · setup-auth:2.2.plan-approval): 在写入任何文件之前,最终批准身份验证实施计划。

触发条件: 阶段 2.2 已以内联方式提交完整计划。 询问原因: 生成了错误的组件;写入了站点设置;ProfileRedirectEnabled 被切换 — 虽然可以修复,但会增加返工。 取消后保留: 无 — 尚未写入任何身份验证文件。

使用 AskUserQuestion 获取批准:

问题选项
以下是身份验证和授权的实施计划。要继续吗?批准并继续(推荐),我想进行修改

如果选择“批准并继续”:继续阶段 3。

如果选择“我想进行修改”:询问用户希望修改哪些内容,修订计划,然后再次提交以供批准。

输出
  • 已确认身份验证范围(登录/注销、基于角色的访问,或两者)
  • 已选择目标 Web 角色
  • 用户已批准实施计划

阶段 3:创建身份验证服务

目标: 创建身份验证服务、类型声明,以及支持本地开发模拟的框架专用身份验证 hook/composable。

参考:${PLUGIN_ROOT}/skills/setup-auth/references/authentication-reference.md

操作
3.1 创建类型声明

创建 src/types/powerPages.d.ts,其中包含 Power Pages 门户对象和用户的类型定义:

  • PowerPagesUser 接口 — userName、firstName、lastName、email、contactId、userRoles[]
  • PowerPagesPortal 接口 — User、version、type、id、geo、tenant 等
  • 为 Microsoft.Dynamic365.Portal 扩展全局 Window 接口
3.2 创建身份验证服务

根据检测到的框架和所选的身份提供方创建身份验证服务文件。

即使只有一个条目,也始终使用 AUTH_PROVIDERS 数组模式。 切勿生成单个 AUTH_PROVIDER 常量。数组模式意味着日后添加第二个提供方(例如,第二个 Entra External ID 租户,或本地提供方 + 外部提供方)时,只需追加到数组中,无需重构。这可以避免一类错误:由于单常量模式无法表示多个提供方,重新运行时会静默丢弃先前配置的提供方。

数组 MUST 包含:

  • Phase 1.5 中 EXISTING_PROVIDERS 的所有提供方(根据用户选择的 MERGE_MODE 合并)
  • 用户通过 Phase 2.1 添加的任何新提供方

为每个提供方使用稳定的 id(例如,entra-external-id-customer、entra-external-id-employee、local),确保 React 键和 switch 语句在重新运行时保持稳定。

所有框架:创建 src/services/authService.ts,包含以下函数和类型:

  • AuthProviderType — 字符串联合类型:'local' | 'oidc' | 'entra-id' | 'saml2' | 'ws-federation' | 'social'
  • AuthProviderConfig — 接口,包含 id、type、displayName,以及可选的 providerIdentifier('oidc' / 'saml2' / 'ws-federation' / 'social' 类型必需;'entra-id' 类型请省略——运行时解析),以及可选的 loginByEmail(仅限本地)
  • AUTH_PROVIDERS: AuthProviderConfig[] — 数组(每个已配置的提供方各一项,按其在登录页面上的显示顺序排列)
  • LOCAL_PROVIDER — 导出的辅助项:AUTH_PROVIDERS.find(p => p.type === 'local')(没有本地提供方时为 undefined)
  • EXTERNAL_PROVIDERS — 导出的辅助项:AUTH_PROVIDERS.filter(p => p.type !== 'local')
  • getCurrentUser() — 从 window.Microsoft.Dynamic365.Portal.User 读取
  • isAuthenticated() — 检查用户是否存在且具有 userName
  • getTenantId() — 从 window.Microsoft.Dynamic365.Portal.tenant(站点的父租户 GUID)读取。尚未填充时返回 undefined。
  • getAuthProvider() — 已弃用。为保持向后兼容,返回第一个本地提供方;若没有,则返回第一个提供方。建议直接读取 AUTH_PROVIDERS。
  • fetchAntiForgeryToken() — 从 /_layout/tokenhtml 获取并解析 HTML 响应
  • resolveProviderIdentifier(provider) — 返回要作为 provider POST 到 /Account/Login/ExternalLogin 的值。对于 type='entra-id',在运行时派生 https://login.windows.net/${getTenantId()}/。对于其他外部类型,返回 provider.providerIdentifier。绝不要在 Entra ID 的 AUTH_PROVIDERS 数组中硬编码租户 ID——此解析器会处理该值。
  • loginExternal(providerIdentifier, returnUrl?, invitationCode?) — 对外部提供方执行表单 POST 到 /Account/Login/ExternalLogin
  • loginLocal(credential, password, rememberMe?, returnUrl?, invitationCode?) — 对本地提供方执行 fetch POST 到 /SignIn
  • loginWithProvider(provider, { returnUrl?, invitationCode?, credentials? }) — 路由器:根据 provider.type 分派到 loginLocal() 或 loginExternal()。使用 resolveProviderIdentifier(),因此 Entra ID 的运行时解析可透明完成。UI 组件应调用此方法。
  • logout(returnUrl?) — 重定向到 /Account/Login/LogOff
  • getAuthError() — 解析服务器端身份验证错误重定向中的 ?message= 或 ?error= 查询参数,并返回用户易懂的错误消息
  • getSessionExpiredMessage() — 检查是否存在 ?sessionExpired=true,并返回会话过期消息
  • parseServerErrors(html) — 本地身份验证必需。 从服务器 HTML 响应(.validation-summary-errors li、.alert-danger li、.field-validation-error)中解析验证错误。登录和注册功能会使用此方法在 SPA 中显示服务器错误。
  • register(fields, returnUrl?, invitationCode?) — 配置了本地身份验证时必需。 使用防伪令牌、电子邮件或用户名(根据阶段 2.1 中的 LocalLoginByEmail 选择)、密码、确认密码以及可选的邀请代码,向 /Account/Login/Register POST 注册表单。LocalLoginByEmail 为 true 时,发送 Email 字段;为 false 时,发送 Username 字段。完整实现请参阅 authentication-reference.md。
  • forgotPassword(email) — 配置了本地身份验证时必需。 使用 Email 和防伪令牌,通过 MVC 表单 POST 到 /Account/Login/ForgotPassword。服务器会发送密码重置电子邮件。与登录一样使用 fetch()。返回一个 promise——成功时(.then())显示“请检查电子邮件”确认消息;失败时(.catch())显示错误。
  • resetPassword(userId, code, password, confirmPassword) — 配置了本地身份验证时必需。 使用 UserId、Code、Password、ConfirmPassword、__RequestVerificationToken,通过 MVC 表单 POST 到 /Account/Login/ResetPassword。UserId 和 Code 来自 URL 查询参数(由电子邮件重置链接设置)。成功后,重定向到 /login?message=password_reset_success。
  • TermsRequiredError — 启用条款时必需。 自定义错误类;服务器在登录或注册后重定向到条款页面时会抛出此错误。登录/注册页面会捕获此错误,并导航到 SPA 的 /terms 页面。
  • acceptTerms(returnUrl?) — 启用条款时必需。 获取服务器条款页面(GET /Account/Login/TermsAndConditions)以取得防伪令牌,然后 POST 接受条款(IsTermsAndConditionsAccepted=true、IsFacebook=False、UseExternalSignInAsync=False、IsInternalAADUser=False)。动态使用响应 URL(服务器可能从 /Account/Login/TermsAndConditions 或 /TermsAndConditions 提供条款页面)。
  • getUserDisplayName() — 优先使用全名,若无则使用 userName
  • getUserInitials() — 用于显示头像 检测条款:loginLocal() 和 register() 都必须在 fetch 完成后检查 response.url.includes('TermsAndConditions')。服务器会根据流程重定向到不同的 URL:
  • 登录:重定向到 /Account/Login/TermsAndConditions
  • 注册:重定向到 /TermsAndConditions?ReturnUrl=%2F

这两种情况都会由 response.url.includes('TermsAndConditions') 捕获。检测到时,抛出 TermsRequiredError。服务器还会设置 DeferredLocalLoginCookie——在用户接受条款之前,延迟创建会话。

重要——本地登录和注册请使用 fetch(),不要使用 form.submit()。 使用 form.submit() 会触发整页导航——如果服务器返回错误,用户就会离开 SPA 并看到服务器渲染的错误页面。改用 fetch() 可让用户留在 SPA 中:成功时(收到重定向),通过 window.location.href 导航;失败时(收到包含 HTML 的 200 响应),使用 parseServerErrors() 解析错误并抛出,以便页面组件在页面内显示错误。完整实现请参阅 authentication-reference.md。

登录流程因提供程序类型而异:

  • Microsoft Entra ID:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序为 https://login.windows.net/{tenantId}/
  • Entra External ID:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序设为 External ID 的 AuthenticationType(通过站点设置 Authentication/OpenIdConnect/{provider}/AuthenticationType 配置)。底层使用 OpenID Connect,并采用 External ID 租户授权机构 URL。
  • OpenID Connect:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序设为 OIDC 的 AuthenticationType(通过站点设置 Authentication/OpenIdConnect/{provider}/AuthenticationType 配置)
  • SAML2:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序设为 SAML2 的 AuthenticationType(通过站点设置 Authentication/SAML2/{provider}/AuthenticationType 配置)
  • WS-Federation:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序设为 WS-Federation 的 AuthenticationType(通过站点设置 Authentication/WsFederation/{provider}/AuthenticationType 配置)
  • 本地身份验证:向 /SignIn 发送表单 POST 请求,包含 PasswordValue(不是 Password)、来自 /_layout/tokenhtml 的防伪令牌,以及可选的 RememberMe。当 LocalLoginByEmail 为 true 时,发送 Email 字段;否则发送 Username 字段。注意:登录端点使用 /SignIn 和 PasswordValue,与使用 /Account/Login/Register 和 Password 的注册端点不同。不使用 ExternalLogin 端点。
  • Microsoft Account:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序为 urn:microsoft:account
  • Facebook:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序为 Facebook
  • Google:向 /Account/Login/ExternalLogin 发送表单 POST 请求,提供程序为 Google

重要:Power Pages 身份验证在服务器端进行(使用会话 Cookie)。外部登录流程会向服务器提交表单,再由服务器重定向到身份提供程序。本地登录会将凭据直接提交给服务器。没有客户端令牌管理。fetchAntiForgeryToken() 调用获取的是用于表单 POST 的 CSRF 令牌,而不是 bearer 令牌。

密钥管理:切勿在身份验证服务代码或任何提交到源代码管理的文件中包含 ClientSecret、AppSecret 或任何凭据值。providerIdentifier 字段是公开标识符(URL 或名称),不是密钥。实际密钥必须通过 Power Pages 管理中心配置。

服务器渲染页面处理:对于外部登录流程,在某些流程中,Power Pages 服务器可能会重定向到服务器渲染的页面(例如,通过 ExternalLoginConfirmation 进行首次注册、通过 SendCode/VerifyCode 进行双重身份验证、通过 TermsAndConditions 接受条款)。这些由服务器端决定,SPA 无法拦截。为尽量减少这些重定向:

  • 确保正确配置 Authentication/Registration/OpenRegistrationEnabled:当其值为 true 时,新外部用户会自动注册,无需显示 ExternalLoginConfirmation 页面
  • 除非明确需要,否则确保 TermsAgreementEnabled 为 false,否则每次首次登录都会显示由服务器渲染的条款页面
  • 对于 2FA 流程,服务器会渲染 SendCode 和 VerifyCode 页面,无法用 SPA 代码替代
  • 用户从服务器渲染的页面返回后,SPA 应检查身份验证状态是否发生变化(getCurrentUser()),并相应更新 UI
  • 身份验证服务的 useAuth hook 应在挂载时调用 refresh(),以获取 SPA 外部发生的会话变更

对于本地身份验证,所有错误处理都在客户端进行:login() 和 register() 函数使用 fetch()(而非 form.submit()),使用户留在 SPA 中。服务器错误会通过 parseServerErrors() 从 HTML 响应中解析出来,并抛出以供 UI 内联显示。

3.3 创建框架专用的身份验证 Hook/Composable

根据检测到的框架:

  • React:创建 src/hooks/useAuth.ts — 自定义 hook,返回 { user, isAuthenticated, isLoading, displayName, initials, login, logout, refresh }
  • Vue:创建 src/composables/useAuth.ts — 使用 ref、computed、onMounted 的 composable,返回响应式身份验证状态
  • Angular:创建 src/app/services/auth.service.ts — 使用 BehaviorSubject 管理用户状态的可注入服务
  • Astro:仅创建 src/services/authService.ts(无需框架专用封装,直接在组件中使用该服务)
3.4 添加本地开发用模拟数据

身份验证仅在通过 Power Pages 提供服务时有效(本地运行 npm run dev 时无效)。在身份验证服务中添加开发用模拟模式:

// In development (localhost), return mock user data for testing
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';

模拟数据应返回一个角色可配置的虚假用户,以便开发者在本地测试基于角色的 UI。

3.5 创建会话保活 Hook

**SPA 会话过期问题:**在 SPA 中,页面导航由客户端处理,不会发送服务器请求。只有浏览器向服务器发送请求时,会话 Cookie 的 SlidingExpiration 才会续期。如果没有保活机制,即使用户正在积极使用 SPA,会话也会悄然过期。默认的 ExpireTimeSpan 为 24 小时,并在过半时(12 小时)续期,但可以将其配置得更短。

**与身份提供商无关——适用于本地和外部身份验证。**无论用户如何登录(本地密码、Entra External ID、通用 OIDC、SAML2、社交登录),Power Pages 都会颁发相同的 ApplicationCookie 会话 Cookie。保活机制作用于该 Cookie,因此同一个 Hook 可覆盖所有身份提供商。isAuthenticated() 从 window.Microsoft.Dynamic365.Portal.User 读取身份验证状态,该对象会为任何已通过身份验证的用户填充数据。无需针对不同身份提供商设置分支。

**外部身份提供商——两个独立的计时器。**对于外部身份验证,Power Pages 会话 Cookie 和 IdP 令牌(例如 Entra External ID 的 ID 令牌/刷新令牌)具有各自独立的有效期。SPA 需要保持活动的是 Power Pages 会话;SPA 无法访问 IdP 令牌。当 Power Pages 会话过期且用户被重定向到 /login?sessionExpired=true 时,点击外部身份提供商按钮会启动 IdP 往返流程——但如果 IdP 的 SSO Cookie 仍然有效(通常如此),该往返流程会静默完成(无需重新输入凭据)。用户会以已登录状态返回网站。这是预期的用户体验,无需额外处理。

创建会话保活 Hook,定期请求 /_layout/tokenhtml 以续期会话 Cookie:

  • React:创建 src/hooks/useSessionKeepAlive.ts
  • Vue:创建 src/composables/useSessionKeepAlive.ts
  • Angular:创建 src/app/services/session-keepalive.service.ts

该 Hook 必须:

  • 根据会话超时时间定义 SESSION_EXPIRE_MS 常量:
    • 如果用户在阶段 2.1.1 中配置了自定义 ApplicationCookie/ExpireTimeSpan,则将该时间跨度转换为毫秒
    • 如果使用默认值,则使用 24 * 60 * 60 * 1000(24 小时)
  • 根据会话超时时间计算间隔——不要硬编码间隔:
    • intervalMs = min(SESSION_EXPIRE_MS / 3, 15 * 60 * 1000)——每隔会话超时时间的 1/3 请求一次,最多间隔 15 分钟。这样可以确保请求在 SlidingExpiration 过半续期时间点之前发出。
    • idleTimeoutMs = min(SESSION_EXPIRE_MS * 0.9, 30 * 60 * 1000)——空闲时间达到会话超时时间的 90% 后停止请求,最多等待 30 分钟。
    • 示例:10 分钟会话 → intervalMs=3.3min、idleTimeoutMs=9min。24 小时会话 → intervalMs=15min、idleTimeoutMs=30min。
  • 按计算出的间隔通过 fetchAntiForgeryToken() 请求 /_layout/tokenhtml
  • 仅在用户已通过身份验证时请求(isAuthenticated())
  • 仅在浏览器标签页可见时请求(document.visibilityState !== 'hidden')
  • 跟踪用户活动(鼠标、键盘、触摸、滚动);空闲时间达到 idleTimeoutMs 后停止请求——出于安全考虑,让会话自然过期
  • 检测会话是否过期:如果请求失败,则调用 onSessionExpired 回调,以便应用重定向到带有 ?sessionExpired=true 的登录页面
  • 在开发模式下完全跳过(没有需要保活的真实会话)

将该 Hook 集成到 Layout 组件中,使其在每个页面运行。传入一个 onSessionExpired 回调,将用户导航到 /login?sessionExpired=true。登录页面已通过 getSessionExpiredMessage() 处理 ?sessionExpired=true。

输出
  • 创建 src/types/powerPages.d.ts,其中包含 Power Pages 类型定义
  • 创建 src/services/authService.ts,其中包含登录/注销函数
  • 创建框架专用的身份验证 Hook/组合式函数
  • 创建会话保活 Hook 并将其集成到 Layout 中
  • 包含本地开发模拟数据

阶段 4:创建授权工具

**目标:**创建角色检查工具和特定于框架的授权组件(守卫、指令、包装组件)。

参考:${PLUGIN_ROOT}/skills/setup-auth/references/authorization-reference.md

操作
4.1 创建核心授权工具

创建 src/utils/authorization.ts,包含:

  • getUserRoles() — 返回当前用户的角色名称数组
  • hasRole(roleName) — 不区分大小写地检查单个角色
  • hasAnyRole(roleNames) — 检查多个角色中是否至少有一个匹配
  • hasAllRoles(roleNames) — 检查多个角色是否全部匹配
  • isAuthenticated() — 从认证服务重新导出
  • isAdmin() — 检查是否具有 "Administrators" 角色
  • hasElevatedAccess(additionalRoles) — 检查是否为管理员或具有指定角色
4.2 创建特定于框架的授权组件

根据检测到的框架:

React:

  • src/components/RequireAuth.tsx — 仅为已认证用户渲染子元素,可选登录提示回退内容
  • src/components/RequireRole.tsx — 仅为具有指定角色的用户渲染子元素,支持 requireAll 模式
  • src/hooks/useAuthorization.ts — 返回 { roles, hasRole, hasAnyRole, hasAllRoles, isAuthenticated, isAdmin } 的钩子

Vue:

  • src/composables/useAuthorization.ts — 提供计算属性角色和角色检查函数的组合式函数
  • src/directives/vRole.ts — 用于基于角色控制可见性的非 SPA 指令 v-role

Angular:

  • src/app/guards/auth.guard.ts — 使用路由数据指定所需角色的 CanActivateFn
  • src/app/directives/has-role.directive.ts — 结构型指令 *appHasRole="'RoleName'"

Astro:

  • 仅创建 src/utils/authorization.ts(在组件脚本中直接使用)
4.3 安全提醒

在授权工具的顶部添加注释:

// IMPORTANT: Client-side authorization is for UX only, not security.
// Server-side table permissions enforce actual access control.
// Always configure table permissions via /integrate-webapi.
输出
  • 创建包含角色检查函数的 src/utils/authorization.ts
  • 创建特定于框架的授权组件(守卫、指令或包装组件)
  • 添加安全提醒注释

阶段 5:创建认证 UI

**目标:**创建登录/退出按钮组件,并将其集成到网站导航中。

操作
5.1 创建认证按钮组件

根据检测到的框架,创建登录/退出按钮组件:

  • React:src/components/AuthButton.tsx + src/components/AuthButton.css
  • Vue:src/components/AuthButton.vue
  • Angular:src/app/components/auth-button/auth-button.component.ts + 模板 + 样式
  • Astro:src/components/AuthButton.astro

组件应:

  • 用户未认证时显示“登录”按钮
  • 用户已认证时显示用户的显示名称、头像(基于姓名首字母)和“退出”按钮
  • 检查认证状态时显示加载状态
  • 样式应与网站现有设计一致(读取现有 CSS 变量/主题)
5.1.1 创建登录页面

路由命名 — 避免与服务器冲突: Power Pages 将 /SignIn、/Register 和所有 /Account/Login/* 路径保留给服务器渲染的身份验证页面。SPA 路由不得与这些路径冲突。登录页面使用 /login,注册页面使用 /registration。

当 AUTH_PROVIDERS.length > 1 时,始终创建 /login 页面。 仅配置了一个提供方(单个外部提供方或单个本地提供方)时,AuthButton 的“Sign In”可以直接调用 login(),严格来说不需要登录页面;但仍建议创建,因为它可以提供一个稳定的位置来显示身份验证错误、密码重置链接、邀请横幅等。

登录页面必须:

  • 从 authService 导入 AUTH_PROVIDERS、LOCAL_PROVIDER、EXTERNAL_PROVIDERS 和 loginWithProvider
  • 将每个外部提供方渲染为按钮(遍历 EXTERNAL_PROVIDERS)——每个按钮调用 loginWithProvider(provider, { returnUrl, invitationCode })
  • 当 LOCAL_PROVIDER 存在时,渲染本地电子邮件/密码表单。提交时调用 loginWithProvider(LOCAL_PROVIDER, { returnUrl, invitationCode, credentials: { credential, password, rememberMe } })
  • 根据 LOCAL_PROVIDER.loginByEmail 设置凭据字段——为 true 时使用 Email(类型为 email),为 false 时使用 Username(类型为 text)
  • 当有任何提交正在进行时,禁用所有按钮(本地提交使用 isSubmitting 标志,外部提交使用 externalSubmittingId)
  • 捕获 loginWithProvider 抛出的 TermsRequiredError,并导航至 /terms
  • 当 URL 中包含 invitationCode 时,显示邀请横幅:“登录以兑换邀请 {code}。登录后,该邀请将关联到你的账户。”
  • 显示从 ?message= 查询参数解析的服务器端身份验证错误(通过 getAuthError()),以及来自 ?sessionExpired=true 的会话过期消息(通过 getSessionExpiredMessage())
  • 当 LOCAL_PROVIDER 存在且 ResetPasswordEnabled 为 true 时,包含指向 /forgot-password(SPA 路由)的“忘记密码?”链接
  • 当 REGISTRATION_MODE 为 Open registration only 或 Both 时,包含指向 /registration 的“创建账户”链接(Invitation-only 和 Registration disabled 时不显示)

根据 Phase 2.1 中的 LOGIN_LAYOUT 渲染布局:

LOGIN_LAYOUT布局结构
horizontal-row(默认)顶部以 flex-wrap 行排列外部提供方,接着是“OR SIGN IN WITH EMAIL”分隔线,下方为本地表单。每个外部按钮使用 flex: 1 1 0; min-width: 0,并以省略号截断过长的标签。
vertical-stack外部提供方垂直堆叠并占满宽度,接着是“OR SIGN IN WITH EMAIL”分隔线,下方为本地表单。每个外部按钮占满卡片宽度。
primary-spotlight将与 PRIMARY_PROVIDER_ID 匹配的提供方渲染为大型主 CTA。其他外部提供方收纳在标记为“More sign-in options”或“Other ways to sign in”的 <details> 展开项下。然后显示分隔线和本地表单。
tabbed使用选项卡栏,每个提供方(displayName)对应一个选项卡。下方渲染所选选项卡的界面。对于外部提供方,选项卡内容仅为“Sign in with X”按钮;对于本地提供方,则为电子邮件/密码表单。

React 中每种布局模式的完整代码示例,请参阅 authentication-reference.md。

当 /login 页面存在时,AuthButton 的“Sign In”必须导航至 /login(使用 <Link to="/login">),而不是直接调用任何登录函数。

5.1.2 创建注册页面

当 REGISTRATION_MODE 不为 Registration disabled 时,始终创建 /registration 页面——无论模式是 Open registration only、Invitation-only 还是 Both,也无论配置了本地提供方、外部提供方,还是两者都有。当本地和外部提供方同时存在时,页面布局与登录页面一致:顶部为水平排列的外部提供方按钮,接着是“或使用电子邮件注册”分隔线,下方为本地注册表单(前提是已配置本地身份验证)。在仅限邀请模式下,用户通过 /redeem-invitation 流程(阶段 5.1.7)或服务器跳转流程(Code-Site-Shell-Header 捕获 /account/login/register)访问该页面,而不是直接导航到该页面;但 SPA 路由仍必须存在,以便 React Router 能够渲染页面。

重要架构说明: 服务器端注册页面(/Account/Login/Register)是 ASP.NET Web Forms 页面,而不是 MVC 操作。authService 中的本地 register() 函数通过先获取服务器页面(GET)、解析 ViewState 和控件名称,再使用正确的负载发送 POST 请求来处理注册。这仅适用于用户提交本地注册表单的情况——外部提供方按钮会跳过这一步,直接调用 loginExternal()。

外部提供方按钮(当 EXTERNAL_PROVIDERS.length > 0 时):

在页面顶部水平排列外部提供方注册按钮——布局模式与登录页面相同(阶段 5.1.1)。每个按钮都会调用 authService 中的 loginExternal(providerIdentifier, returnUrl, invitationCode)——用户点击“使用 Entra External ID 注册”时,SPA 会发起外部身份验证,并在 URL 中附上邀请代码(如果存在)。随后,服务器的 ExternalLoginCallback 会通过 AllowContactMappingWithEmail 自动关联,或显示 SPA 化的 ExternalLoginConfirmation 页面(阶段 5.1.8),或在提供了邀请的情况下兑换该邀请。这与登录页面外部按钮使用相同的代码路径——loginExternal() 不关心用户是从 /login 还是 /registration 进入的;如果联系人不存在,服务器会在首次登录时创建联系人。

如果 EXTERNAL_PROVIDERS.length > 0 且存在 LOCAL_PROVIDER,则在外部按钮和本地表单之间显示分隔线(“或使用电子邮件注册”)。如果只有外部提供方(没有本地提供方),则仅显示外部按钮,不显示分隔线和本地表单——此时页面实际上变成“选择一个提供方进行注册”。

本地表单(当存在 LOCAL_PROVIDER 时):

本地注册表单必须:

  • 调用 authService 中的 register() 函数。该函数会处理 Web Forms ViewState 模式(获取服务器页面 → 解析 → 使用正确的控件名称提交 POST 请求)。
  • 根据第 2.1 阶段的 LocalLoginByEmail 选项显示相应的凭据字段:
    • 邮箱模式(LocalLoginByEmail = true):显示 Email 字段(类型为 email)。该字段既是登录标识,也是邮箱地址。
    • 用户名模式(LocalLoginByEmail = false):显示 Username 字段(类型为 text)以及单独的 Email 字段(类型为 email)。两者均为必填项——Username 是登录标识,Email 用于联系人记录。
  • 添加 Password 和 Confirm Password 字段(两者类型均为 password)。
  • 提交前在客户端验证两个密码是否一致。
  • 显示从 ?message= 查询参数解析出的服务器端注册错误(通过 getAuthError())。
  • 从 URL 查询字符串解析并传递 invitationCode(适用于用户通过 ?invitationCode=... 进入的邀请注册流程)。
  • 添加“已有账户?登录”链接,指向 /login。
  • 在开发模式下跳过身份验证重定向——开发模式下,模拟用户始终处于“已验证身份”状态,这会阻止测试注册表单。添加:const isDev = window.location.hostname === 'localhost',并且仅在 isAuthenticated && !isDev 时进行重定向。
  • 样式应与网站现有的登录页面设计一致(卡片居中布局)。

布局——与登录页面(第 5.1.1 阶段)一致。当用户在第 2.1 阶段选择 LOGIN_LAYOUT 时,该布局选项(水平排列 / 垂直堆叠 / 主项突出 / 选项卡式)同时适用于 /login 和 /registration,让用户在两个页面上获得一致的体验。

根据邀请预填邮箱(当 InvitationEnabled 为 true 时,即“仅限邀请”或“两者皆可”模式):

用户访问 /registration?invitationCode=X 时,邮箱字段应预填受邀联系人的邮箱(与服务器渲染页面的行为一致)。在组件挂载时调用 fetchInvitationDetails(invitationCode) 来实现:

useEffect(() => {
  if (!invitationCode) return
  fetchInvitationDetails(invitationCode).then(details => {
    if (details.email) setEmail(details.email)
  }).catch(() => { /* silent — user can enter email manually */ })
}, [invitationCode])

fetchInvitationDetails() 函数(位于 authService.ts 中)会对 /Account/Login/Register?invitationCode={code} 发起 GET 请求,并从渲染后 HTML 的 #EmailTextBox 输入框的 value 属性中解析邮箱。实现方式请参见 authentication-reference.md。

邮箱输入框必须为受控输入框(value={email}),且允许编辑——用户可以根据需要更改邮箱(与服务器行为一致)。

特定框架的实现:

  • React:创建 src/pages/Registration.tsx,并向路由器添加 <Route path="/registration" element={<Registration />} />。实现模式请参见 authentication-reference.md 中的 RegisterForm 组件——根据网站现有的样式模式(内联样式、CSS 变量等)进行调整。
  • Vue:创建 src/pages/Registration.vue,并在 src/router/index.ts 中添加路由。
  • Angular:创建 src/app/pages/registration/registration.component.ts,并在路由配置中添加路由。
  • Astro:创建 src/pages/registration.astro。

如果 REGISTRATION_MODE 为 Registration disabled,则完全跳过创建 /registration 页面——没有任何流程会将用户引导至该页面。

5.1.3 创建忘记密码页面(仅限本地身份验证)

如果已配置本地身份验证,且 ResetPasswordEnabled 为 true,请创建 /forgot-password 页面。这是一个简单的表单,用于收集用户的邮箱并向服务器发送 POST 请求,服务器会通过邮件发送密码重置链接。

注意: 忘记密码端点(/Account/Login/ForgotPassword)是 MVC 表单(类似登录),而不是 Web Forms 页面(类似注册)。使用简单的 fetch() POST 请求并传入 Email 和 __RequestVerificationToken 即可。authService 中的 forgotPassword() 函数会处理此操作。

忘记密码页面必须:

  • 显示邮箱输入框
  • 提交时调用 authService 中的 forgotPassword(email)
  • 处理成功和错误两种情况:成功时使用 .then()(显示带绿色勾号的“检查邮箱”确认消息,并隐藏表单),错误时使用 .catch()(内联显示错误,并重置按钮)。不要只处理 .catch()——如果不处理 .then(),按钮会一直停留在“发送中”状态。
  • 跟踪 emailSent 状态——为 true 时,将表单替换为成功消息:“我们已向你的邮箱发送密码重置链接。请检查收件箱并按照说明操作。”并显示“返回登录”链接
  • 内联显示服务器错误(forgotPassword() 函数使用 fetch 并抛出解析后的错误)
  • 添加指向 /login 的“返回登录”链接
  • 使用与登录和注册相同的失焦时验证模式(失焦时验证邮箱格式,内容更改时清除错误)

登录页面中的“忘记密码?”链接应指向 /forgot-password(SPA 路由),而不是 /Account/Login/ForgotPassword(服务器 URL)。

服务器处理请求后会发送重置邮件。邮件中的重置链接指向服务器的 /Account/Login/ResetPassword?UserId=...&Code=...,但 Header 模板重定向脚本会拦截此链接(参见 Phase 5.1.6)并将其重定向到 SPA 的 /reset-password 页面。

5.1.4 所有身份验证页面的验证模式(仅限本地身份验证)

所有本地身份验证页面(登录、注册、忘记密码)都必须实现失焦时验证、内容更改时清除,以便实时验证字段。这是一种现代 UX 模式——用户离开字段时显示错误,修正内容时清除错误。

实现模式:

  1. 按字段跟踪 touched 状态(用户与哪些字段进行过交互)
  2. 失焦时(onBlur):将字段标记为已触碰,执行验证,并立即显示错误
  3. 内容更改时(onChange):如果字段已触碰,则重新验证,并在值变为有效时立即清除错误。同时,在任何内容更改时清除服务器错误。
  4. 提交时:将所有字段标记为已触碰,验证所有内容,并一次性显示所有错误
  5. showError(field) 辅助函数:仅在字段已触碰时返回错误

验证规则:

页面字段验证
登录邮箱必填 + 有效的邮箱格式
登录密码必填
注册邮箱必填 + 有效的邮箱格式
注册密码必填 + 至少 8 个字符 + 包含以下 4 类字符中的至少 3 类(小写字母、大写字母、数字、特殊字符)
注册确认密码必填 + 必须与密码一致
忘记密码邮箱必填 + 有效的邮箱格式

密码强度验证与 Power Pages 默认密码策略(EnforcePasswordPolicy)一致。如果站点创建者通过 Authentication/UserManager/PasswordValidator/* 站点设置自定义密码策略,客户端验证也应与之匹配。

5.1.5 创建条款与条件页面(启用条款时)

如果用户在第 2 阶段启用了条款与条件,则创建 /terms SPA 页面。此页面适用于所有身份验证流程——本地登录、本地注册以及外部提供商(Entra External ID、OIDC、社交登录)——通过两种互补机制实现:

  1. 本地身份验证流程(loginLocal、register):使用 fetch(),使 SPA 保持在当前页面中。身份验证服务会从 response.url 检测服务器的 TermsAndConditions 重定向,并抛出 TermsRequiredError。登录/注册页面捕获该错误后导航至 /terms。
  2. 外部身份验证流程(loginExternal、loginWithProvider 的外部分支):使用 form.submit(),使浏览器在 IdP 往返过程中离开 SPA。IdP 回调后,服务器可能会重定向至 /Account/Login/TermsAndConditions?ReturnUrl=/&UseExternalSignInAsync=True&IsFacebook=False&IsInternalAADUser=False。Code-Site-Shell-Header 脚本(第 5.1.6 阶段)会捕获此 URL 并重定向至 SPA 的 /terms 路由,同时保留查询字符串,以便 acceptTerms() 知道服务器期望使用哪条登录完成路径。

条款页面必须:

  • 将第 2 阶段收集的 4 个片段值硬编码为组件顶部的常量:
    const TERMS_HEADING = '<value from Phase 2 or default>'
    const TERMS_CONTENT = '<HTML content from Phase 2 or default>'
    const TERMS_AGREEMENT_TEXT = '<value from Phase 2 or default>'
    const TERMS_BUTTON_TEXT = '<value from Phase 2 or default>'
    
  • 显示标题、条款内容(通过 dangerouslySetInnerHTML 渲染为 HTML)、带同意文本的复选框,以及确认按钮
  • 确认按钮调用 authService 中的 acceptTerms('/')——此函数会读取 window.location.search 中的查询字符串,获取服务器条款页面以取得防伪令牌(同时保留相同的查询字符串),然后将接受确认 POST 回相同 URL,并在请求体中包含查询字符串中的标记
  • 只有勾选复选框后,确认按钮才可用
  • 如果 acceptTerms() 抛出错误,则在页面内显示服务器错误
  • 包含指向 /login 的“返回登录”链接

登录和注册页面必须捕获 TermsRequiredError(本地身份验证路径):

  • 在登录页面的 loginLocal() catch 代码块中:如果 err instanceof TermsRequiredError,则导航至 /terms
  • 在注册页面的 register() catch 代码块中:如果 err instanceof TermsRequiredError,则导航至 /terms

外部身份验证路径无需更改 loginExternal 中的 SPA 代码——标头模板重定向(第 5.1.6 阶段)会透明地处理此流程。外部用户在 IdP 往返后会到达 /terms?ReturnUrl=/&UseExternalSignInAsync=True&...;SPA 会渲染条款页面;acceptTerms() 会使用查询字符串,携带正确的 UseExternalSignInAsync/IsFacebook/IsInternalAADUser 标记进行 POST。

服务器触发条款的方式(供参考):

  • 本地登录流程:服务器在身份验证后重定向至 /Account/Login/TermsAndConditions——在 loginLocal() 中通过 response.url.includes('TermsAndConditions') 捕获
  • 本地注册流程:服务器在注册后重定向至 /TermsAndConditions?ReturnUrl=%2F——在 register() 中通过 response.url.includes('TermsAndConditions') 捕获
  • 外部登录流程:服务器从 /Account/Login/ExternalLoginCallback 重定向至 /Account/Login/TermsAndConditions?ReturnUrl=/&UseExternalSignInAsync=True&IsFacebook=False&IsInternalAADUser=False——通过标头模板重定向捕获

acceptTerms() 必须能够处理查询字符串(当外部提供商与条款同时配置时为必需):

export async function acceptTerms(returnUrl?: string): Promise<void> {
  // Parse the flags from window.location.search — set by the server's redirect URL.
  // For local-auth users (no query string), defaults apply.
  const params = new URLSearchParams(window.location.search);
  const useExternalSignInAsync = params.get('UseExternalSignInAsync') || 'False';
  const isFacebook = params.get('IsFacebook') || 'False';
  const isInternalAADUser = params.get('IsInternalAADUser') || 'False';

  // Fetch the server terms page WITH the original query string preserved
  const serverTermsUrl = `/Account/Login/TermsAndConditions${window.location.search}`;
  const pageResponse = await fetch(serverTermsUrl, { credentials: 'same-origin', redirect: 'follow' });

  // ... extract anti-forgery token from rendered HTML ...

  // POST back to the same URL with body flags matching the query-string flags.
  // DO NOT hardcode UseExternalSignInAsync=False — external users need True.
  const body = new URLSearchParams();
  body.set('__RequestVerificationToken', antiForgeryToken);
  body.set('IsTermsAndConditionsAccepted', 'true');
  body.set('UseExternalSignInAsync', useExternalSignInAsync);
  body.set('IsFacebook', isFacebook);
  body.set('IsInternalAADUser', isInternalAADUser);
  body.set('InvitationCode', '');
  // ... POST and handle response ...
}

完整实现请参阅 authentication-reference.md。

特定框架的实现:

  • React:创建 src/pages/Terms.tsx,并在路由器中添加 <Route path="/terms" element={<Terms />} />

内容更新:网站创建者要更改条款文本时,应更新 Terms 页面组件中的常量并重新部署。Dataverse 中的内容片段(Account/Signin/TermsAndConditionsCopy 等)也必须同步更新——服务器渲染的条款页面从内容片段读取文本,而 SPA 从硬编码常量读取文本。两者必须保持一致。

5.1.6 创建重置密码页面 + 页眉模板重定向(仅限本地身份验证)

如果已配置本地身份验证,且 ResetPasswordEnabled 为 true,则创建完整的 SPA 重置密码体验。这涉及两个部分:

1. Code-Site-Shell-Header 模板

密码重置邮件会将用户引导至 /Account/Login/ResetPassword?UserId=...&Code=...——这是一个服务器渲染的页面。为了让用户留在 SPA 中,我们需要一个在服务器渲染页面上运行的客户端重定向脚本。

为什么要创建新模板? pac pages upload-code-site 命令会在每次上传时,将原始“Header”和“Footer”Web 模板内容替换为 <div/>。添加到 Header 模板中的任何脚本都会被清除。解决方法是创建一个单独的 Web 模板(Code-Site-Shell-Header),并改为将网站记录指向该模板。

在 .powerpages-site/web-templates/code-site-shell-header/ 中创建一个新的 Web 模板:

Code-Site-Shell-Header.webtemplate.yml:

id: <generate-a-new-uuid>
name: Code-Site-Shell-Header

Code-Site-Shell-Header.webtemplate.source.html:

<div/>
<script>
  // SPA Site Shell Header — Server-to-SPA redirect for auth pages.
  // This template runs on server-rendered pages and redirects to SPA equivalents.
  // Uses a separate template because pac pages upload-code-site wipes the original Header.
  (function () {
    var path = window.location.pathname.toLowerCase();
    var search = window.location.search;
    var spaBase = window.location.origin;
    // Add an entry here for each server-rendered auth page that has a SPA equivalent.
    // Only include entries the site actually needs (e.g., omit /redeeminvitation when
    // InvitationEnabled is false).
    var redirects = {
      '/account/login/resetpassword': '/reset-password',
      '/account/login/redeeminvitation': '/redeem-invitation',
      '/register': '/redeem-invitation',
      '/account/login/register': '/registration',
      '/account/login/externallogincallback': '/external-login-confirmation',
      '/account/login/termsandconditions': '/terms',
      '/account/login/externalauthenticationfailed': '/login',
      '/signin': '/login'
    };
    // Special case: ExternalAuthenticationFailed may arrive with no query string
    // (generic failure) or with ?message=access_denied (user-denied at IdP).
    // Ensure the Login page always shows SOME error indication.
    if (path === '/account/login/externalauthenticationfailed' && !search) {
      window.location.replace(spaBase + '/login?message=external_auth_failed');
      return;
    }
    for (var serverPath in redirects) {
      if (path === serverPath) {
        window.location.replace(spaBase + redirects[serverPath] + search);
        return;
      }
    }
  })();
</script>

条件条目——仅包含指向 SPA 中现有页面的重定向条目:

重定向包含条件
'/account/login/resetpassword': '/reset-password'本地身份验证 + ResetPasswordEnabled = true(阶段 5.1.6)
'/account/login/redeeminvitation': '/redeem-invitation'REGISTRATION_MODE 为 Invitation-only 或 Both(阶段 5.1.7)
'/register': '/redeem-invitation'REGISTRATION_MODE 为 Invitation-only 或 Both(阶段 5.1.7)。/Register 是 LoginController.RedeemInvitation 的别名路由——当外部用户没有联系人且没有邀请上下文时,服务器会从 ExternalLoginCallback 将他们重定向到此处(依据 RegistrationManager.cs 中的门控逻辑)。捕获此路由可保留外部用户的 SPA 用户体验:他们在没有邀请链接的情况下发起登录。
'/account/login/register': '/registration'REGISTRATION_MODE 不为 Registration disabled(阶段 5.1.2)。这是本地 Register Web Forms 页面。提交 RedeemInvitation 表单后,服务器会将用户重定向到此处(从 /Register → /Account/Login/Register?invitationCode=...)。捕获此路由可让用户留在 SPA 的 /registration 页面;该页面本身沿用 Login 页面的布局——分隔线之上是外部提供商按钮,之下是本地表单——因此用户可以通过任一方式完成注册(阶段 5.1.2)。
'/account/login/externallogincallback': '/external-login-confirmation'配置了任意外部提供商(阶段 5.1.8)——将首次通过外部提供商登录的用户引导至 SPA
'/account/login/termsandconditions': '/terms'已启用条款与条件(任何身份验证流程——参见阶段 5.1.5)——捕获服务器对外部提供商进行身份验证后重定向至条款页面的操作
'/account/login/externalauthenticationfailed': '/login'配置了任意外部提供商。外部身份验证失败时,服务器会重定向到此处——包括令牌无效、颁发者不匹配、用户在 IdP 拒绝授权、IdP 故障等。此路径在 OWIN 启动代码中硬编码,无法通过站点设置覆盖(参见 authentication-reference.md)。重定向脚本会对此 URL 特殊处理:当服务器附加 ?message=access_denied(用户拒绝的情况)时,会保留该参数,getAuthError() 将显示“访问被拒绝。”当服务器未附加查询字符串时(一般故障),脚本会注入 ?message=external_auth_failed,使 Login 页面显示“使用外部提供商登录失败。请重试。”(将此代码添加到 authService 中的 AUTH_ERROR_MESSAGES。)
'/signin': '/login'始终包含(任何身份验证流程)。/SignIn 是服务器的旧版登录页面——当未经身份验证的用户访问受保护的服务器渲染路径时(例如 /profile、/Account/Manage,以及任何由 Web 角色限制访问的服务器页面),服务器会将其重定向到此处。若没有此条目,用户就会离开 SPA,进入服务器渲染的登录界面。服务器附加的任何 ?ReturnUrl=... 查询参数都会原样保留——SPA 的 /login 页面目前会忽略该参数(登录后始终返回 /),这是可接受的行为。

**关于这两个新增条目(/register 和 /account/login/register):**它们用于处理一种特殊的外部身份验证流程,否则很难发现。当外部用户(Entra External ID、OIDC、SAML2、社交登录)点击“登录”按钮,却没有先点击邀请邮件链接,且他们在 Dataverse 中没有现有联系人时,服务器会强制他们进入服务器端渲染的邀请流程:

  1. POST /Account/Login/ExternalLogin → IdP → /signin-{ProviderName-lowercased} → /Account/Login/ExternalLoginCallback
  2. ExternalLoginCallback 未找到联系人和邀请 → 重定向到 /Register?ReturnUrl=/(服务器端渲染的 RedeemInvitation 表单)
  3. 用户输入邀请代码 → 服务器验证 → 重定向到 /Account/Login/Register?invitationCode=...(本地 Register Web Forms 页面,该页面上会呈现外部提供商按钮)
  4. 用户点击 Register 页面上的外部提供商按钮 → /Account/Login/ExternalLogin?InvitationCode=... → 在 URL 中携带邀请信息进行第二轮外部身份验证 → 创建联系人并兑换邀请

如果没有 /register 和 /account/login/register 重定向,用户就会看到服务器端渲染的 RedeemInvitation 表单和 Web Forms Register 页面,两者都会破坏 SPA 用户体验。这些重定向适用于所有外部提供商,而不仅仅是 Entra External ID,因为服务器的跳转逻辑与提供商无关。

然后更新 website.yml,将 headerwebtemplateid 指向新模板的 ID:

headerwebtemplateid: <new-template-uuid>

原来的“Header”模板仍保留为 <div/>(上传命令会继续清空它,这没问题)。Code-Site-Shell-Header 模板在上传后仍会保留,因为该命令只针对名为“Header”和“Footer”的模板。

这种方式可以扩展——可向 redirects 对象添加其他服务器到 SPA 的重定向(例如,电子邮件确认页面)。

2. SPA 重置密码页面

创建一个 /reset-password 页面,用于:

  • 从 URL 查询参数中读取 UserId 和 Code(由页眉重定向保留)
  • 如果缺少任一参数,则显示“无效的重置链接”,并提供指向 /forgot-password 的链接
  • 显示新密码和确认密码字段,并在失去焦点时验证(密码强度验证与注册时相同)
  • 提交时调用 authService 中的 resetPassword(userId, code, password, confirmPassword)
  • 成功后重定向到 /login?message=password_reset_success
  • 出错时,在页面内显示服务器错误

resetPassword() 是一个 MVC 表单 POST(无 ViewState),提交到 /Account/Login/ResetPassword,字段包括:__RequestVerificationToken、UserId、Code、Password、ConfirmPassword。注意:此处的密码字段是 Password(而非登录时使用的 PasswordValue——不同端点使用不同的字段名称)。

登录页面必须处理成功消息:页面挂载时检查 URL 中是否有 ?message=password_reset_success,并显示绿色成功横幅:“你的密码已重置。请使用新密码登录。”

特定框架的实现:

  • React:创建 src/pages/ResetPassword.tsx,并向路由器添加 <Route path="/reset-password" element={<ResetPassword />} />
5.1.7 创建兑换邀请页面(仅限邀请模式)

如果 REGISTRATION_MODE 为 Invitation-only 或 Both,则创建一个 /redeem-invitation SPA 页面。这是用户点击邀请邮件链接后到达的落地页。链接格式为 {site-url}/Account/Login/RedeemInvitation?invitation={code}——Code-Site-Shell-Header 重定向(阶段 5.1.6)会捕获该链接并转发到 SPA 路由。

用户可通过以下两种路径进入 /redeem-invitation:

  1. 直接邀请链接:用户点击邮件链接 /Account/Login/RedeemInvitation?invitation={code} → 页眉脚本重定向到 /redeem-invitation?invitation={code}。干净的 SPA 入口——邀请代码从一开始就在 URL 中。
  2. 服务器跳转路径(外部身份验证,无联系人,无邀请):用户未先点击邀请链接,便在登录页面点击“使用 [external provider] 登录”。完成外部身份验证后,服务器的 ExternalLoginCallback 找不到联系人或邀请,于是将用户重定向到 /Register?ReturnUrl=...(LoginController.RedeemInvitation 的别名)。页眉脚本会捕获 /register 并将用户重定向到 /redeem-invitation——这是同一个 SPA 页面,不会预填邀请代码,用户需要自行输入。此路径适用于所有外部提供商,而不仅仅是 Entra External ID——服务器端的跳转逻辑与提供商无关。已通过在启用了 Entra External ID + Terms 的线上网站分析 HAR 确认。

**此流程将取代的服务器流程:**服务器的 LoginController.RedeemInvitation 操作(crm.solutions.portal/Samples/MasterPortal/Areas/Account/Controllers/LoginController.cs 第 3232-3310 行)会验证邀请代码并进行分支处理:

  • RedeemByLogin = false(新用户)→ 302 重定向到 /Account/Login/Register?invitationCode={code}——服务器要求用户使用该代码注册
  • RedeemByLogin = true(现有用户)→ 返回 200 OK 和 Login 视图,邀请代码嵌入表单操作 URL 中——服务器要求用户登录,并在身份验证后的 RedirectOnPostAuthenticate 中兑换邀请
  • 代码无效/已过期/已兑换 → 返回 200 OK 并重新渲染表单,错误信息位于 #redeemInvitation-validation-summary 中(这三种情况都会显示相同的 Invalid_Invitation_Code_Exception)

SPA 设计——完全取代服务器渲染页面:

SPA 页面会从 authService 调用 redeemInvitation(code, redeemByLogin),该方法使用 fetch() 并设置 redirect: 'manual',以拦截服务器的 302 重定向。根据响应:

  • response.type === 'opaqueredirect' → 服务器已验证代码,并会重定向到 Register → SPA 导航到 /registration?invitationCode={code}(Phase 5.1.2 中已有的页面)
  • HTML 中包含 Login 表单标记,且响应为 200 OK → 用户选择了登录流程 → SPA 导航到 /login?invitationCode={code}(已有页面——也必须更新,见下文)
  • HTML 中包含验证摘要,且响应为 200 OK → 代码无效 → 抛出解析后的服务器错误(使用现有的 parseServerErrors() 助手函数)

DevTools 说明:POST 请求后,浏览器的网络面板会显示 302 响应的 Location 目标(例如 /Account/Login/Register)为已中止的请求(net::ERR_ABORTED)。这是预期行为——这是我们通过 redirect: 'manual' 特意选择不跟随的重定向。此流程使用 response.type === 'opaqueredirect' 检测重定向是否发生,而不实际跟随重定向。

兑换邀请页面必须:

  • 从 URL 查询参数中读取 invitation 或 InvitationCode(处理两种大小写形式——邮件中可能使用任意一种)
  • 预填邀请代码输入框,但保持其可编辑(以便没有邮件链接的用户手动输入)
  • 显示复选框:“使用现有账户登录,而不是注册”(用于控制 RedeemByLogin)
  • 在失焦时验证:邀请代码必须填写且不能为空
  • 提交时调用 redeemInvitation(),并根据返回的 nextStep 进行导航
  • 在页面内显示服务器错误(使用现有的 parseServerErrors 解析)
  • 包含指向 /login 的“返回登录”链接
  • 样式应与其他身份验证页面一致

登录页面更新(必需):

启用邀请模式时,还必须更新登录页面(Phase 5.1.1):

  1. 挂载时从 URL 查询参数中读取 invitationCode
  2. 如果存在,则显示提示横幅:“登录以兑换邀请 {code}。登录后,该邀请将关联到你的账户。”
  3. 将 invitationCode 作为第 5 个参数传给 loginLocal()——身份验证服务会将其附加为 /SignIn POST URL 中的 ?InvitationCode={code}。服务器的 Login(model, returnUrl, invitationCode) 处理程序会读取此参数,并在身份验证成功后的 RedirectOnPostAuthenticate 中兑换邀请。 特定框架的实现:
  • React:创建 src/pages/RedeemInvitation.tsx,并将 <Route path="/redeem-invitation" element={<RedeemInvitation />} /> 添加到路由器中。实现模式请参阅 authentication-reference.md 中的 RedeemInvitation 组件。
  • Vue / Angular / Astro:按照各框架的惯用方式采用 React 模式。

身份验证服务新增内容(启用邀请模式时必需):

  • redeemInvitation(invitationCode, redeemByLogin, returnUrl) — 函数清单请参阅阶段 3.2;完整代码见 authentication-reference.md
  • fetchInvitationDetails(invitationCode) — 已在阶段 5.1.2 中介绍,用于预填电子邮件
  • 更新 loginLocal(),使其接受可选的 invitationCode 参数(第 5 个参数)——在 /SignIn URL 后追加 ?InvitationCode={code}
5.1.8 创建外部登录确认页面(仅限外部提供方)

如果配置了任何外部提供方(OIDC、Entra External ID、SAML2、WS-Federation、社交登录),请创建 /external-login-confirmation SPA 页面。此页面用于处理首次外部登录流程;否则,服务器会渲染 ExternalLoginConfirmation.aspx。

此页面替代的服务器流程: 用户首次通过外部方式登录且不存在 Dataverse 联系人时,服务器的 LoginController.ExternalLoginCallback 操作(crm.solutions.portal/Samples/MasterPortal/Areas/Account/Controllers/LoginController.cs 约第 761 行)会在回调 URL(/Account/Login/ExternalLoginCallback)处渲染 ExternalLoginConfirmation 视图。该视图显示一个可编辑的电子邮件字段,以及来自声明的隐藏 firstName/lastName/username,并向 /Account/Login/ExternalLoginConfirmation 发送 POST 请求。

为什么可以将此流程改为 SPA(与重置密码和兑换邀请的模式相同):

  • __External cookie(TTL 为 5 分钟,AuthenticationMode = Passive,Secure = Always)会在 IdP 回调与表单 POST 之间存储声明。它会在 same-origin 请求中自动发送。
  • SPA 获取服务器 URL,解析渲染后的 HTML 以提取预填值和防伪令牌,然后显示自己的表单并向服务器发送 POST 请求。服务器按原样处理该表单。

跳过条件——在以下情况下,服务器会完全跳过此页面:

  • 用户的电子邮件声明与现有联系人匹配,且 AllowContactMappingWithEmail = true → 服务器自动登录
  • 用户拥有邀请代码,且该代码在 ESS 系统中解析为现有联系人 → 服务器自动登录
  • 注册已禁用,或用户不允许注册

在这些情况下,用户会在 IdP 回调后直接进入主页——SPA 页面不会挂载。

重定向链
Email link / "Sign in with Entra External ID" button
   ↓
POST /Account/Login/ExternalLogin → server redirects to IdP
   ↓
User authenticates at IdP → IdP callback to /signin-{providername}
   ↓
OIDC middleware processes callback (sets __External cookie with claims)
   ↓
Forward to /Account/Login/ExternalLoginCallback
   ↓
ExternalLoginCallback action: new user, no existing contact
   → returns ExternalLoginConfirmation view at the callback URL
   ↓
┌─ Code-Site-Shell-Header script catches /account/login/externallogincallback ─┐
│  → window.location.replace('/external-login-confirmation' + query)            │
└────────────────────────────────────────────────────────────────────────────────┘
   ↓
SPA /external-login-confirmation page mounts
   → calls fetchExternalLoginDetails()
     → GET /Account/Login/ExternalLoginCallback (__External cookie auto-sent)
     → server returns the same rendered HTML
     → SPA parses #Email, #FirstName, #LastName, #Username, #InvitationCode,
       __RequestVerificationToken, and the form's action URL for ReturnUrl
   ↓
SPA renders own form, email pre-filled and editable
   ↓
User clicks "Create my account"
   → confirmExternalLogin() POST /Account/Login/ExternalLoginConfirmation
     (form fields + anti-forgery token, redirect:'manual')
   ↓
   ├── response.type === 'opaqueredirect' → window.location.href = returnUrl
   │     (server sets ApplicationCookie BEFORE returning 302 — user is signed in)
   ├── 200 OK with validation-summary → throw parsed error (e.g., duplicate email
   │     when RequireUniqueEmail is true and user typed an existing email)
   └── 200 OK with TermsAndConditions markers → throw TermsRequiredError →
         SPA navigates to /terms
身份验证服务新增内容(配置了任何外部提供方时)

在 src/services/authService.ts 中添加以下三项内容——完整代码见 authentication-reference.md:

  • ExternalLoginCookieExpiredError 类——当 __External cookie 已过期(超过 5 分钟 TTL)时抛出。页面会导航至 /login 并显示会话过期消息。
  • fetchExternalLoginDetails()——获取 /Account/Login/ExternalLoginCallback,解析 HTML 以提取预填值和防伪令牌。如果表单不存在,则抛出 ExternalLoginCookieExpiredError。
  • confirmExternalLogin(details)——使用 redirect:'manual' 向 /Account/Login/ExternalLoginConfirmation 发送 POST 请求。根据响应类型导航或抛出异常。
SPA 页面

创建 src/pages/ExternalLoginConfirmation.tsx:

  • 挂载时调用 fetchExternalLoginDetails()。包含三种状态:加载中、Cookie 已过期、就绪。
  • Cookie 过期时:显示“登录会话已过期”,并提供指向 /login 的“返回登录”链接。
  • 就绪时:以只读方式显示用户的全名(来自声明),提供可编辑的电子邮件输入框(默认值为电子邮件声明),并在 invitationCode 非空时显示“邀请横幅”。
  • 提交时调用 confirmExternalLogin(details)。处理 TermsRequiredError → 导航到 /terms。处理 ExternalLoginCookieExpiredError(会话中途过期)→ 切换到过期状态。
  • 通过 parseServerErrors 内联显示服务器端验证错误。
路由

添加 <Route path="/external-login-confirmation" element={<ExternalLoginConfirmation />} />。

DevTools 中的现象

POST 请求后,网络面板可能会将 302 的 Location 目标(例如 returnUrl 路径)显示为已中止的请求(net::ERR_ABORTED)——与 RedeemInvitation 模式相同。

这是 redirect:'manual' 的预期行为,并非错误。

边缘情况
  • 2FA:如果用户启用了 2FA,验证挑战会在 SignInAsync 完成后触发(发生在 302 重定向期间)。SPA 化流程不会造成干扰——2FA 挑战(SendCode/VerifyCode)是独立的服务器渲染流程,无法拦截。
  • SameSite=Strict:如果 __External Cookie 配置为 SameSite=Strict,SPA 的 fetch 请求不会携带该 Cookie,fetchExternalLoginDetails 会立即抛出 ExternalLoginCookieExpiredError。默认值为 Lax(通过 SameSiteCookieHelper.GetOwinSameSiteFromSiteSettings 设置),可以正常工作。
  • 通过外部登录的受邀用户:fetchExternalLoginDetails 会从表单操作 URL 中获取邀请代码,并在 POST 请求中重新发送。服务器会在创建联系人时兑换邀请。
5.1.9 创建个人资料页面(当 INCLUDE_PROFILE_PAGE = true 时)

仅当 INCLUDE_PROFILE_PAGE = true 时启用(阶段 2.1)。否则跳过整个阶段。

创建 /user-profile SPA 页面,让已登录用户通过 Power Pages Web API 编辑其联系人记录。不依赖特定提供商——适用于本地和所有外部提供商,因为它操作的是登录后的联系人记录。

⚠ 必须遵守命名规范——不得偏离:

项目必须使用的名称避免使用的错误示例
SPA 路由/user-profile❌ /profile(服务器保留)、/my-profile、/account/profile
页面组件文件src/pages/UserProfile.tsx❌ Profile.tsx、MyProfile.tsx、UserProfilePage.tsx
组件导出名称UserProfile(默认导出)❌ Profile、ProfilePage
服务函数——读取getMyProfile(contactId)❌ getOwnContact、getProfile、fetchProfile
服务函数——写入updateMyProfile(contactId, payload)❌ patchOwnContact、updateProfile、saveProfile
类型——联系人结构ProfileContact❌ Contact、UserContact
类型——补丁负载ProfileUpdate❌ ContactUpdate、ProfilePatch

命名为何重要:文件名和路由名会被本技能的其他部分(阶段 7.1 验证、阶段 8.3 摘要)、auth-reference.md 和评估场景引用。更改名称会导致交叉引用连锁失效。特别是绝不能使用 /profile 路由,因为 Power Pages 服务器将其保留给旧版服务器渲染的个人资料页面(Authentication/Registration/ProfileRedirectEnabled 默认会在登录后将用户重定向到 /profile——我们将其设为 false 以阻止重定向,但在同一路径创建 SPA 路由会造成冲突)。

5.1.9.a 使用个人资料函数扩展 authService

添加到 src/services/authService.ts(将所有相关的身份验证/个人资料逻辑放在同一处;PATCH 防伪令牌请复用现有的 fetchAntiForgeryToken() 辅助函数)。

使用以下确切名称——执行者不得擅自改用较短或风格不同的名称,例如 patchOwnContact、getOwnContact、updateProfile:

必需名称类型说明
ProfileContact导出的接口类型化的联系人结构,包含 contactid 和 8 个可编辑字段(firstname、lastname、mobilephone、address1_line1、address1_city、address1_stateorprovince、address1_postalcode、address1_country),类型均为 string | null。不要包含 emailaddress1 或 middlename——电子邮件通过 useAuth().user.email 只读显示(无需为此往返调用 Web API),而 middlename 则有意不纳入简单表单。
ProfileUpdate导出的类型别名PATCH 负载使用的 Partial<Omit<ProfileContact, 'contactid'>>
getMyProfile导出的异步函数签名:(contactId: string): Promise<ProfileContact>。使用 credentials: 'same-origin',GET /_api/contacts({contactId})?$select=contactid,firstname,lastname,mobilephone,address1_line1,address1_city,address1_stateorprovince,address1_postalcode,address1_country($select 的值必须与第 8.1 阶段 Webapi/contact/fields 站点设置中的 9 项列表完全一致)。非 OK 响应时抛出异常。返回解析后的 JSON。开发模式下返回模拟对象。
updateMyProfile导出的异步函数签名:(contactId: string, payload: ProfileUpdate): Promise<void>。PATCH /_api/contacts({contactId}),请求体仅包含已定义的字段(跳过 undefined,以免部分更新清空未受影响的列;但用户明确清除的字段须发送 null)。请求头:Content-Type: application/json、If-Match: *、来自 fetchAntiForgeryToken() 的 __RequestVerificationToken。非 OK 响应时,使用解析后的错误消息抛出异常。开发模式下无操作并视为成功。
applyContactUpdateLocally导出的函数签名:(payload: ProfileUpdate): void。将已保存的 firstname/lastname 从 payload 同步回 window.Microsoft.Dynamic365.Portal.User(原地修改 firstName/lastName 属性——注意快照中使用 camelCase,而负载中使用小写形式)。如果 window 或 Portal.User 不可用,则提前返回。仅同步 firstname 和 lastname——标头不会使用其他字段。如果没有此辅助函数,保存后标头会继续显示旧姓名,直至下次完整页面重新加载——请参阅 5.1.9.b 中的“保存后刷新标头”要求。

完整参考代码见 authentication-reference.md 的“User Profile Page”部分。请原样复制该代码——不要重命名函数或更改签名。

还要更新 useAuth()(src/hooks/useAuth.ts),在 refresh()/loadUser() 中展开用户对象——使用 setUser(current ? { ...current } : undefined),而不是 setUser(getCurrentUser())。原因:applyContactUpdateLocally 原地修改 Portal.User 后,getCurrentUser() 返回的引用与原对象实例相同。新状态引用与当前引用相同时,React 的 setState 会跳过重新渲染。展开为新对象会强制 React 识别新引用并重新运行消费者,从而让 AuthButton 使用新姓名重新绘制。(Vue/Angular 中的等效改动:确保用户引用/Subject 发出新值,而非同一实例。)

5.1.9.b 创建 UserProfile.tsx

创建 src/pages/UserProfile.tsx(文件名必须如此,不要重命名为 Profile.tsx)。

分为两个部分——顶部为只读的 Account Details,然后是编辑表单。不要添加其他内容。

Account Details 部分(只读,位于顶部)

一个简单的展示区块,只显示 useAuth().user 中的两项信息:

标签值来源
全名user.firstName + ' ' + user.lastName(合并显示;如果两者均为空,则显示“—”)
电子邮件user.email

不要显示 contactId、userRoles、登录提供方、用户名、上次登录日期或任何其他字段。Account Details 部分的唯一目的是提醒用户当前登录的是哪个账户——显示姓名和电子邮件即可。

渲染方式:使用一个小卡片 / 标题“账户详情”,包含两行标签和值。使用现有 CSS 变量。不要提供编辑操作入口。不要添加“编辑电子邮件”按钮。不要添加“编辑账户”链接。

页面逻辑
  • 从 useAuth() 中读取 user、isAuthenticated、refresh。如果 !isAuthenticated,则重定向到 /login?returnUrl=/user-profile。
  • 如果 user.contactId 为空或缺失,则显示“此账户无法使用个人资料。你的管理员需要为身份验证提供方设置 RegistrationClaimsMapping 站点设置,以便使用有效 ID 创建联系人。”(处理此技能前文记录的 Entra workforce claims 映射损坏边缘情况)
  • 页面挂载时,调用 getMyProfile(user.contactId) 获取当前值以填充可编辑表单。显示加载状态。
必须包含的可编辑字段集(恰好 8 个字段)

表单必须且只能包含以下 8 个字段,不得多于或少于。不要添加电子邮件(它在 Account Details 中为只读)。不要添加 middlename。不要添加“更改密码”链接。不要添加“退出登录”按钮。

表单标签表单字段 name 属性 / 联系人列类型提交时必填?
名字firstnametext否(全部选填)
姓氏lastnametext否
手机号码mobilephonetel否
地址第 1 行address1_line1text否
城市address1_citytext否
州 / 省address1_stateorprovincetext否
邮政编码address1_postalcodetext否
国家 / 地区address1_countrytext否

所有字段均为选填。表单中的空字符串会作为 null 发送到 Web API(因此用户可以清除已有值)。页面布局按以下分组:姓名字段(firstname/lastname)、联系方式(手机号码),然后是地址(所有 address1_* 字段)。使用 CSS 网格,并设置 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)),以实现响应式的 1 至 2 列布局。

个人资料页面中不得包含的内容

执行者不得添加以下任何内容——它们要么与现有界面重复,要么超出范围:

  • ❌ “更改密码”链接或按钮——密码重置通过现有的 /forgot-password 流程进行(登录页面上有该链接)
  • ❌ 个人资料页面上的“退出登录”按钮——退出登录功能仅位于页眉的 AuthButton 下拉菜单中
  • ❌ 可编辑的电子邮件字段——电子邮件在上方的 Account Details 部分中只读显示
  • ❌ 中间名字段
  • ❌ ContactId、userRoles、登录方式、提供方名称、上次登录时间戳或任何其他账户元数据
  • ❌ 电子邮件注意事项——关于 LoginClaimsMapping 会覆盖电子邮件编辑的说明已不再适用,因为电子邮件不可编辑
  • ❌ 账户删除或“关闭账户”按钮——超出范围
逻辑详情
  • 失焦时验证,使用 Login.tsx / Registration.tsx 中已有的模式(touched 状态、validateField、show() 辅助函数)

  • 字段级验证:手机号码非空时至少 6 个字符。其他 7 个字段不进行格式验证。所有字段均为可选 — 空字符串允许提交,并以 null 发送。

  • 提交时调用 updateMyProfile(contactId, payload)。成功后,按以下精确顺序执行:

    1. 将 successMessage 状态设为 "Profile updated."
    2. 更新本地 profile 快照(这样表单下次保存时的差异计算才正确)
    3. 调用 applyContactUpdateLocally(payload) — 将已保存的姓名字段同步回 window.Microsoft.Dynamic365.Portal.User。这是必需的,才能让页眉重新渲染 — 如果不调用,页眉会继续显示保存前的姓名,直到用户导航到会触发完整页面加载的路由。
    4. 调用 useAuth() 中的 refresh() — 将 Portal.User(现在已修改)通过展开操作(即上述 useAuth 更新)重新读入 React 状态,从而触发 AuthButton 重新渲染。如果不使用展开操作,这会变成空操作,因为 React 会跳过引用相同的 setState。

    发生错误时:在页面内显示服务器错误。

  • 设置浏览器标签页标题 — 在组件靠前的位置添加一个 useEffect(遵循 Login.tsx、Registration.tsx、ForgotPassword.tsx、ResetPassword.tsx、RedeemInvitation.tsx、ExternalLoginConfirmation.tsx、Terms.tsx 等页面使用的惯例):

    useEffect(() => { document.title = 'My Profile — <SITE_NAME>' }, [])
    

    将 <SITE_NAME> 替换为第 1 阶段中发现的实际站点显示名称(与其他身份验证页面使用的值相同 — 例如,Contoso Portal)。格式必须与现有页面完全一致:<Page Title> — <Site Name>(使用 em-dash —,两侧各有一个空格)。不要使用 |、: 或连字符。

  • 使用现有样式惯例(CSS 变量、卡片布局、最大宽度约 580)

  • 完整参考实现请见 authentication-reference.md 的 "User Profile Page" 部分

5.1.9.c 将 AuthButton 改为下拉菜单

更新 src/components/AuthButton.tsx。无论 INCLUDE_PROFILE_PAGE 的值如何,已验证身份状态都默认使用下拉菜单样式,使组件保持一致并为未来添加菜单项做好准备:

  • 匿名状态:不变 — <Link to="/login" className="btn-primary">Sign In</Link>
  • 已验证身份状态(新增):
    • 触发器:[Avatar] {displayName} ▾ — 可点击
    • 菜单(基于状态,通过 useState<boolean> 打开):打开时渲染
    • INCLUDE_PROFILE_PAGE = true 时的菜单项:"我的个人资料"(指向 /user-profile 的 Link)+ "退出登录"(调用 logout('/'))
    • INCLUDE_PROFILE_PAGE = false 时的菜单项:仅 "退出登录"
    • useEffect 监听下拉菜单容器外部的 mousedown 事件 → 关闭。监听 Escape 键 → 关闭。组件卸载时清理。
    • "我的个人资料"链接的 onClick 也会关闭下拉菜单
    • ARIA:触发器具有 aria-haspopup="menu"、aria-expanded={open};菜单具有 role="menu";菜单项具有 role="menuitem"
    • 样式:菜单使用现有 CSS 变量(var(--color-surface)、var(--color-border)、var(--shadow-md)、var(--radius-sm))
5.1.9.d 添加路由

添加到 src/App.tsx — 必须严格使用此路径(/user-profile,而不是 /profile):

import UserProfile from './pages/UserProfile'
// ...inside <Routes>:
<Route path="/user-profile" element={<UserProfile />} />

⚠ 不要使用 <Route path="/profile" ... />。 服务器将 /profile 保留给其旧版服务器端渲染页面。即使此技能将 ProfileRedirectEnabled 设置为 false(这会阻止用户登录后服务器自动将其重定向到该页面),在相同路径下创建 SPA 路由仍会导致冲突。始终使用 /user-profile。

身份验证门控:页面本身会处理将 !isAuthenticated 重定向到 /login?returnUrl=/user-profile 的逻辑(参见 5.1.9.b)。如果你更喜欢使用 RequireAuth 包装组件,可以使用 <Route path="/user-profile" element={<RequireAuth><UserProfile /></RequireAuth>} />,但前提是项目中已经存在该包装组件。不要仅为此页面引入新的 RequireAuth 组件。

保存后的行为

当 updateMyProfile 成功解析后:

  1. 设置内联 successMessage 状态("Profile updated.")——在表单顶部显示绿色横幅,可关闭。
  2. 调用 useAuth().refresh() ——重新读取 window.Microsoft.Dynamic365.Portal.User。请注意,Portal.User 是初次加载页面时的快照,在完整页面重新加载之前不会包含新值。因此,正确的做法是同时更新本地 UI 状态(表单字段保持用户输入的内容;刷新生效前,标题栏头像可能会短暂显示旧的姓名首字母)。在参考文档中说明此注意事项。
  3. 停留在当前页面——不自动重定向。
5.2 集成到导航栏

找到站点的导航组件,并集成身份验证按钮:

  1. 在站点源代码中搜索导航栏/标题栏组件。
  2. 导入 AuthButton 组件。
  3. 替换任何现有的硬编码登录链接(例如 <Link to="/login">Sign In</Link> 或 <a href="/signin">),改为 AuthButton 组件。AuthButton 会读取 window.Microsoft.Dynamic365.Portal.User,动态显示“Sign In”(未通过身份验证时)或用户姓名、头像和“Sign Out”按钮(通过身份验证时)。硬编码链接无法响应身份验证状态。
  4. 配置了多个提供程序时:AuthButton 的“Sign In”操作应导航到 /login 页面。
  5. 只有一个提供程序时:AuthButton 的“Sign In”操作应直接调用 login()。
  6. 集成后进行验证,确保 Navbar 中没有同时存在硬编码登录链接和 AuthButton ——导航栏中必须恰好只有一个身份验证入口。
5.3 Git 提交

暂存并提交身份验证文件:

git add -A
git commit -m "Add authentication service and auth UI component"
输出
  • 为检测到的框架创建了身份验证按钮组件
  • 将身份验证按钮集成到站点导航栏
  • 创建了注册页面(配置了本地身份验证并开放注册时)
  • 更改已提交到 git

第 6 阶段:实现基于角色的 UI

**目标:**识别受保护的内容区域,并将基于角色的授权模式应用于站点组件。

操作
6.1 识别受保护的内容

分析站点组件,查找应受角色限制的内容:

  • 仅限管理员访问的区域(仪表板、设置)
  • 仅限已通过身份验证的用户访问的内容(个人资料、数据视图)
  • 特定角色的功能(编辑按钮、创建表单)

向用户展示调查结果,并确认要保护哪些区域。

6.2 应用授权模式

根据用户的选择,包装相应组件:

React 示例:

<RequireAuth fallback={<p>Please sign in to view this content.</p>}>
  <Dashboard />
</RequireAuth>

<RequireRole roles={['Administrators']} fallback={<p>Access denied.</p>}>
  <AdminPanel />
</RequireRole>

Vue 示例:

<div v-role="'Administrators'">
  <AdminPanel />
</div>

Angular 示例:

{ path: 'admin', component: AdminComponent, canActivate: [authGuard, roleGuard], data: { roles: ['Administrators'] } }
6.3 Git 提交

暂存并提交:

git add -A
git commit -m "Add role-based access control to site components"
输出
  • 已识别受保护的内容区域,并与用户确认
  • 已将基于角色的授权模式应用于组件
  • 更改已提交到 git

第 7 阶段:验证身份验证设置

**目标:**验证所有身份验证文件均已存在、项目可以构建,且身份验证 UI 能正确渲染。

操作
7.1 验证文件清单

确认已创建以下文件:

  • src/types/powerPages.d.ts — Power Pages 类型声明
  • src/services/authService.ts — 包含登录/登出函数的身份验证服务
  • 框架专用的身份验证钩子/组合式函数(例如,React 的 src/hooks/useAuth.ts)
  • src/utils/authorization.ts — 角色检查工具
  • 框架专用的授权组件(例如,React 的 RequireAuth.tsx、RequireRole.tsx)
  • 身份验证按钮组件(例如,React 的 src/components/AuthButton.tsx)
  • 注册页面(例如,React 的 src/pages/Registration.tsx)——仅在启用本地身份验证且 REGISTRATION_MODE 不是 Registration disabled 时创建(在 Open、Invitation-only 和 Both 模式下始终创建)
  • 忘记密码页面(例如,React 的 src/pages/ForgotPassword.tsx)——仅在配置了密码重置的本地身份验证时创建
  • 会话保持活动状态钩子(例如,React 的 src/hooks/useSessionKeepAlive.ts)——集成到 Layout 中
  • 条款页面(例如,React 的 src/pages/Terms.tsx)——仅在启用条款时创建
  • 重置密码页面(例如,React 的 src/pages/ResetPassword.tsx)——仅在配置了密码重置的本地身份验证时创建
  • 兑换邀请页面(例如,React 的 src/pages/RedeemInvitation.tsx)——仅当 REGISTRATION_MODE 为 Invitation-only 或 Both 时创建
  • 用户资料页面(例如,React 的 src/pages/UserProfile.tsx)——仅当 INCLUDE_PROFILE_PAGE = true 时创建。此外,还需在 authService.ts 中添加 getMyProfile + updateMyProfile 函数,将 AuthButton 改为下拉菜单,并在 App.tsx 中添加 /user-profile 路由。
  • Code-Site-Shell-Header 模板(.powerpages-site/web-templates/code-site-shell-header/),包含重定向脚本——其中的条目取决于已启用的功能(resetpassword、redeeminvitation)
  • 已更新 website.yml,将 headerwebtemplateid 指向 Code-Site-Shell-Header
  • Web API 站点设置(Webapi/contact/enabled、Webapi/contact/fields)——仅当 INCLUDE_PROFILE_PAGE = true 时添加
  • 表权限 .powerpages-site/table-permissions/My-Profile-Edit-Own-Contact.tablepermission.yml,其中 scope: 756150004(Self)——仅当 INCLUDE_PROFILE_PAGE = true 时添加

阅读每个文件并验证其中包含预期的导出项和函数:

  • 身份验证服务:login、logout、getCurrentUser、isAuthenticated、fetchAntiForgeryToken、parseServerErrors、register、forgotPassword(本地身份验证时)、TermsRequiredError、acceptTerms(启用条款时)、redeemInvitation 和 fetchInvitationDetails(邀请模式时)、loginLocal 接受 invitationCode 参数(邀请模式时)
  • 授权工具:hasRole、hasAnyRole、hasAllRoles、getUserRoles
  • 登录和注册页面:采用失焦时验证模式,包含 touched 状态、handleBlur、handleChange 和 showError 辅助函数。两者都必须捕获 TermsRequiredError 并导航到 /terms(启用条款时)。登录页面必须从 URL 读取 invitationCode 并显示信息横幅,同时传递该参数(邀请模式时)。注册页面必须调用 fetchInvitationDetails() 以预填电子邮件(邀请模式时)。
  • 兑换邀请页面(邀请模式时):从 URL 预填代码,包含“改用现有账户登录而非注册”复选框,并根据 redeemInvitation() 的结果跳转到 /registration 或 /login
  • 会话保持活动状态:集成到 Layout 中,向 /_layout/tokenhtml 发送请求、跟踪活动并检测过期
7.2 验证构建

运行项目构建,检查导入错误、类型错误或缺失的依赖项:

npm run build

如果构建失败,请先修复问题再继续。

7.3 验证身份验证 UI 是否渲染

启动开发服务器,并确认导航栏中显示身份验证按钮:

npm run dev

使用 Playwright 导航到网站并获取快照,确认身份验证按钮可见:

  • 导航到 http://localhost:<port>
  • 获取浏览器快照
  • 确认导航区域中显示身份验证按钮(Sign In / mock user)

如果身份验证按钮不可见,或页面存在渲染错误,请修复问题。

7.4 最终网站完整性检查

完成所有身份验证源代码更改、构建和浏览器验证后,运行:

node "${PLUGIN_ROOT}/scripts/validate-site-integrity.js" --projectRoot "<PROJECT_ROOT>"

在报告完成情况或询问是否部署之前,修复所有阻塞错误。检查并报告两个方向的审查结果,并展开内容。此后每次修改源代码,都要重新运行此检查。

输出
  • 已验证所有身份验证文件(文件存在且包含预期导出)
  • 项目构建成功
  • 身份验证 UI 在浏览器中正确渲染

第 8 阶段:审查与部署

目标: 创建所需的网站设置,提供所有工作内容的摘要,并提示部署。

操作
8.1 创建网站设置

网站需要针对各提供程序的网站设置。检查 .powerpages-site/site-settings/ 是否存在。使用 create-site-setting.js 脚本创建所有网站设置:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "<Setting/Name>" \
  --value "<value>" \
  --description "<description>"

{ProviderName} 命名约定: 将 {ProviderName} 替换为协议名称,后接递增数字:

  • OpenID Connect:OpenIdConnect_1、OpenIdConnect_2 等
  • Entra External ID:OpenIdConnect_1(使用 OIDC 路径)
  • SAML2:SAML2_1、SAML2_2 等
  • WS-Federation:WsFederation_1、WsFederation_2 等

处理重复运行: 如果 create-site-setting.js 因设置已存在而以代码 1 退出,请跳过该设置并继续。已有设置已在之前的运行中完成配置。不要将此视为致命错误。脚本会同时按设置名称和文件名检查重复项(不区分大小写),不会覆盖现有设置。

重要 — 配置多个 OIDC 提供程序时,Redirect URI / CallbackPath 必须唯一:

OWIN OpenID Connect 中间件会将每个 OIDC 提供程序的 CallbackPath 默认设为 /signin-oidc。如果配置两个 OIDC 提供程序(例如 Entra External ID + Okta),却没有设置唯一的 CallbackPath,它们都会占用 /signin-oidc,导致其中一个提供程序的身份验证悄然失败。

直接使用 ProviderName 作为 CallbackPath 后缀——这种方式具有确定性,并能保证唯一性,因为 ProviderName 本身已唯一(遵循 {ProviderName} 命名约定:OpenIdConnect_1、OpenIdConnect_2、EntraExternalId 等)。

每个 OIDC 提供程序(包括 Entra External ID)都使用此固定模式:

  • CallbackPath = /signin-{ProviderName-lowercased}(例如 /signin-entraexternalid、/signin-openidconnect_1)
  • RedirectUri = {site-url}/signin-{ProviderName-lowercased}(必须与 CallbackPath 完全匹配)

两个 OIDC 提供程序的示例:

ProviderNameCallbackPathRedirectUri
EntraExternalId/signin-entraexternalidhttps://contoso.powerappsportals.com/signin-entraexternalid
OpenIdConnect_1(Okta)/signin-openidconnect_1https://contoso.powerappsportals.com/signin-openidconnect_1

创建网站设置之前,请读取现有的 .powerpages-site/site-settings/,并确认没有其他 Authentication/OpenIdConnect/*/CallbackPath 或 RedirectUri 设置使用相同的值。如果存在冲突(因为重复使用了相同的 ProviderName),请递增 ProviderName 中的数字后缀(例如 OpenIdConnect_1 → OpenIdConnect_2),然后重新生成 CallbackPath。

告知用户在身份提供程序的应用注册中注册确切的 RedirectUri(Microsoft Entra 管理中心 → 应用注册 → 重定向 URI)。 值的来源:

  • 非机密值(Authority URL、site URL、重定向 URI、AuthenticationType)→ 根据流程中收集的信息自动填入。用户无需编辑任何文件。
  • ClientId / AppId → 在阶段 2.1 中向用户收集(每个提供商的后续问题)。创建站点设置时使用收集到的值。
  • 机密值(ClientSecret、AppSecret)→ 使用 create-environment-variable.js 通过环境变量设置。切勿直接询问或存储机密值。参见下文阶段 8.1.1。

始终创建 — 以下设置适用于所有提供商类型,均为必需:

对于 SPA 站点,ProfileRedirectEnabled 必须为 false。 如果 create-site-setting.js 报告此设置已存在,请读取 YAML 文件并检查其值。如果为 true,请编辑文件,将 value: false 设为该项的值。若此值为 true,服务器会在登录/注册后将用户重定向到 /profile,而不是遵循 ReturnUrl,这会破坏 SPA 流程。

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/ProfileRedirectEnabled" \
  --value "false" \
  --description "Disable profile redirect for SPA sites" \
  --type boolean

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/Enabled" \
  --value "true" \
  --description "Enable user registration (global toggle)" \
  --type boolean

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/ExternalLoginEnabled" \
  --value "true" \
  --description "Enable external identity provider login" \
  --type boolean

个人资料映射设置 — 对于每个外部提供商(OIDC、SAML2、WS-Federation、社交),根据阶段 2.1 中的 PROFILE_MAPPING_CHOICE 写入 RegistrationClaimsMapping;当 PROFILE_SYNC_FREQUENCY = "Both" 时,还要写入 LoginClaimsMapping:

# Skip if PROFILE_MAPPING_CHOICE = "None"
# Generate the value based on PROFILE_MAPPING_CHOICE:
#   "Standard"           → firstname=given_name,lastname=family_name,emailaddress1=email
#   "Standard + phone"   → firstname=given_name,lastname=family_name,emailaddress1=email,mobilephone=phone_number
#   "Custom"             → user-provided comma-separated pairs
# For SAML2/WsFed, use the claim URI form instead of OIDC short names.

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/{Type}/{ProviderName}/RegistrationClaimsMapping" \
  --value "firstname=given_name,lastname=family_name,emailaddress1=email" \
  --description "Map IdP claims to contact fields on first sign-in"

# Only write if PROFILE_SYNC_FREQUENCY = "Both"
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/{Type}/{ProviderName}/LoginClaimsMapping" \
  --value "firstname=given_name,lastname=family_name,emailaddress1=email" \
  --description "Map IdP claims to contact fields on every login"

联系人关联设置 — 根据 CONTACT_LINKING_CHOICE 写入 AllowContactMappingWithEmail:

# CONTACT_LINKING_CHOICE = "Link to existing"  → value "true"
# CONTACT_LINKING_CHOICE = "Create new"        → value "false" (or skip — false is the server default)
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/{Type}/{ProviderName}/AllowContactMappingWithEmail" \
  --value "<true-or-false-from-choice>" \
  --description "Auto-link external sign-in to existing contact by email match" \
  --type boolean

多租户保护措施:为 OIDC 提供商写入 AllowContactMappingWithEmail=true 之前,请检查 Authority URL。如果其中包含 /organizations/、/common/,或者 IssuerFilter 设置为通配符模式,则将用户的选择覆盖为 false,并告知用户:“出于安全原因,多租户 Entra External ID 配置无法使用联系人映射(服务器会强制禁用此功能)。若要启用联系人映射,请使用包含特定租户 GUID 的单租户 Authority URL。”

每个提供商的 RegistrationEnabled 设置 — 对于每个外部提供商(OIDC、SAML2、WS-Federation、社交),还要写入:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/{Type}/{ProviderName}/RegistrationEnabled" \
  --value "true" \
  --description "Allow new users to register via this specific provider" \
  --type boolean

其中 {Type} 为 OpenIdConnect、SAML2、WsFederation 或 OpenAuth。这是一个针对每个提供商的开关,不同于全局设置 Authentication/Registration/ExternalLoginEnabled — 要使注册正常工作,两者都应设为 true。将某个提供商的 RegistrationEnabled=false 的使用场景:暂时阻止新用户通过指定 IdP 注册,同时允许现有用户登录。

每个提供商的 Caption — 所有提供商都必须设置。 对每个已配置的提供商写入 Authentication/{Type}/{ProviderName}/Caption(登录按钮标签),其中 {Type} = OpenIdConnect/SAML2/WsFederation/OpenAuth;Local Authentication 没有登录按钮,无需设置 Caption。使用阶段 2.1 中的显示名称:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/{Type}/{ProviderName}/Caption" \
  --value "<display-name from Phase 2.1>" \
  --description "Login button label for this provider"

注销设置 — 取决于阶段 2.1.1 中选择的注销模式

对于外部提供商,仅当用户在阶段 2.1.1 中选择**“联合注销”**时才写入注销设置。如果用户选择“仅本地注销”(默认),则两个设置都不写入 — 服务器默认值(RPInitiatedLogout=false、ExternalLogoutEnabled=false)可以正确处理。

# Only when LOGOUT_MODE = "Federated logout"
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/RPInitiatedLogout" \
  --value "true" \
  --description "Federated logout — call IdP end_session_endpoint with id_token_hint" \
  --type boolean

# REQUIRED when RPInitiatedLogout=true — must be paired
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/PostLogoutRedirectUri" \
  --value "<POST_LOGOUT_REDIRECT_URI from Phase 2.1.1>" \
  --description "URL the IdP redirects to after federated logout completes"

对于 SAML2 / WS-Federation / 社交提供商,对应的设置名称有所不同 — Authentication/{Type}/{ProviderName}/RPInitiatedLogout 和 /PostLogoutRedirectUri 遵循相同的命名模式。对于社交提供商(Microsoft Account / Facebook / Google),提供商通常不支持联合注销,因此无需询问 — 跳过社交提供商的注销模式问题,也不要写入这两个设置。

服务行为提醒:没有这些设置(即“仅本地注销”模式)时,/Account/Login/LogOff 会清除 Power Pages 会话,并重定向到 returnUrl 查询参数指定的位置(如果缺失或无效,则重定向到网站根目录)。IdP 会话仍保持有效——下次登录时会静默执行 SSO。此模式无需更改应用注册。

使用这些设置(“联合注销”)时,/Account/Login/LogOff 会使用 id_token_hint 和 post_logout_redirect_uri,向 IdP 的 end_session_endpoint 发送 302 重定向。IdP 会将用户注销,并重定向到已注册的注销后 URI。用户还必须在 IdP 应用注册中注册该 URI(参见阶段 2.1.1)——通过 HAR 分析已确认,如果未在应用注册中注册前端通道注销 URL,IdP 会静默丢弃该参数,导致用户无法继续操作。

特定提供商的设置——为阶段 2.1 中选定的每个提供商创建站点设置。如果用户选择了多个提供商(例如 Entra External ID + 本地身份验证),则为所有提供商创建设置:

Microsoft Entra ID——Power Pages 会在创建站点时自动配置 Authority、ClientId、Metadata 等设置(提供商名称为 AzureAD)。但是,始终要编写声明映射设置,以便为首次登录的联系人填充 firstname/lastname/email。自动配置的 AzureAD 提供商默认没有声明映射——没有这些设置时,新联系人只会关联用户的 oid,其他字段都会为空(经实证验证:workforce Entra ID v1.0 令牌不包含 email 声明,且除非配置了显式映射,否则服务器不会根据 given_name/family_name 声明自动派生联系人字段)。

# RegistrationClaimsMapping — applied once at first sign-in, before contact is created.
# Uses `upn` for email because workforce Entra ID v1.0 tokens don't include the `email`
# claim by default. UPN (user principal name, e.g. [email protected]) is the standard
# substitute and matches what the user expects in their profile.
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/AzureAD/RegistrationClaimsMapping" \
  --value "firstname=given_name,lastname=family_name,emailaddress1=upn" \
  --description "Map AzureAD claims to contact fields on first sign-in (UPN as email — workforce v1.0 tokens lack email claim)"

# LoginClaimsMapping — applied every sign-in. Updates contact fields if user info
# changes in Entra (e.g., name change). Same mapping as Registration; safe to write
# both for consistency.
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/AzureAD/LoginClaimsMapping" \
  --value "firstname=given_name,lastname=family_name,emailaddress1=upn" \
  --description "Re-apply claim mapping on every sign-in so contact fields stay in sync with Entra"

为什么用 upn 而不是 email? Workforce Entra ID 的 OIDC 终结点默认使用 v1.0 令牌(颁发者为 sts.windows.net/{tid}/),默认不包含 email 声明。要获取 email 声明,必须将应用注册配置为使用 v2.0 令牌、请求 email 范围,并且用户的 Entra 配置文件中必须有经过验证的电子邮件地址(这些都不是默认设置)。不过,workforce 用户的令牌始终会包含 upn 声明——它是用户主体名称(例如 [email protected]),在大多数 workforce 场景中可用作电子邮件地址。这仅适用于 workforce Entra ID(自动配置的 AzureAD 提供商)。Entra External ID 使用带有正确 email 声明的 v2.0 令牌——其映射应为 emailaddress1=email,而不是 emailaddress1=upn(有关 External ID,请参见阶段 2.1 中的 Profile 映射问题)。

不会针对这一点提问。 与其他外部提供商不同,Profile 映射问题(Track B)是阶段 2.1 的一部分;Entra ID 映射会静默写入,因为 (a) workforce Entra 中哪个声明适合作为电子邮件地址的替代项是确定的(始终为 upn);(b) 没有此映射就会导致联系人创建异常,因此选择不配置并无意义。

OpenID Connect——为提供商创建设置(ClientId 来自阶段 2.1.2 的设置,或手动粘贴):

# Authority (required — or use MetadataAddress as alternative)
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/Authority" \
  --value "<authority-url-from-user>" \
  --description "OIDC authority URL"

# MetadataAddress (optional — alternative to Authority for providers that need explicit metadata URL)
# Create this if the user provides a metadata URL distinct from the authority
# node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
#   --projectRoot "<PROJECT_ROOT>" \
#   --name "Authentication/OpenIdConnect/{ProviderName}/MetadataAddress" \
#   --value "<metadata-url>" \
#   --description "OIDC metadata endpoint URL"

# ClientId — use value collected in Phase 2.1
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/ClientId" \
  --value "<client-id-from-user>" \
  --description "Application client ID"

# AuthenticationType
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/AuthenticationType" \
  --value "<authority-url-from-user>" \
  --description "Provider identifier for ExternalLogin"

# RedirectUri — use /signin-{ProviderName-lowercased} for guaranteed uniqueness
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/RedirectUri" \
  --value "<site-url>/signin-{ProviderName-lowercased}" \
  --description "OAuth callback URL — unique per provider (matches CallbackPath)"

# CallbackPath — required to prevent collision when multiple OIDC providers exist
# OWIN defaults ALL OIDC providers to /signin-oidc
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/CallbackPath" \
  --value "/signin-{ProviderName-lowercased}" \
  --description "Unique callback path derived from ProviderName"

注意: AuthenticationType 值是在 ExternalLogin 表单 POST 中使用的唯一提供商标识符。此值必须与身份验证服务中 resolveProviderIdentifier() 返回的值一致。 注销设置 — 不要在此自动写入 RPInitiatedLogout 或 ExternalLogoutEnabled。服务器端两者的默认值都是 false(根据 StartupSettingsManager.cs:418,425),这意味着注销只会清除 Power Pages 会话,而不会结束 IdP 会话(用户可以通过 SSO 静默重新登录)。这是更简单的默认设置,也是大多数站点所需的设置。如果用户希望启用联合注销(同时在 IdP 处注销,并通过 post_logout_redirect_uri 重定向回站点),则应在第 2.1.1 阶段通过高级设置流程进行配置——该流程会同时写入 RPInitiatedLogout=true 和 PostLogoutRedirectUri={SITE_URL}/(只设置其中一个会导致用户停留在 IdP 的已注销页面,完整操作说明请参见第 2.1.1 阶段)。

Entra External ID — 使用第 2.1 阶段四步操作指南中的值。根据租户子域名和租户 ID 推导 Authority 和 MetadataAddress,不要让用户粘贴这些值:

  • Authority = https://{EXTERNAL_ID_TENANT_SUBDOMAIN}.ciamlogin.com/{EXTERNAL_ID_TENANT_ID} — 末尾不要加 /v2.0/。Entra External ID 使用不带 /v2.0/ 的租户路径(这与经典 B2C 以及通常需要 /v2.0/ 的 Okta 等通用 OIDC 提供商不同)。
  • MetadataAddress = https://{EXTERNAL_ID_TENANT_SUBDOMAIN}.ciamlogin.com/{EXTERNAL_ID_TENANT_ID}/v2.0/.well-known/openid-configuration
  • AuthenticationType = 与 Authority 相同(ExternalLogin POST 中的提供商标识符必须匹配)
  • ClientId = 操作指南第 2 步中的 EXTERNAL_ID_CLIENT_ID
  • RedirectUri = {SITE_URL}/signin-{ProviderName-lowercased} — 与第 2 步显示给用户的 Redirect URI 相同
# Authority — derived: https://{subdomain}.ciamlogin.com/{tenantId}
# (do NOT append /v2.0/ — that breaks Entra External ID)
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/Authority" \
  --value "https://<EXTERNAL_ID_TENANT_SUBDOMAIN>.ciamlogin.com/<EXTERNAL_ID_TENANT_ID>" \
  --description "Entra External ID authority URL (derived)"

# MetadataAddress — derived: Authority + /v2.0/.well-known/openid-configuration
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/MetadataAddress" \
  --value "https://<EXTERNAL_ID_TENANT_SUBDOMAIN>.ciamlogin.com/<EXTERNAL_ID_TENANT_ID>/v2.0/.well-known/openid-configuration" \
  --description "OIDC metadata document URL (derived)"

# ClientId — from walkthrough Step 2
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/ClientId" \
  --value "<EXTERNAL_ID_CLIENT_ID>" \
  --description "Application client ID"

# AuthenticationType — must match Authority exactly (used as the 'provider' form value in ExternalLogin POST)
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/AuthenticationType" \
  --value "https://<EXTERNAL_ID_TENANT_SUBDOMAIN>.ciamlogin.com/<EXTERNAL_ID_TENANT_ID>" \
  --description "Provider identifier for ExternalLogin — must match Authority exactly"

# RedirectUri — the full URI the user registered in their Entra app.
# Confirmed/customized by user in Step 2 of walkthrough (stored as REDIRECT_URI).
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/RedirectUri" \
  --value "<REDIRECT_URI>" \
  --description "OAuth callback URL (must match the URI registered in the app registration)"

# CallbackPath — derived from RedirectUri (just the path portion, extracted via
# new URL(REDIRECT_URI).pathname). Required to prevent CallbackPath collision when
# multiple OIDC providers exist (OWIN defaults all OIDC to /signin-oidc otherwise).
# The user doesn't see this separately — the skill writes it automatically.
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/CallbackPath" \
  --value "<path-portion-of-REDIRECT_URI>" \
  --description "OWIN callback path (derived from RedirectUri)"

Entra External ID 的注销设置 — 不要在此自动写入 RPInitiatedLogout 或 ExternalLogoutEnabled。服务器端两者的默认值都是 false。默认行为:注销只会清除 Power Pages 会话;用户仍在 Entra External ID 租户中保持登录状态。下次登录时,IdP 会通过 Cookie 静默执行 SSO。这是更简单的默认设置,也符合大多数面向客户的网站的需求。如果用户希望启用联合注销(同时在 Entra External ID 处注销),则应在第 2.1.1 阶段通过高级设置流程启用——该流程会同时写入 RPInitiatedLogout=true 和 PostLogoutRedirectUri={SITE_URL}/,并添加一个应用注册步骤来设置 Front-channel 注销 URL。

自定义域名:如果用户为其 Entra External ID 租户使用自定义域名(例如 https://login.contoso.com/{tenantId}/v2.0/),而不是 *.ciamlogin.com,请将上述推导出的值替换为自定义域名对应的值。第 2.1 阶段的操作指南目前不会询问是否使用自定义域名——如果现有站点设置中使用了自定义域名的 Authority(第 1.5 阶段发现),重新运行时应保留这些值,而不是根据 EXTERNAL_ID_TENANT_SUBDOMAIN 重新构建。

**默认情况下,Entra External ID 不需要 ClientSecret 块。**使用 PKCE 的公共客户端不需要客户端密钥——操作指南明确不会询问客户端密钥。对于此提供商,完全跳过第 8.1.1 阶段(Key Vault)。如果部署后机密客户端场景需要客户端密钥,请在第 8.5 阶段将其记录为高级手动步骤(通过 Power Pages 管理中心 → Authentication settings 添加)。

**用户流名称(EXTERNAL_ID_USER_FLOW)不会写入为站点设置。**Entra External ID 会将用户流附加到应用注册本身,因此登录时会自动运行用户流,无需 Power Pages 在 URL/元数据中按名称引用它(这与经典 B2C 不同)。操作指南仅记录此名称,以确认用户已创建用户流。如果用户之后配置了单独的密码重置或个人资料编辑用户流,可以手动添加 PasswordResetPolicyId / ProfileEditPolicyId 站点设置作为高级覆盖项。

SAML2 — 为提供商创建设置:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/SAML2/{ProviderName}/MetadataAddress" \
  --value "<metadata-url-from-user>" \
  --description "SAML IdP metadata URL"

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/SAML2/{ProviderName}/AuthenticationType" \
  --value "<site-url>" \
  --description "Provider identifier for ExternalLogin — MUST match providerIdentifier in authService exactly"

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/SAML2/{ProviderName}/ServiceProviderRealm" \
  --value "<site-url>" \
  --description "SP entity ID"

SAML2 的关键注意事项:AuthenticationType 站点设置值和身份验证服务代码中的 providerIdentifier 必须逐字符完全一致,包括协议(https:// 与 http://)、尾部斜杠和大小写。不匹配会导致登录静默失败。两处都使用完全相同的 <site-url> 值。

WS-Federation — 为提供商创建设置:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/WsFederation/{ProviderName}/MetadataAddress" \
  --value "<metadata-url-from-user>" \
  --description "WS-Fed metadata URL"

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/WsFederation/{ProviderName}/AuthenticationType" \
  --value "<provider-realm-or-identifier>" \
  --description "Provider identifier for ExternalLogin"

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/WsFederation/{ProviderName}/Wtrealm" \
  --value "<site-url>" \
  --description "Relying party realm"

注意:AuthenticationType 值必须与身份验证服务中 resolveProviderIdentifier() 返回的值一致。

本地身份验证 — 根据用户在阶段 2.1 中的选择写入这些设置。

始终写入的设置(无论注册模式如何):

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/LocalLoginEnabled" \
  --value "true" \
  --description "Enable local username/password login" \
  --type boolean

# Set to "true" if the user chose email login, "false" if they chose username login (Phase 2.1)
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/LocalLoginByEmail" \
  --value "<true-or-false-from-user-choice>" \
  --description "Login by email (true) or username (false)" \
  --type boolean

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/ResetPasswordEnabled" \
  --value "true" \
  --description "Enable forgot password flow for local accounts" \
  --type boolean

注册模式设置 — 根据 REGISTRATION_MODE 确定性映射:

REGISTRATION_MODEAuthentication/Registration/EnabledAuthentication/Registration/OpenRegistrationEnabledAuthentication/Registration/InvitationEnabled
仅开放注册truetruefalse
仅限邀请truefalsetrue
两者兼有truetruetrue
禁用注册false(跳过 — 不适用)(跳过 — 不适用)

**外部提供商站点(无本地身份验证):**如果用户未选择本地身份验证,则不会收集 REGISTRATION_MODE。此时应遵循阶段 2.1.2 中的 OPEN_REGISTRATION_CHOICE(阶段 2.1.2 的开放注册问题):对于 开放 — 自助服务,写入 Authentication/Registration/OpenRegistrationEnabled = true;对于 受控 — 仅预先配置,写入 false。此时不要写入 Enabled/InvitationEnabled(它们控制本地注册页面,而仅使用外部身份验证的站点不会搭建这些页面)。如果未收集 REGISTRATION_MODE 或 OPEN_REGISTRATION_CHOICE,则跳过这些设置并采用服务器默认值。

不要创建 Authentication/Registration/RequireInvitationCode 设置。服务器上不存在此设置(服务器不会读取它)。仅限邀请的行为完全由 OpenRegistrationEnabled = false + InvitationEnabled = true 实现。此技能的早期版本曾写入此设置;如果在项目的站点设置(.powerpages-site/site-settings/Authentication-Registration-RequireInvitationCode.sitesetting.yml)中找到该设置,作为设置流程的一部分删除该文件。

示例(写入所有适用的设置 — 当模式为 Registration disabled 时,跳过 Enabled/OpenReg/Invitation,但 Enabled=false 除外):

# For all modes EXCEPT "Registration disabled":
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/Enabled" \
  --value "true" \
  --description "Master switch: registration is enabled" \
  --type boolean

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/OpenRegistrationEnabled" \
  --value "<true-or-false-from-mode>" \
  --description "Allow self-registration without an invitation" \
  --type boolean

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/InvitationEnabled" \
  --value "<true-or-false-from-mode>" \
  --description "Enable invitation-based registration" \
  --type boolean

# For "Registration disabled" mode ONLY:
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/Enabled" \
  --value "false" \
  --description "Disable all registration (only existing users can sign in)" \
  --type boolean

CAPTCHA 设置 — 根据模式设置:

REGISTRATION_MODECaptchaEnabled / IsCaptchaEnabledForRegistration原因
仅开放注册 / 两者兼有false(附注释)SPA 注册表单无法呈现服务器端 CAPTCHA 小组件 — 保持启用会导致注册静默失败。对于生产环境,站点所有者应添加自己的客户端 CAPTCHA 解决方案,并重新启用服务器设置。
仅限邀请false邀请已对用户进行筛选 — CAPTCHA 会增加阻碍,却不带来安全价值。
禁用注册(跳过 — 注册已关闭)—
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/CaptchaEnabled" \
  --value "false" \
  --description "Disable server-rendered CAPTCHA — SPA cannot render the widget" \
  --type boolean

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/IsCaptchaEnabledForRegistration" \
  --value "false" \
  --description "Disable server-rendered CAPTCHA on registration form" \
  --type boolean

联系人实体的 Web API — 仅当 INCLUDE_PROFILE_PAGE = true 时

当用户选择启用个人资料页面(阶段 2.1)时,为 contact 实体启用 Web API。这些设置用于告知 Power Pages 服务器哪些实体和字段可通过 /_api/{entity} 访问,以便 SPA 的 getMyProfile 和 updateMyProfile 读取和写入用户的联系人记录。

⚠ Webapi/contact/fields 的必填值 — 请原样使用以下完整字符串。执行器绝不能对其进行修剪。必须包含全部 9 个条目,以便个人资料表单能够读取和写入每个可编辑字段:

contactid,firstname,lastname,mobilephone,address1_line1,address1_city,address1_stateorprovince,address1_postalcode,address1_country

此列表包含 contactid(用于记录识别)以及 Phase 2.1 / Phase 5.1.9.b 中列出的 8 个可编辑字段。此列表不包含 emailaddress1,因为电子邮件在帐户详情部分中以只读方式显示,数据来源为 useAuth().user.email——无需通过 Web API 往返获取。此列表不包含 middlename,因为它被有意排除在简单表单之外。跳过上述列表中的任何条目,都会导致相应的表单输入项因 Web API 返回 403 而静默失败。

# Enable Web API on the contact table
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Webapi/contact/enabled" \
  --value "true" \
  --description "Enable Web API access for contact table (profile page)" \
  --type boolean

# Allowed fields — USE THE EXACT VALUE BELOW (9 entries, all lowercase)
# Case-sensitive: PascalCase or Title Case produces 403 even if the column exists
node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Webapi/contact/fields" \
  --value "contactid,firstname,lastname,mobilephone,address1_line1,address1_city,address1_stateorprovince,address1_postalcode,address1_country" \
  --description "Fields allowed via Web API for profile page (read + write)"

字段名称格式:fields 值中的所有条目都必须是小写的 Dataverse LogicalNames。Web API 会区分大小写并进行字面匹配——即使列确实存在,FirstName 或 Firstname 也会导致服务器返回 403 Forbidden。上述列表是正确的。

稍后自定义字段列表:如果用户有想要在个人资料页面中显示的自定义联系人列(例如 cr123_jobtitle),可以在此 fields 值中添加该字段,并在技能执行完毕后,将匹配的字段添加到 UserProfile.tsx 中的 ProfileContact 接口和表单。技能本身必须恰好包含上述 9 个条目——搭建过程中不得添加或删除条目。

8.1.x 为联系人创建表权限(当 INCLUDE_PROFILE_PAGE = true 时)

仅当 INCLUDE_PROFILE_PAGE = true 时执行。 否则跳过。

为联系人表启用 Web API 并不足够——Power Pages 服务器还要求有匹配的 adx_entitypermission 记录,以授予已登录用户读写权限。若没有此记录,即使站点设置允许访问该实体,所有 /_api/contacts(...) 调用仍会返回 403。

对于个人资料页面这一使用场景,Self 范围(756150004) 是正确选择——它仅授予用户访问其本人联系人记录的权限。create-table-permission.js 脚本会对此进行验证,并拒绝为联系人表本身使用 Contact 范围。

步骤 1 — 查找 Authenticated Users Web 角色 UUID。

Phase 1.4 已清点 Web 角色。在 .powerpages-site/web-roles/*.yml 中找到 authenticatedusersrole: true 的角色。典型文件名:Authenticated-Users.webrole.yml。提取其 id 字段。

如果不存在此类角色,请警告用户:

"此站点未找到 'Authenticated Users' Web 角色。在存在 authenticatedusersrole: true 的角色并将其分配给用户之前,任何用户都无法编辑个人资料。运行 /create-webroles 添加该角色,然后重新运行此技能。"

如果未找到角色,则跳过本阶段的其余步骤。

步骤 2 — 创建表权限。

node "${PLUGIN_ROOT}/scripts/create-table-permission.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --permissionName "My Profile - Edit Own Contact" \
  --tableName "contact" \
  --webRoleIds "<authenticated-users-role-uuid>" \
  --scope "Self" \
  --read \
  --write

该脚本将创建 .powerpages-site/table-permissions/My-Profile-Edit-Own-Contact.tablepermission.yml:

adx_entitypermission_webrole:
- <authenticated-users-role-uuid>
append: false
appendto: false
create: false
delete: false
entitylogicalname: contact
entityname: My Profile - Edit Own Contact
id: <generated-uuid>
read: true
scope: 756150004
write: true

注意:create: false + delete: false——用户可以读取和更新自己的联系人,但不能创建新联系人或删除现有联系人。这是个人资料编辑中遵循最小权限原则的默认设置。

权限边界验证:使用 Self 范围时,即使用户通过 DevTools 构造恶意请求,例如 PATCH /_api/contacts({someone-elses-contact-id}),服务器也会返回 403。Self 范围会根据会话 Cookie 中已登录用户的 contactid 强制执行行级安全。

Facebook — 使用 AppId(而非 ClientId)。App ID 已在 Phase 2.1 中收集:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenAuth/Facebook/AppId" \
  --value "<app-id-from-user>" \
  --description "Facebook App ID"

Google — Client ID 已在 Phase 2.1 中收集:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenAuth/Google/ClientId" \
  --value "<client-id-from-user>" \
  --description "Google Client ID"

Microsoft Account — Client ID 已在 Phase 2.1 中收集:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenAuth/MicrosoftAccount/ClientId" \
  --value "<client-id-from-user>" \
  --description "Microsoft Account Client ID"
8.1.1 通过 Azure Key Vault 处理机密

仅当某个提供商需要机密时才运行本阶段。 如果已配置的提供商均不需要机密,则完全跳过。 可能需要机密的提供商:

  • OpenID Connect — 默认不需要。Power Pages 的默认 code id_token 流程是无需机密的公共客户端(参见 Phase 2.1.2 和 idp-provisioning-reference.md)。只有机密客户端覆盖配置才需要机密——除非用户明确选择使用,否则跳过本节。
  • Entra External ID — 默认不需要。Phase 2.1 演练会使用 PKCE 将 Entra External ID 配置为公共客户端(无需客户端机密)。对于 Entra External ID,始终跳过本节。如果用户之后需要使用机密的机密客户端配置,可通过 Power Pages 管理中心手动添加 ClientSecret——详见 Phase 8.5 部署后说明。
  • Microsoft Account / Facebook / Google — 需要(社交 OAuth 需要应用机密)
  • SAML2 / WS-Federation — 不需要(基于证书,而非机密)
  • Local Authentication — 不需要
  • Microsoft Entra ID — 不需要(通过 Power Pages 管理中心配置)

如果没有任何提供商需要机密,请跳过整个 Phase 8.1.1,然后继续执行邀请部分。

对于机密(ClientSecret、AppSecret),切勿将其存储在站点设置 YAML 文件或明文环境变量中。请使用 Azure Key Vault 存储机密,然后通过 --type secret 参数使用 Dataverse 环境变量引用这些机密。

Step 1 — 列出可用的 Key Vault:

node "${PLUGIN_ROOT}/scripts/list-azure-keyvaults.js"

Step 2 — 选择或创建 Key Vault:

如果找到了 Key Vault,请询问要使用哪个:

问题上下文
你想使用哪个 Azure Key Vault 来存储身份验证机密?显示脚本输出中的名称

如果未找到任何 Key Vault:

问题选项
未找到任何 Azure Key Vault。是否要创建一个?创建新的 Key Vault(推荐)、跳过 Key Vault — 我稍后再配置机密

如果选择“创建新的 Key Vault”:询问 Key Vault 名称、资源组和位置:

node "${PLUGIN_ROOT}/scripts/create-azure-keyvault.js" \
  --name "<vault-name>" \
  --resourceGroup "<resource-group>" \
  --location "<location>"

如果选择“跳过 Key Vault”:跳转到下方的“回退”部分。

Step 3 — 指示用户将每个机密存储在 Key Vault 中:

不要询问机密值——它们绝不能经过对话传递。请提供两种选项:

选项 A — Azure CLI(推荐):

For each secret, run the following command (replacing <YOUR_SECRET_VALUE> with the actual value):

1. <Provider> Client Secret:
   printf '%s' '<YOUR_SECRET_VALUE>' | node "${PLUGIN_ROOT}/scripts/store-keyvault-secret.js" \
     --vaultName "<selected-vault>" \
     --secretName "<provider>-client-secret"

告知用户,每条命令都会输出一个包含 secretUri 的 JSON 对象,并请用户分享输出,以便工作流继续。

选项 B — Azure 门户:

1. Go to https://portal.azure.com → Key vaults → <selected-vault> → Secrets
2. Click "+ Generate/Import"
3. Name: <provider>-client-secret, Value: paste your secret
4. Click "Create", then click the secret → current version → copy "Secret Identifier" URI
5. Share the URI here so the workflow can continue

Step 4 — 在 Dataverse 中创建环境变量(类型:secret):

用户分享 secretUri 后,创建一个引用 Key Vault 机密的环境变量:

node "${PLUGIN_ROOT}/scripts/create-environment-variable.js" "<ENV_URL>" \
  --schemaName "<prefix_ProviderClientSecret>" \
  --displayName "<Provider> Client Secret" \
  --type "secret" \
  --value "<secretUri-from-step-3>"

Step 5 — 为环境变量创建站点设置:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/OpenIdConnect/{ProviderName}/ClientSecret" \
  --envVarSchema "<prefix_ProviderClientSecret>"

这会创建一个带有 envvar_schema 和 source: 1 的站点设置,告知 Power Pages 从 Dataverse 环境变量(由 Key Vault 提供支持)解析该值。

为所选提供商所需的每个机密重复 Steps 3-5:

提供商机密名称站点设置Env Var Schema
OIDC / Entra External ID{provider}-client-secretAuthentication/OpenIdConnect/{ProviderName}/ClientSecret{prefix}_ProviderClientSecret
Facebookfacebook-app-secretAuthentication/OpenAuth/Facebook/AppSecret{prefix}_FacebookAppSecret
Googlegoogle-client-secretAuthentication/OpenAuth/Google/ClientSecret{prefix}_GoogleClientSecret
Microsoft Accountmicrosoft-client-secretAuthentication/OpenAuth/MicrosoftAccount/ClientSecret{prefix}_MicrosoftClientSecret

回退 — 如果用户跳过 Key Vault:

如果用户选择不使用 Key Vault,则创建带占位值的环境变量(普通字符串类型,而非机密类型)。用户之后通过 Power Apps maker portal 更新这些变量:

node "${PLUGIN_ROOT}/scripts/create-environment-variable.js" "<ENV_URL>" \
  --schemaName "<prefix_ProviderClientSecret>" \
  --displayName "<Provider> Client Secret" \
  --value "PLACEHOLDER_SET_ACTUAL_VALUE"

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "<site-setting-name-from-table-above>" \
  --envVarSchema "<prefix_ProviderClientSecret>"

告知用户通过以下方式更新每个占位符:

  • Power Apps 创作者门户(make.powerapps.com)→ 解决方案 → 默认解决方案 → 环境变量 → 按显示名称查找 → 更新值

列出需要更新的环境变量(每个变量都包含显示名称和架构名称)。

双重身份验证——**不支持。**此技能不得创建 Authentication/Registration/TwoFactorEnabled、Authentication/Registration/RememberMeEnabled 或 Authentication/Registration/RememberBrowserEnabled 站点设置。Power Pages 内置的 2FA 流程使用服务器呈现的 /Account/Login/SendCode 和 /Account/Login/VerifyCode 页面——2FA 令牌状态通过服务器端 Cookie 保存在凭据 POST 与代码验证 POST 之间,而且我们无法提供与之等效的 SPA 用户界面。如果需要 2FA,建议用户在身份提供商层启用(例如 Entra External ID 条件访问、B2C 用户流 MFA、Auth0 Guardian、Okta Verify 等)——IdP 级别的 2FA 对 Power Pages 透明,且整个用户体验都在 IdP 的品牌体验中完成。

条款和条件——启用条款时:

前提条件:Dataverse 环境中必须安装 GDPR/隐私扩展解决方案(msdynce_PortalPrivacyExtensions)。如果未安装,服务器会完全忽略 TermsAgreementEnabled。请提醒用户此要求。

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/TermsAgreementEnabled" \
  --value "true" \
  --description "Require terms acceptance before accessing the site" \
  --type boolean

如果用户提供了 TermsPublicationDate:

node "${PLUGIN_ROOT}/scripts/create-site-setting.js" \
  --projectRoot "<PROJECT_ROOT>" \
  --name "Authentication/Registration/TermsPublicationDate" \
  --value "<ISO-date-from-user>" \
  --description "Users who accepted before this date will be re-prompted"

创建必需的内容片段 Account/Signin/TermsAndConditionsCopy,并将其放入 .powerpages-site/content-snippets/。此片段必须存在且内容非空——否则,即使该设置为 true,服务器也会禁用条款。片段内容应与 SPA 条款页面中硬编码的 TERMS_CONTENT 常量一致。

检查内容片段目录是否存在,并创建片段 YAML 文件。格式应遵循 .powerpages-site/content-snippets/ 中现有片段的模式。如果存在用于创建内容片段的脚本,请使用该脚本。否则,参照现有片段的模式手动创建 YAML 文件。

也可以选择创建服务器呈现的条款页面使用的其他 3 个片段(SPA 未加载时使用,例如通过深层链接访问):

  • Account/Signin/TermsAndConditionsHeading
  • Account/Signin/TermsAndConditionsAgreementText
  • Account/Signin/TermsAndConditionsButtonText
8.2 记录技能使用情况

参考:${PLUGIN_ROOT}/references/skill-tracking-reference.md

按照参考文档中的技能跟踪说明,记录此技能的使用情况。使用 --skillName "SetupAuth"。

8.3 汇总

汇总所有已创建的内容:

组件文件状态
类型声明src/types/powerPages.d.ts已创建
身份验证服务src/services/authService.ts已创建
身份验证 Hook/Composablesrc/hooks/useAuth.ts(或框架对应实现)已创建
授权工具src/utils/authorization.ts已创建
身份验证组件RequireAuth、RequireRole(或框架对应实现)已创建
身份验证按钮src/components/AuthButton.tsx(或框架对应实现)已创建
注册页面src/pages/Registration.tsx(或框架对应实现)— 本地身份验证,未禁用已创建(如适用)
邀请兑换页面src/pages/RedeemInvitation.tsx(或框架对应实现)— Invitation-only 或 Both 模式已创建(如适用)
忘记密码页面src/pages/ForgotPassword.tsx(或框架对应实现)— 仅限本地身份验证已创建(如适用)
会话保活src/hooks/useSessionKeepAlive.ts(或框架对应实现)— 已集成到布局中已创建
条款页面src/pages/Terms.tsx(或框架对应实现)— 启用条款时已创建(如适用)
条款片段Account/Signin/TermsAndConditionsCopy 内容片段已创建(如适用)
重置密码页面src/pages/ResetPassword.tsx(或框架对应实现)— 仅限本地身份验证已创建(如适用)
用户个人资料页面src/pages/UserProfile.tsx(或框架对应实现)— 当 INCLUDE_PROFILE_PAGE = true 时。另包括 AuthButton 下拉菜单、authService 中的 getMyProfile/updateMyProfile,以及 /user-profile 路由已创建(如适用)
Shell 页眉Code-Site-Shell-Header Web 模板 — 将服务器身份验证页面重定向到 SPA已创建(上传后仍保留)
站点设置根据注册模式设置 ProfileRedirectEnabled = false、Enabled、OpenRegistrationEnabled、InvitationEnabled已创建
Web API 站点设置Webapi/contact/enabled = true、Webapi/contact/fields = ... — 当 INCLUDE_PROFILE_PAGE = true 时已创建(如适用)
表权限My Profile - Edit Own Contact(联系人,Self 范围,读取+写入,Authenticated Users 角色)— 当 INCLUDE_PROFILE_PAGE = true 时已创建(如适用)
8.3.5 生成 HTML 设置报告

总结之后,生成 HTML 设置报告,保存到 <PROJECT_ROOT>/docs/auth-setup-report.html,记录本次运行中的每项决策和产物。报告会在用户的浏览器中打开,作为一份持久、可分享的记录,供用户日后查看。

原因:此技能会做出许多组合配置决策(提供方选择、注册模式、配置文件映射、联系人关联、配置文件页面、条款、联合登出等)。并排呈现的 HTML 报告让用户能够在一处审查完整配置,比上面的聊天摘要更易浏览。审核人员和队友也可以查看这份单独的产物,而不必重新运行此技能。

1. 构建数据载荷。 构造一个 JSON 对象,包含以下键,并将其写入临时文件:

{
  "META_DATA": {
    "siteName": "<SITE_NAME>",
    "reportDate": "<YYYY-MM-DD>",
    "framework": "<React|Vue|Angular|Astro>",
    "nextStepsHtml": "<HTML string — the same provider-specific guidance from 8.5, formatted as an <ol>>"
  },
  "PROVIDERS_DATA": [
    {
      "type": "Entra ID | Entra External ID | OIDC | SAML2 | WS-Federation | Local | Social (Microsoft|Facebook|Google)",
      "displayName": "<friendly name shown in the login UI>",
      "name": "<ProviderName used in site setting keys>",
      "identifier": "<providerIdentifier / authority URL / issuer URI>",
      "authority": "<Authority site-setting value, if applicable>",
      "clientId": "<ClientId site-setting value, if applicable>",
      "redirectUri": "<Computed redirect URI, if applicable>",
      "scopes": "<Scopes site-setting value, if applicable>",
      "registrationClaimsMapping": "<RegistrationClaimsMapping value, if applicable>",
      "loginClaimsMapping": "<LoginClaimsMapping value, or null when sync = First sign-in only>",
      "contactLinking": "Link to existing contact by email | Create a new contact",
      "profileSync": "First sign-in only | Both",
      "federatedLogout": "Enabled | Disabled",
      "isPrimary": true | false
    }
  ],
  "LOCAL_AUTH_DATA": {
    "loginBy": "Email | Username",
    "registrationMode": "Open | Invitation-only | Both | Disabled",
    "resetPasswordEnabled": true | false,
    "emailConfirmationEnabled": true | false
  },
  "OPTIONAL_FEATURES_DATA": {
    "profilePage": true | false,
    "termsAndConditions": true | false,
    "termsEnforced": true | false,
    "federatedLogout": true | false,
    "sessionKeepAlive": true | false
  },
  "SITE_SETTINGS_DATA": [
    { "name": "Authentication/Registration/ProfileRedirectEnabled", "value": "false" }
  ],
  "TABLE_PERMISSIONS_DATA": [
    { "name": "My Profile - Edit Own Contact", "table": "contact", "scope": "Self (756150004)", "read": true, "write": true, "create": false, "delete": false }
  ],
  "FILES_DATA": [
    { "path": "src/services/authService.ts", "action": "Created | Updated", "notes": "<short description>" }
  ]
}
  • 如果未配置本地身份验证,则将 LOCAL_AUTH_DATA 设为 null。
  • 在 SITE_SETTINGS_DATA(阶段 8.1)中包含此技能创建的所有站点设置,包括 ProfileRedirectEnabled、每个 Authentication/{Type}/{Name}/... 块,以及适用时的 Webapi/contact/* 等。对名称中包含 Secret 的任何设置进行屏蔽(将值替换为 ***——报告中不得出现机密)。
  • 在 FILES_DATA 中包含此技能创建或更新的所有 YAML/代码文件。新建文件的操作使用 "Created",编辑文件使用 "Updated"。
  • nextStepsHtml 应反映下方第 8.5 节中的指导,格式为 <ol>...</ol>,并在命令周围加上 <code> 标签。仅包含与实际配置的提供方相关的步骤。

2. 呈现报告。 运行:

node "${PLUGIN_ROOT}/scripts/render-auth-report.js" \
  --output "<PROJECT_ROOT>/docs/auth-setup-report.html" \
  --data "<path-to-temp-data.json>"

呈现器拒绝覆盖已有文件。如果该路径下已存在之前生成的报告,则追加日期后缀:auth-setup-report-2026-05-27.html。

3. 在浏览器中打开报告(尽力而为——绝不能因此阻塞技能流程):

  • Windows:start "" "<PROJECT_ROOT>/docs/auth-setup-report.html"
  • macOS:open "<PROJECT_ROOT>/docs/auth-setup-report.html"
  • Linux:xdg-open "<PROJECT_ROOT>/docs/auth-setup-report.html"

4. 告知用户报告文件的绝对路径,以便浏览器启动失败时,用户可以手动打开。示例措辞:“我已将完整设置报告写入 <path>,并在浏览器中打开。你可以随时重新查看此文件,了解本次运行中的每项决策和产物。”

8.4 询问是否部署

🚦 关卡(计划 · setup-auth:8.4.deploy): 最终部署提示——身份验证在部署之前无法生效(站点设置随部署发布)。

触发条件: 所有身份验证文件均已创建并验证。 询问原因: 自动部署会选错环境。 取消后保留: 无——身份验证产物会保留在磁盘上;不会触发部署。

使用 AskUserQuestion:

问题选项
身份验证和授权已配置。要使登录生效,需要部署站点。现在要部署吗?是,现在部署(推荐);不,我稍后部署

如果选择“是,现在部署”:调用 /deploy-site。

如果选择“否”:提醒用户:

“准备好后,请记得使用 /deploy-site 部署站点。只有使用新的站点设置部署站点后,身份验证才会生效。”

8.5 部署后须知

部署后(或跳过部署时),根据提供方提醒用户相关事项:

  • 在已部署的网站上测试:身份验证仅在已部署的 Power Pages 网站上有效,在 localhost 上无效
  • 身份提供商配置:需要针对提供商进行专属设置:
    • Entra ID:在 Power Pages 管理中心配置身份提供商
    • OpenID Connect:在 OIDC 提供商处注册客户端应用程序,并更新 ClientId 站点设置。将提供商中的重定向 URI 设置为 {site-url}/signin-{ProviderName-lowercased}
    • SAML2:将网站作为服务提供商(SP)注册到 SAML IdP。ServiceProviderRealm 和 AssertionConsumerServiceUrl 必须与网站 URL 匹配
    • WS-Federation:将网站作为信赖方注册到 WS-Fed 提供商
    • 本地身份验证:无需外部提供商 — 用户直接在网站上使用用户名和密码注册并登录
    • Microsoft Account:在 Azure 门户中注册应用程序,并通过 Power Apps maker portal 更新 ClientSecret 环境变量 — 不要将机密提交到源代码管理
    • Facebook:在 Facebook Developer Console 中注册应用程序,并通过 Power Apps maker portal 更新 AppSecret 环境变量 — 不要将机密提交到源代码管理
    • Google:在 Google Cloud Console 中注册应用程序,并通过 Power Apps maker portal 更新 ClientSecret 环境变量 — 不要将机密提交到源代码管理
    • Entra External ID:在 Entra External ID 租户中注册应用程序。更新 ClientId 站点设置。将重定向 URI 设置为 {site-url}/signin-{ProviderName-lowercased}。颁发机构 URL 可以使用 {tenant}.ciamlogin.com 或自定义域名。
  • 身份验证失败处理(让用户留在 SPA 中):OIDC/SAML2/WS-Fed 身份验证失败时,服务器会重定向到 /Account/Login/ExternalAuthenticationFailed — 一个会中断 SPA 的服务器呈现页面。若要在失败时让用户留在 SPA 中,请在 Power Pages 管理中心编辑 Dataverse 内容片段 Account/Register/ExternalAuthenticationFailed 和 Account/Register/ExternalAuthenticationFailed/AccessDenied,注入一个将用户重定向到 /login?message={error-code} 的 <script>。随后,SPA 的 getAuthError() 会以内联方式显示错误。确切脚本请参阅 authentication-reference.md。
  • 用户个人资料显示:登录后,身份验证服务的 getUserDisplayName() 会按以下顺序回退:firstName + lastName → firstName → lastName → email → userName → 'User'。email 优先于 userName,因为对于外部提供商(Entra External ID、OIDC),userName 字段是 OIDC subject 标识符 — 一个类似 vs25QwNe1ZAHqlWK1Naw9dVEBe-TbF5tZEpb0XjAEZQ 的冗长不透明字符串,在导航栏中既难看又毫无意义。Power Pages 会根据标准 OIDC 声明(given_name、family_name、email)填充 firstName/lastName/email。Entra External ID 用户流通常默认不会在返回的声明中包含 given_name/family_name — 若要填充姓名,请确保在用户流的“要收集的用户属性”和“应用程序声明”/“要作为声明返回的用户属性”中都选中这两个属性(参见 Phase 2.1 Entra External ID Step 3)。几乎总是会发出 email 声明,因此即使未填充姓名,电子邮件地址也能可靠地填充。getUserInitials() 会使用相同的优先级链,并取每个回退来源的首字符。
  • 双因素身份验证:此技能不会搭建 Power Pages 内置的 2FA — SendCode/VerifyCode 流程由服务器呈现,无法集成到 SPA 体验中。如需 MFA,请在身份提供商层进行配置(Entra External ID 条件式访问、B2C 用户流 MFA、Auth0 Guardian、Okta Verify 等)— IdP 层级的 MFA 对 Power Pages 透明,并会保留在 IdP 的品牌化体验中。
  • 基于邀请的注册:如果启用了邀请(REGISTRATION_MODE 为 Invitation-only 或 Both),请在 Dataverse 中创建 Invitation 记录(adx_invitation 表)来生成邀请代码 — adx_invitationcode 字段中的值就是 URL 中要使用的代码。按 {site-url}/Account/Login/RedeemInvitation?invitation={code} 格式分享邀请链接 — Code-Site-Shell-Header 脚本会自动将其重定向到 SPA 的 /redeem-invitation?invitation={code} 路由。兑换后,邀请会关联到用户的联系人记录(单次兑换邀请会标记为已兑换;群组邀请会在集合中跟踪已兑换的联系人)。条款接受和外部登录流程会在整个身份验证流程中保留邀请代码。
  • 分配 Web 角色:必须在 Power Pages 管理中心为用户分配相应的 Web 角色
  • 表权限:客户端身份验证检查仅用于改善用户体验 — 请通过 /integrate-webapi 配置服务器端表权限,以实现实际的数据安全
  • 本地开发:身份验证服务包含用于在 localhost 上测试的模拟数据 — 生产环境部署前请移除或禁用
输出
  • 已创建 ProfileRedirectEnabled 站点设置
  • 已向用户呈现完整摘要
  • 已提示部署(或跳过并提供提醒)
  • 已提供部署后指导

重要说明

进度跟踪

开始时使用 TaskCreate 跟踪各个阶段:

任务描述
阶段 1检查先决条件 — 验证站点、框架、部署和 Web 角色
阶段 2制定计划 — 收集需求并征得用户批准
阶段 3创建身份验证服务 — 创建身份验证服务、类型和框架钩子/组合式函数
阶段 4创建授权工具 — 创建角色检查函数和组件
阶段 5创建身份验证界面 — 创建 AuthButton 组件并集成导航
阶段 6实现基于角色的界面 — 将授权模式应用于组件
阶段 7验证身份验证设置 — 确认文件存在、构建成功且身份验证界面正常渲染
阶段 8审查并部署 — 设置站点设置、提供摘要并提示部署

各阶段完成后,使用 TaskUpdate 更新对应任务。

关键决策点
  • 阶段 1.3:现在部署还是停止?(设置身份验证前必须先部署站点)
  • 阶段 1.4:现在创建 Web 角色还是跳过?(授权需要角色)
  • 阶段 1.5:覆盖还是跳过现有身份验证文件?
  • 阶段 2.1:包含哪些身份验证功能?(登录/退出、基于角色的控制,或两者兼有)
  • 阶段 2.2:批准计划还是要求修改?
  • 阶段 6.1:哪些内容区域需要通过基于角色的访问加以保护?
  • 阶段 8.3:现在部署还是稍后部署?

从阶段 1:检查先决条件开始

评论

支持 Markdown