diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..33b613a --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,28 @@ +name: Build and Test + +on: + push: + branches: + - '**' + workflow_dispatch: + +jobs: + verify: + runs-on: container-builder + container: + image: golang:1.24-bookworm + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Show Go Version + run: go version + + - name: Go Vet + run: go vet ./... + + - name: Go Test + run: go test ./... -v + + - name: Go Build + run: go build ./cmd/authproxy diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..cc737af --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,65 @@ +name: Build and Push Container Image + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + +env: + REGISTRY: gitea.nehmer.net/torben + IMAGE_NAME: gitea-mcp-auth-proxy + +jobs: + build-and-push-image: + runs-on: container-builder + container: + image: debian:trixie-slim + steps: + - name: Install CI Dependencies + run: | + apt-get update + apt-get install -y --no-install-recommends git nodejs curl docker-cli docker-buildx unzip ca-certificates iproute2 gawk + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Load Secrets from 1Password + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + REGISTRY_USER: op://CI-CD/gitea-package-token/username + REGISTRY_PAT: op://CI-CD/gitea-package-token/password + + - name: BuildKit Setup (Remote Builder konfigurieren) + run: | + HOST_IP=$(ip route | awk '/default/ { print $3 }') + docker buildx create --name remote-builder --driver remote tcp://$HOST_IP:1234 --use --bootstrap + + - name: Log in to the Container registry + run: | + echo "$REGISTRY_PAT" | docker login $REGISTRY -u "$REGISTRY_USER" --password-stdin + + - name: Build and Push + run: | + TAG="${{ gitea.ref_name }}" + + # Note: This repository only builds and pushes images. + # FluxCD Image Automation in another repository performs deployment. + # Stable tags (vX.Y.Z) may become :latest. Pre-release tags never do. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + docker buildx build \ + --platform linux/amd64 \ + --push \ + --tag $REGISTRY/$IMAGE_NAME:$TAG \ + --tag $REGISTRY/$IMAGE_NAME:latest \ + . + else + docker buildx build \ + --platform linux/amd64 \ + --push \ + --tag $REGISTRY/$IMAGE_NAME:$TAG \ + . + fi diff --git a/.gitignore b/.gitignore index 5b90e79..55e9bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.dll *.so *.dylib +authproxy # Test binary, built with `go test -c` *.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..371114a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +ARG GO_VERSION=1.24 + +FROM golang:${GO_VERSION}-bookworm AS build +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o /out/authproxy ./cmd/authproxy + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/authproxy /authproxy + +EXPOSE 8080 +USER nonroot:nonroot + +ENTRYPOINT ["/authproxy"] diff --git a/README.md b/README.md index 5e69a39..6080655 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,107 @@ # gitea-mcp-forward-auth -Traefik-ForwardAuth-kompatiblen Microservice baut, der mehrere Bearer-Tokens aus einem gemounteten Secret validiert. \ No newline at end of file +Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen eine konfigurierbare Menge gueltiger Tokens prueft. + +## Verhalten + +- Prueft `Authorization: Bearer `. +- Validiert Token gegen Tokens aus: + - `AUTH_PROXY_TOKENS_DIR` (jede Datei enthaelt genau einen Token), und/oder + - `AUTH_PROXY_TOKENS` (kommagetrennte Liste). +- Antwortet mit: + - `200` (leer) bei gueltigem Token, + - `401` bei fehlendem/ungueltigem Header oder ungueltigem Token. +- `GET /healthz` ist immer ohne Auth erreichbar. +- Loggt jeden Auth-Versuch strukturiert, ohne Klartext-Token. + +## Sicherheitsaspekte + +- Token-Matching erfolgt auf Basis von SHA-256-Digests mit `crypto/subtle.ConstantTimeCompare`. +- Token werden nie im Klartext geloggt; es wird nur ein kurzer Fingerprint (`sha256:...`) geloggt. +- Start bricht fail-fast ab, wenn keine gueltigen Tokens geladen werden konnten. + +## Konfiguration (ENV) + +- `AUTH_PROXY_LISTEN_ADDR` + - Default: `:8080` + - Beispiel: `:8080` +- `AUTH_PROXY_TOKENS_DIR` + - Optional + - Pfad auf ein Verzeichnis, in dem jede Datei einen Token enthaelt (z. B. Kubernetes Secret Volume) +- `AUTH_PROXY_TOKENS` + - Optional + - Kommagetrennte Tokenliste, z. B. `token-a,token-b` +- `AUTH_PROXY_LOG_LEVEL` + - Default: `info` + - Werte wie `debug`, `info`, `warn`, `error` + +Hinweis: Es muss mindestens eine Tokenquelle (`AUTH_PROXY_TOKENS_DIR` oder `AUTH_PROXY_TOKENS`) konfiguriert sein. + +## Lokal bauen und starten + +```bash +go test ./... -v +go vet ./... +go build ./cmd/authproxy + +AUTH_PROXY_TOKENS="my-token-1,my-token-2" \ +AUTH_PROXY_LISTEN_ADDR=":8080" \ +go run ./cmd/authproxy +``` + +## Beispielaufrufe + +```bash +# healthcheck (ohne Auth) +curl -i http://localhost:8080/healthz + +# fehlender Token -> 401 +curl -i http://localhost:8080/ + +# ungueltiger Token -> 401 +curl -i -H "Authorization: Bearer wrong" http://localhost:8080/ + +# gueltiger Token -> 200 +curl -i -H "Authorization: Bearer my-token-1" http://localhost:8080/ +``` + +## Docker + +Das Projekt enthaelt ein Multi-Stage-Dockerfile mit statisch gelinktem Binary (`CGO_ENABLED=0`) und non-root Runtime auf Distroless. + +```bash +docker build -t gitea-mcp-auth-proxy:dev . +docker run --rm -p 8080:8080 \ + -e AUTH_PROXY_TOKENS="my-token-1,my-token-2" \ + gitea-mcp-auth-proxy:dev +``` + +## CI/CD (Gitea Actions) + +- Build-Workflow: `.gitea/workflows/build.yaml` + - Push auf beliebige Branches + - `go vet`, `go test`, `go build` + - Kein Registry-Push +- Release-Workflow: `.gitea/workflows/release.yaml` + - Trigger auf Tags `v*.*.*` + - Buildx-Remote-Builder und Push in Registry + - `latest` nur fuer stabile Tags `vX.Y.Z` + - Pre-Releases (z. B. `v1.2.3-rc1`) werden nicht als `latest` markiert + +Wichtig: Dieses Repository baut und pusht nur Images. Das eigentliche Kubernetes-Deployment erfolgt separat ueber FluxCD Image Automation in einem anderen Repository. + +## Kubernetes-Referenzmanifeste + +Referenzbeispiele fuer lokale Verifikation liegen unter: + +- `deploy/k3s/deployment.yaml` +- `deploy/k3s/service.yaml` + +Diese Manifeste sind bewusst minimal und nicht als produktive FluxCD-Quelle gedacht. + +## Annahmen + +- Annahme: Go-Version ist `1.24` (aktuelle stabile Version zum Implementierungszeitpunkt muss ggf. angepasst werden). +- Annahme: Release-Build pusht initial nur `linux/amd64`. +- Annahme: Remote BuildKit ist im Runner-Netz unter `tcp://:1234` erreichbar. +- Annahme: Registry-Pfad ist `gitea.nehmer.net/torben/gitea-mcp-auth-proxy`. \ No newline at end of file diff --git a/deploy/k3s/deployment.yaml b/deploy/k3s/deployment.yaml new file mode 100644 index 0000000..404cbee --- /dev/null +++ b/deploy/k3s/deployment.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitea-mcp-auth-proxy + labels: + app: gitea-mcp-auth-proxy +spec: + replicas: 1 + selector: + matchLabels: + app: gitea-mcp-auth-proxy + template: + metadata: + labels: + app: gitea-mcp-auth-proxy + spec: + containers: + - name: auth-proxy + image: gitea.nehmer.net/torben/gitea-mcp-auth-proxy:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: AUTH_PROXY_LISTEN_ADDR + value: ":8080" + - name: AUTH_PROXY_TOKENS_DIR + value: /var/run/secrets/auth-proxy + - name: AUTH_PROXY_LOG_LEVEL + value: info + volumeMounts: + - name: auth-tokens + mountPath: /var/run/secrets/auth-proxy + readOnly: true + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + volumes: + - name: auth-tokens + secret: + secretName: gitea-mcp-auth-proxy-tokens diff --git a/deploy/k3s/service.yaml b/deploy/k3s/service.yaml new file mode 100644 index 0000000..debcc5b --- /dev/null +++ b/deploy/k3s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: gitea-mcp-auth-proxy + labels: + app: gitea-mcp-auth-proxy +spec: + selector: + app: gitea-mcp-auth-proxy + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + type: ClusterIP diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93bd54c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea-mcp-forward-auth + +go 1.24 diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..f8dc907 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,162 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const EnvTokens = "AUTH_PROXY_TOKENS" + +var ( + ErrMissingAuthHeader = errors.New("missing authorization header") + ErrInvalidAuthHeader = errors.New("invalid authorization header format") + ErrEmptyTokenSet = errors.New("no tokens configured") + ErrTokenDirUnreadable = errors.New("unable to read token directory") +) + +type Validator struct { + tokenDigests [][sha256.Size]byte +} + +func NewValidator(tokens []string) (*Validator, error) { + if len(tokens) == 0 { + return nil, ErrEmptyTokenSet + } + + unique := make(map[[sha256.Size]byte]struct{}, len(tokens)) + digests := make([][sha256.Size]byte, 0, len(tokens)) + for _, token := range tokens { + t := strings.TrimSpace(token) + if t == "" { + continue + } + digest := sha256.Sum256([]byte(t)) + if _, exists := unique[digest]; exists { + continue + } + unique[digest] = struct{}{} + digests = append(digests, digest) + } + + if len(digests) == 0 { + return nil, ErrEmptyTokenSet + } + + return &Validator{tokenDigests: digests}, nil +} + +func (v *Validator) IsValid(token string) bool { + token = strings.TrimSpace(token) + if token == "" || v == nil { + return false + } + + provided := sha256.Sum256([]byte(token)) + matched := 0 + for _, allowed := range v.tokenDigests { + matched |= subtle.ConstantTimeCompare(provided[:], allowed[:]) + } + + return matched == 1 +} + +func (v *Validator) TokenCount() int { + if v == nil { + return 0 + } + return len(v.tokenDigests) +} + +func ParseBearerToken(authHeader string) (string, error) { + authHeader = strings.TrimSpace(authHeader) + if authHeader == "" { + return "", ErrMissingAuthHeader + } + + parts := strings.Fields(authHeader) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + return "", ErrInvalidAuthHeader + } + + return parts[1], nil +} + +func LoadTokens(tokensDir, tokensCSV string) ([]string, error) { + tokens := make([]string, 0) + + dirTokens, err := readTokensFromDir(tokensDir) + if err != nil { + return nil, err + } + tokens = append(tokens, dirTokens...) + tokens = append(tokens, parseTokensCSV(tokensCSV)...) + + cleaned := make([]string, 0, len(tokens)) + for _, token := range tokens { + t := strings.TrimSpace(token) + if t != "" { + cleaned = append(cleaned, t) + } + } + + if len(cleaned) == 0 { + return nil, ErrEmptyTokenSet + } + + return cleaned, nil +} + +func readTokensFromDir(tokensDir string) ([]string, error) { + if strings.TrimSpace(tokensDir) == "" { + return nil, nil + } + + entries, err := os.ReadDir(tokensDir) + if err != nil { + return nil, fmt.Errorf("%w %q: %v", ErrTokenDirUnreadable, tokensDir, err) + } + + tokens := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(tokensDir, entry.Name()) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read token file %q: %w", path, err) + } + tokens = append(tokens, strings.TrimSpace(string(content))) + } + + return tokens, nil +} + +func parseTokensCSV(tokensCSV string) []string { + if strings.TrimSpace(tokensCSV) == "" { + return nil + } + parts := strings.Split(tokensCSV, ",") + tokens := make([]string, 0, len(parts)) + for _, part := range parts { + t := strings.TrimSpace(part) + if t != "" { + tokens = append(tokens, t) + } + } + return tokens +} + +func TokenFingerprint(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return fmt.Sprintf("sha256:%x", sum[:6]) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..c55d8b5 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,87 @@ +package auth + +import "testing" + +func TestValidator_IsValid(t *testing.T) { + t.Parallel() + + validator, err := NewValidator([]string{"token-one", "token-two", "token-three"}) + if err != nil { + t.Fatalf("NewValidator() error = %v", err) + } + + tests := []struct { + name string + token string + want bool + }{ + {name: "valid token one", token: "token-one", want: true}, + {name: "valid token two", token: "token-two", want: true}, + {name: "unknown token", token: "wrong-token", want: false}, + {name: "empty token", token: "", want: false}, + {name: "whitespace token", token: " ", want: false}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := validator.IsValid(tc.token); got != tc.want { + t.Fatalf("IsValid() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestNewValidator_EmptyTokenSet(t *testing.T) { + t.Parallel() + + if _, err := NewValidator(nil); err == nil { + t.Fatal("NewValidator(nil) expected error, got nil") + } + + if _, err := NewValidator([]string{"", " "}); err == nil { + t.Fatal("NewValidator(empty tokens) expected error, got nil") + } +} + +func TestParseBearerToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + header string + want string + wantErr bool + }{ + {name: "valid bearer", header: "Bearer abc123", want: "abc123"}, + {name: "valid lowercase prefix", header: "bearer abc123", want: "abc123"}, + {name: "valid uppercase prefix", header: "BEARER abc123", want: "abc123"}, + {name: "missing header", header: "", wantErr: true}, + {name: "wrong scheme", header: "Basic abc123", wantErr: true}, + {name: "missing token", header: "Bearer", wantErr: true}, + {name: "too many parts", header: "Bearer one two", wantErr: true}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ParseBearerToken(tc.header) + if tc.wantErr { + if err == nil { + t.Fatal("ParseBearerToken() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("ParseBearerToken() error = %v", err) + } + + if got != tc.want { + t.Fatalf("ParseBearerToken() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/auth/http.go b/internal/auth/http.go new file mode 100644 index 0000000..b375b7c --- /dev/null +++ b/internal/auth/http.go @@ -0,0 +1,65 @@ +package auth + +import ( + "log/slog" + "net/http" +) + +type Handler struct { + validator *Validator + logger *slog.Logger +} + +func NewHandler(validator *Validator, logger *slog.Logger) *Handler { + if logger == nil { + logger = slog.Default() + } + return &Handler{ + validator: validator, + logger: logger, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + return + } + + authHeader := r.Header.Get("Authorization") + token, err := ParseBearerToken(authHeader) + if err != nil { + h.logAttempt(r, "denied", "auth_header_invalid", "") + h.unauthorized(w) + return + } + + fingerprint := TokenFingerprint(token) + if !h.validator.IsValid(token) { + h.logAttempt(r, "denied", "token_invalid", fingerprint) + h.unauthorized(w) + return + } + + h.logAttempt(r, "allowed", "token_valid", fingerprint) + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) unauthorized(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", "Bearer") + w.WriteHeader(http.StatusUnauthorized) +} + +func (h *Handler) logAttempt(r *http.Request, result, reason, fingerprint string) { + attrs := []any{ + "result", result, + "reason", reason, + "method", r.Method, + "path", r.URL.Path, + } + if fingerprint != "" { + attrs = append(attrs, "token_fingerprint", fingerprint) + } + h.logger.Info("auth attempt", attrs...) +}