Replaces the shared GITEA_MCP_DEFAULT_TOKEN for all callers. When a request's bearer validates directly against Gitea's own /api/v1/user, that token is used for every upstream call this request makes instead of the service PAT, and the caller identity comes from Gitea's own login rather than the proxy header. Any other bearer (static token, JWT, none) falls through unchanged to the existing chassis auth. Prep: Authentik now SSOs into Gitea (infra a32801c), so each real user can mint their own PAT from their own linked account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
4.2 KiB
Go
114 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
|
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
|
)
|
|
|
|
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
|
|
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
|
|
// "0.1.0" — it now tells the truth instead of a stale literal).
|
|
var version = "dev"
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
|
|
// the same structured handler as the rest of the server.
|
|
slog.SetDefault(logger)
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
logger.Error("load config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/
|
|
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
|
|
// (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client
|
|
// can't be built (not in a pod) or its discovery is unreachable at startup,
|
|
// it is dropped so existing Authentik/static auth is never taken down.
|
|
var issuers []chassisauth.IssuerConfig
|
|
if cfg.DexIssuerURL != "" {
|
|
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience})
|
|
}
|
|
if cfg.K8sIssuerURL != "" {
|
|
if client, cerr := k8sOIDCClient(); cerr != nil {
|
|
logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr)
|
|
} else {
|
|
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client})
|
|
}
|
|
}
|
|
|
|
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
|
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
|
|
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
|
|
// startup. Don't let it take down Authentik JWT auth — retry without it.
|
|
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
|
|
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
|
|
for _, ic := range issuers {
|
|
if ic.IssuerURL != cfg.K8sIssuerURL {
|
|
pub = append(pub, ic)
|
|
}
|
|
}
|
|
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
|
|
}
|
|
if jwtInitErr != nil {
|
|
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
|
}
|
|
|
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
|
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
|
|
|
reg := registry.New()
|
|
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
|
|
|
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
|
Registry: reg,
|
|
Sessions: mcp.NewSessionStore(),
|
|
})
|
|
|
|
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
|
|
// when both MCPResourceURL and a Dex issuer are wired; empty disables
|
|
// the challenge so static-only clients aren't pushed into OAuth discovery.
|
|
var resourceMetadataURL string
|
|
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
|
|
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
|
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
|
auth.CallerMiddleware(logger, mcpSrv),
|
|
),
|
|
),
|
|
))
|
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
|
if cfg.DexIssuerURL != "" {
|
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
|
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
|
}
|
|
|
|
addr := ":" + cfg.Port
|
|
logger.Info("gitea-mcp starting", "addr", addr, "version", version)
|
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
logger.Error("server stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|