跳转到内容

已安装 PWA 的登录

发布于 更新于

OAuth 回调可以留在 manifest scope 内;条件式通行密钥 mediation 不可用时,保留密码表单供用户登录。

RFC 10017 定义浏览器应用的 OAuth 要求。MDN 将 PublicKeyCredential.isConditionalMediationAvailable() 标为仅限安全上下文,并记录其自 2023 年 10 月起跨浏览器广泛可用。manifest 的 scope 成员定义已安装体验包含的 URL; MDN 说明浏览器允许导航到 scope 外,但可能显示 URL 和浏览器控件。

RFC 10017 要求浏览器中的公共客户端使用带 PKCE 的授权码模式,还要求客户端注册重定向 URI, 并只使用与注册值准确匹配的重定向 URI。应用位于 /app/ 下,且回调需要留在 manifest scope 内时,把回调也放在这个路径下:

{
"name": "Field Notes",
"start_url": "/app/",
"scope": "/app/",
"display": "standalone"
}

向授权服务器注册 https://example.com/app/oauth/callback。在此示例中, https://example.com/oauth/callback 位于 manifest scope 外,不过浏览器不会阻止该导航。

OAuth 示例把授权请求的构造和 PKCE verifier 的保存交给应用服务器。通行密钥不可用时,用户仍可使用表单。

按钮从应用服务器请求授权 URL,再导航当前顶层窗口。错误分支重新启用按钮,并保留密码表单。

<button id="oauth" type="button">使用身份提供方继续</button>
<p id="oauth-status" role="status"></p>
<script type="module">
const button = document.querySelector('#oauth');
const status = document.querySelector('#oauth-status');
button.addEventListener('click', async () => {
button.disabled = true;
try {
const response = await fetch('/app/api/oauth/start', { method: 'POST' });
if (!response.ok) throw new Error(`OAuth start failed: ${response.status}`);
const { authorizationUrl } = await response.json();
location.assign(authorizationUrl);
} catch (error) {
status.textContent = '请使用邮箱和密码登录。';
button.disabled = false;
}
});
</script>

重定向返回 /app/oauth/callback 后,用创建 PKCE verifier 的同一事务处理授权响应。

web.dev 的通行密钥自动填充指南要求在用户名字段加入 webauthn token,并以 mediation: 'conditional' 调用 navigator.credentials.get()。先按 MDN 检测特性; 不支持或失败的路径都会保留普通表单。

<form action="/app/session" method="post">
<label for="username">邮箱</label>
<input id="username" name="username" autocomplete="username webauthn" required>
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<button>登录</button>
</form>
<script type="module">
async function offerPasskey() {
if (!('PublicKeyCredential' in window) ||
!('credentials' in navigator) ||
!PublicKeyCredential.isConditionalMediationAvailable ||
!PublicKeyCredential.parseRequestOptionsFromJSON ||
!PublicKeyCredential.prototype.toJSON) {
return; // 保留密码表单作为回退路径。
}
if (!(await PublicKeyCredential.isConditionalMediationAvailable())) {
return; // 保留密码表单作为回退路径。
}
const response = await fetch('/app/api/passkey/options');
if (!response.ok) return;
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
await response.json(),
);
const credential = await navigator.credentials.get({
publicKey,
mediation: 'conditional',
});
if (!credential) return;
const verification = await fetch('/app/api/passkey/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(credential.toJSON()),
});
if (verification.ok) location.assign('/app/');
}
offerPasskey().catch(() => {
// 条件式请求被拒绝时仍启用密码表单。
});
</script>

显式能力检测和每个提前返回分支都会在运行时保留密码表单作为回退。服务器提供 WebAuthn 请求 选项并验证返回的凭据。MDN 记录了上面使用的 JSON 转换方法,并说明 relying-party 域无效时, 可用性检测可能以 SecurityError 拒绝。

  • 与注册值不同的重定向 URI 违反 RFC 10017 的准确匹配规则。
  • manifest scope 外的回调仍可导航,但 MDN 说明浏览器可能为 scope 外页面增加 URL 或导航控件。
  • 条件式 mediation 检测不存在或返回 false 时,本示例使用密码表单。
  • 检测成功不会选择凭据;按照 MDN 的定义,它只报告条件式 mediation 是否可用。