Skip to content

④ 换 token 与 claims 重建 ​

客户端拿到 code 后(浏览器已跳回 http://localhost:5173/signin-callback?code=...),由 oidc-client-ts 发起:

http
POST https://localhost:7016/connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&client_id=client-demo
&code=<授权码>
&redirect_uri=http://localhost:5173/signin-callback
&code_verifier=<PKCE verifier>

OP 端:AuthorizationController.Exchange ​

csharp
[HttpPost("~/connect/token"), IgnoreAntiforgeryToken, Produces("application/json")]
public async Task<IActionResult> Exchange()
{
    var request = HttpContext.GetOpenIddictServerRequest() ??
        throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

    if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
    {
        var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        var user = await userManager.FindByIdAsync(result.Principal!.GetClaim(Claims.Subject)!);
        ...
    }
    throw new InvalidOperationException("The specified grant type is not supported.");
}

分成"框架做"和"控制器做"两部分:

步骤谁做
校验 client_id / redirect_uri / 授权码是否有效、code_verifier 与 code_challenge 是否匹配OpenIddict(框架)
解密授权码(它本身就是被保护的数据)→ 还原出 ① 里那份 claims principalOpenIddict:AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)
检查用户是否还存在控制器:FindByIdAsync(...GetClaim(Claims.Subject))
检查用户是否还被允许登录(锁定/未确认邮箱等)控制器:signInManager.CanSignInAsync(user)
用数据库里的最新信息重建身份控制器
签发 access / id / refresh tokenOpenIddict:SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)

两个失败分支 ​

csharp
if (user is null)
    return Forbid(... Errors.InvalidGrant, "The token is no longer valid.");

if (!await signInManager.CanSignInAsync(user))
    return Forbid(... Errors.InvalidGrant, "The user is no longer allowed to sign in.");

两个都返回 invalid_grant:用户被删 → 旧 token 立刻作废;用户被锁定 → 同样作废。 这是"服务端可撤销"的关键——token 里的 sub 只是 Id,每次刷新都要回来查人。

重建身份(claims 以数据库为准) ​

csharp
var identity = new ClaimsIdentity(result.Principal!.Claims,
    authenticationType: TokenValidationParameters.DefaultAuthenticationType,
    nameType: Claims.Name,
    roleType: Claims.Role);

// Override the user claims present in the principal in case they
// changed since the authorization code/refresh token was issued.
identity.SetClaim(Claims.Subject, await userManager.GetUserIdAsync(user))
        .SetClaim(Claims.Email, await userManager.GetEmailAsync(user))
        .SetClaim(Claims.Name, await userManager.GetUserNameAsync(user))
        .SetClaim(Claims.PreferredUsername, await userManager.GetUserNameAsync(user))
        .SetClaims(Claims.Role, [.. await userManager.GetRolesAsync(user)]);

identity.SetDestinations(GetDestinations);

new ClaimsIdentity(result.Principal.Claims, ...) 先继承原有 claims(保留 scope、authorization id 等 OpenIddict 内部信息), 再用数据库最新值覆盖用户信息类 claims。

这条规则的实际价值

给 alice 加一个角色(AddToRoleAsync)后,下一次刷新 token 就会带上新角色—— 因为 SetClaims(Claims.Role, ...) 每次都重新读数据库。 而如果直接把 claims 抄进 token 不管,就会一直用旧角色直到 token 过期。

GetDestinations:claim 去哪 ​

OpenIddict 默认不会把自定义 claim 放进 token,必须显式声明目的地:

csharp
private IEnumerable<string> GetDestinations(Claim claim)
{
    switch (claim.Type)
    {
        case Claims.Name or Claims.PreferredUsername:
            yield return Destinations.AccessToken;
            if (claim.Subject!.HasScope(Scopes.Profile))
                yield return Destinations.IdentityToken;
            yield break;

        case Claims.Email:
            yield return Destinations.AccessToken;
            if (claim.Subject!.HasScope(Scopes.Email))
                yield return Destinations.IdentityToken;
            yield break;

        case Claims.Role:
            yield return Destinations.AccessToken;
            if (claim.Subject!.HasScope(Scopes.Roles))
                yield return Destinations.IdentityToken;
            yield break;

        // 安全戳永不入 token(敏感值)。claim 类型来自 IdentityOptions(运行时值),
        // 不能当常量 case 标签,所以用 pattern + when 守卫把这条判断留在 switch 里。
        case var claimType when claimType == identityOptions.Value.ClaimsIdentity.SecurityStampClaimType:
            yield break;

        default:
            yield return Destinations.AccessToken;
            yield break;
    }
}

规则总结:

claimaccess tokenid token
name / preferred_username总是只有请求了 profile scope
email总是只有请求了 email scope
role总是只有请求了 roles scope
安全戳 claim(默认 AspNet.Identity.SecurityStamp)永不(敏感值)永不
其他(含 sub 等)总是不

本项目客户端请求的 scope 是 openid offline_access demo_api(没有 profile/email/roles), 所以 access token 里能看到 role,但 id_token 里不会有。

为什么 refresh_token 需要 offline_access ​

OpenIddict 的 IncludeRefreshToken 判定:令牌请求里必须包含 offline_access(除非显式 AllowRefreshTokenFlow 之外的特殊配置)。 本项目客户端在 scope 里显式请求了 offline_access, 所以 automaticSilentRenew 能工作(refresh token 默认滚动刷新)。

撤销端点 ​

csharp
options.SetRevocationEndpointUris("connect/revocation");

客户端可以 POST /connect/revocation 主动吊销 refresh token(demo 客户端登出时会用它)。 端点由 OpenIddict 内置处理器接管,不需要写控制器。

实测响应(节选) ​

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJSU0EtT0FFUC0yNTYi...",
  "token_type": "Bearer",
  "expires_in": 3599,
  "scope": "openid offline_access demo_api"
}

access token 解出来(本项目 access token 未加密,方便调试):

json
{
  "sub": "3f0c...",              // Identity 用户 Id
  "name": "alice",
  "preferred_username": "alice",
  "email": "alice@example.com",
  "role": "admin",
  "scope": ["openid", "offline_access", "demo_api"],
  "iss": "https://localhost:7016/",
  "exp": 1780000000
}

用 access token 调 API

GET /weatherforecast 带上 Authorization: Bearer <access_token> → 200; 不带 → 401。校验由 AddValidation 完成(本项目用 discovery + jwks_uri 拉公钥验签,见 Program.cs 走读)。

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