From 416a7d5023019653d3991bdbd41c03dc33857e4e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 8 May 2026 08:06:41 +0800 Subject: [PATCH] feat(mcp): auth middleware (Bearer + ?token= fallback) --- internal/mcp/auth_middleware.go | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 internal/mcp/auth_middleware.go diff --git a/internal/mcp/auth_middleware.go b/internal/mcp/auth_middleware.go new file mode 100644 index 0000000..a58c4e0 --- /dev/null +++ b/internal/mcp/auth_middleware.go @@ -0,0 +1,73 @@ +package mcp + +import ( + "context" + "net/http" + "strings" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// authCtxKey 是 ctx 上注入鉴权结果的 key 类型。 +type authCtxKey struct{} + +// AuthContextKey 暴露给同包文件(auth_middleware / handler / tools)从 ctx 取值。 +var AuthContextKey = authCtxKey{} + +// AuthSession 是中间件解析后注入 ctx 的鉴权快照。 +// 不直接暴露 *MCPToken(隐藏 hash 字段,避免下游误用)。 +type AuthSession struct { + TokenID string // 用于审计 metadata 写入 + UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换) + Scope Scope +} + +// FromContext 从 ctx 取出 AuthSession,未鉴权时返 (nil, false)。 +func FromContext(ctx context.Context) (*AuthSession, bool) { + s, ok := ctx.Value(AuthContextKey).(*AuthSession) + return s, ok +} + +// NewAuthMiddleware 返回 chi 风格 middleware: +// 1. 从 Authorization: Bearer 取 plaintext;fallback 到 ?token= +// 2. 调 svc.Authenticate 验证 +// 3. 把 AuthSession 注入 ctx 后 next.ServeHTTP +// 4. 失败 → 调 httpx.WriteError 返 4xx +func NewAuthMiddleware(svc TokenService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + plain := extractPlaintext(r) + if plain == "" { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeMcpTokenInvalid, "missing token")) + return + } + tok, err := svc.Authenticate(r.Context(), plain) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + sess := &AuthSession{ + TokenID: tok.ID.String(), + UserID: tok.UserID.String(), + Scope: tok.Scope, + } + ctx := context.WithValue(r.Context(), AuthContextKey, sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// extractPlaintext 优先取 Authorization 头部 Bearer 后的 token;空时回落 ?token= 查询参数。 +// 这与 ACP WS 鉴权对齐(spec §4.3)。 +func extractPlaintext(r *http.Request) string { + if h := r.Header.Get("Authorization"); h != "" { + const prefix = "Bearer " + if strings.HasPrefix(h, prefix) { + return strings.TrimSpace(h[len(prefix):]) + } + } + return r.URL.Query().Get("token") +}