Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions auth/authorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ func NewAuthorizer(appCtx context.Context, logger *zerolog.Logger, projectId str
if err != nil {
return nil, err
}
case common.AuthTypeForwardedClientId:
if cfg.ForwardedClientId == nil {
return nil, common.NewErrInvalidConfig("forwardedClientId strategy config is nil")
}
strategy = NewForwardedClientIdStrategy(cfg.ForwardedClientId)
default:
return nil, common.NewErrInvalidConfig(fmt.Sprintf("unknown auth strategy type: %s", cfg.Type))
}
Expand Down
58 changes: 57 additions & 1 deletion auth/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import (
"errors"
"net/http"
"net/url"
"path"
"strings"

"github.com/erpc/erpc/common"
)

func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, args url.Values) (*AuthPayload, error) {
func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, args url.Values, requestPath string) (*AuthPayload, error) {
ap := &AuthPayload{
Method: method,
}
Expand All @@ -25,11 +26,22 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a
ap.Secret = &SecretPayload{
Value: secret,
}
} else if apikey := args.Get("apikey"); apikey != "" {
// Alias used by edge gateways / clients that speak "apikey" rather than "secret".
ap.Type = common.AuthTypeSecret
ap.Secret = &SecretPayload{
Value: apikey,
}
} else if tkn := headers.Get("X-ERPC-Secret-Token"); tkn != "" {
ap.Type = common.AuthTypeSecret
ap.Secret = &SecretPayload{
Value: tkn,
}
} else if apikey := firstNonEmptyHeader(headers, "apikey", "X-Api-Key"); apikey != "" {
ap.Type = common.AuthTypeSecret
ap.Secret = &SecretPayload{
Value: apikey,
}
} else if ath := headers.Get("Authorization"); ath != "" {
ath = strings.TrimSpace(ath)
parts := strings.SplitN(ath, " ", 2)
Expand Down Expand Up @@ -77,6 +89,19 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a
Message: normalizeSiweMessage(msg),
}
}
} else if pathSecret := singlePathSegmentSecret(requestPath); pathSecret != "" {
// Path form: https://host/<SECRET> (with domain aliasing so the segment
// is not consumed as project/network). Avoids edge Lua/WASM filters.
ap.Type = common.AuthTypeSecret
ap.Secret = &SecretPayload{
Value: pathSecret,
}
} else if clientId := firstNonEmptyHeader(headers, "X-Client-Id", "x-client-id"); clientId != "" {
// Gateway-injected identity after edge API-key auth (Envoy forwardClientIDHeader).
ap.Type = common.AuthTypeForwardedClientId
ap.ForwardedClientId = &ForwardedClientIdPayload{
Value: clientId,
}
}

// Default to network strategy when no other auth signals are present.
Expand All @@ -87,6 +112,37 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a
return ap, nil
}

// singlePathSegmentSecret returns the sole path segment when the URL is
// `/<secret>` (or `/<secret>/`). Multi-segment eRPC paths and reserved
// endpoints are ignored so routing/healthchecks stay unchanged.
func singlePathSegmentSecret(requestPath string) string {
if requestPath == "" {
return ""
}
ps := path.Clean(requestPath)
if ps == "/" || ps == "." {
return ""
}
seg := strings.TrimPrefix(ps, "/")
if seg == "" || strings.Contains(seg, "/") {
return ""
}
switch seg {
case "admin", "healthcheck", "metrics":
return ""
}
return seg
}

func firstNonEmptyHeader(headers http.Header, names ...string) string {
for _, name := range names {
if v := strings.TrimSpace(headers.Get(name)); v != "" {
return v
}
}
return ""
}

