Skip to content

feat(socrate): opt-in client attribution for OAuth calls made on a user's behalf - #58

Merged
ovander merged 1 commit into
mainfrom
feat/socrate-client-attribution
Sep 29, 2026
Merged

ovander merged 1 commit into
mainfrom
feat/socrate-client-attribution

Conversation

@ovander

@ovander ovander commented Sep 29, 2026

Copy link
Copy Markdown
Owner

What and why

Since Socrate v1.5.0, audit rows record the client IP and User-Agent that Socrate resolves. A Backend-for-Frontend calls /oauth/token (code exchange, refresh) and /oauth/revoke server-to-server over loopback. Those events are therefore logged as 127.0.0.1 / Go-http-client/1.1: the monitoring console's Sessions view shows loopback "sessions", and per-IP analysis of token events is blind. This lets a BFF tell Socrate which browser a call is made for.

New API (all additive; no changed signature, no changed default):

  • type socrate.ClientAttribution struct{ IP, UserAgent string }: the browser a call is made for.
  • socrate.WithClientAttribution(ctx, a) context.Context and socrate.ClientAttributionFrom(ctx) (ClientAttribution, bool), using an unexported context key. The doc requires the IP to come from the caller's own trusted-proxy resolution, never from a raw request header.
  • socrate.ApplyClientAttribution(req *http.Request). With attribution on the context:
    • A valid IP replaces X-Forwarded-For with exactly that one address (canonical form) and removes X-Real-IP. An invalid IP sets nothing.
    • User-Agent is set to the browser's value, stripped of control characters and invalid UTF-8, and capped at 512 bytes without splitting a character.
    • Without attribution, it is a no-op.
  • bff.WithClientAttribution(r, clientIP) *http.Request: a middleware helper that fills UserAgent from r.UserAgent().

Why replace, never append: from a trusted proxy (loopback), Socrate takes the leftmost X-Forwarded-For entry (go-oauth2 internal/middleware/ratelimit.go, GetClientIPSafe). Appending to a browser-supplied value would let the browser choose the address Socrate logs.

Applied automatically in the socrate.Client calls made for a browser: ExchangeCode, RefreshToken, RevokeToken, VerifyMagicLink, AdminLogin and Logout. Not applied to the client-credentials grant, IntrospectToken, GetCurrentUserProfile, Decide or the admin API: there's no browser behind those, and the service token is cached and shared. With no attribution on the context, requests are byte-for-byte as before.

bff: the proxy's refresh already runs under context.WithoutCancel(ctx), which keeps the request's values, so attribution reaches it. A new test covers this.

Side effect, intended: Socrate's per-IP rate limit and IP blocking on /oauth/token now apply per browser, as they do for direct sign-ins, instead of to the whole BFF.

Consumers: the admin and monitoring consoles build their own token and revoke requests. Their follow-up PRs call ApplyClientAttribution there and bump to the release that contains this.

How it was tested

  • Table test of ApplyClientAttribution:

    • X-Forwarded-For: 6.6.6.6, 1.2.3.4 is replaced, and X-Real-IP removed;
    • invalid and empty IPs;
    • User-Agent sanitising and truncation;
    • no attribution; nil request.
  • Each of the six calls against an httptest server, with and without attribution. Without it, there is no X-Forwarded-For and the UA is Go's default.

  • Service-account token, introspect and userinfo send no X-Forwarded-For, even with attribution on the context.

  • A bff proxy-refresh test with a real socrate.Client: the refresh carries the attribution.

  • These tests fail with ApplyClientAttribution disabled, and the bff test fails with the refresh detached to context.Background().

  • go mod tidy && git diff --exit-code go.sum

  • go build ./..., go vet ./...

  • go test -race -count=1 -timeout=120s ./...

  • golangci-lint run ./... (v2.14.0): 0 issues

  • govulncheck ./...: could not run here, because the network policy blocks vuln.go.dev. No dependency changed.

  • A throwaway module using the new API compiled and ran.

  • README (package reference, "Client attribution"), docs/CLIENT-INTEGRATION.md (BFF section, the replace-not-append rule) and a CHANGELOG line under Added.

Compatibility

Additive only (v1): new exported identifiers; no removed, renamed or changed ones; default behaviour unchanged. After merging, a v1.15.0 release lets the consoles bump.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA


Generated by Claude Code

…er's behalf

A BFF calls /oauth/token and /oauth/revoke over loopback, so Socrate
(v1.5.0+) audits those events as 127.0.0.1 / Go-http-client/1.1.

Add socrate.ClientAttribution, WithClientAttribution,
ClientAttributionFrom and ApplyClientAttribution. ExchangeCode,
RefreshToken, RevokeToken, VerifyMagicLink, AdminLogin and Logout apply
the attribution carried by their context: X-Forwarded-For is replaced
with exactly the resolved address (Socrate reads the leftmost entry from
a trusted proxy, so appending would let the browser pick its address),
X-Real-IP is removed, and the User-Agent is sanitised and capped at 512
bytes. The client_credentials grant, introspection and userinfo never
carry it. Without attribution in the context, requests are unchanged.

Add bff.WithClientAttribution(r, clientIP) to set it from an incoming
request; the gateway's refresh already keeps the request context's
values, now covered by a test. IP resolution stays with the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA
@ovander
ovander merged commit 78b7303 into main Sep 29, 2026
6 checks passed
@ovander ovander mentioned this pull request Sep 29, 2026
2 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants