mcpauth

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 13 Imported by: 0

README

invoker-mcpauth

A small, strict RS256 JWT verifier for OAuth resource servers in Go.

English | 简体中文

It does exactly one thing: validate a Bearer JWT issued by your authorization server, by fetching public keys from that server's JWKS endpoint. It performs no OAuth behaviour of its own — no discovery, no redirects, no WWW-Authenticate challenge. Every failure is an HTTP 401 with a machine-readable error code.

The only dependency is golang-jwt/jwt/v5.

Why another one

Most JWT middleware defaults to permissive. This one defaults to the strictest reading at every fork, because each of the following is a token-forgery hole and none of them is the default behaviour of the underlying library:

Check Why the permissive default is dangerous
Algorithm pinned to RS256 Without pinning, alg=none and HS256 confusion both forge tokens — an attacker signs with your public key as the HMAC secret, and the library accepts it.
aud must be exactly one value jwt.WithAudience is membership semantics: aud: ["my-api", "attacker-api"] passes. A compromised co-audience could then replay that token against you.
exp must be present Without WithExpirationRequired, a token with no exp is treated as never expiring.
kid missing → reject No "if there's only one key, use it" fallback. During key rotation the JWKS holds two keys, the fallback picks wrong, and the symptom is intermittent 401s.
iss exact match No prefix/suffix leniency.

Beyond correctness, the JWKS client is built for production: single-flight coalescing, a minimum refresh interval, a response size cap, and detachment of the fetch from the caller's request context (so a client that disconnects cannot abort an in-flight key refresh).

Install

go get github.com/dailyyoga/invoker-mcpauth

Quick start

As net/http middleware
verifier, err := mcpauth.New(mcpauth.Config{
    Issuer:   "https://auth.example.com", // must match the `iss` claim verbatim
    Audience: "my-api",                   // must match the `aud` claim verbatim
    // JWKSURL defaults to {Issuer}/.well-known/jwks.json
})
if err != nil {
    log.Fatal(err)
}

// Optional: warm the key cache at startup. Failure is NOT fatal —
// treating it as fatal makes startup order a hard dependency between services.
if err := verifier.Prime(ctx); err != nil {
    log.Warn("JWKS prime failed, will retry lazily", "err", err)
}

onDenied := func(r *http.Request, e *mcpauth.AuthError) {
    // e.Reason is diagnostic detail — log it, never return it to the client.
    log.Warn("auth denied", "code", e.Code, "reason", e.Reason, "path", r.URL.Path)
}

mux := http.NewServeMux()
mux.Handle("/v1/", verifier.Middleware(onDenied)(yourHandler))

Inside a wrapped handler, retrieve the verified claims:

claims, ok := mcpauth.ClaimsFromContext(r.Context())
if ok {
    log.Info("request", "sub", claims.Subject, "client_id", claims.ClientID)
}
Verifying directly
claims, authErr := verifier.Verify(ctx, r.Header.Get("Authorization"))
if authErr != nil {
    mcpauth.WriteUnauthorized(w, authErr.Code)
    return
}

Configuration

type Config struct {
    JWKSURL  string // defaults to {Issuer}/.well-known/jwks.json
    Issuer   string // required — no default
    Audience string // required — no default

    ClockSkew          time.Duration // default 60s
    MinRefreshInterval time.Duration // default 10s
    HTTPTimeout        time.Duration // default 5s

    OnResult func(result string) // optional observability hook
}

Issuer and Audience deliberately have no defaults. They define the trust boundary, so a guessed default could let a token validate under the wrong one. A missing value fails at construction, which puts the configuration error at startup rather than in production traffic.

OnResult fires once per verification with "ok" or one of the error codes below. The value set is bounded, so it is safe to use directly as a metrics label. It is a callback rather than a direct Prometheus dependency so that callers are not forced onto a particular metrics stack.

Error codes

The 401 response body is {"error": "<code>"}.

Code Meaning
missing_token No Authorization header, or not a Bearer token
invalid_token Signature, iss, aud, sub, structure — every non-expiry failure
expired_token exp passed or nbf not yet reached (broken out to make clock/TTL problems visible)