func normalizeSiweMessage(msg string) string {
decoded, err := base64.StdEncoding.DecodeString(msg)
if err != nil {
Expand Down
17 changes: 12 additions & 5 deletions auth/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ package auth
import "github.com/erpc/erpc/common"

type AuthPayload struct {
Method string
Type common.AuthType
Secret *SecretPayload
Jwt *JwtPayload
Siwe *SiwePayload
Method string
Type common.AuthType
Secret *SecretPayload
Jwt *JwtPayload
Siwe *SiwePayload
ForwardedClientId *ForwardedClientIdPayload
}

// ForwardedClientIdPayload carries a gateway-injected client id (not a secret).
type ForwardedClientIdPayload struct {
Value string
RateLimitBudget string
}

// This payload is used by both "secret" and "database" strategies
Expand Down
40 changes: 40 additions & 0 deletions auth/strategy_forwarded_client_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package auth

import (
"context"
"strings"

"github.com/erpc/erpc/common"
)

// ForwardedClientIdStrategy authenticates using a non-secret client id
// header injected by a trusted gateway after API-key verification
// (e.g. Envoy SecurityPolicy apiKeyAuth.forwardClientIDHeader).
type ForwardedClientIdStrategy struct {
cfg *common.ForwardedClientIdStrategyConfig
}

var _ AuthStrategy = &ForwardedClientIdStrategy{}

func NewForwardedClientIdStrategy(cfg *common.ForwardedClientIdStrategyConfig) *ForwardedClientIdStrategy {
return &ForwardedClientIdStrategy{cfg: cfg}
}

func (s *ForwardedClientIdStrategy) Supports(ap *AuthPayload) bool {
return ap != nil && ap.Type == common.AuthTypeForwardedClientId
}

func (s *ForwardedClientIdStrategy) Authenticate(ctx context.Context, req *common.NormalizedRequest, ap *AuthPayload) (*common.User, error) {
if ap == nil || ap.ForwardedClientId == nil || strings.TrimSpace(ap.ForwardedClientId.Value) == "" {
return nil, common.NewErrAuthUnauthorized("forwardedClientId", "missing client id header")
}

id := strings.TrimSpace(ap.ForwardedClientId.Value)
user := &common.User{Id: id}
if s.cfg != nil && s.cfg.RateLimitBudget != "" {
user.RateLimitBudget = s.cfg.RateLimitBudget
} else if ap.ForwardedClientId.RateLimitBudget != "" {
user.RateLimitBudget = ap.ForwardedClientId.RateLimitBudget
}
return user, nil
}
104 changes: 104 additions & 0 deletions auth/strategy_forwarded_client_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package auth

import (
"context"
"net/http"
"net/url"
"testing"

"github.com/erpc/erpc/common"
"github.com/stretchr/testify/require"
)

func TestNewPayloadFromHttp_ForwardedClientId(t *testing.T) {
headers := http.Header{}
headers.Set("X-Client-Id", "cl-no-alpha")
ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}, "/")
require.NoError(t, err)
require.Equal(t, common.AuthTypeForwardedClientId, ap.Type)
require.NotNil(t, ap.ForwardedClientId)
require.Equal(t, "cl-no-alpha", ap.ForwardedClientId.Value)
}

func TestNewPayloadFromHttp_PathSecret(t *testing.T) {
ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/my-secret-key")
require.NoError(t, err)
require.Equal(t, common.AuthTypeSecret, ap.Type)
require.NotNil(t, ap.Secret)
require.Equal(t, "my-secret-key", ap.Secret.Value)

// Trailing slash is cleaned to a single segment.
ap, err = NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/my-secret-key/")
require.NoError(t, err)
require.Equal(t, common.AuthTypeSecret, ap.Type)
require.Equal(t, "my-secret-key", ap.Secret.Value)
}

func TestNewPayloadFromHttp_PathSecretIgnoredForMultiSegment(t *testing.T) {
ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/main/evm/1")
require.NoError(t, err)
require.Equal(t, common.AuthTypeNetwork, ap.Type)
}

func TestNewPayloadFromHttp_PathSecretIgnoredForReserved(t *testing.T) {
for _, seg := range []string{"/admin", "/healthcheck", "/metrics"} {
ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, seg)
require.NoError(t, err)
require.Equal(t, common.AuthTypeNetwork, ap.Type, "path %s", seg)
}
}

func TestSecretStrategy_RejectsEmpty(t *testing.T) {
s := NewSecretStrategy(&common.SecretStrategyConfig{Id: "cl-no-01", Value: ""})
_, err := s.Authenticate(context.Background(), nil, &AuthPayload{
Type: common.AuthTypeSecret,
Secret: &SecretPayload{Value: ""},
})
require.Error(t, err)

s = NewSecretStrategy(&common.SecretStrategyConfig{Id: "cl-no-01", Value: "real-secret"})
user, err := s.Authenticate(context.Background(), nil, &AuthPayload{
Type: common.AuthTypeSecret,
Secret: &SecretPayload{Value: "real-secret"},
})
require.NoError(t, err)
require.Equal(t, "cl-no-01", user.Id)
}

func TestNewPayloadFromHttp_ApiKeyQueryAndHeader(t *testing.T) {
ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{"apikey": []string{"q-key"}}, "/")
require.NoError(t, err)
require.Equal(t, common.AuthTypeSecret, ap.Type)
require.Equal(t, "q-key", ap.Secret.Value)

headers := http.Header{}
headers.Set("apikey", "h-key")
ap, err = NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}, "/")
require.NoError(t, err)
require.Equal(t, common.AuthTypeSecret, ap.Type)
require.Equal(t, "h-key", ap.Secret.Value)
}

func TestForwardedClientIdStrategy_Authenticate(t *testing.T) {
s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{
Header: "X-Client-Id",
RateLimitBudget: "default-budget",
})
ap := &AuthPayload{
Type: common.AuthTypeForwardedClientId,
ForwardedClientId: &ForwardedClientIdPayload{
Value: "cl-no-beta",
},
}
user, err := s.Authenticate(context.Background(), nil, ap)
require.NoError(t, err)
require.Equal(t, "cl-no-beta", user.Id)
require.Equal(t, "default-budget", user.RateLimitBudget)
}

