④ 换 token 与 claims 重建
客户端拿到 code 后(浏览器已跳回 http://localhost:5173/signin-callback?code=...),由 oidc-client-ts 发起:
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
[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 principal | OpenIddict:AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) |
| 检查用户是否还存在 | 控制器:FindByIdAsync(...GetClaim(Claims.Subject)) |
| 检查用户是否还被允许登录(锁定/未确认邮箱等) | 控制器:signInManager.CanSignInAsync(user) |
| 用数据库里的最新信息重建身份 | 控制器 |
| 签发 access / id / refresh token | OpenIddict:SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) |
两个失败分支
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 以数据库为准)
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,必须显式声明目的地:
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;
}
}规则总结:
| claim | access token | id 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 默认滚动刷新)。
撤销端点
options.SetRevocationEndpointUris("connect/revocation");客户端可以 POST /connect/revocation 主动吊销 refresh token(demo 客户端登出时会用它)。 端点由 OpenIddict 内置处理器接管,不需要写控制器。
实测响应(节选)
{
"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 未加密,方便调试):
{
"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 走读)。