Clients typically treat all 401s identically (refresh once, retry), so the code is for diagnostics and metrics. The status matters more than the code: returning 403 or 500 breaks that self-heal path and turns a silently recoverable expiry into a user-visible failure.

AuthError.Reason carries the internal diagnostic detail ("issuer mismatch", "bad signature"). It is deliberately kept out of the response body — which step failed is useful to an attacker and useless to a legitimate client. Log it; never serialize it.

Notes on the JWKS client

  • Single-flight. Concurrent requests with an unknown kid share one in-flight fetch instead of "one succeeds, the rest get throttled". Otherwise a key rotation causes a burst of 401s, and a client's retry landing inside the same window fails a second time.
  • Rate limited by attempt time, including failures. Forged-kid traffic would otherwise turn your JWKS endpoint into a DDoS amplifier — and an unauthenticated one, since forging a kid requires no valid token.
  • Fetch is detached from the request context. If it inherited the caller's context, an attacker could send an unknown kid and immediately disconnect, aborting the refresh and defeating rotation recovery without authenticating.
  • Keys are replaced wholesale, not merged. A kid withdrawn from the JWKS must stop working; merging would make removal a no-op for the running process.
  • Only RSA keys usable for RS256 signatures are loaded. Other key types, uses, and algorithms in the set are skipped — accepting them only creates opportunities to select the wrong key.

License

MIT

Documentation

Overview

Package mcpauth 是面向资源服务器(OAuth Resource Server)的 RS256 JWT 验签库。

它只做一件事:校验一枚由授权服务器签发的 Bearer JWT —— 按 kid 从授权服务器的 JWKS 端点选公钥验签名 → iss 精确匹配 → aud 精确匹配 → exp/nbf(±clock skew)。除此之外**不做任何 OAuth 行为**:不发起 discovery、 不重定向、不返回 WWW-Authenticate 这类会诱发客户端启动 OAuth 流程的响应头 (理由见 WriteUnauthorized),失败一律 HTTP 401 + 机器可读错误码。

默认取值一律选最严的一侧:算法锁定 RS256、aud 必须恰为单值、exp 必须存在、 kid 缺失不做兜底。这几条都**不是** golang-jwt 的默认行为,而漏掉任意一条 都是一个可伪造 token 的洞——各自的攻击场景写在对应的行内注释里。

生产考量:JWKS 拉取带 singleflight 合流、最小间隔限流与响应体积上限; 启动预热可选(Prime);观测通过回调注入(Config.OnResult),不绑定任何指标栈。

Index

Constants

View Source
const (
	CodeMissingToken = "missing_token" // 无 Authorization 头或非 Bearer 形态
	CodeInvalidToken = "invalid_token" // 签名/iss/aud/结构等一切非过期类失败
	CodeExpiredToken = "expired_token" // 过期(含 nbf 未生效),单列便于发现时钟/TTL 问题
)

错误码取值 —— 401 响应体 {"error": "<code>"} 的机器可读部分。 客户端对任何 401 的处置通常都一样(就地刷新 token 重试一次),错误码只服务于诊断与指标; 但状态码必须是 401:回 403 或 500 会让客户端的自愈路径失灵,把一次本可静默 恢复的过期变成一次用户可见的失败。

Variables

This section is empty.

Functions

func WriteUnauthorized

func WriteUnauthorized(w http.ResponseWriter, code string)

WriteUnauthorized 写出统一的 401 响应:{"error":"<code>"}。

刻意**不设置** WWW-Authenticate:RFC 6750 的挑战头会诱发部分客户端就地启动 OAuth discovery 流程,去本服务上找授权服务器元数据——而本库面向的是 「授权收敛在独立授权服务器、资源服务器只验签」的部署形态,资源服务器不该 暴露任何会把客户端引向自己的 OAuth 线索。需要挑战头的场景请自行包一层。

状态码恒为 401:客户端的「刷新 token 重试一次」自愈路径通常只挂在 401 上, 回 403 或 500 会让它直接放弃。

Types

type AuthError

type AuthError struct {
	Code   string
	Reason string
}

