访问令牌一小时就过期,用户每次都得重登?可把令牌签长又怕泄漏。本篇用刷新令牌破这局:短命访问令牌 + 可轮换的刷新令牌,登录一次、长期免登。
先补 30 秒上下文(没看前面也能懂)
我们已经有两个进程:LocalOAuthServer:7031 签发 JWT,TaskMcpServer:7030 只认合法令牌。目前访问令牌有效期 1 小时,过期后用户必须重新走一遍登录授权------这就是本篇要解决的体验问题。本篇的改动全部落在授权服务器上:给它增量加上刷新令牌能力,MCP 服务侧只做两处配合。
没读过前文也没关系,你只需知道:现在"令牌会过期、过期要重登",本篇让过期这件事对用户透明。
读完本篇你能拿到什么
- 一套短访问令牌 + 可轮换刷新令牌机制:登录一次,之后长期免登,暴露面始终压在短令牌上;
- 规范里两条极易踩坑的硬要求:
refresh_token必须轮换 (OAuth 2.1 对公共客户端的强制项)、以及客户端凭什么知道可以刷新(元数据两个字段缺一就不刷); - 一个刷新令牌家族链模型:每次刷新签发新令牌、旧令牌立即失效,为"重放检测"打底;
- 知道为什么"把令牌签长一点"是错的------公共客户端存不住秘密,长令牌 = 大泄漏窗口。
(撤销 / revoke 相关内容统一放在第五篇,本篇不涉及。)
一、为什么需要刷新令牌
第三篇留下的问题很直接:访问令牌过期即重新登录。但解法不能是"把令牌签长一点"------WorkBuddy 是公共客户端(Native App,不持有 client_secret),令牌存在客户端本地,生命周期越长,泄漏后的影响窗口越大。OAuth 2.1 §7.1 对此有明确要求:授权服务器应签发短生命周期访问令牌。
矛盾因此清晰:安全要求令牌短,体验要求不用反复登录。标准答案是把一件事拆成两个令牌:
- 访问令牌:短 TTL(本篇演示用 1 小时,联调时压到 30 秒),泄漏了也只影响一小段时间;
- 刷新令牌:长 TTL(30 天),只用来在访问令牌过期后换发新令牌,不直接接触资源服务器。
这样用户登录一次,之后长期免登录,而暴露面始终被压缩在短访问令牌上。本篇的全部改动都在授权服务器上,资源服务器只做两处配合性调整。
二、规范的两条硬性要求
补刷新令牌最值得记的不是代码,而是两条只在规范里写明、却极易被忽略的约束。
2.1 公共客户端必须轮换刷新令牌
Authorization servers SHOULD issue short-lived access tokens to reduce the impact of leaked tokens. For public clients, authorization servers MUST rotate refresh tokens as described in OAuth 2.1 Section 4.3.1 "Token Endpoint Extension".
即:每次刷新都必须签发新的 refresh_token 并让旧的立即失效,只换 access_token 是不够的。这一点与很多人的第一直觉相反------觉得刷新令牌长期复用才省事------却是 OAuth 2.1 对公共客户端的强制项。原因同样落在"公共客户端存不住秘密"上:刷新令牌一旦泄漏,攻击者可以无限续杯;轮换之后,攻击者与合法客户端必然有一方用到旧令牌,服务端就能察觉(见第四章的重放检测)。
2.2 客户端凭什么知道可以刷新
这是本次最容易漏的一步:即便 /token 的响应里已经返回了 refresh_token,客户端也未必会去用它。
按 SEP-2207(OIDC-Flavored Refresh Token Guidance),MCP 客户端的行为取决于授权服务器的元数据:只有 grant_types_supported 含 refresh_token、且 scopes_supported 含 offline_access,客户端才会在授权请求中追加 offline_access 去索要刷新令牌。两个字段缺一,客户端拿到刷新令牌也会当没看见,过期后照旧弹重新登录。
因此元数据必须补齐。对比第二篇的版本:
csharp
// 第二篇
["grant_types_supported"] = new[] { "authorization_code" },
["scopes_supported"] = new[] { "mcp:tools" },
// 本篇
// 必须列出 refresh_token:MCP 客户端靠这个字段决定要不要走刷新流程(SEP-2207)
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
// offline_access 是客户端请求刷新令牌的约定 scope:
// 客户端只有在 scopes_supported 里看到它,才会把 offline_access 追加到授权请求的 scope 上。
["scopes_supported"] = new[] { "mcp:tools", "offline_access" },
同理,/register 的 grant type 白名单也要放行 refresh_token(回显不认识的枚举值会让客户端 Zod 校验直接失败,这是第二篇踩过的坑):
csharp
var SupportedGrantTypes = new HashSet<string>(StringComparer.Ordinal)
{ "authorization_code", "refresh_token" }; // 第二篇仅有 authorization_code
三、授权服务器改动
3.1 令牌链数据结构
刷新令牌是不透明随机串(不可猜测、不承载信息,便于服务端随时令其失效),内存存储,每条记录挂在一条"令牌链"(family)上:
csharp
// 刷新令牌内存存储:token -> 记录。
// 轮换模型(OAuth 2.1 §4.14.2):一次授权产生一条"令牌链"(family),每次刷新都签发新令牌,
// 并把旧令牌标记为 Rotated 而【不是】直接删除 ------ 只有保留旧记录,旧令牌被重放时才能识别出来。
var refreshTokens = new System.Collections.Concurrent.ConcurrentDictionary<string, RefreshTokenRecord>();
// FamilyId 把一次授权后产生的所有刷新令牌串成一条链:
// 重放检测命中时,撤销的是整条链而不是单个令牌。
sealed class RefreshTokenRecord
{
public required string Token { get; init; }
public required string FamilyId { get; init; }
public required string ClientId { get; init; }
public required TokenSubject Subject { get; init; } // 第二篇已有:UserId/Username/DisplayName
public required string Scope { get; init; }
public required DateTime CreatedAt { get; init; }
public required DateTime ExpiresAt { get; init; }
public RefreshTokenStatus Status { get; set; } = RefreshTokenStatus.Active;
}
// 刷新令牌状态机:Active --(使用一次)--> Rotated --(再次出现)--> 整条链 Revoked
enum RefreshTokenStatus { Active, Rotated, Revoked }
Subject 复用第二篇的 TokenSubject------访问令牌与刷新令牌共用同一个归属主体,保证刷新后 sub 不漂移,否则用户数据会串号。
3.2 签发时机
公共客户端无法安全保管长期凭据,只能靠"短访问令牌 + 可轮换的刷新令牌"维持会话,所以在 authorization_code grant 里一律签发刷新令牌,新起一条令牌链:
csharp
// authorization_code grant 内,PKCE 等四道校验通过之后:
var refresh = IssueRefreshToken(familyId: Guid.NewGuid().ToString("N"), clientId, subject, scope);
var accessToken = IssueAccessToken(subject, scope, clientId);
return Results.Json(new Dictionary<string, object?>
{
["access_token"] = accessToken,
["token_type"] = "Bearer",
["expires_in"] = accessTokenLifetimeSeconds,
["scope"] = scope,
["refresh_token"] = refresh.Token, // 第二篇的响应没有这一项
});
3.3 三档 TTL
| 令牌 | 有效期 | 配置方式 |
|---|---|---|
access_token |
3600s(1 小时) | 常量 DefaultAccessTokenLifetimeSeconds,运行时用 --access-token-lifetime-seconds 覆盖 |
authorization_code |
60s | 常量 AuthorizationCodeLifetimeSeconds |
refresh_token |
2592000s(30 天) | 常量 RefreshTokenLifetimeSeconds |
访问令牌 TTL 做成命令行可覆盖,是为了联调时不用等满 1 小时(见第六章)。刷新令牌的安全性不靠短有效期,而靠"轮换 + 重放检测"(下一章),所以可以放心签 30 天。
四、轮换与重放检测
4.1 旧令牌保留,而不是删除
轮换实现里有一个反直觉的设计:用过的旧刷新令牌要保留记录,标记为 Rotated,而不是从字典里删掉。
- 若直接删除,服务端之后再收到它时只能判断"这个令牌不存在",无法区分"令牌泄漏后被攻击者重放"与"客户端发了个无效串";
- 保留记录后,旧令牌再次出现即可确定是重放。
4.2 状态机与整链撤销
scss
Active ──(使用一次)──> Rotated ──(再次出现)──> 整条 family 全部 Revoked
重放命中时,不能只拒绝这一次请求 ------必须撤销整条令牌链。道理是:旧令牌被用了两次,说明它同时握在两个人手里,合法客户端与攻击者必有一个是假的,但服务端无法分辨是谁。唯一安全的选择是整条链作废,逼双方重新登录。client_id 不匹配(令牌被另一个客户端使用)同样触发整链撤销:
csharp
// 重放检测(OAuth 2.1 §4.14.2):已轮换掉的旧令牌再次出现,说明它可能已泄漏。
if (rec.Status != RefreshTokenStatus.Active)
{
RevokeFamily(rec.FamilyId, "refresh token replay detected");
Console.WriteLine($" [token] !!! REPLAY DETECTED family={Short(rec.FamilyId)} user={rec.Subject.Username} --- 整条令牌链已撤销");
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token reuse detected" });
}
if (rec.ClientId != clientId)
{
RevokeFamily(rec.FamilyId, "client mismatch on refresh");
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token was issued to another client" });
}
4.3 刷新处理流程
/token 端点按 grant_type 分流,refresh_token 分支的完整校验顺序:
csharp
// 1. 令牌存在(不存在可能是瞎发的,也可能是已被清理的过期令牌)
if (!refreshTokens.TryGetValue(refreshToken, out var rec))
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token invalid or revoked" });
// 2. 重放检测 + 3. client_id 归属(见 4.2,命中即整链撤销)
// 4. 有效期
if (DateTime.UtcNow >= rec.ExpiresAt)
{
refreshTokens.TryRemove(refreshToken, out _);
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token expired" });
}
// 5. 刷新只能收窄 scope,不能放大(RFC 6749 §6)
var scope = rec.Scope;
if (!string.IsNullOrEmpty(requestedScope))
{
if (!IsSubsetScope(requestedScope, rec.Scope))
return Results.BadRequest(new { error = "invalid_scope", error_description = "cannot widen scope on refresh" });
scope = requestedScope;
}
// 6. 轮换:旧令牌立即失效;新令牌沿用同一个 family,日后旧令牌被重放时可追溯到整条链
rec.Status = RefreshTokenStatus.Rotated;
var rotated = IssueRefreshToken(rec.FamilyId, clientId, rec.Subject, scope);
var accessToken = IssueAccessToken(rec.Subject, scope, clientId);
响应同时返回新 access_token 与新 refresh_token,客户端用新对旧,无缝续期。
五、资源服务器侧的配合
刷新流程本身与资源服务器无关(令牌交换只发生在客户端与授权服务器之间),但联调时有一个默认值会让现象失真。
5.1 ClockSkew
JwtBearer 的 ClockSkew 默认是 5 分钟,即访问令牌过期后仍会被判为有效 5 分钟。联调时表现为:令牌明明已经过期,请求却照常返回 200,看上去就像客户端从未去刷新。收紧到 5 秒后,过期才立即表现为 401:
csharp
// 第三篇的 TokenValidationParameters 内新增一行:
// 默认 ClockSkew 是 5 分钟,会让已过期的令牌继续被接受,
// 造成"令牌过期了却还是 200"的假象。同机部署,5 秒足够吸收时钟抖动。
ClockSkew = TimeSpan.FromSeconds(5)
5.2 暴露令牌过期时间
把 exp 换算后暴露到 get_current_user 的返回值里------刷新成功后再调用,过期时间会整体后移一个访问令牌生命周期,这是判断刷新是否生效最直接的凭据:
csharp
// CurrentUser 增加两个字段(第三篇版本只有 UserId/Username/DisplayName)
public string TokenExpiresAt { get; set; } = ""; // UTC,ISO8601
public double TokenExpiresInSeconds { get; set; } // 剩余秒数,便于观察刷新是否发生
// 读 exp claim(unix 秒),换算为 UTC 时间与剩余秒数
internal static (string? ExpiresAt, double? ExpiresInSeconds) ReadExpiry(ClaimsPrincipal? user)
{
var raw = user?.FindFirst("exp")?.Value;
if (string.IsNullOrEmpty(raw) || !long.TryParse(raw, out var seconds)) return (null, null);
var exp = DateTimeOffset.FromUnixTimeSeconds(seconds);
return (exp.UtcDateTime.ToString("o"), (exp - DateTimeOffset.UtcNow).TotalSeconds);
}
资源服务器的改动到此为止,其余与第三篇完全一致。
六、联调验证
6.1 时序补全
在第三篇的时序图后面接上刷新的尾巴,完整闭环如下:
6.2 压缩观测窗口
把访问令牌生命周期调到 30 秒,否则要等满 1 小时才能看到一次刷新:
bash
dotnet run --access-token-lifetime-seconds 30
启动时控制台会打印实际生效的配置:
ini
[config] access_token=30s, authorization_code=60s, refresh_token=2592000s (30d)
在 WorkBuddy 中 disconnect 再 reconnect(清掉旧令牌缓存),用 alice / Pass@123 重新授权。之后正常调用工具,等待 30 秒后再调一次 get_current_user:客户端会先用过期令牌吃到 401,自动走刷新,重试成功------返回的 TokenExpiresAt 比上一次调用整体后移,证明令牌确实换过了。
6.3 日志闭环
一次完整的刷新闭环,在授权服务器日志中长这样:
ini
[token] authorization_code -> user=alice (sub=u-1001) scope=mcp:tools offline_access access_token_expires_in=30s refresh_family=62c925f555...
[token] refresh_token -> user=alice (sub=u-1001) scope=mcp:tools offline_access 旧令牌=rt_rqpZJ_i... 已轮换为=rt_Zg1ShzB...
[token] !!! REPLAY DETECTED family=62c925f555... user=alice --- 整条令牌链已撤销
第三行需要手动构造:把上一次刷新前的旧 refresh_token 再发一次(curl 即可),服务端识别重放并撤销整条链------合法客户端下一次刷新会失败,被迫重新登录。这正是 2.1 那条"必须轮换"规范想要的效果:泄漏的令牌一旦使用,全家陪葬。
七、完整源码
本篇只改授权服务器,以下给出本篇版本的 LocalOAuthServer/Program.cs 全文(相对第二篇的增量:grant 白名单与元数据、refreshTokens 存储、/token 的 refresh_token 分支、IssueRefreshToken / RevokeFamily / SweepExpired 及配套类型;撤销端点 /revoke 属第五篇,本篇不含)。资源服务器仅按第五章加两行,不重复列出。
csharp
// OAuth 2.0 + PKCE 授权服务器(本篇补齐刷新令牌:轮换 + 重放检测)
// 相对第二篇的增量:grant_type=refresh_token、令牌链(family)模型、三档 TTL、离线 scope 声明
// 签发被 TaskMcpServer:7030 的 JwtBearer 认可的 RS256 JWT:
// iss = http://localhost:7031
// aud = http://localhost:7030/
// 用户体系:本地实验用固定用户(明文密码仅限本地 demo,真实部署应改为数据库 + 密码哈希)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
var builder = WebApplication.CreateBuilder(new[] { "--urls", "http://localhost:7031" });
var app = builder.Build();
const string Issuer = "http://localhost:7031";
const string McpResourceAudience = "http://localhost:7030/";
// ---- 令牌生命周期 ----
// 访问令牌默认 1 小时。联调刷新流程时建议临时调小,否则要等满 1 小时才会触发 refresh:
// dotnet run --access-token-lifetime-seconds 30
// OAuth 2.1 §7.1 要求授权服务器签发短生命周期访问令牌,令牌泄漏后的影响窗口才可控;
// 这正是需要 refresh_token 的原因 ------ 短令牌 + 长刷新令牌,而不是直接发长令牌。
const int DefaultAccessTokenLifetimeSeconds = 3600;
int accessTokenLifetimeSeconds = ResolveIntOption(args, "access-token-lifetime-seconds", DefaultAccessTokenLifetimeSeconds);
// 授权码 TTL:RFC 6749 §4.1.2 建议不超过 10 分钟
const int AuthorizationCodeLifetimeSeconds = 60;
// 刷新令牌 TTL:远长于访问令牌,安全性由"轮换 + 重放检测"而非短有效期保证
const int RefreshTokenLifetimeSeconds = 30 * 24 * 60 * 60;
// ---- 固定用户(本地实验用)。真实部署请改为数据库 + 密码哈希存储 ----
var users = new Dictionary<string, UserInfo>(StringComparer.OrdinalIgnoreCase)
{
["alice"] = new("u-1001", "alice", "Pass@123", "Alice Wang"),
["bob"] = new("u-1002", "bob", "Pass@123", "Bob Li"),
["carol"] = new("u-1003", "carol", "Pass@123", "Carol Chen"),
};
// 启动时加载 RSA 密钥:优先从 oauth_key.json 读取(持久化),否则生成并保存。
// 关键:keyId 必须固定,否则授权服务器重启后 kid 变化,而资源服务器 JwtBearer 缓存旧 JWKS
// → 新 token 验签失败 → 客户端反复重新授权,形成死循环。
const string KeyFile = "oauth_key.json";
RSA rsa;
string keyId;
if (File.Exists(KeyFile))
{
var k = JsonDocument.Parse(File.ReadAllText(KeyFile)).RootElement;
keyId = k.GetProperty("kid").GetString()!;
rsa = RSA.Create();
rsa.ImportParameters(new RSAParameters
{
Modulus = Base64UrlDecode(k.GetProperty("n").GetString()!),
Exponent = Base64UrlDecode(k.GetProperty("e").GetString()!),
D = Base64UrlDecode(k.GetProperty("d").GetString()!),
DP = Base64UrlDecode(k.GetProperty("dp").GetString()!),
DQ = Base64UrlDecode(k.GetProperty("dq").GetString()!),
InverseQ = Base64UrlDecode(k.GetProperty("qi").GetString()!),
P = Base64UrlDecode(k.GetProperty("p").GetString()!),
Q = Base64UrlDecode(k.GetProperty("q").GetString()!),
});
Console.WriteLine($"[key] loaded persisted RSA key kid={keyId}");
}
else
{
rsa = RSA.Create(2048);
keyId = Guid.NewGuid().ToString("N");
var p = rsa.ExportParameters(true);
var obj = new Dictionary<string, string?>
{
["kid"] = keyId,
["n"] = Base64UrlEncode(p.Modulus!),
["e"] = Base64UrlEncode(p.Exponent!),
["d"] = Base64UrlEncode(p.D!),
["dp"] = Base64UrlEncode(p.DP!),
["dq"] = Base64UrlEncode(p.DQ!),
["qi"] = Base64UrlEncode(p.InverseQ!),
["p"] = Base64UrlEncode(p.P!),
["q"] = Base64UrlEncode(p.Q!),
};
File.WriteAllText(KeyFile, JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"[key] generated + persisted RSA key kid={keyId}");
}
var signingKey = new RsaSecurityKey(rsa) { KeyId = keyId };
Console.WriteLine($"[config] access_token={accessTokenLifetimeSeconds}s, " +
$"authorization_code={AuthorizationCodeLifetimeSeconds}s, " +
$"refresh_token={RefreshTokenLifetimeSeconds}s ({RefreshTokenLifetimeSeconds / 86400}d)");
// 授权码内存存储:code -> 记录(绑定 code_challenge 实现 PKCE 校验 + 绑定用户)。一次性消费 + TTL。
var codes = new System.Collections.Concurrent.ConcurrentDictionary<string, AuthCodeRecord>();
// 刷新令牌内存存储:token -> 记录。
// 轮换模型(OAuth 2.1 §4.14.2):一次授权产生一条"令牌链"(family),每次刷新都签发新令牌,
// 并把旧令牌标记为 Rotated 而【不是】直接删除 ------ 只有保留旧记录,旧令牌被重放时才能识别出来,
// 识别到即撤销整条令牌链。
var refreshTokens = new System.Collections.Concurrent.ConcurrentDictionary<string, RefreshTokenRecord>();
// 动态注册的客户端信息(RFC 7591):client_id -> 注册信息
var clients = new System.Collections.Concurrent.ConcurrentDictionary<string, RegisteredClient>();
// 服务端实际支持的 grant type 白名单:注册响应只回显白名单内的值
var SupportedGrantTypes = new HashSet<string>(StringComparer.Ordinal) { "authorization_code", "refresh_token" };
// 观测日志中间件
app.Use(async (ctx, next) =>
{
var ts = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
Console.WriteLine($"[{ts}] >>> {ctx.Request.Method} {ctx.Request.Path}{ctx.Request.QueryString}");
await next(ctx);
});
// ---- 授权服务器元数据 (RFC 8414) ----
app.MapGet("/.well-known/oauth-authorization-server", () => Results.Json(new Dictionary<string, object?>
{
["issuer"] = Issuer,
["authorization_endpoint"] = $"{Issuer}/authorize",
["token_endpoint"] = $"{Issuer}/token",
["registration_endpoint"] = $"{Issuer}/register",
["jwks_uri"] = $"{Issuer}/.well-known/jwks",
["response_types_supported"] = new[] { "code" },
// 必须列出 refresh_token:MCP 客户端靠这个字段决定要不要走刷新流程(SEP-2207)
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
["code_challenge_methods_supported"] = new[] { "S256" },
// offline_access 是客户端请求刷新令牌的约定 scope(SEP-2207):
// 客户端只有在 scopes_supported 里看到它,才会把 offline_access 追加到授权请求的 scope 上。
["scopes_supported"] = new[] { "mcp:tools", "offline_access" },
["token_endpoint_auth_methods_supported"] = new[] { "none" },
}));
// ---- OIDC 发现文档(JwtBearer 通过 Authority 取此文档,缺 jwks_uri 会令资源服务器报 500)----
app.MapGet("/.well-known/openid-configuration", () => Results.Json(new Dictionary<string, object?>
{
["issuer"] = Issuer,
["authorization_endpoint"] = $"{Issuer}/authorize",
["token_endpoint"] = $"{Issuer}/token",
["registration_endpoint"] = $"{Issuer}/register",
["jwks_uri"] = $"{Issuer}/.well-known/jwks",
["response_types_supported"] = new[] { "code" },
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
["code_challenge_methods_supported"] = new[] { "S256" },
["scopes_supported"] = new[] { "mcp:tools", "offline_access" },
["token_endpoint_auth_methods_supported"] = new[] { "none" },
}));
// ---- JWKS:暴露 RSA 公钥,JwtBearer 按 kid 取它验证签名 ----
app.MapGet("/.well-known/jwks", () =>
{
var p = rsa.ExportParameters(false);
var jwk = new Dictionary<string, object?>
{
["kty"] = "RSA",
["use"] = "sig",
["alg"] = "RS256",
["kid"] = keyId,
["n"] = Base64UrlEncode(p.Modulus!),
["e"] = Base64UrlEncode(p.Exponent!),
};
return Results.Json(new { keys = new[] { jwk } });
});
// ---- 动态客户端注册 (RFC 7591) ----
// 注册【响应】必须回显 redirect_uris 且为数组,否则客户端 Zod 校验会因
// redirect_uris 缺失/类型不符而报 invalid_type,表现为 connect failed。
app.MapPost("/register", async (HttpContext ctx) =>
{
string? clientName = null;
string? clientUri = null;
var redirectUris = new List<string>();
var grantTypes = new List<string>();
string? scope = null;
try
{
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
if (doc.RootElement.TryGetProperty("client_name", out var cn)) clientName = cn.GetString();
if (doc.RootElement.TryGetProperty("client_uri", out var cu)) clientUri = cu.GetString();
if (doc.RootElement.TryGetProperty("scope", out var sc)) scope = sc.GetString();
if (doc.RootElement.TryGetProperty("redirect_uris", out var ru) &&
ru.ValueKind == JsonValueKind.Array)
{
foreach (var item in ru.EnumerateArray())
{
var v = item.GetString();
if (!string.IsNullOrEmpty(v)) redirectUris.Add(v!);
}
}
if (doc.RootElement.TryGetProperty("grant_types", out var gt) &&
gt.ValueKind == JsonValueKind.Array)
{
foreach (var item in gt.EnumerateArray())
{
var v = item.GetString();
if (!string.IsNullOrEmpty(v)) grantTypes.Add(v!);
}
}
}
catch { /* 忽略解析失败,仍签发 client_id */ }
// 只回显服务端确实支持的 grant type:客户端侧有 Zod 校验,
// 回显它不认识的枚举值会直接让注册响应校验失败、连接中断。
var effectiveGrantTypes = grantTypes.Where(g => SupportedGrantTypes.Contains(g)).ToList();
if (effectiveGrantTypes.Count == 0)
effectiveGrantTypes = new List<string> { "authorization_code", "refresh_token" };
var clientId = Guid.NewGuid().ToString("N");
clients[clientId] = new RegisteredClient(
ClientId: clientId,
ClientName: clientName,
RedirectUris: redirectUris.ToArray(),
GrantTypes: effectiveGrantTypes.ToArray(),
Scope: scope,
TokenEndpointAuthMethod: "none");
Console.WriteLine($" [register] issued client_id={clientId} name={clientName} " +
$"grant_types=[{string.Join(",", effectiveGrantTypes)}]");
var resp = new Dictionary<string, object?>
{
["client_id"] = clientId,
["client_id_issued_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["token_endpoint_auth_method"] = "none",
["redirect_uris"] = redirectUris.ToArray(), // 必回显,类型 string[]
["grant_types"] = effectiveGrantTypes.ToArray(),
};
if (clientName != null) resp["client_name"] = clientName;
if (clientUri != null) resp["client_uri"] = clientUri;
if (scope != null) resp["scope"] = scope;
return Results.Json(resp);
});
// ---- 授权端点 GET:渲染登录表单(用户名 + 密码)----
app.MapGet("/authorize", (HttpContext ctx) =>
{
var clientId = ctx.Request.Query["client_id"].ToString();
var redirectUri = ctx.Request.Query["redirect_uri"].ToString();
var state = ctx.Request.Query["state"].ToString();
var codeChallenge = ctx.Request.Query["code_challenge"].ToString();
var scope = ctx.Request.Query["scope"].ToString();
Console.WriteLine($" [authorize] client_id={clientId} scope={scope}");
return Results.Content(
RenderLoginForm(clientId, redirectUri, state, codeChallenge, scope, username: "", error: ""),
"text/html; charset=utf-8");
});
// ---- 同意提交:校验用户名/密码,成功则签发授权码 ----
app.MapPost("/authorize/consent", async (HttpContext ctx) =>
{
var form = await ctx.Request.ReadFormAsync();
var clientId = form["client_id"].ToString();
var redirectUri = form["redirect_uri"].ToString();
var state = form["state"].ToString();
var codeChallenge = form["code_challenge"].ToString();
var scope = form["scope"].ToString();
var username = form["username"].ToString();
var password = form["password"].ToString();
users.TryGetValue(username ?? "", out var user);
if (user is null || user.Password != password)
{
Console.WriteLine($" [consent] login failed username={username}");
return Results.Content(
RenderLoginForm(clientId, redirectUri, state, codeChallenge, scope, username, "用户名或密码错误"),
"text/html; charset=utf-8");
}
if (!IsAllowedRedirect(redirectUri))
{
Console.WriteLine($" [consent] rejected unsafe redirect_uri={redirectUri}");
return Results.BadRequest("unsafe redirect_uri");
}
var code = Guid.NewGuid().ToString("N");
codes[code] = new AuthCodeRecord(
CodeChallenge: codeChallenge,
RedirectUri: redirectUri,
State: state,
Scope: string.IsNullOrEmpty(scope) ? "mcp:tools" : scope,
ClientId: clientId,
CreatedAt: DateTime.UtcNow,
UserId: user.UserId,
Username: user.Username,
DisplayName: user.DisplayName);
Console.WriteLine($" [consent] login ok user={user.Username} (sub={user.UserId}) issued code={code}");
var sep = redirectUri.Contains('?') ? '&' : '?';
var location = $"{redirectUri}{sep}code={code}&state={Uri.EscapeDataString(state)}";
return Results.Redirect(location);
});
// ---- Token 端点:授权码换取令牌 / 刷新令牌换新令牌 ----
app.MapPost("/token", async (HttpContext ctx) =>
{
var form = await ctx.Request.ReadFormAsync();
var grantType = form["grant_type"].ToString();
var clientId = form["client_id"].ToString();
if (string.IsNullOrEmpty(clientId))
return Results.BadRequest(new { error = "invalid_request", error_description = "client_id is required" });
return grantType switch
{
"authorization_code" => await HandleAuthorizationCodeGrant(form, clientId),
"refresh_token" => HandleRefreshTokenGrant(form, clientId),
_ => Results.BadRequest(new { error = "unsupported_grant_type" }),
};
});
app.Run();
// ---- 授权码换取令牌:校验 code + PKCE + TTL,签发访问令牌 + 刷新令牌 ----
async Task<IResult> HandleAuthorizationCodeGrant(IFormCollection form, string clientId)
{
var code = form["code"].ToString();
var codeVerifier = form["code_verifier"].ToString();
var redirectUri = form["redirect_uri"].ToString();
// 先取出(一次性消费)再判断过期:否则过期码在被清理前可被反复尝试
if (string.IsNullOrEmpty(code) || !codes.TryRemove(code, out var rec))
return Results.BadRequest(new { error = "invalid_grant", error_description = "code invalid or expired" });
if (DateTime.UtcNow - rec.CreatedAt > TimeSpan.FromSeconds(AuthorizationCodeLifetimeSeconds))
return Results.BadRequest(new { error = "invalid_grant", error_description = "authorization code expired" });
if (!string.IsNullOrEmpty(rec.ClientId) && rec.ClientId != clientId)
return Results.BadRequest(new { error = "invalid_grant", error_description = "code was issued to another client" });
if (!string.IsNullOrEmpty(redirectUri) && !string.IsNullOrEmpty(rec.RedirectUri) && redirectUri != rec.RedirectUri)
return Results.BadRequest(new { error = "invalid_grant", error_description = "redirect_uri mismatch" });
// PKCE 校验:S256(code_verifier) 必须等于授权时登记的 code_challenge
var expectedChallenge = Sha256Base64Url(codeVerifier);
if (expectedChallenge != rec.CodeChallenge)
return Results.BadRequest(new { error = "invalid_grant", error_description = "PKCE verification failed" });
var subject = new TokenSubject(rec.UserId, rec.Username, rec.DisplayName);
var scope = NormalizeScope(rec.Scope);
// 公共客户端(PKCE、token_endpoint_auth_method=none)无法安全保管长期凭据,
// 只能靠"短访问令牌 + 可轮换的刷新令牌"维持会话,故这里一律签发刷新令牌。
var refresh = IssueRefreshToken(familyId: Guid.NewGuid().ToString("N"), clientId, subject, scope);
var accessToken = IssueAccessToken(subject, scope, clientId);
Console.WriteLine($" [token] authorization_code -> user={subject.Username} (sub={subject.UserId}) " +
$"scope={scope} access_token_expires_in={accessTokenLifetimeSeconds}s refresh_family={Short(refresh.FamilyId)}");
return Results.Json(new Dictionary<string, object?>
{
["access_token"] = accessToken,
["token_type"] = "Bearer",
["expires_in"] = accessTokenLifetimeSeconds,
["scope"] = scope,
["refresh_token"] = refresh.Token,
});
}
// ---- 刷新令牌换新令牌:轮换 + 重放检测 ----
IResult HandleRefreshTokenGrant(IFormCollection form, string clientId)
{
var refreshToken = form["refresh_token"].ToString();
var requestedScope = form["scope"].ToString();
if (string.IsNullOrEmpty(refreshToken))
return Results.BadRequest(new { error = "invalid_request", error_description = "refresh_token is required" });
if (!refreshTokens.TryGetValue(refreshToken, out var rec))
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token invalid or revoked" });
// 重放检测(OAuth 2.1 §4.14.2):已轮换掉的旧令牌再次出现,说明它可能已泄漏。
// 此时不能只拒绝这一次请求 ------ 必须撤销整条令牌链,逼攻击者与合法客户端都重新登录。
if (rec.Status != RefreshTokenStatus.Active)
{
RevokeFamily(rec.FamilyId, "refresh token replay detected");
Console.WriteLine($" [token] !!! REPLAY DETECTED family={Short(rec.FamilyId)} user={rec.Subject.Username} --- 整条令牌链已撤销");
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token reuse detected" });
}
if (rec.ClientId != clientId)
{
RevokeFamily(rec.FamilyId, "client mismatch on refresh");
Console.WriteLine($" [token] !!! client mismatch family={Short(rec.FamilyId)} expected={rec.ClientId} got={clientId}");
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token was issued to another client" });
}
if (DateTime.UtcNow >= rec.ExpiresAt)
{
refreshTokens.TryRemove(refreshToken, out _);
Console.WriteLine($" [token] refresh token expired user={rec.Subject.Username} --- 客户端需重新授权");
return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token expired" });
}
// 刷新只能收窄 scope,不能放大(RFC 6749 §6)
var scope = rec.Scope;
if (!string.IsNullOrEmpty(requestedScope))
{
if (!IsSubsetScope(requestedScope, rec.Scope))
return Results.BadRequest(new { error = "invalid_scope", error_description = "cannot widen scope on refresh" });
scope = requestedScope;
}
// 轮换:旧令牌立即失效;新令牌沿用同一个 family,使得旧令牌日后被重放时可追溯到整条链
rec.Status = RefreshTokenStatus.Rotated;
var rotated = IssueRefreshToken(rec.FamilyId, clientId, rec.Subject, scope);
var accessToken = IssueAccessToken(rec.Subject, scope, clientId);
Console.WriteLine($" [token] refresh_token -> user={rec.Subject.Username} (sub={rec.Subject.UserId}) " +
$"scope={scope} 旧令牌={Short(refreshToken)} 已轮换为={Short(rotated.Token)}");
return Results.Json(new Dictionary<string, object?>
{
["access_token"] = accessToken,
["token_type"] = "Bearer",
["expires_in"] = accessTokenLifetimeSeconds,
["scope"] = scope,
["refresh_token"] = rotated.Token,
});
}
// 签发刷新令牌:32 字节随机不透明串(不可猜测,也不承载任何信息,便于服务端随时令其失效)
RefreshTokenRecord IssueRefreshToken(string familyId, string clientId, TokenSubject subject, string scope)
{
SweepExpired();
var token = "rt_" + Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
var rec = new RefreshTokenRecord
{
Token = token,
FamilyId = familyId,
ClientId = clientId,
Subject = subject,
Scope = scope,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddSeconds(RefreshTokenLifetimeSeconds),
};
refreshTokens[token] = rec;
return rec;
}
// 撤销一条令牌链上的所有刷新令牌(本篇仅供重放检测内部调用;对外的 /revoke 端点见第五篇)。
// 注意:已签发的访问令牌是自制 JWT、无状态,无法在此撤销 ------
// 这正是访问令牌必须短生命周期的原因(最长残留时间 = accessTokenLifetimeSeconds)。
void RevokeFamily(string familyId, string reason)
{
foreach (var kv in refreshTokens)
{
if (kv.Value.FamilyId == familyId && kv.Value.Status != RefreshTokenStatus.Revoked)
{
kv.Value.Status = RefreshTokenStatus.Revoked;
Console.WriteLine($" [revoke] token={Short(kv.Key)} family={Short(familyId)} reason={reason}");
}
}
}
// 清理过期的授权码与已失效的刷新令牌(顺带在每次签发时执行,避免内存无界增长)
void SweepExpired()
{
var now = DateTime.UtcNow;
foreach (var kv in codes)
if (now - kv.Value.CreatedAt > TimeSpan.FromSeconds(AuthorizationCodeLifetimeSeconds))
codes.TryRemove(kv.Key, out _);
foreach (var kv in refreshTokens)
if (kv.Value.ExpiresAt < now)
refreshTokens.TryRemove(kv.Key, out _);
}
// 签发访问令牌(RS256 JWT)。生命周期由 accessTokenLifetimeSeconds 决定------
// 刷新流程得以存在的意义就在这里:短到即使泄漏也只影响一小段时间。
string IssueAccessToken(TokenSubject subject, string scope, string clientId)
{
var handler = new JwtSecurityTokenHandler();
var descriptor = new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = McpResourceAudience,
Subject = new System.Security.Claims.ClaimsIdentity(new[]
{
// sub = 当前登录用户的 id,TaskMcpServer:7030 工具内据此隔离各用户的数据
new System.Security.Claims.Claim(JwtRegisteredClaimNames.Sub, subject.UserId),
new System.Security.Claims.Claim("name", subject.DisplayName),
new System.Security.Claims.Claim("preferred_username", subject.Username),
new System.Security.Claims.Claim("scope", scope),
new System.Security.Claims.Claim("client_id", clientId),
}),
NotBefore = DateTime.UtcNow,
Expires = DateTime.UtcNow.AddSeconds(accessTokenLifetimeSeconds),
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256),
};
return handler.WriteToken(handler.CreateToken(descriptor));
}
// ---------- helpers ----------
string RenderLoginForm(string? clientId, string? redirectUri, string? state, string? codeChallenge, string? scope, string? username, string? error)
{
var errHtml = string.IsNullOrEmpty(error) ? "" :
$"<p style=\"color:#c0392b;margin:8px 0\">{Escape(error)}</p>";
var userVal = Escape(username);
return $@"<html><head><meta charset=""utf-8""><title>登录授权</title></head>
<body style=""font-family:sans-serif;max-width:480px;margin:40px auto"">
<h2>登录并授权</h2>
<p>应用 <code>{Escape(clientId)}</code> 请求访问你的 MCP 服务,权限范围:<b>{Escape(scope)}</b></p>
{errHtml}
<form method=""post"" action=""/authorize/consent"">
<label>用户名:<br/><input type=""text"" name=""username"" value=""{userVal}"" style=""width:100%;padding:8px;margin:6px 0;box-sizing:border-box"" autocomplete=""username"" /></label><br/>
<label>密码:<br/><input type=""password"" name=""password"" value="""" style=""width:100%;padding:8px;margin:6px 0;box-sizing:border-box"" autocomplete=""current-password"" /></label><br/>
<input type=""hidden"" name=""client_id"" value=""{Escape(clientId)}"" />
<input type=""hidden"" name=""redirect_uri"" value=""{Escape(redirectUri)}"" />
<input type=""hidden"" name=""state"" value=""{Escape(state)}"" />
<input type=""hidden"" name=""code_challenge"" value=""{Escape(codeChallenge)}"" />
<input type=""hidden"" name=""scope"" value=""{Escape(scope)}"" />
<button type=""submit"" style=""padding:10px 24px;font-size:16px;margin-top:8px"">授权</button>
</form>
</body></html>";
}
// 规整 scope:去掉空白,保证默认 scope 存在。
// offline_access 只是一个"我要刷新令牌"的信号,不作为资源权限参与校验,故保留在串里不影响鉴权。
static string NormalizeScope(string? scope)
{
var parts = (scope ?? "")
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) return "mcp:tools";
return string.Join(' ', parts);
}
// requested 是否为 granted 的子集(RFC 6749 §6:刷新时不得放大权限)
static bool IsSubsetScope(string requested, string granted)
{
var grantedSet = granted.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(StringComparer.Ordinal);
return requested.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.All(s => grantedSet.Contains(s));
}
// 日志里只打印令牌前 10 位,避免把完整凭据写进控制台
static string Short(string? token) =>
string.IsNullOrEmpty(token) ? "<none>" : token.Length <= 10 ? token : token[..10] + "...";
// 解析 --name=value / --name value 形式的整数选项,缺省时回退到默认值
static int ResolveIntOption(string[] args, string name, int fallback)
{
var flag = "--" + name;
for (var i = 0; i < args.Length; i++)
{
if (args[i].StartsWith(flag + "=", StringComparison.Ordinal))
return int.TryParse(args[i][(flag.Length + 1)..], out var v) ? v : fallback;
if (args[i] == flag && i + 1 < args.Length && int.TryParse(args[i + 1], out var next))
return next;
}
return fallback;
}
static string Base64UrlEncode(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static byte[] Base64UrlDecode(string s) =>
Convert.FromBase64String(s.Replace('-', '+').Replace('_', '/') +
new string('=', (4 - s.Length % 4) % 4));
static string Sha256Base64Url(string input) =>
Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(input)));
// 允许:loopback(http/https localhost/127.0.0.1) + 私有 URI scheme(RFC 8252 native app, 如 workbuddy://)
// 拒绝:http/https 非 loopback(防授权码泄漏到外网) + javascript:/data:/vbscript 等危险 scheme
static bool IsAllowedRedirect(string? uri)
{
if (string.IsNullOrEmpty(uri)) return false;
if (!Uri.TryCreate(uri, UriKind.Absolute, out var u)) return false;
if (u.Scheme is "http" or "https")
return u.Host is "localhost" or "127.0.0.1" or "[::1]";
if (u.Scheme is "javascript" or "data" or "vbscript") return false;
return Regex.IsMatch(u.Scheme, @"^[a-z][a-z0-9+.\-]*$");
}
static string Escape(string? s) =>
string.IsNullOrEmpty(s) ? "" : s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
record AuthCodeRecord(string CodeChallenge, string RedirectUri, string State, string Scope, string ClientId, DateTime CreatedAt,
string UserId, string Username, string DisplayName);
// 令牌的归属主体:访问令牌与刷新令牌共用,保证刷新后 sub 不漂移(否则用户数据会串号)
record TokenSubject(string UserId, string Username, string DisplayName);
// 刷新令牌状态机:Active --(使用一次)--> Rotated --(再次出现)--> 整条链 Revoked
enum RefreshTokenStatus
{
Active, // 当前可用
Rotated, // 已被用来换新令牌,不再有效;保留记录是为了识别重放
Revoked, // 整条令牌链因重放而失效
}
// FamilyId 把一次授权后产生的所有刷新令牌串成一条链:
// 重放检测命中时,撤销的是整条链而不是单个令牌。
sealed class RefreshTokenRecord
{
public required string Token { get; init; }
public required string FamilyId { get; init; }
public required string ClientId { get; init; }
public required TokenSubject Subject { get; init; }
public required string Scope { get; init; }
public required DateTime CreatedAt { get; init; }
public required DateTime ExpiresAt { get; init; }
public RefreshTokenStatus Status { get; set; } = RefreshTokenStatus.Active;
}
// 动态注册的客户端信息(RFC 7591)
record RegisteredClient(string ClientId, string? ClientName, string[] RedirectUris, string[] GrantTypes,
string? Scope, string TokenEndpointAuthMethod);
// 固定用户记录(本地实验用)。真实部署请改为数据库 + 密码哈希存储。
record UserInfo(string UserId, string Username, string Password, string DisplayName);
依赖与第二篇相同:
Microsoft.IdentityModel.Tokens与System.IdentityModel.Tokens.Jwt。
八、小结与下篇预告
本篇完成的事:
- 授权服务器增量支持
refresh_tokengrant:元数据声明(SEP-2207)、令牌链存储、签发与兑换; - 按 OAuth 2.1 §4.14.2 实现轮换 + 重放检测:旧令牌标记 Rotated 保留记录,重放即整链撤销;
- 资源服务器两处配合:
ClockSkew收紧到 5 秒、工具暴露令牌过期时间,刷新全程可观测。
遗留问题只剩一个,也是 JWT 的结构性难题:撤销刷新令牌(或重放触发整链撤销)之后,已签发的访问令牌是无状态 JWT,依然能活到自然过期。30 秒的残留窗口无所谓,1 小时就很难说无所谓了------用户"登出"、管理员"踢人"这类场景都要求立即生效。下一篇正面解决这个问题:给访问令牌加上即时吊销能力(JTI 吊销列表 + RFC 7009 撤销端点),系列收官。
参考
- OAuth 2.1(draft-ietf-oauth-v2-1)§4.14.2 刷新令牌轮换与重放检测、§7.1 短生命周期访问令牌:datatracker.ietf.org/doc/html/dr...
- RFC 9700 --- Best Current Practice for OAuth 2.0 Security(公共客户端强制轮换)
- SEP-2207 --- OIDC-Flavored Refresh Token Guidance(
offline_access与grant_types的约定) - RFC 6749 --- OAuth 2.0 授权框架(§6 刷新时 scope 不得放大)
- MCP 官方授权教程:modelcontextprotocol.org/docs/2026-0...
我是小金,如果这篇帮你解决了一个小问题,欢迎关注我的公众号:小金的研发日志。