diff --git a/CHANGELOG.md b/CHANGELOG.md index 4644b43..e2d429c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 066f352..2b4b89b 100644 --- a/README.md +++ b/README.md @@ -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). --- @@ -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`, @@ -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 diff --git a/bff/attribution.go b/bff/attribution.go new file mode 100644 index 0000000..6f854a3 --- /dev/null +++ b/bff/attribution.go @@ -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(), + })) +} diff --git a/bff/attribution_test.go b/bff/attribution_test.go new file mode 100644 index 0000000..68f4e3e --- /dev/null +++ b/bff/attribution_test.go @@ -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]) + } +} diff --git a/bff/doc.go b/bff/doc.go index e041f31..5c375ec 100644 --- a/bff/doc.go +++ b/bff/doc.go @@ -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 diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index 8acc833..06703d9 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -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 @@ -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 diff --git a/socrate/attribution.go b/socrate/attribution.go new file mode 100644 index 0000000..b35755c --- /dev/null +++ b/socrate/attribution.go @@ -0,0 +1,107 @@ +package socrate + +// attribution.go — opt-in client attribution for OAuth calls a server makes on +// a browser's behalf. A Backend-for-Frontend calls /oauth/token and +// /oauth/revoke server-to-server, usually over loopback, so without this +// Socrate audits those events as the BFF (127.0.0.1, Go-http-client/1.1) +// rather than as the browser that caused them. + +import ( + "context" + "net" + "net/http" + "strings" + "unicode" + "unicode/utf8" +) + +// maxAttributedUserAgentBytes caps the User-Agent sent by ApplyClientAttribution. +const maxAttributedUserAgentBytes = 512 + +// ClientAttribution identifies the end user's browser on whose behalf a server +// calls Socrate. IP is the browser's address as the caller resolved it with its +// own trusted-proxy rules; UserAgent is the browser's User-Agent. Either may be +// empty, in which case that part is not sent. +type ClientAttribution struct { + IP string + UserAgent string +} + +// clientAttributionKey is the unexported context key for a ClientAttribution. +type clientAttributionKey struct{} + +// WithClientAttribution returns a copy of ctx carrying a. The Client methods +// that call Socrate on a user's behalf (ExchangeCode, RefreshToken, +// RevokeToken, VerifyMagicLink, AdminLogin, Logout) then tell Socrate who the +// browser is, through ApplyClientAttribution. +// +// The caller MUST pass the browser address it resolved itself, with its own +// trusted-proxy rules (for example: honour X-Forwarded-For only when the peer +// is the local edge proxy, else use RemoteAddr). Never pass a raw request +// header such as X-Forwarded-For or X-Real-IP: Socrate trusts what the BFF +// sends from loopback, so a browser-controlled value here would let the +// browser choose the address Socrate rate-limits, blocks and audits it as. +// backendkit deliberately does not resolve client IPs itself. +func WithClientAttribution(ctx context.Context, a ClientAttribution) context.Context { + return context.WithValue(ctx, clientAttributionKey{}, a) +} + +// ClientAttributionFrom returns the ClientAttribution carried by ctx, and +// whether there was one. +func ClientAttributionFrom(ctx context.Context) (ClientAttribution, bool) { + a, ok := ctx.Value(clientAttributionKey{}).(ClientAttribution) + return a, ok +} + +// ApplyClientAttribution sets the attribution headers on req from the +// ClientAttribution in req's context. It is called by the Client methods that +// act on a user's behalf; a BFF that builds its own token or revoke requests +// can call it too, after setting the request's context. +// +// With a non-empty IP that parses as an IP address, X-Forwarded-For is SET to +// exactly that one address (canonical form), replacing any existing value, and +// X-Real-IP is removed. It is replaced and never appended because Socrate, for +// a connection from a trusted proxy, takes the LEFTMOST X-Forwarded-For entry +// as the client: appending to a browser-supplied value would leave the +// browser's own claim leftmost and let it choose its logged address. An IP that +// does not parse sets nothing. +// +// With a non-empty UserAgent, User-Agent is set to it with invalid UTF-8 and +// control characters removed, truncated on a character boundary to 512 bytes. +// +// Without attribution in the context the request is left untouched. +func ApplyClientAttribution(req *http.Request) { + if req == nil { + return + } + a, ok := ClientAttributionFrom(req.Context()) + if !ok { + return + } + if ip := net.ParseIP(strings.TrimSpace(a.IP)); ip != nil { + req.Header.Set("X-Forwarded-For", ip.String()) + req.Header.Del("X-Real-IP") + } + if ua := sanitizeUserAgent(a.UserAgent); ua != "" { + req.Header.Set("User-Agent", ua) + } +} + +// sanitizeUserAgent drops invalid UTF-8 and control characters (which would +// otherwise make the transport reject the header, or smuggle line breaks into +// Socrate's logs) and truncates to maxAttributedUserAgentBytes without +// splitting a multi-byte character. +func sanitizeUserAgent(ua string) string { + ua = strings.ToValidUTF8(ua, "") + var b strings.Builder + for _, r := range ua { + if unicode.IsControl(r) { + continue + } + if b.Len()+utf8.RuneLen(r) > maxAttributedUserAgentBytes { + break + } + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} diff --git a/socrate/attribution_test.go b/socrate/attribution_test.go new file mode 100644 index 0000000..9591069 --- /dev/null +++ b/socrate/attribution_test.go @@ -0,0 +1,287 @@ +package socrate_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/ovander/backendkit/socrate" +) + +func TestClientAttributionFrom(t *testing.T) { + if _, ok := socrate.ClientAttributionFrom(context.Background()); ok { + t.Fatal("empty context reported an attribution") + } + want := socrate.ClientAttribution{IP: "203.0.113.7", UserAgent: "Mozilla/5.0"} + got, ok := socrate.ClientAttributionFrom(socrate.WithClientAttribution(context.Background(), want)) + if !ok || got != want { + t.Fatalf("got %+v, %v; want %+v, true", got, ok, want) + } +} + +func newAttributedRequest(t *testing.T, a *socrate.ClientAttribution) *http.Request { + t.Helper() + ctx := context.Background() + if a != nil { + ctx = socrate.WithClientAttribution(ctx, *a) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://socrate.invalid/oauth/token", nil) + if err != nil { + t.Fatal(err) + } + // Headers a browser (or a careless caller) could have put there. + req.Header.Set("X-Forwarded-For", "6.6.6.6, 1.2.3.4") + req.Header.Set("X-Real-IP", "6.6.6.6") + req.Header.Set("User-Agent", "existing-ua") + return req +} + +func TestApplyClientAttribution(t *testing.T) { + longUA := strings.Repeat("é", 300) // 600 bytes of 2-byte runes + tests := []struct { + name string + attr *socrate.ClientAttribution + wantXFF string + wantRealIP string + wantUA string + checkUASize bool + }{ + { + name: "replaces XFF, drops X-Real-IP, sets UA", + attr: &socrate.ClientAttribution{IP: " 203.0.113.7 ", UserAgent: "Mozilla/5.0 (X11)"}, + wantXFF: "203.0.113.7", wantUA: "Mozilla/5.0 (X11)", + }, + { + name: "IPv6 in canonical form", + attr: &socrate.ClientAttribution{IP: "2001:DB8:0:0::1"}, + wantXFF: "2001:db8::1", wantUA: "existing-ua", + }, + { + name: "invalid IP sets no XFF", + attr: &socrate.ClientAttribution{IP: "6.6.6.6, 1.2.3.4", UserAgent: "ua"}, + wantXFF: "6.6.6.6, 1.2.3.4", wantRealIP: "6.6.6.6", wantUA: "ua", + }, + { + name: "empty IP sets no XFF", + attr: &socrate.ClientAttribution{UserAgent: "ua"}, + wantXFF: "6.6.6.6, 1.2.3.4", wantRealIP: "6.6.6.6", wantUA: "ua", + }, + { + name: "UA control characters and invalid UTF-8 stripped", + attr: &socrate.ClientAttribution{IP: "198.51.100.1", UserAgent: "evil\r\nX-Injected: 1\x00\x7f\u0085\xff ua"}, + wantXFF: "198.51.100.1", wantUA: "evilX-Injected: 1 ua", + }, + { + name: "UA truncated on a rune boundary", + attr: &socrate.ClientAttribution{IP: "198.51.100.1", UserAgent: longUA}, + wantXFF: "198.51.100.1", wantUA: strings.Repeat("é", 256), checkUASize: true, + }, + { + name: "UA made only of control characters leaves UA alone", + attr: &socrate.ClientAttribution{IP: "198.51.100.1", UserAgent: "\r\n\t"}, + wantXFF: "198.51.100.1", wantUA: "existing-ua", + }, + { + name: "no attribution leaves headers untouched", + attr: nil, + wantXFF: "6.6.6.6, 1.2.3.4", wantRealIP: "6.6.6.6", wantUA: "existing-ua", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := newAttributedRequest(t, tt.attr) + socrate.ApplyClientAttribution(req) + if got := req.Header.Values("X-Forwarded-For"); len(got) != 1 || got[0] != tt.wantXFF { + t.Errorf("X-Forwarded-For = %q, want exactly [%q]", got, tt.wantXFF) + } + if got := req.Header.Get("X-Real-IP"); got != tt.wantRealIP { + t.Errorf("X-Real-IP = %q, want %q", got, tt.wantRealIP) + } + got := req.Header.Get("User-Agent") + if got != tt.wantUA { + t.Errorf("User-Agent = %q, want %q", got, tt.wantUA) + } + if tt.checkUASize && (len(got) > 512 || !utf8.ValidString(got)) { + t.Errorf("User-Agent is %d bytes (valid UTF-8: %v), want <= 512 and valid", len(got), utf8.ValidString(got)) + } + }) + } +} + +func TestApplyClientAttributionNilRequest(_ *testing.T) { + socrate.ApplyClientAttribution(nil) // must not panic +} + +// seenRequest is what the fake Socrate observed for one request. +type seenRequest struct { + xff, realIP, ua string + hasXFF bool +} + +// attributionServer is a fake Socrate recording the attribution headers of +// every request, per path. +type attributionServer struct { + *httptest.Server + mu sync.Mutex + seen map[string][]seenRequest +} + +func newAttributionServer(t *testing.T) *attributionServer { + t.Helper() + s := &attributionServer{seen: map[string][]seenRequest{}} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + key := r.URL.Path + if gt := r.PostForm.Get("grant_type"); gt != "" { + key += "#" + gt + } + _, hasXFF := r.Header["X-Forwarded-For"] + s.mu.Lock() + s.seen[key] = append(s.seen[key], seenRequest{ + xff: r.Header.Get("X-Forwarded-For"), realIP: r.Header.Get("X-Real-IP"), + ua: r.Header.Get("User-Agent"), hasXFF: hasXFF, + }) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/oauth/token": + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at", "refresh_token": "rt", "token_type": "Bearer", "expires_in": 3600}) + case "/oauth/revoke", "/api/auth/logout": + w.WriteHeader(http.StatusOK) + case "/api/auth/magic-link/verify", "/api/admin/login": + _ = json.NewEncoder(w).Encode(socrate.LoginResult{AccessToken: "at", UserID: 7}) + case "/oauth/userinfo": + _ = json.NewEncoder(w).Encode(socrate.ProfileInfo{Sub: "7"}) + case "/oauth/introspect": + _ = json.NewEncoder(w).Encode(socrate.IntrospectResponse{Active: true}) + case "/api/apps/42/users/7": + _ = json.NewEncoder(w).Encode(socrate.User{ID: 7}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(s.Close) + return s +} + +func (s *attributionServer) last(t *testing.T, key string) seenRequest { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + reqs := s.seen[key] + if len(reqs) == 0 { + t.Fatalf("fake Socrate saw no request for %s", key) + } + return reqs[len(reqs)-1] +} + +func (s *attributionServer) client(t *testing.T) *socrate.Client { + t.Helper() + c, err := socrate.NewClient(socrate.ClientConfig{ + BaseURL: s.URL, AdminBaseURL: s.URL, ClientID: "bff", ClientSecret: "test-secret", AppID: "42", + }) + if err != nil { + t.Fatal(err) + } + return c +} + +var browser = socrate.ClientAttribution{IP: "203.0.113.7", UserAgent: "Mozilla/5.0 (Browser)"} + +// onBehalfCalls are the Client calls made on a user's behalf; each must carry +// the attribution when the context has one. +var onBehalfCalls = []struct { + name, key string + call func(ctx context.Context, c *socrate.Client) error +}{ + {"ExchangeCode", "/oauth/token#authorization_code", func(ctx context.Context, c *socrate.Client) error { + _, err := c.ExchangeCode(ctx, "code", "https://bff.example/cb", "verifier") + return err + }}, + {"RefreshToken", "/oauth/token#refresh_token", func(ctx context.Context, c *socrate.Client) error { + _, err := c.RefreshToken(ctx, "rt") + return err + }}, + {"RevokeToken", "/oauth/revoke", func(ctx context.Context, c *socrate.Client) error { + return c.RevokeToken(ctx, "rt") + }}, + {"VerifyMagicLink", "/api/auth/magic-link/verify", func(ctx context.Context, c *socrate.Client) error { + _, err := c.VerifyMagicLink(ctx, "ml-token") + return err + }}, + {"AdminLogin", "/api/admin/login", func(ctx context.Context, c *socrate.Client) error { + _, err := c.AdminLogin(ctx, "admin@example.com", "pw") + return err + }}, + {"Logout", "/api/auth/logout", func(ctx context.Context, c *socrate.Client) error { + return c.Logout(socrate.WithJWT(ctx, "user-jwt")) + }}, +} + +func TestOnBehalfCallsSendAttribution(t *testing.T) { + for _, tc := range onBehalfCalls { + t.Run(tc.name, func(t *testing.T) { + srv := newAttributionServer(t) + ctx := socrate.WithClientAttribution(context.Background(), browser) + if err := tc.call(ctx, srv.client(t)); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + got := srv.last(t, tc.key) + if got.xff != browser.IP { + t.Errorf("X-Forwarded-For = %q, want %q", got.xff, browser.IP) + } + if got.ua != browser.UserAgent { + t.Errorf("User-Agent = %q, want %q", got.ua, browser.UserAgent) + } + }) + } +} + +func TestOnBehalfCallsUnchangedWithoutAttribution(t *testing.T) { + for _, tc := range onBehalfCalls { + t.Run(tc.name, func(t *testing.T) { + srv := newAttributionServer(t) + if err := tc.call(context.Background(), srv.client(t)); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + got := srv.last(t, tc.key) + if got.hasXFF { + t.Errorf("X-Forwarded-For sent without attribution: %q", got.xff) + } + if got.ua != "Go-http-client/1.1" { + t.Errorf("User-Agent = %q, want Go's default", got.ua) + } + }) + } +} + +// TestNonUserCallsNeverSendAttribution: the client_credentials grant (no +// browser; the token is cached and shared), token introspection and userinfo +// (the backend checking a token, not a browser action) never carry it, even +// when the context does. +func TestNonUserCallsNeverSendAttribution(t *testing.T) { + srv := newAttributionServer(t) + c := srv.client(t) + ctx := socrate.WithClientAttribution(socrate.WithJWT(context.Background(), "user-jwt"), browser) + + if _, err := c.GetUserAsService(ctx, "7"); err != nil { + t.Fatalf("GetUserAsService: %v", err) + } + if _, err := c.IntrospectToken(ctx, "at"); err != nil { + t.Fatalf("IntrospectToken: %v", err) + } + if _, err := c.GetCurrentUserProfile(ctx); err != nil { + t.Fatalf("GetCurrentUserProfile: %v", err) + } + for _, key := range []string{"/oauth/token#client_credentials", "/api/apps/42/users/7", "/oauth/introspect", "/oauth/userinfo"} { + got := srv.last(t, key) + if got.hasXFF || got.ua != "Go-http-client/1.1" { + t.Errorf("%s carried attribution: XFF=%q UA=%q", key, got.xff, got.ua) + } + } +} diff --git a/socrate/bff.go b/socrate/bff.go index 3552401..1c6e48d 100644 --- a/socrate/bff.go +++ b/socrate/bff.go @@ -20,6 +20,8 @@ import ( "fmt" "net/http" "net/url" + + "github.com/ovander/backendkit/ctxutil" ) // ErrMagicLinkAlreadyUsed is returned by VerifyMagicLink when the single-use @@ -80,13 +82,16 @@ type LoginResult struct { MustChangePassword bool `json:"must_change_password,omitempty"` } -// postForm sends an application/x-www-form-urlencoded POST to fullURL. +// postForm sends an application/x-www-form-urlencoded POST to fullURL. It is +// used only for grants made on a user's behalf (authorization_code, +// refresh_token), so it applies the ClientAttribution in ctx, if any. func (c *Client) postForm(ctx context.Context, fullURL string, data url.Values) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewBufferString(data.Encode())) if err != nil { return nil, fmt.Errorf("build form request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ApplyClientAttribution(req) return c.httpClient.Do(req) } @@ -208,9 +213,21 @@ func (c *Client) AdminLogin(ctx context.Context, email, password string) (*Login } // Logout invalidates the caller's current session/token server-side. Maps to -// POST /api/auth/logout and forwards the user JWT from context. +// POST /api/auth/logout and forwards the user JWT from context, with the +// ClientAttribution in ctx, if any. func (c *Client) Logout(ctx context.Context) error { - resp, err := c.doWithJWT(ctx, http.MethodPost, c.oauthURL("/api/auth/logout"), nil) + jwt := ctxutil.GetRawJWT(ctx) + if jwt == "" { + return errors.New("socrate: no JWT in context — use WithJWT or jwtauth.Middleware") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.oauthURL("/api/auth/logout"), nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Content-Type", "application/json") + ApplyClientAttribution(req) + resp, err := c.httpClient.Do(req) if err != nil { return err } @@ -218,7 +235,9 @@ func (c *Client) Logout(ctx context.Context) error { } // doHTTPNoAuth sends a JSON request without an Authorization header — for the -// public token/login endpoints that authenticate via the body instead. +// public token/login endpoints that authenticate via the body instead. Those +// are logins made on a user's behalf (VerifyMagicLink, AdminLogin), so it +// applies the ClientAttribution in ctx, if any. func (c *Client) doHTTPNoAuth(ctx context.Context, method, fullURL string, body interface{}) (*http.Response, error) { var reader *bytes.Reader if body != nil { @@ -235,5 +254,6 @@ func (c *Client) doHTTPNoAuth(ctx context.Context, method, fullURL string, body return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") + ApplyClientAttribution(req) return c.httpClient.Do(req) } diff --git a/socrate/client.go b/socrate/client.go index c2265ae..339705b 100644 --- a/socrate/client.go +++ b/socrate/client.go @@ -287,6 +287,9 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) { return "", fmt.Errorf("build token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // Deliberately no ApplyClientAttribution: this is the application acting + // as itself (no browser involved), and the token it yields is cached and + // shared across every later caller. resp, err := c.httpClient.Do(req) if err != nil { @@ -717,7 +720,9 @@ func (c *Client) GetCurrentUserProfile(ctx context.Context) (*ProfileInfo, error } // RevokeToken revokes an access or refresh token (RFC 7009). -// Uses the service-account credentials for client authentication. +// Uses the service-account credentials for client authentication. Revocation +// is done on a user's behalf (logout), so the ClientAttribution in ctx, if any, +// is applied. func (c *Client) RevokeToken(ctx context.Context, token string) error { if c.clientSecret == "" { return errors.New("socrate: client_secret required for token revocation") @@ -733,6 +738,7 @@ func (c *Client) RevokeToken(ctx context.Context, token string) error { return fmt.Errorf("build revoke request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ApplyClientAttribution(req) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("revoke: %w", err)