Initial public release
Build Docker Image / build (push) Failing after 1m22s

MCP (Model Context Protocol) server for reading and writing an Obsidian
vault, gated by OAuth-issued JWT bearer tokens. See README.md for setup.
This commit is contained in:
2026-05-17 23:53:00 +08:00
commit 515763bc72
31 changed files with 1931 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ObsidianMcp.Config;
using System.Text;
namespace ObsidianMcp.Auth;
public static class JwtBearerSetup
{
/// <summary>
/// 配置 HS256 JWT Bearer 认证。
/// 支持 Current + Previous 双密钥,方便密钥轮换过渡期。
/// </summary>
public static IServiceCollection AddObsidianJwtBearer(
this IServiceCollection services,
JwtOptions opts)
{
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// 关闭默认的入站 claim type 映射,否则 "sub"/"scope" 会被改写成
// ClaimTypes.NameIdentifier 之类的长 URI,下游 FindFirst("sub") 取不到。
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = opts.Issuer,
ValidateAudience = true,
ValidAudience = opts.Audience,
ValidateIssuerSigningKey = true,
// Current 必须有值;Previous 可选(密钥轮换过渡期)。
// ToList 物化一次,避免每次验签都重建 SymmetricSecurityKey。
IssuerSigningKeys = BuildSigningKeys(opts).ToList(),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(2),
// scope claim 的 claim type 直接保持原样,User.FindAll("scope") 能取到。
NameClaimType = "sub",
};
});
return services;
}
private static IEnumerable<SecurityKey> BuildSigningKeys(JwtOptions opts)
{
if (string.IsNullOrWhiteSpace(opts.SigningKey.Current))
throw new InvalidOperationException("Jwt:SigningKey:Current 未配置,服务无法启动。");
yield return new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(opts.SigningKey.Current));
if (!string.IsNullOrWhiteSpace(opts.SigningKey.Previous))
yield return new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(opts.SigningKey.Previous));
}
}
+85
View File
@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.Authorization;
namespace ObsidianMcp.Auth;
/// <summary>
/// 自定义 scope 校验 Policy
/// RequireScope("read:obsidian")
/// RequireScope("write:obsidian")
///
/// JWT 的 scope claim 可能是单个字符串(空格分隔)或多个 claim,两种都处理。
/// </summary>
public static class ScopePolicies
{
public const string ReadObsidian = "read:obsidian";
public const string WriteObsidian = "write:obsidian";
/// <summary>注册两条 scope policy 到 AuthorizationOptions。</summary>
public static void AddScopePolicies(this AuthorizationOptions opts)
{
opts.AddPolicy(ReadObsidian, policy =>
policy.RequireAuthenticatedUser()
.AddRequirements(new ScopeRequirement(ReadObsidian)));
opts.AddPolicy(WriteObsidian, policy =>
policy.RequireAuthenticatedUser()
.AddRequirements(new ScopeRequirement(WriteObsidian)));
}
}
// ---------- Requirement ----------
public class ScopeRequirement(string scope) : IAuthorizationRequirement
{
public string RequiredScope { get; } = scope;
}
// ---------- Handler ----------
public class ScopeAuthorizationHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
// scope claim 在 JWT 里可能是一整个空格分隔的字符串,也可能是多个 claim。
// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感,按 Ordinal 比对。
var scopes = context.User
.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.Ordinal);
if (scopes.Contains(requirement.RequiredScope))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// ---------- Per-tool scope guard helper ----------
/// <summary>
/// MCP Tool 内部 scope 校验:从当前 HttpContext.User 读 scope claim
/// 不包含 requiredScope 时抛 UnauthorizedAccessException。
///
/// 用法:在每个读 / 写 Tool 的方法体首行调一下,给客户端可读的失败原因。
/// 端点级 RequireAuthorization 只确保 JWT 验签通过;scope 颗粒度门禁在这里。
/// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感。
/// </summary>
public static class ToolScopeGuard
{
public static void EnsureScope(IHttpContextAccessor http, string requiredScope)
{
var ctx = http.HttpContext
?? throw new InvalidOperationException("无 HttpContext,无法校验 scope。");
var scopes = ctx.User
.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.Ordinal);
if (!scopes.Contains(requiredScope))
throw new UnauthorizedAccessException(
$"当前 Token 缺少所需 scope{requiredScope}");
}
}