feat(auth): support RS256 + OIDC discovery (JWKS auto-fetch)
Build Docker Image / build (push) Failing after 1m42s

Add Jwt__Algorithm config to choose between HS256 (shared symmetric key,
existing behavior, default) and RS256 (Authority-based OIDC discovery,
public-key auto-fetch with periodic refresh).

RS256 mode makes the server compatible with any standard OAuth 2.1 / OIDC
provider (Logto, ZITADEL, Keycloak, Auth0) without requiring a shared
secret. HS256 mode remains the default for minimal self-built AS setups.
This commit is contained in:
2026-05-18 00:18:50 +08:00
parent 71600adba9
commit 1ccddae692
4 changed files with 96 additions and 13 deletions
+42 -9
View File
@@ -6,8 +6,12 @@ using System.Text;
namespace GiteaMcp.Auth; namespace GiteaMcp.Auth;
/// <summary> /// <summary>
/// JWT Bearer 验签配置HS256 对称密钥方案。 /// JWT Bearer 验签配置。根据 JwtOptions.Algorithm 选择:
/// ValidIssuer / ValidAudience 由配置驱动;支持 Current + Previous 双密钥(轮换窗口)。 /// <list type="bullet">
/// <item><c>HS256</c>:与 AS 共享对称密钥(Current + Previous 双密钥用于轮换)。</item>
/// <item><c>RS256</c>:委托 ASP.NET Core 通过 OIDC discovery 拉 JWKS
/// 自动刷新公钥,AS 端密钥轮换无需重启本服务。</item>
/// </list>
/// </summary> /// </summary>
public static class JwtBearerSetup public static class JwtBearerSetup
{ {
@@ -18,6 +22,8 @@ public static class JwtBearerSetup
var jwtOpts = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() var jwtOpts = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? new JwtOptions(); ?? new JwtOptions();
var algorithm = (jwtOpts.Algorithm ?? "HS256").Trim().ToUpperInvariant();
services services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
@@ -26,7 +32,7 @@ public static class JwtBearerSetup
// ClaimTypes.NameIdentifier 之类的长 URI,下游 FindFirst("scope") 取不到。 // ClaimTypes.NameIdentifier 之类的长 URI,下游 FindFirst("scope") 取不到。
options.MapInboundClaims = false; options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters var tvp = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = jwtOpts.Issuer, ValidIssuer = jwtOpts.Issuer,
@@ -35,28 +41,51 @@ public static class JwtBearerSetup
ValidAudience = jwtOpts.Audience, ValidAudience = jwtOpts.Audience,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
// ToList 物化一次,避免每次验签重建 SymmetricSecurityKey。
IssuerSigningKeys = BuildSigningKeys(jwtOpts).ToList(),
ValidateLifetime = true, ValidateLifetime = true,
// 允许 2 分钟时钟偏差,缓解 AS 与本服务时钟漂移 // 允许 2 分钟时钟偏差,缓解 AS 与本服务时钟漂移
ClockSkew = TimeSpan.FromMinutes(2), ClockSkew = TimeSpan.FromMinutes(2),
}; };
if (algorithm == "RS256")
{
if (string.IsNullOrWhiteSpace(jwtOpts.Issuer))
throw new InvalidOperationException(
"Jwt:Issuer 未配置,RS256 模式无法启动。");
// Authority = Issuer 时,JwtBearer 会自动从
// <Issuer>/.well-known/openid-configuration 拉 OIDC 元数据,
// 再从其中的 jwks_uri 拉公钥并周期性刷新。IssuerSigningKeys
// 由 ConfigurationManager 在验签时动态注入,无需在此设置。
options.Authority = jwtOpts.Issuer;
options.RequireHttpsMetadata = !IsLocalhostUrl(jwtOpts.Issuer);
}
else if (algorithm == "HS256")
{
// ToList 物化一次,避免每次验签重建 SymmetricSecurityKey。
tvp.IssuerSigningKeys = BuildHs256Keys(jwtOpts).ToList();
}
else
{
throw new InvalidOperationException(
$"Jwt:Algorithm '{jwtOpts.Algorithm}' 不支持。可选值:HS256, RS256");
}
options.TokenValidationParameters = tvp;
}); });
return services; return services;
} }
/// <summary> /// <summary>
/// 构建 Current + Previous 双密钥列表,供 SDK 依次尝试验签。 /// 构建 Current + Previous 双 HS256 密钥列表,供 SDK 依次尝试验签。
/// 轮换期间两把钥匙同时有效,过期的旧 Token 会被 ValidateLifetime 自然拦截。 /// 轮换期间两把钥匙同时有效,过期的旧 Token 会被 ValidateLifetime 自然拦截。
/// Current 未配置直接抛错,避免容器静默以"任何 token 都不通过"的状态运行。 /// Current 未配置直接抛错,避免容器静默以"任何 token 都不通过"的状态运行。
/// </summary> /// </summary>
private static IEnumerable<SecurityKey> BuildSigningKeys(JwtOptions opts) private static IEnumerable<SecurityKey> BuildHs256Keys(JwtOptions opts)
{ {
if (string.IsNullOrWhiteSpace(opts.SigningKey.Current)) if (string.IsNullOrWhiteSpace(opts.SigningKey.Current))
throw new InvalidOperationException( throw new InvalidOperationException(
"Jwt:SigningKey:Current 未配置,gitea-mcp 无法启动。" + "Jwt:SigningKey:Current 未配置,HS256 模式无法启动。" +
"请通过 env Jwt__SigningKey__Current 注入与 auth server 共享的 HS256 密钥。"); "请通过 env Jwt__SigningKey__Current 注入与 auth server 共享的 HS256 密钥。");
yield return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(opts.SigningKey.Current)); yield return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(opts.SigningKey.Current));
@@ -64,4 +93,8 @@ public static class JwtBearerSetup
if (!string.IsNullOrWhiteSpace(opts.SigningKey.Previous)) if (!string.IsNullOrWhiteSpace(opts.SigningKey.Previous))
yield return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(opts.SigningKey.Previous)); yield return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(opts.SigningKey.Previous));
} }
private static bool IsLocalhostUrl(string url) =>
url.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("http://127.0.0.1", StringComparison.OrdinalIgnoreCase);
} }
+14 -2
View File
@@ -1,19 +1,31 @@
namespace GiteaMcp.Config; namespace GiteaMcp.Config;
/// <summary> /// <summary>
/// JWT 验签配置,与 auth server 共用同款 HS256 对称密钥 /// JWT 验签配置。
/// 环境变量:Jwt__Issuer, Jwt__Audience, Jwt__SigningKey__Current, Jwt__SigningKey__Previous /// 环境变量:Jwt__Algorithm, Jwt__Issuer, Jwt__Audience, Jwt__SigningKey__Current, Jwt__SigningKey__Previous
/// </summary> /// </summary>
public class JwtOptions public class JwtOptions
{ {
public const string SectionName = "Jwt"; public const string SectionName = "Jwt";
/// <summary>
/// JWT 签名算法。
/// <list type="bullet">
/// <item><c>HS256</c>(默认):与 AS 共享对称密钥(SigningKey.Current 必填)。适合自建极简 AS。</item>
/// <item><c>RS256</c>:从 Issuer 走 OIDC discovery 自动拉 JWKS(含自动刷新)。
/// 适合任何标准 OAuth 2.1 / OIDC ASLogto / ZITADEL / Keycloak / Auth0 等)。
/// 要求 Issuer 暴露 <c>/.well-known/openid-configuration</c>。</item>
/// </list>
/// </summary>
public string Algorithm { get; set; } = "HS256";
/// <summary>期望的 iss claim(你的 auth server 的 issuer URL),必须通过 env 注入</summary> /// <summary>期望的 iss claim(你的 auth server 的 issuer URL),必须通过 env 注入</summary>
public string Issuer { get; set; } = string.Empty; public string Issuer { get; set; } = string.Empty;
/// <summary>期望的 aud claim,默认 gitea</summary> /// <summary>期望的 aud claim,默认 gitea</summary>
public string Audience { get; set; } = "gitea"; public string Audience { get; set; } = "gitea";
/// <summary>HS256 模式使用;RS256 模式下忽略。</summary>
public SigningKeyPair SigningKey { get; set; } = new(); public SigningKeyPair SigningKey { get; set; } = new();
public class SigningKeyPair public class SigningKeyPair
+39 -2
View File
@@ -8,6 +8,13 @@ The MCP server holds a single read-only Gitea Personal Access Token (PAT)
internally and never exposes it to the MCP client. Clients authenticate to internally and never exposes it to the MCP client. Clients authenticate to
this server with an OAuth-issued JWT instead. this server with an OAuth-issued JWT instead.
Two JWT signing modes are supported:
- **HS256** (default) — shared symmetric key between AS and this server. Use for self-built minimal AS.
- **RS256** — fetches JWKS automatically via OIDC discovery from `<Issuer>/.well-known/openid-configuration`. Use with any standard provider: [Logto](https://logto.io), [ZITADEL](https://zitadel.com), [Keycloak](https://www.keycloak.org), [Auth0](https://auth0.com), etc.
See [Choosing an AS](#choosing-an-as) below for setup guidance.
## Architecture ## Architecture
``` ```
@@ -60,10 +67,11 @@ All tools require a valid JWT with `scope=read:gitea`.
| `Gitea__RepoBlacklist` | *(empty)* | no | Comma-separated `owner/repo` pairs to hide | | `Gitea__RepoBlacklist` | *(empty)* | no | Comma-separated `owner/repo` pairs to hide |
| `Gitea__DefaultLimit` | `50` | no | Default page size for list operations | | `Gitea__DefaultLimit` | `50` | no | Default page size for list operations |
| `Gitea__MaxFileBytes` | `1048576` | no | Max file/log read size in bytes (1MB) | | `Gitea__MaxFileBytes` | `1048576` | no | Max file/log read size in bytes (1MB) |
| `Jwt__Algorithm` | `HS256` | no | `HS256` or `RS256` |
| `Jwt__Issuer` | — | **yes** | Expected `iss` claim — your AS's issuer URL | | `Jwt__Issuer` | — | **yes** | Expected `iss` claim — your AS's issuer URL |
| `Jwt__Audience` | `gitea` | no | Expected `aud` claim | | `Jwt__Audience` | `gitea` | no | Expected `aud` claim |
| `Jwt__SigningKey__Current` | — | **yes** | HS256 signing key, shared with your AS | | `Jwt__SigningKey__Current` | — | HS256 only | HS256 signing key, shared with your AS |
| `Jwt__SigningKey__Previous` | — | no | Previous key for rotation window | | `Jwt__SigningKey__Previous` | — | no | Previous HS256 key for rotation window |
| `Mcp__OAuthDiscovery__Issuer` | — | **yes** | `/.well-known` `issuer` field | | `Mcp__OAuthDiscovery__Issuer` | — | **yes** | `/.well-known` `issuer` field |
| `Mcp__OAuthDiscovery__AuthorizationEndpoint` | — | **yes** | Your AS's `/authorize` URL | | `Mcp__OAuthDiscovery__AuthorizationEndpoint` | — | **yes** | Your AS's `/authorize` URL |
| `Mcp__OAuthDiscovery__TokenEndpoint` | — | **yes** | Your AS's `/token` URL | | `Mcp__OAuthDiscovery__TokenEndpoint` | — | **yes** | Your AS's `/token` URL |
@@ -146,6 +154,35 @@ on every push to `main`. It expects these repository Variables / Secrets:
- `vars.IMAGE_OWNER` — registry owner/namespace - `vars.IMAGE_OWNER` — registry owner/namespace
- `secrets.PACKAGES_TOKEN` — registry push token - `secrets.PACKAGES_TOKEN` — registry push token
## Choosing an AS
Claude.ai chat enforces the full OAuth Authorization Code + PKCE flow against
your MCP server's `/.well-known/oauth-authorization-server` endpoint — there
is no bearer-token shortcut. Pick one of these paths:
**Hosted (fastest start, recommended for new setups)** — RS256 mode:
| Provider | Free tier | Notes |
|---|---|---|
| [Logto Cloud](https://logto.io) | 5000 MAU | Lightest, ~30 min setup |
| [ZITADEL Cloud](https://zitadel.com) | 25k auths/month | More featureful, slightly heavier docs |
Set `Jwt__Algorithm=RS256` and `Jwt__Issuer=<your-tenant-issuer-URL>`.
Public keys are fetched automatically from `<Issuer>/.well-known/openid-configuration`.
**Self-hosted, full-featured** — RS256 mode:
[Keycloak](https://www.keycloak.org), [ZITADEL](https://github.com/zitadel/zitadel), [Logto](https://github.com/logto-io/logto), [Authentik](https://goauthentik.io).
**Self-hosted, minimal** — HS256 mode:
Write your own ~500 LoC AS that issues HS256 JWTs with the right claims. The
MCP server's `Jwt__SigningKey__Current` and the AS's signing key must match.
**Required AS features regardless of choice:**
- OAuth 2.1 + PKCE (RFC 7636)
- Dynamic Client Registration (RFC 7591) — so Claude.ai can self-register
- `resource` parameter support (RFC 8707) — for audience-bound tokens
- Custom scope support (`read:gitea`)
## License ## License
MIT MIT
+1
View File
@@ -15,6 +15,7 @@
"MaxFileBytes": 1048576 "MaxFileBytes": 1048576
}, },
"Jwt": { "Jwt": {
"Algorithm": "HS256",
"Issuer": "", "Issuer": "",
"Audience": "gitea", "Audience": "gitea",
"SigningKey": { "SigningKey": {