func TestForwardedClientIdStrategy_MissingHeader(t *testing.T) {
s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{})
ap := &AuthPayload{Type: common.AuthTypeForwardedClientId}
_, err := s.Authenticate(context.Background(), nil, ap)
require.Error(t, err)
}
9 changes: 9 additions & 0 deletions auth/strategy_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"context"
"strings"

"github.com/erpc/erpc/common"
)
Expand All @@ -21,6 +22,14 @@ func (s *SecretStrategy) Supports(ap *AuthPayload) bool {
}

func (s *SecretStrategy) Authenticate(ctx context.Context, req *common.NormalizedRequest, ap *AuthPayload) (*common.User, error) {
if ap == nil || ap.Secret == nil {
return nil, common.NewErrAuthUnauthorized("secret", "missing secret")
}
// Reject empty configured or presented secrets so a missing env expansion
// (value="") can never authenticate an empty path/query credential.
if strings.TrimSpace(s.cfg.Value) == "" || strings.TrimSpace(ap.Secret.Value) == "" {
return nil, common.NewErrAuthUnauthorized("secret", "invalid secret")
}
if ap.Secret.Value != s.cfg.Value {
return nil, common.NewErrAuthUnauthorized("secret", "invalid secret")
}
Expand Down
38 changes: 27 additions & 11 deletions common/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2389,11 +2389,12 @@ func (s *SelectionPolicyConfig) UnmarshalYAML(unmarshal func(interface{}) error)
type AuthType string

const (
AuthTypeSecret AuthType = "secret"
AuthTypeDatabase AuthType = "database"
AuthTypeJwt AuthType = "jwt"
AuthTypeSiwe AuthType = "siwe"
AuthTypeNetwork AuthType = "network"
AuthTypeSecret AuthType = "secret"
AuthTypeDatabase AuthType = "database"
AuthTypeJwt AuthType = "jwt"
AuthTypeSiwe AuthType = "siwe"
AuthTypeNetwork AuthType = "network"
AuthTypeForwardedClientId AuthType = "forwardedClientId"
)

type AuthConfig struct {
Expand All @@ -2405,12 +2406,27 @@ type AuthStrategyConfig struct {
AllowMethods []string `yaml:"allowMethods,omitempty" json:"allowMethods,omitempty"`
RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"`

Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"`
Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"`
Secret *SecretStrategyConfig `yaml:"secret,omitempty" json:"secret,omitempty"`
Database *DatabaseStrategyConfig `yaml:"database,omitempty" json:"database,omitempty"`
Jwt *JwtStrategyConfig `yaml:"jwt,omitempty" json:"jwt,omitempty"`
Siwe *SiweStrategyConfig `yaml:"siwe,omitempty" json:"siwe,omitempty"`
Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"`
Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"`
Secret *SecretStrategyConfig `yaml:"secret,omitempty" json:"secret,omitempty"`
Database *DatabaseStrategyConfig `yaml:"database,omitempty" json:"database,omitempty"`
Jwt *JwtStrategyConfig `yaml:"jwt,omitempty" json:"jwt,omitempty"`
Siwe *SiweStrategyConfig `yaml:"siwe,omitempty" json:"siwe,omitempty"`
ForwardedClientId *ForwardedClientIdStrategyConfig `yaml:"forwardedClientId,omitempty" json:"forwardedClientId,omitempty"`
}

// ForwardedClientIdStrategyConfig trusts a non-secret client identity header
// injected by an upstream gateway after API-key auth (e.g. Envoy
// apiKeyAuth.forwardClientIDHeader → X-Client-Id). Must only be enabled
// behind a gateway that overwrites/strips client-supplied values of that header.
type ForwardedClientIdStrategyConfig struct {
// Header documents the expected gateway identity header (default conceptually
// "X-Client-Id"). Payload extraction in auth.NewPayloadFromHttp currently
// always reads X-Client-Id via case-insensitive Header.Get; this field is
// not yet used to select the header name at runtime.
Header string `yaml:"header,omitempty" json:"header,omitempty"`
// RateLimitBudget, if set, is applied to the authenticated user.
RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"`
}

type SecretStrategyConfig struct {
Expand Down
17 changes: 17 additions & 0 deletions common/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -2681,6 +2681,23 @@ func (s *AuthStrategyConfig) SetDefaults() error {
}
}

if s.Type == AuthTypeForwardedClientId && s.ForwardedClientId == nil {
s.ForwardedClientId = &ForwardedClientIdStrategyConfig{}
}
if s.ForwardedClientId != nil {
s.Type = AuthTypeForwardedClientId
if err := s.ForwardedClientId.SetDefaults(); err != nil {
return fmt.Errorf("failed to set defaults for forwardedClientId strategy: %w", err)
}
}

return nil
}

func (s *ForwardedClientIdStrategyConfig) SetDefaults() error {
if s.Header == "" {
s.Header = "X-Client-Id"
}
return nil
}

Expand Down
Loading
Loading