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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to backendkit are documented here. Format:

## [Unreleased]

### Added

- **Client attribution for OAuth calls made on a user's behalf** (`socrate`, `bff`; opt-in,
additive). `socrate.ClientAttribution`, `socrate.WithClientAttribution`,
`socrate.ClientAttributionFrom` and `socrate.ApplyClientAttribution` carry the browser's
address and User-Agent on the context; `ExchangeCode`, `RefreshToken`, `RevokeToken`,
`VerifyMagicLink`, `AdminLogin` and `Logout` then send them as `X-Forwarded-For` and
`User-Agent`, so Socrate (v1.5.0+) audits, rate-limits and blocks by the browser rather than
the BFF's loopback address. `X-Forwarded-For` is replaced with the single address, never
appended to (Socrate reads the leftmost entry), `X-Real-IP` is removed, an unparsable address
sends nothing, and the User-Agent is stripped of control characters and capped at 512 bytes.
The `client_credentials` grant, introspection and userinfo never carry it.
`bff.WithClientAttribution(r, ip)` sets it from an incoming request; the gateway's refresh path
already keeps the request context's values, so the refresh is attributed too. The caller
resolves the IP; backendkit never reads it from headers. Without attribution on the context, requests are unchanged.

## [1.14.0] - 2026-09-29

Minor release on the **v1** line: no breaking change to any exported identifier. It adds the
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,41 @@ logs, and policy decisions (`Decide`). The
auth modes and port routing and has the method reference, with the auth mode and port of each
call.

#### Client attribution

A BFF calls Socrate's token and revoke endpoints server-to-server, so Socrate would audit a login,
refresh or logout as the BFF (`127.0.0.1`, `Go-http-client/1.1`). Put the browser's address and
User-Agent on the context and the calls made on a user's behalf — `ExchangeCode`, `RefreshToken`,
`RevokeToken`, `VerifyMagicLink`, `AdminLogin`, `Logout` — send them as `X-Forwarded-For` and
`User-Agent`. Service-account calls (the `client_credentials` grant), `IntrospectToken` and
`GetCurrentUserProfile` never do. Without attribution on the context nothing changes.

```go
// ip comes from YOUR resolver (trust X-Forwarded-For only from your own edge proxy),
// never from a raw request header.
ctx := socrate.WithClientAttribution(r.Context(), socrate.ClientAttribution{
IP: ip,
UserAgent: r.UserAgent(),
})
ts, err := client.ExchangeCode(ctx, code, redirectURI, verifier)

// A BFF building its own token/revoke requests applies it itself:
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, body)
socrate.ApplyClientAttribution(req)
```

| Symbol | Purpose |
|---|---|
| `ClientAttribution{IP, UserAgent}` | The browser a call is made for |
| `WithClientAttribution(ctx, a)` / `ClientAttributionFrom(ctx)` | Store / read it on a context |
| `ApplyClientAttribution(req)` | Set the headers on a request from its context |

`X-Forwarded-For` is **replaced** with exactly the one address (and `X-Real-IP` removed), never
appended to: Socrate takes the leftmost entry from a trusted proxy, so appending to a
browser-supplied value would let the browser choose its logged address. An address that does not
parse sends nothing; the User-Agent loses its control characters and is capped at 512 bytes. In a
`bff` BFF, [`bff.WithClientAttribution`](#bff) sets this from the incoming request.

Full API: [pkg.go.dev/…/socrate](https://pkg.go.dev/github.com/ovander/backendkit/socrate).

---
Expand Down Expand Up @@ -625,6 +660,13 @@ gw := &bff.Gateway{
// Every API call: session cookie in, bearer out. No valid session ⇒ 401, never a pass-through.
// Unsafe methods must carry the session's CSRF token in X-CSRF-Token.
mux.HandleFunc("/api/", gw.ProxyWithSession(bff.NewSingleHostProxy(apiURL)))

// Optional: attribute token calls to the browser; serve this instead of mux.
// clientIP is your own resolver (X-Forwarded-For only from your edge proxy).
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.ServeHTTP(w, bff.WithClientAttribution(r, clientIP(r)))
})
log.Fatal(http.ListenAndServe("127.0.0.1:8080", handler))
```

The login and callback handlers that create the session (`NewPKCE`, `LoginBinding`,
Expand All @@ -644,6 +686,7 @@ built this way.
| Open redirect | `SanitizeReturnTo` keeps only same-site paths such as `/dashboard?x=1`; absolute URLs, `//host`, backslash and control-character tricks all become `/` |
| Token refresh | Proactive, coalesced per session, detached from the triggering request, and written through to the store so the rotated refresh token is kept. Only a refresh the server rejects (`IsFatalRefreshError`) ends the session; a transient failure answers 502 and keeps it |
| Upstream attribution | `NewSingleHostProxy` strips client-supplied IP-attribution headers (`X-Real-IP`, `True-Client-IP`, `Forwarded`), so the browser cannot steer Socrate's rate limits, IP blocks or audit trail; `X-Forwarded-For` is left to the edge proxy |
| Token-call attribution | Opt-in: `WithClientAttribution(r, ip)` puts the browser's address (as **your** resolver found it) and User-Agent on the request context, so the code exchange, the gateway's refresh and revocation are audited by Socrate as the browser rather than as the BFF (see [client attribution](#client-attribution)) |

**Several instances.** `MemoryStore` is per process. Behind a load balancer, implement
`SessionStore` (`Get`, `Put`, `Delete`, `Sweep`) over a shared database, serialising sessions
Expand Down
31 changes: 31 additions & 0 deletions bff/attribution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package bff

import (
"net/http"

"github.com/ovander/backendkit/socrate"
)

// WithClientAttribution returns a shallow copy of r whose context carries a
// socrate.ClientAttribution for the browser that sent r: IP is clientIP and
// UserAgent is r.UserAgent(). The socrate.Client calls made with that context
// (ExchangeCode in the callback, RefreshToken in Gateway.EnsureFresh and
// Gateway.ProxyWithSession, RevokeToken at logout) then tell Socrate who the
// browser is instead of appearing as the BFF itself.
//
// clientIP MUST be the address the BFF resolved with its own trusted-proxy
// rules (typically: honour X-Forwarded-For only from the local edge proxy,
// else RemoteAddr), never a raw request header. This package does not resolve
// it. An empty or unparsable clientIP sends no address; the User-Agent is
// still sent.
//
// Install it as early as possible, in a middleware in front of the login,
// callback, logout and proxy handlers:
//
// next.ServeHTTP(w, bff.WithClientAttribution(r, resolvedIP))
func WithClientAttribution(r *http.Request, clientIP string) *http.Request {
return r.WithContext(socrate.WithClientAttribution(r.Context(), socrate.ClientAttribution{
IP: clientIP,
UserAgent: r.UserAgent(),
}))
}
101 changes: 101 additions & 0 deletions bff/attribution_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package bff

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"

"github.com/ovander/backendkit/socrate"
)

func TestWithClientAttributionSetsContext(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/bff/callback", nil)
r.Header.Set("User-Agent", "Mozilla/5.0 (Browser)")
r.Header.Set("X-Forwarded-For", "6.6.6.6") // must be ignored: the caller resolves the IP

got, ok := socrate.ClientAttributionFrom(WithClientAttribution(r, "203.0.113.7").Context())
want := socrate.ClientAttribution{IP: "203.0.113.7", UserAgent: "Mozilla/5.0 (Browser)"}
if !ok || got != want {
t.Fatalf("got %+v, %v; want %+v, true", got, ok, want)
}
if _, ok := socrate.ClientAttributionFrom(r.Context()); ok {
t.Fatal("the original request's context must not be modified")
}
}

// fakeTokenEndpoint is a Socrate /oauth/token recording the attribution
// headers of each refresh.
type fakeTokenEndpoint struct {
mu sync.Mutex
xff, ua []string
hasXFF []bool
refreshes int
}

func (f *fakeTokenEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.refreshes++
_, has := r.Header["X-Forwarded-For"]
f.hasXFF = append(f.hasXFF, has)
f.xff = append(f.xff, r.Header.Get("X-Forwarded-For"))
f.ua = append(f.ua, r.Header.Get("User-Agent"))
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at-new", "refresh_token": "rt-new", "token_type": "Bearer", "expires_in": 3600})
}

// TestProxyRefreshCarriesClientAttribution: attribution set on the incoming
// request by the BFF's middleware reaches Socrate's /oauth/token on the
// gateway's refresh path, although that refresh runs under a context detached
// from the request's cancellation.
func TestProxyRefreshCarriesClientAttribution(t *testing.T) {
tokenEP := &fakeTokenEndpoint{}
socrateSrv := httptest.NewServer(tokenEP)
defer socrateSrv.Close()
client, err := socrate.NewClient(socrate.ClientConfig{BaseURL: socrateSrv.URL, ClientID: "bff", ClientSecret: "test-secret"})
if err != nil {
t.Fatal(err)
}

var gotAuth string
up := upstreamRecorder(&gotAuth)
defer up.Close()
uu, _ := url.Parse(up.URL)

store := NewMemoryStore(time.Hour, time.Hour)
g := newTestGateway(uu, store, client)
h := g.ProxyWithSession(NewSingleHostProxy(uu))

for i, attributed := range []bool{true, false} {
// Expired access token → the proxy must refresh.
store.Put(NewSession("sid", "csrf", tokenSet("old-at", "rt", 0), UserInfo{Sub: "u1"}, time.Now().Add(-time.Minute)))
req := httptest.NewRequest(http.MethodGet, "/api/admin/x", nil)
req.AddCookie(&http.Cookie{Name: "sess", Value: "sid"})
req.Header.Set("User-Agent", "Mozilla/5.0 (Browser)")
req.Header.Set("X-Forwarded-For", "6.6.6.6")
if attributed {
req = WithClientAttribution(req, "203.0.113.7")
}
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK || gotAuth != "Bearer at-new" {
t.Fatalf("request %d: got %d, upstream Authorization %q", i, rec.Code, gotAuth)
}
}

tokenEP.mu.Lock()
defer tokenEP.mu.Unlock()
if tokenEP.refreshes != 2 {
t.Fatalf("want 2 refreshes, got %d", tokenEP.refreshes)
}
if tokenEP.xff[0] != "203.0.113.7" || tokenEP.ua[0] != "Mozilla/5.0 (Browser)" {
t.Errorf("attributed refresh: Socrate saw XFF=%q UA=%q, want 203.0.113.7 / the browser UA", tokenEP.xff[0], tokenEP.ua[0])
}
if tokenEP.hasXFF[1] || tokenEP.ua[1] != "Go-http-client/1.1" {
t.Errorf("unattributed refresh: Socrate saw XFF=%q UA=%q, want none / Go's default", tokenEP.xff[1], tokenEP.ua[1])
}
}
4 changes: 3 additions & 1 deletion bff/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@
// IP-attribution headers so the upstream's rate limits, IP blocks and audit
// trail cannot be steered by the browser, and [LoginBinding] ties a pending
// login to the browser that started it so a captured callback URL cannot
// swap a victim onto an attacker's session.
// swap a victim onto an attacker's session. [WithClientAttribution] lets the
// token calls (code exchange, refresh, revocation) tell Socrate which browser
// they are made for, from an address the BFF resolved itself.
package bff
52 changes: 52 additions & 0 deletions docs/CLIENT-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,10 @@ confidential clients.
| `VerifyMagicLink(ctx, token)` | client_id | `*LoginResult` | completes passwordless login; `ErrMagicLinkAlreadyUsed` (422), `ErrMagicLinkInvalid` (401). |
| `AdminLogin(ctx, email, password)` | creds | `*LoginResult` | superadmin portal login; `ErrInvalidCredentials` (401). |

These calls, with `RevokeToken` and `Logout`, send the browser's address and
User-Agent when the context carries a `socrate.ClientAttribution` — see
[client attribution](#client-attribution-telling-socrate-who-the-browser-is).

#### App-scoped user management — Admin port

Operates on **your app's** users (`/api/apps/{app_id}/users`). App ID resolved
Expand Down Expand Up @@ -685,6 +689,54 @@ is a complete BFF built this way, with logout and token revocation.
Magic links fit the same model: the page the emailed link opens posts the
token to your BFF, which calls `client.VerifyMagicLink` and creates the session.

#### Client attribution: telling Socrate who the browser is

The BFF calls `/oauth/token` (code exchange, refresh) and `/oauth/revoke`
server-to-server, usually over loopback. Socrate records the client IP and
User-Agent of every audited event, so without more it logs those as the BFF
(`127.0.0.1`, `Go-http-client/1.1`), and its per-IP rate limits and IP blocks
on the token endpoint apply to the BFF as a whole. Client attribution is
opt-in: put the browser's address and User-Agent on the request context, and
the `socrate.Client` calls made on the user's behalf (`ExchangeCode`,
`RefreshToken`, `RevokeToken`, `VerifyMagicLink`, `AdminLogin`, `Logout`) send
them as `X-Forwarded-For` and `User-Agent`. The `client_credentials` grant,
introspection and userinfo never do: no browser is involved.

```go
// clientIP is YOUR resolver: trust X-Forwarded-For only when the peer is your
// own edge proxy (e.g. loopback), else use RemoteAddr.
attribute := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, bff.WithClientAttribution(r, clientIP(r)))
})
}
log.Fatal(http.ListenAndServe("127.0.0.1:8080", attribute(mux)))
```

With that one wrapper, the callback's `client.ExchangeCode(r.Context(), …)`,
the gateway's refresh (it keeps the request context's values while detaching
its cancellation) and a logout's `client.RevokeToken(r.Context(), …)` are all
attributed. A BFF that builds its own token or revoke requests calls
`socrate.ApplyClientAttribution(req)` on each, with a context carrying the
attribution.

Two rules:

- **Pass an address you resolved, never a header.** Socrate trusts what the BFF
sends from loopback. Passing the browser's own `X-Forwarded-For` or
`X-Real-IP` would let it pick the address it is rate-limited, blocked and
audited as. backendkit does not resolve client IPs; your BFF does.
- **Replace, never append.** `X-Forwarded-For` is set to exactly the one
address, and `X-Real-IP` removed, because Socrate takes the **leftmost**
entry from a trusted proxy: appending to a browser-supplied value would leave
the browser's claim leftmost. An address that does not parse sends nothing;
the User-Agent has control characters removed and is capped at 512 bytes.

Socrate honours the header only when the connection comes from one of its
`TRUSTED_PROXIES` (default `127.0.0.1/32,::1/128`), so a BFF on the same host
is covered; a BFF on another host needs its address added there, never a wide
range.

### 7.2 Alternative: clients that hold their own tokens

> ⚠️ **Use this only for mobile, native or CLI clients, or a legacy SPA not yet
Expand Down
Loading
Loading