Skip to content

① 授权请求与未认证 Challenge ​

谁发起 ​

客户端 userManager.signinRedirect() → 顶层导航:

GET https://localhost:7016/connect/authorize
      ?client_id=client-demo
      &redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fsignin-callback
      &response_type=code
      &scope=openid%20offline_access%20demo_api
      &state=<客户端随机串>
      &code_challenge=<base64url(SHA256(verifier))>
      &code_challenge_method=S256
      &nonce=<随机串>

为什么必须是顶层导航

响应是 302(跳登录页 / 同意页 / 回调地址)。fetch 拿不到跨源重定向的 Location,也没法让浏览器种 cookie。

OP 端:AuthorizationController.Authorize ​

csharp
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Authorize()
{
    var request = HttpContext.GetOpenIddictServerRequest() ??
        throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

    var result = await HttpContext.AuthenticateAsync();
    if (result is not { Succeeded: true } ||
        ((request.HasPromptValue(PromptValues.Login) || request.MaxAge is 0 ||
         (request.MaxAge is not null && result.Properties?.IssuedUtc is not null &&
          TimeProvider.System.GetUtcNow() - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) &&
         TempData["IgnoreAuthenticationChallenge"] is null or false))
    {
        ...
    }
    ...
}

这段 if 是整个登录流程的分岔口,满足其中任一条件就要去登录页:

条件含义
AuthenticateAsync() 不成功没有 Identity 应用 cookie(没登录 / cookie 过期)
prompt=login客户端要求"必须重新输密码"(例如刚登出,或点了"切换账号")
max_age=0等价 prompt=login
max_age=N 且 cookie 的 IssuedUtc 已超过 N 秒会话不够"新鲜"(prompt=none 之外也常见)
TempData["IgnoreAuthenticationChallenge"] is null or false防死循环的开关(见下)

HttpContext.AuthenticateAsync() 不带 scheme = 默认 scheme(本项目里是 Identity 应用 cookie)。 注意这里断言的是 Succeeded: true——AuthenticateAsync 对"没有 cookie"不会抛异常,只会返回未成功。

防无限跳转的 TempData 开关 ​

csharp
TempData["IgnoreAuthenticationChallenge"] = true;

return Challenge(new AuthenticationProperties
{
    RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
        Request.HasFormContentType ? Request.Form : Request.Query)
});
  • Challenge(...) 会交给 Identity cookie 处理器处理 → 302 到 LoginPath(本项目 /Account/Login)并附 ?ReturnUrl=<被拦截的地址>。
  • 但"登录成功后顶层导航回 ReturnUrl"会让浏览器又一次打到 /connect/authorize。 如果此时 prompt=login 还在(或 cookie 的 max_age 判定仍不满足),就会又 Challenge 回登录页 → 无限循环。
  • 所以先写 TempData["IgnoreAuthenticationChallenge"] = true:TempData 是一次性会话数据(读过即删), 下一次请求里它还在 → if 条件里的 ... && TempData[...] is null or false 为假 → 跳过整个分支 → 直接按"已认证"继续走。
  • 换句话说:"刚从登录页回来"这一次,即使 prompt=login 也不再强制重登,避免死循环。

TempData 不是"用户真的重新认证过"的证明

官方注释特意说明:这个标记不保证用户真的重新认证了。如果业务要求"必须重新认证", 正确做法是删掉旧 cookie 并撤销(比如用 Identity 的 security stamp + 极短的重验证间隔),再发起 Challenge。

prompt=none 的特殊处理(不能跳登录页) ​

csharp
if (request.HasPromptValue(PromptValues.None))
{
    return Forbid(
        authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
        properties: new AuthenticationProperties(new Dictionary<string, string?>
        {
            [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired,
            [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in."
        }));
}

prompt=none 的语义是"别弹界面,能授权就直接给,不能就报错"(静默刷新常用)。 所以这里必须返回错误而不是跳登录页:Forbid(OpenIddict scheme) 会让 OpenIddict 按 response_mode 把 error=login_required 回给客户端。

实际响应(本项目实测) ​

http
HTTP/1.1 302 Found
Location: https://localhost:7016/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dclient-demo%26...

Location 的参数顺序就是客户端发来的顺序,ReturnUrl 是被 URL 编码后的原始授权请求。

谁决定 302 到哪

不是控制器写死的,是 Identity cookie handler 用的默认落点: LoginPath = /Account/Login(CookieAuthenticationDefaults.LoginPath)、附带的参数名 ReturnUrl (CookieAuthenticationDefaults.ReturnUrlParameter)。 本项目 Program.cs 没有配置 ConfigureApplicationCookie,这两项(以及过期时间 14 天)都走 Cookie 认证的默认值; appsettings 里的 OAuth:LoginPageUrl 供 AccountController 的失败回跳等页面端点使用。

下一步 ​

浏览器落在 /Account/Login(React 登录页)→ ② 登录

OpenIddict + ASP.NET Core Identity 授权服务器,React 登录页,逐步拆解登录链路