AuthError 是一次验签失败的结论。

Code 是有界枚举(进 401 响应体与指标标签);Reason 是内部诊断文案, 只进日志——它可能携带「哪一步失败」这类对攻击者有价值的细节,绝不外发。

func (*AuthError) Error

func (e *AuthError) Error() string

Error 实现 error 接口,输出仅含内部诊断信息。

type Claims

type Claims struct {
	jwt.RegisteredClaims

	ClientID string `json:"client_id"`
	Name     string `json:"name"`
}

Claims 是验签通过后暴露给业务层的载荷子集。

Name 仅供日志展示,**不得用于授权判定**:它是签发方填的展示名,可变、不唯一, 也不参与签名之外的任何约束。要标识主体请用 Subject。

func ClaimsFromContext

func ClaimsFromContext(ctx context.Context) (*Claims, bool)

ClaimsFromContext 取出 Middleware 注入的验签结果。 只在被 Middleware 包裹的 handler 内非零;工具层用它取 sub 做结构化日志。

type Config

type Config struct {
	// JWKSURL 是授权服务器的 JWK Set 端点(RFC 7517)。留空时按惯例由 Issuer 派生为
	// {issuer}/.well-known/jwks.json;授权服务器若把 JWKS 挂在别处,显式填这个字段。
	JWKSURL string
	// Issuer 必须与 token 的 iss 声明逐字一致,精确匹配,不做前缀/后缀宽容。
	Issuer string
	// Audience 必须与 token 的 aud 声明逐字一致。本库要求 aud 恰为单值(见 verify)。
	Audience string

	// ClockSkew 是 exp/nbf 的容忍偏差,零值取 60s。
	ClockSkew time.Duration
	// MinRefreshInterval 是「未知 kid 触发 JWKS 重取」的最小间隔,零值取 10s。
	// 没有它,一个伪造 kid 的请求流就能把授权服务器的 JWKS 端点打成 DDoS 放大器
	// ——而且这个放大器是**免鉴权**的:伪造 kid 的请求根本不需要一枚有效 token。
	MinRefreshInterval time.Duration
	// HTTPTimeout 是拉取 JWKS 的超时,零值取 5s。
	HTTPTimeout time.Duration

	// OnResult 是可选观测钩子:每次验签结束回调一次,result 为 "ok" 或错误码。
	// 用回调而不是直接依赖某个指标库,调用方引用本包时不被迫对齐指标栈。
	// result 取值有界(只可能是 "ok" 或本文件定义的三个错误码),可直接用作指标标签。
	OnResult func(result string)
}

Config 是验签器配置。

Issuer / Audience 刻意**没有默认值**:这两个值定义了信任域,任何「猜一个」 的默认值都可能让 token 在错误的信任域下被判为有效。缺失即构造失败, 把配置错误挡在启动期而不是运行期。取值必须逐字抄自授权服务器发布的契约。

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier 按 kid 从授权服务器的 JWKS 取公钥验签。并发安全,可长期复用。

func New

func New(cfg Config) (*Verifier, error)

New 构造验签器并校验配置。不发网络请求——首拉由 Prime 或首个未知 kid 触发。

func (*Verifier) Middleware

func (v *Verifier) Middleware(onDenied func(r *http.Request, authErr *AuthError)) func(http.Handler) http.Handler

Middleware 返回标准 net/http 中间件:验签失败一律 401,成功把 Claims 注入 context。

onDenied 可选(可传 nil),在写出 401 前回调,供服务侧记结构化日志—— Reason 只该出现在日志里,所以由这里回调而不是写进响应。

func (*Verifier) Prime

func (v *Verifier) Prime(ctx context.Context) error

Prime 启动期预拉一次 JWKS。失败**不该致命**(授权服务器可能暂时不可达, 或本服务比它先起来),调用方记日志即可——后续请求遇未知 kid 会在限流约束下 重试拉取。把 Prime 的失败当作启动失败,会让两个服务的启动顺序变成硬依赖。

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, authorization string) (*Claims, *AuthError)

Verify 校验 Authorization 头。成功返回 Claims,失败返回 *AuthError。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL