Skip to content
Open
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
8 changes: 7 additions & 1 deletion clients/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U
var c ClientInterface
var cerr error
switch cfg.Type {
case common.UpstreamTypeEvm:
case common.UpstreamTypeEvm, common.UpstreamTypeJsonRpc:
// jsonrpc architecture reuses the generic HTTP/WS JSON-RPC clients
// (passthrough; no EVM chainId/state poller). gRPC BDS remains EVM-only.
switch parsedUrl.Scheme {
case "http", "https":
c, cerr = NewGenericHttpJsonRpcClient(
Expand Down Expand Up @@ -126,6 +128,10 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U
cerr = fmt.Errorf("failed to create WebSocket client for upstream %v: %w", cfg.Id, cerr)
}
case "grpc", "grpc+bds":
if cfg.Type == common.UpstreamTypeJsonRpc {
cerr = fmt.Errorf("unsupported endpoint scheme: %v for upstream type jsonrpc: %v", parsedUrl.Scheme, cfg.Id)
break
}
c, cerr = NewGrpcBdsClient(
appCtx,
&lg,
Expand Down
12 changes: 12 additions & 0 deletions common/architecture_jsonrpc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package common

const (
UpstreamTypeJsonRpc UpstreamType = "jsonrpc"
)

// JsonRpcNetworkConfig identifies a non-EVM JSON-RPC network (Solana, Starknet, …).
// Network id becomes jsonrpc:<id>. No chainId / state poller / EVM method hooks.
type JsonRpcNetworkConfig struct {
// Id is a stable slug (usually the CLL alias), e.g. solana-mainnet.
Id string `yaml:"id" json:"id"`
}
63 changes: 63 additions & 0 deletions common/architecture_jsonrpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package common

import (
"testing"

"gopkg.in/yaml.v3"
)

func TestJsonRpcNetworkId(t *testing.T) {
n := &NetworkConfig{
Architecture: ArchitectureJsonRpc,
Alias: "solana-mainnet",
JsonRpc: &JsonRpcNetworkConfig{Id: "solana-mainnet"},
}
if got := n.NetworkId(); got != "jsonrpc:solana-mainnet" {
t.Fatalf("NetworkId()=%q", got)
}
if !IsValidArchitecture(string(ArchitectureJsonRpc)) {
t.Fatal("ArchitectureJsonRpc should be valid")
}
if !IsValidNetwork("jsonrpc:solana-mainnet") {
t.Fatal("jsonrpc:solana-mainnet should be valid")
}
if IsValidNetwork("jsonrpc:") || IsValidNetwork("jsonrpc:a:b") {
t.Fatal("invalid jsonrpc ids accepted")
}
}

func TestJsonRpcNetworkConfig_UnmarshalYAML_FailsafeObject(t *testing.T) {
// Chart emits failsafe as a single object (not a list). NetworkConfig must
// still accept architecture/jsonRpc via the oldNetworkConfig fallback.
const raw = `
architecture: jsonrpc
alias: solana-mainnet
jsonRpc:
id: solana-mainnet
failsafe:
timeout:
duration: 30s
retry:
maxAttempts: 5
delay: 50ms
`
var n NetworkConfig
if err := yaml.Unmarshal([]byte(raw), &n); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if n.Architecture != ArchitectureJsonRpc {
t.Fatalf("architecture=%q", n.Architecture)
}
if n.JsonRpc == nil || n.JsonRpc.Id != "solana-mainnet" {
t.Fatalf("jsonRpc=%v", n.JsonRpc)
}
if n.Alias != "solana-mainnet" {
t.Fatalf("alias=%q", n.Alias)
}
if len(n.Failsafe) != 1 || n.Failsafe[0].Timeout == nil {
t.Fatalf("failsafe not converted from object: %+v", n.Failsafe)
}
if got := n.NetworkId(); got != "jsonrpc:solana-mainnet" {
t.Fatalf("NetworkId()=%q", got)
}
}
Loading
Loading