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]) }