Update build configuration and improve token handling
Build and Test / verify (push) Failing after 1m22s

- Change CI container image to debian:trixie-slim and set GOPROXY.
- Update Go version to 1.26 in Dockerfile and go.mod.
- Refactor token validation to use SHA-256 hashes instead of plain tokens.
- Add network policy to restrict access to the service.
- Enhance README with new configuration details and usage examples.
- Add tests for new token hash validation logic.
This commit is contained in:
2026-07-11 22:08:56 +02:00
parent ea4aac6641
commit 1acdbb5519
8 changed files with 195 additions and 57 deletions
+13 -1
View File
@@ -10,11 +10,23 @@ jobs:
verify: verify:
runs-on: container-builder runs-on: container-builder
container: container:
image: golang:1.24-bookworm image: debian:trixie-slim
env:
GOPROXY: https://gitea.nehmer.net/api/packages/torben/go,https://proxy.golang.org,direct
steps: steps:
- name: Install CI Dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends git nodejs curl unzip ca-certificates
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.x'
- name: Show Go Version - name: Show Go Version
run: go version run: go version
+3 -1
View File
@@ -1,7 +1,9 @@
ARG GO_VERSION=1.24 ARG GO_VERSION=1.26
ARG GOPROXY_URL=https://gitea.nehmer.net/api/packages/torben/go,https://proxy.golang.org,direct
FROM golang:${GO_VERSION}-bookworm AS build FROM golang:${GO_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
ENV GOPROXY=${GOPROXY_URL}
COPY go.mod ./ COPY go.mod ./
RUN go mod download RUN go mod download
+59 -12
View File
@@ -5,9 +5,9 @@ Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen e
## Verhalten ## Verhalten
- Prueft `Authorization: Bearer <token>`. - Prueft `Authorization: Bearer <token>`.
- Validiert Token gegen Tokens aus: - Validiert Token gegen SHA-256-Hashes aus:
- `AUTH_PROXY_TOKENS_DIR` (jede Datei enthaelt genau einen Token), und/oder - `AUTH_PROXY_TOKEN_HASHES_DIR` (jede Datei enthaelt genau einen Token-Hash), und/oder
- `AUTH_PROXY_TOKENS` (kommagetrennte Liste). - `AUTH_PROXY_TOKEN_HASHES` (kommagetrennte Hash-Liste).
- Antwortet mit: - Antwortet mit:
- `200` (leer) bei gueltigem Token, - `200` (leer) bei gueltigem Token,
- `401` bei fehlendem/ungueltigem Header oder ungueltigem Token. - `401` bei fehlendem/ungueltigem Header oder ungueltigem Token.
@@ -17,6 +17,7 @@ Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen e
## Sicherheitsaspekte ## Sicherheitsaspekte
- Token-Matching erfolgt auf Basis von SHA-256-Digests mit `crypto/subtle.ConstantTimeCompare`. - Token-Matching erfolgt auf Basis von SHA-256-Digests mit `crypto/subtle.ConstantTimeCompare`.
- Der Service speichert nur Hashes der erlaubten Tokens, keine Klartext-Tokens in der Konfiguration.
- Token werden nie im Klartext geloggt; es wird nur ein kurzer Fingerprint (`sha256:...`) geloggt. - 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. - Start bricht fail-fast ab, wenn keine gueltigen Tokens geladen werden konnten.
@@ -25,17 +26,46 @@ Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen e
- `AUTH_PROXY_LISTEN_ADDR` - `AUTH_PROXY_LISTEN_ADDR`
- Default: `:8080` - Default: `:8080`
- Beispiel: `:8080` - Beispiel: `:8080`
- `AUTH_PROXY_TOKENS_DIR` - `AUTH_PROXY_TOKEN_HASHES_DIR`
- Optional - Optional
- Pfad auf ein Verzeichnis, in dem jede Datei einen Token enthaelt (z. B. Kubernetes Secret Volume) - Pfad auf ein Verzeichnis, in dem jede Datei einen SHA-256-Token-Hash enthaelt (z. B. Kubernetes Secret Volume)
- `AUTH_PROXY_TOKENS` - `AUTH_PROXY_TOKEN_HASHES`
- Optional - Optional
- Kommagetrennte Tokenliste, z. B. `token-a,token-b` - Kommagetrennte Liste von SHA-256-Hashes (64 Hex-Zeichen)
- Hash-Format
- `abcdef...` (64 hex) oder `sha256:abcdef...` (64 hex)
- `AUTH_PROXY_LOG_LEVEL` - `AUTH_PROXY_LOG_LEVEL`
- Default: `info` - Default: `info`
- Werte wie `debug`, `info`, `warn`, `error` - Werte wie `debug`, `info`, `warn`, `error`
Hinweis: Es muss mindestens eine Tokenquelle (`AUTH_PROXY_TOKENS_DIR` oder `AUTH_PROXY_TOKENS`) konfiguriert sein. Hinweis: Es muss mindestens eine Hash-Quelle (`AUTH_PROXY_TOKEN_HASHES_DIR` oder `AUTH_PROXY_TOKEN_HASHES`) konfiguriert sein.
## Go Package Registry (Gitea)
Dieses Repo ist auf die Gitea Go Package Registry ausgerichtet.
- Registry-Muster: `https://<gitea-host>/api/packages/{owner}/go`
- In dieser Konfiguration: `https://gitea.nehmer.net/api/packages/torben/go`
Beispiel lokal:
```bash
go env -w GOPROXY="https://gitea.nehmer.net/api/packages/torben/go,https://proxy.golang.org,direct"
```
Annahme: Deine Angabe `https://gitea.example.com/api/packages/{owner}/go` ist ein Muster/Template. Implementiert wurde konkret `gitea.nehmer.net` mit Owner `torben`.
## Healthcheck Security
`/healthz` muss nicht world-readable sein. Fuer k3s Probes und Prometheus reicht interne Erreichbarkeit im Cluster.
Empfehlung:
- Service als `ClusterIP` belassen (bereits in Referenzmanifesten umgesetzt)
- Keine externe Ingress-Route auf `/healthz` publizieren
- Zugriff auf Pod/Service-Netzwerkebene einschränken (z. B. NetworkPolicy), falls eure CNI/Policies das bereits vorsehen
Annahme: In eurem Setup ist der ForwardAuth-Service nur intern erreichbar und wird nicht direkt aus dem Internet exponiert.
## Lokal bauen und starten ## Lokal bauen und starten
@@ -44,7 +74,21 @@ go test ./... -v
go vet ./... go vet ./...
go build ./cmd/authproxy go build ./cmd/authproxy
AUTH_PROXY_TOKENS="my-token-1,my-token-2" \ AUTH_PROXY_TOKEN_HASHES="<sha256-hash-1>,<sha256-hash-2>" \
AUTH_PROXY_LISTEN_ADDR=":8080" \
go run ./cmd/authproxy
```
Token-Hashes erzeugen:
```bash
echo -n "my-token-1" | sha256sum | awk '{print $1}'
```
Startbeispiel mit Hashes:
```bash
AUTH_PROXY_TOKEN_HASHES="<sha256-hash-1>,<sha256-hash-2>" \
AUTH_PROXY_LISTEN_ADDR=":8080" \ AUTH_PROXY_LISTEN_ADDR=":8080" \
go run ./cmd/authproxy go run ./cmd/authproxy
``` ```
@@ -72,7 +116,7 @@ Das Projekt enthaelt ein Multi-Stage-Dockerfile mit statisch gelinktem Binary (`
```bash ```bash
docker build -t gitea-mcp-auth-proxy:dev . docker build -t gitea-mcp-auth-proxy:dev .
docker run --rm -p 8080:8080 \ docker run --rm -p 8080:8080 \
-e AUTH_PROXY_TOKENS="my-token-1,my-token-2" \ -e AUTH_PROXY_TOKEN_HASHES="<sha256-hash-1>,<sha256-hash-2>" \
gitea-mcp-auth-proxy:dev gitea-mcp-auth-proxy:dev
``` ```
@@ -96,12 +140,15 @@ Referenzbeispiele fuer lokale Verifikation liegen unter:
- `deploy/k3s/deployment.yaml` - `deploy/k3s/deployment.yaml`
- `deploy/k3s/service.yaml` - `deploy/k3s/service.yaml`
- `deploy/k3s/networkpolicy.yaml`
Diese Manifeste sind bewusst minimal und nicht als produktive FluxCD-Quelle gedacht. Diese Manifeste sind bewusst minimal und nicht als produktive FluxCD-Quelle gedacht.
## Annahmen ## Annahmen
- Annahme: Go-Version ist `1.24` (aktuelle stabile Version zum Implementierungszeitpunkt muss ggf. angepasst werden). - Annahme: Go-Version ist `1.26`.
- Annahme: Release-Build pusht initial nur `linux/amd64`. - Annahme: Release-Build pusht initial nur `linux/amd64`.
- Annahme: Remote BuildKit ist im Runner-Netz unter `tcp://<default-gateway>:1234` erreichbar. - Annahme: Remote BuildKit ist im Runner-Netz unter `tcp://<default-gateway>:1234` erreichbar.
- Annahme: Registry-Pfad ist `gitea.nehmer.net/torben/gitea-mcp-auth-proxy`. - Annahme: Registry-Pfad ist `gitea.nehmer.net/torben/gitea-mcp-auth-proxy`.
- Annahme: Traefik laeuft in `kube-system` mit Label `app.kubernetes.io/name=traefik`.
- Annahme: Prometheus laeuft in `monitoring` mit Label `app.kubernetes.io/name=prometheus`.
+3 -1
View File
@@ -4,6 +4,8 @@ metadata:
name: gitea-mcp-auth-proxy name: gitea-mcp-auth-proxy
labels: labels:
app: gitea-mcp-auth-proxy app: gitea-mcp-auth-proxy
annotations:
security.note/healthz: "Do not expose /healthz publicly; keep service internal and restrict ingress at network/ingress layer."
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -24,7 +26,7 @@ spec:
env: env:
- name: AUTH_PROXY_LISTEN_ADDR - name: AUTH_PROXY_LISTEN_ADDR
value: ":8080" value: ":8080"
- name: AUTH_PROXY_TOKENS_DIR - name: AUTH_PROXY_TOKEN_HASHES_DIR
value: /var/run/secrets/auth-proxy value: /var/run/secrets/auth-proxy
- name: AUTH_PROXY_LOG_LEVEL - name: AUTH_PROXY_LOG_LEVEL
value: info value: info
+31
View File
@@ -0,0 +1,31 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: gitea-mcp-auth-proxy-ingress
labels:
app: gitea-mcp-auth-proxy
spec:
podSelector:
matchLabels:
app: gitea-mcp-auth-proxy
policyTypes:
- Ingress
ingress:
- from:
# Assumption: Traefik Pods run in namespace kube-system and expose label app.kubernetes.io/name=traefik.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
# Assumption: Prometheus Pods run in namespace monitoring and expose label app.kubernetes.io/name=prometheus.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- protocol: TCP
port: 8080
+2 -2
View File
@@ -1,3 +1,3 @@
module gitea-mcp-forward-auth module gitea.nehmer.net/torben/gitea-mcp-forward-auth
go 1.24 go 1.26
+65 -38
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -10,32 +11,36 @@ import (
"strings" "strings"
) )
const EnvTokens = "AUTH_PROXY_TOKENS" const EnvTokenHashes = "AUTH_PROXY_TOKEN_HASHES"
var ( var (
ErrMissingAuthHeader = errors.New("missing authorization header") ErrMissingAuthHeader = errors.New("missing authorization header")
ErrInvalidAuthHeader = errors.New("invalid authorization header format") ErrInvalidAuthHeader = errors.New("invalid authorization header format")
ErrEmptyTokenSet = errors.New("no tokens configured") ErrEmptyTokenSet = errors.New("no token hashes configured")
ErrTokenDirUnreadable = errors.New("unable to read token directory") ErrTokenDirUnreadable = errors.New("unable to read token hash directory")
ErrInvalidTokenHash = errors.New("invalid token hash format")
) )
type Validator struct { type Validator struct {
tokenDigests [][sha256.Size]byte tokenDigests [][sha256.Size]byte
} }
func NewValidator(tokens []string) (*Validator, error) { func NewValidator(tokenHashes []string) (*Validator, error) {
if len(tokens) == 0 { if len(tokenHashes) == 0 {
return nil, ErrEmptyTokenSet return nil, ErrEmptyTokenSet
} }
unique := make(map[[sha256.Size]byte]struct{}, len(tokens)) unique := make(map[[sha256.Size]byte]struct{}, len(tokenHashes))
digests := make([][sha256.Size]byte, 0, len(tokens)) digests := make([][sha256.Size]byte, 0, len(tokenHashes))
for _, token := range tokens { for _, hashValue := range tokenHashes {
t := strings.TrimSpace(token) hashValue = normalizeHash(hashValue)
if t == "" { if hashValue == "" {
continue continue
} }
digest := sha256.Sum256([]byte(t)) digest, err := decodeHash(hashValue)
if err != nil {
return nil, err
}
if _, exists := unique[digest]; exists { if _, exists := unique[digest]; exists {
continue continue
} }
@@ -86,21 +91,21 @@ func ParseBearerToken(authHeader string) (string, error) {
return parts[1], nil return parts[1], nil
} }
func LoadTokens(tokensDir, tokensCSV string) ([]string, error) { func LoadTokenHashes(hashesDir, hashesCSV string) ([]string, error) {
tokens := make([]string, 0) hashes := make([]string, 0)
dirTokens, err := readTokensFromDir(tokensDir) dirHashes, err := readHashesFromDir(hashesDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tokens = append(tokens, dirTokens...) hashes = append(hashes, dirHashes...)
tokens = append(tokens, parseTokensCSV(tokensCSV)...) hashes = append(hashes, parseHashesCSV(hashesCSV)...)
cleaned := make([]string, 0, len(tokens)) cleaned := make([]string, 0, len(hashes))
for _, token := range tokens { for _, hashValue := range hashes {
t := strings.TrimSpace(token) h := normalizeHash(hashValue)
if t != "" { if h != "" {
cleaned = append(cleaned, t) cleaned = append(cleaned, h)
} }
} }
@@ -111,45 +116,67 @@ func LoadTokens(tokensDir, tokensCSV string) ([]string, error) {
return cleaned, nil return cleaned, nil
} }
func readTokensFromDir(tokensDir string) ([]string, error) { func readHashesFromDir(hashesDir string) ([]string, error) {
if strings.TrimSpace(tokensDir) == "" { if strings.TrimSpace(hashesDir) == "" {
return nil, nil return nil, nil
} }
entries, err := os.ReadDir(tokensDir) entries, err := os.ReadDir(hashesDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w %q: %v", ErrTokenDirUnreadable, tokensDir, err) return nil, fmt.Errorf("%w %q: %v", ErrTokenDirUnreadable, hashesDir, err)
} }
tokens := make([]string, 0, len(entries)) hashes := make([]string, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
continue continue
} }
path := filepath.Join(tokensDir, entry.Name()) path := filepath.Join(hashesDir, entry.Name())
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("read token file %q: %w", path, err) return nil, fmt.Errorf("read hash file %q: %w", path, err)
} }
tokens = append(tokens, strings.TrimSpace(string(content))) hashes = append(hashes, strings.TrimSpace(string(content)))
} }
return tokens, nil return hashes, nil
} }
func parseTokensCSV(tokensCSV string) []string { func parseHashesCSV(hashesCSV string) []string {
if strings.TrimSpace(tokensCSV) == "" { if strings.TrimSpace(hashesCSV) == "" {
return nil return nil
} }
parts := strings.Split(tokensCSV, ",") parts := strings.Split(hashesCSV, ",")
tokens := make([]string, 0, len(parts)) hashes := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
t := strings.TrimSpace(part) h := normalizeHash(part)
if t != "" { if h != "" {
tokens = append(tokens, t) hashes = append(hashes, h)
} }
} }
return tokens return hashes
}
func normalizeHash(value string) string {
value = strings.TrimSpace(value)
if strings.HasPrefix(strings.ToLower(value), "sha256:") {
value = strings.TrimSpace(value[len("sha256:"):])
}
return strings.ToLower(value)
}
func decodeHash(value string) ([sha256.Size]byte, error) {
var digest [sha256.Size]byte
if len(value) != sha256.Size*2 {
return digest, fmt.Errorf("%w: expected %d hex chars, got %d", ErrInvalidTokenHash, sha256.Size*2, len(value))
}
decoded, err := hex.DecodeString(value)
if err != nil {
return digest, fmt.Errorf("%w: %v", ErrInvalidTokenHash, err)
}
copy(digest[:], decoded)
return digest, nil
} }
func TokenFingerprint(token string) string { func TokenFingerprint(token string) string {
+19 -2
View File
@@ -1,11 +1,20 @@
package auth package auth
import "testing" import (
"crypto/sha256"
"fmt"
"testing"
)
func hashHex(token string) string {
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum[:])
}
func TestValidator_IsValid(t *testing.T) { func TestValidator_IsValid(t *testing.T) {
t.Parallel() t.Parallel()
validator, err := NewValidator([]string{"token-one", "token-two", "token-three"}) validator, err := NewValidator([]string{hashHex("token-one"), hashHex("token-two"), hashHex("token-three")})
if err != nil { if err != nil {
t.Fatalf("NewValidator() error = %v", err) t.Fatalf("NewValidator() error = %v", err)
} }
@@ -45,6 +54,14 @@ func TestNewValidator_EmptyTokenSet(t *testing.T) {
} }
} }
func TestNewValidator_InvalidHash(t *testing.T) {
t.Parallel()
if _, err := NewValidator([]string{"not-a-hash"}); err == nil {
t.Fatal("NewValidator(invalid hash) expected error, got nil")
}
}
func TestParseBearerToken(t *testing.T) { func TestParseBearerToken(t *testing.T) {
t.Parallel() t.Parallel()