Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/workflows/codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Codecov

on: [push, pull_request]
Comment thread
simonnix marked this conversation as resolved.
Resolved

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2

- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0

- name: Install dependencies
run: go mod download

- name: Run tests
run: go test -coverprofile=coverage.txt -coverpkg=./... ./pkg/... ./internal/chain ./internal/manager ./internal/autoconfig

- name: Upload results to Codecov
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: BeamFoundry/rays-web-security
fail_ci_if_error: true
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# BeamFoundry Rays Web Security 🛡️

[![beamfoundry](https://rays.bfdy.dev/rays-web-security/rays-web-security-bfdy.jpeg)](https://rays.bfdy.dev/rays-web-security/)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

[![Go Version](https://img.shields.io/badge/Go-1.27+-00ADD8?style=flat&logo=go)](https://golang.org)
[![codecov](https://codecov.io/gh/BeamFoundry/rays-web-security/graph/badge.svg?token=DJWS93PEI8)](https://codecov.io/gh/BeamFoundry/rays-web-security)

**This is an extract of the full documentation available at [rays.bdfy.dev](https://rays.bfdy.dev/rays-web-security/).**

**Rays Web Security** brings the declarative power and robust architecture of Spring Security directly to Go.
Expand Down
146 changes: 146 additions & 0 deletions docs/content/docs/4-security-chains/5-pre-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
title: "Pre-Authentication"
weight: 5
---

# Pre-Authentication

In enterprise architectures, authentication is often offloaded to an external infrastructure component before the request ever reaches your application:

* **Reverse Proxies & API Gateways**: NGINX, Apache, Envoy, or Traefik validating client tokens or mutual TLS (mTLS) and injecting identity headers (e.g. `X-Remote-User`, `X-Forwarded-User`).
* **Identity & Access Gateways**: Cloudflare Access, OAuth2-Proxy, AWS ALB, Google Cloud IAP, or CA SiteMinder asserting verified identity.
* **Direct Mutual TLS (mTLS)**: Clients authenticating with an X.509 client certificate directly terminated by the application.

Rays Web Security provides first-class support for **Pre-Authentication**, inspired by Spring Security's pre-authentication model.

## Enabling Pre-Authentication

Enable and configure pre-authentication by invoking `.PreAuth()` on your `HttpSecurityBuilder`:

```go
package configurations

import (
. "github.com/BeamFoundry/rays-web-security/pkg/config"
. "github.com/BeamFoundry/rays/pkg/core"
)

type SecurityConfig struct {
Configuration
}

func (this *SecurityConfig) HttpSecurity() *HttpSecurity {
return (&HttpSecurityBuilder{}).
AuthorizedHTTPRequests().
Paths("/api/admin/**").HasRole("ADMIN").
Paths("/api/**").Authenticated().
And().
PreAuth().
PrincipalHeader("X-Remote-User").
RolesHeader("X-Remote-Roles").
RolesDelimiter(",").
And().
SessionCreationPolicy().Stateless().
Build()
}

var _, _ = any(&SecurityConfig{}).(Stereotype)
```

## Configuration Options

The `PreAuth()` builder provides the following methods:

| Method | Default Value | Description |
| :--- | :--- | :--- |
| `PrincipalHeader(string)` | `"X-Remote-User"` | HTTP request header used to extract the user principal (username/identity). |
| `CredentialsHeader(string)` | `""` (empty) | Optional HTTP header to extract pre-authenticated credentials (e.g. certificate or proxy token). |
| `RolesHeader(string)` | `""` (empty) | Optional HTTP header to extract roles or granted authorities. |
| `RolesDelimiter(string)` | `","` | Delimiter used to split multiple roles in `RolesHeader`. |
| `AllowMtlsFallback(bool)` | `true` | When `true`, falls back to direct mTLS client certificates if no valid header identity is present. |
| `TrustedProxies(...string)` | `[]` (empty) | List of allow-listed proxy identifiers (CommonName or Subject) allowed to forward identity headers. |
| `ExceptionOnMissing(bool)` | `false` | When `true`, immediately rejects requests with `403 Forbidden` if pre-authenticated credentials are missing, rather than proceeding down the filter chain. |

## Security & Header Spoofing Protection

Pre-authentication trusts the identity forwarded in HTTP headers. If an untrusted client can send requests directly to your backend, they could forge `X-Remote-User` and impersonate any user.

Rays Web Security includes built-in safeguards to prevent header spoofing:

### 1. Mutual Exclusivity Between Headers & mTLS

When a client establishes a direct connection with a verified mTLS certificate, an untrusted request header cannot override the verified mTLS identity. The framework enforces direct mTLS identity and ignores spoofed user headers.

### 2. Trusted Proxy Restrictions (`TrustedProxies`)

When your backend terminates mTLS connections from both reverse proxies and direct clients, you can configure `TrustedProxies`:

```go
func (this *SecurityConfig) HttpSecurity() *HttpSecurity {
return (&HttpSecurityBuilder{}).
AuthorizedHTTPRequests().
AnyRequest().Authenticated().
And().
PreAuth().
PrincipalHeader("X-Remote-User").
TrustedProxies("proxy.internal.net", "CN=gateway.infra").
AllowMtlsFallback(true).
And().
Build()
}
```

* **Verified Proxy**: If the incoming connection presents a verified mTLS certificate matching `TrustedProxies`, the header `X-Remote-User` is accepted.
* **Direct Client**: If the client connects with their own mTLS certificate (not in `TrustedProxies`), header override is blocked and their own certificate identity is used.
* **Unverified Connection**: Plaintext or unverified connections cannot assert user identity via headers when trusted proxies are configured.

### 3. Role Stripping on mTLS Fallback

When authentication falls back to direct mTLS client certificates, any request-supplied header roles (`RolesHeader`) are automatically cleared. Authorities must be derived from the verified certificate or loaded via an `AuthenticationUserDetailsService`.

## User Details & Authority Mapping

Once a principal is extracted (from headers or mTLS), authentication is processed by an `AuthenticationProvider` delegating to an `AuthenticationUserDetailsService`:

```go
type AuthenticationUserDetailsService interface {
LoadUserDetails(token Authentication) (UserDetails, AuthenticationException)
}
```

Rays Web Security includes two built-in implementations:

1. **`PreAuthenticatedGrantedAuthoritiesUserDetailsService`**: Creates a `UserDetails` instance using the principal string and authorities parsed directly from the pre-authentication token / roles header.
2. **`UserDetailsByNameServiceWrapper`**: Adapts an existing `UserDetailsService` (such as a database or in-memory user repository) to load full account details and permissions for the pre-authenticated username.

By default, `internal/autoconfig` automatically configures a `PreAuthenticatedAuthenticationProvider` and provides a default `AuthenticationUserDetailsService`:
* If a `UserDetailsService` beam is found in the IoC container, it is automatically wrapped using `UserDetailsByNameServiceWrapper`.
* If no `UserDetailsService` beam is present, it defaults to `PreAuthenticatedGrantedAuthoritiesUserDetailsService`.

### Registering a Custom `AuthenticationUserDetailsService`

To map pre-authenticated identities to database users or custom permissions, register an `AuthenticationUserDetailsService` Beam in your configuration:

```go
package configurations

import (
. "github.com/BeamFoundry/rays-web-security/pkg/auth"
. "github.com/BeamFoundry/rays-web-security/pkg/core"
. "github.com/BeamFoundry/rays/pkg/core"
)

type PreAuthConfig struct {
Configuration
}

func (this *PreAuthConfig) PreAuthUserDetailsService(
that struct {
userDetailsService UserDetailsService `@:"Inject"`
},
) AuthenticationUserDetailsService {
return UserDetailsByNameServiceWrapper{}.New(that.userDetailsService)
}

var _, _ = any(&PreAuthConfig{}).(Stereotype)
```
Binary file added docs/static/rays-web-security-bfdy.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions internal/autoconfig/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

. "github.com/BeamFoundry/rays-web-security/internal/manager"

. "github.com/BeamFoundry/rays-web-security/pkg/auth"
. "github.com/BeamFoundry/rays-web-security/pkg/core"
. "github.com/BeamFoundry/rays-web-security/pkg/password"
)
Expand Down Expand Up @@ -43,3 +44,28 @@ func (this *DefaultConfiguration) UserDetailsAuthenticationProvider(
provider.SetEncoder(that.encoder)
return provider
}

func (this *DefaultConfiguration) AuthenticationUserDetailsService(
that struct {
ConditionalOnMissingBeam
userDetailsService Optional[UserDetailsService] `@:"Inject"`
},
) AuthenticationUserDetailsService {
if uds, ok := that.userDetailsService.Get(); ok {
return UserDetailsByNameServiceWrapper{}.New(uds)
}
return PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New()
}

func (this *DefaultConfiguration) PreAuthenticatedAuthenticationProvider(
that struct {
ConditionalOnMissingBeam
service Optional[AuthenticationUserDetailsService] `@:"Inject"`
},
) *PreAuthenticatedAuthenticationProvider {
service, ok := that.service.Get()
if !ok {
return nil
}
return PreAuthenticatedAuthenticationProvider{}.New(service)
}
104 changes: 104 additions & 0 deletions internal/autoconfig/default_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package autoconfig

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

. "github.com/BeamFoundry/rays/pkg/core"
. "github.com/BeamFoundry/rays/pkg/lang"

. "github.com/BeamFoundry/rays-web-security/pkg/auth"
. "github.com/BeamFoundry/rays-web-security/pkg/core"
. "github.com/BeamFoundry/rays-web-security/pkg/password"
. "github.com/BeamFoundry/rays-web-security/pkg/testing"
)

func TestAutoconfig(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Internal/Autoconfig Suite")
}

var _ = Describe("DefaultConfiguration", func() {
It("provides default PasswordEncoder", func() {
cfg := &DefaultConfiguration{}
encoder := cfg.PasswordEncoder(struct{ ConditionalOnMissingBeam }{})
Expect(encoder).NotTo(BeNil())
})

It("provides UserDetailsAuthenticationProvider when service is present or nil when absent", func() {
cfg := &DefaultConfiguration{}
encoder := NoOpPasswordEncoder{}.New()

// Absent
pNil := cfg.UserDetailsAuthenticationProvider(struct {
ConditionalOnMissingBeam
encoder PasswordEncoder `@:"Inject"`
service Optional[UserDetailsService] `@:"Inject"`
}{
encoder: encoder,
service: OptionalEmpty[UserDetailsService](),
})
Expect(pNil).To(BeNil())

// Present
uds := InMemoryUserDetailsManager{}.New(encoder)
p := cfg.UserDetailsAuthenticationProvider(struct {
ConditionalOnMissingBeam
encoder PasswordEncoder `@:"Inject"`
service Optional[UserDetailsService] `@:"Inject"`
}{
encoder: encoder,
service: OptionalOf[UserDetailsService](uds),
})
Expect(p).NotTo(BeNil())
})

It("provides AuthenticationUserDetailsService wrapping UserDetailsService or defaulting", func() {
cfg := &DefaultConfiguration{}

// Default (no UserDetailsService)
sDefault := cfg.AuthenticationUserDetailsService(struct {
ConditionalOnMissingBeam
userDetailsService Optional[UserDetailsService] `@:"Inject"`
}{
userDetailsService: OptionalEmpty[UserDetailsService](),
})
Expect(sDefault).NotTo(BeNil())

// Wrapped UserDetailsService
encoder := NoOpPasswordEncoder{}.New()
uds := InMemoryUserDetailsManager{}.New(encoder)
sWrapped := cfg.AuthenticationUserDetailsService(struct {
ConditionalOnMissingBeam
userDetailsService Optional[UserDetailsService] `@:"Inject"`
}{
userDetailsService: OptionalOf[UserDetailsService](uds),
})
Expect(sWrapped).NotTo(BeNil())
})

It("provides PreAuthenticatedAuthenticationProvider when service is present or nil when absent", func() {
cfg := &DefaultConfiguration{}

// Absent
pNil := cfg.PreAuthenticatedAuthenticationProvider(struct {
ConditionalOnMissingBeam
service Optional[AuthenticationUserDetailsService] `@:"Inject"`
}{
service: OptionalEmpty[AuthenticationUserDetailsService](),
})
Expect(pNil).To(BeNil())

// Present
service := PreAuthenticatedGrantedAuthoritiesUserDetailsService{}.New()
p := cfg.PreAuthenticatedAuthenticationProvider(struct {
ConditionalOnMissingBeam
service Optional[AuthenticationUserDetailsService] `@:"Inject"`
}{
service: OptionalOf[AuthenticationUserDetailsService](service),
})
Expect(p).NotTo(BeNil())
})
})
Loading
Loading