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,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