Add initial implementation of gitea-mcp-forward-auth microservice
Build and Test / verify (push) Failing after 22s
Build and Test / verify (push) Failing after 22s
- Create build and release workflows for CI/CD - Implement Dockerfile for multi-stage builds - Add core authentication logic with token validation - Include HTTP handler for authorization checks - Set up Kubernetes deployment and service manifests - Update README with usage instructions and configuration details
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
authproxy
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
+22
@@ -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"]
|
||||||
@@ -1,3 +1,107 @@
|
|||||||
# gitea-mcp-forward-auth
|
# gitea-mcp-forward-auth
|
||||||
|
|
||||||
Traefik-ForwardAuth-kompatiblen Microservice baut, der mehrere Bearer-Tokens aus einem gemounteten Secret validiert.
|
Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen eine konfigurierbare Menge gueltiger Tokens prueft.
|
||||||
|
|
||||||
|
## Verhalten
|
||||||
|
|
||||||
|
- Prueft `Authorization: Bearer <token>`.
|
||||||
|
- 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://<default-gateway>:1234` erreichbar.
|
||||||
|
- Annahme: Registry-Pfad ist `gitea.nehmer.net/torben/gitea-mcp-auth-proxy`.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user