From 0a0a43188b568d7d69f1d97f281e95429896f3f8 Mon Sep 17 00:00:00 2001 From: Umputun Date: Mon, 25 May 2026 11:02:55 -0500 Subject: [PATCH 1/4] fix(auth): redact sensitive auth logging --- provider/apple.go | 4 +--- provider/direct.go | 29 ++++++++++++++++++++++------- provider/direct_test.go | 28 ++++++++++++++++++++++++++++ provider/oauth1.go | 4 ---- provider/oauth1_test.go | 25 +++++++++++++++++++++++++ provider/oauth2.go | 4 ---- provider/oauth2_test.go | 25 +++++++++++++++++++++++++ v2/provider/apple.go | 4 +--- v2/provider/direct.go | 29 ++++++++++++++++++++++------- v2/provider/direct_test.go | 28 ++++++++++++++++++++++++++++ v2/provider/oauth1.go | 4 ---- v2/provider/oauth1_test.go | 25 +++++++++++++++++++++++++ v2/provider/oauth2.go | 4 ---- v2/provider/oauth2_test.go | 25 +++++++++++++++++++++++++ 14 files changed, 202 insertions(+), 36 deletions(-) diff --git a/provider/apple.go b/provider/apple.go index 05a7d36..b1b7187 100644 --- a/provider/apple.go +++ b/provider/apple.go @@ -396,8 +396,6 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } - ah.Logf("[DEBUG] user info %+v", u) - // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, ah.URL, ah.AllowedRedirectHosts) { @@ -516,7 +514,7 @@ func (ah *AppleHandler) parseUserData(user *token.User, jUser string) { // catch error for log only. No need break flow if user name doesn't exist if err := json.Unmarshal([]byte(jUser), &userData); err != nil { - ah.Logf("[DEBUG] failed to parse user data %s: %v", user, err) + ah.Logf("[DEBUG] failed to parse apple user data: %v", err) user.Name = "noname_" + user.ID[6:12] // paste noname if user name failed to parse return } diff --git a/provider/direct.go b/provider/direct.go index e1cef6c..ab84ccb 100644 --- a/provider/direct.go +++ b/provider/direct.go @@ -76,24 +76,25 @@ func (p DirectHandler) Name() string { return p.ProviderName } // "aud": "bar", // } func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { + logReq := p.scrubPasswordFromRequest(r) creds, err := p.getCredentials(w, r) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusBadRequest, err, "failed to parse credentials") + rest.SendErrorJSON(w, logReq, p.L, http.StatusBadRequest, err, "failed to parse credentials") return } sessOnly := r.URL.Query().Get("sess") == "1" if p.CredChecker == nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, fmt.Errorf("no credential checker"), "no credential checker") return } ok, err := p.CredChecker.Check(creds.User, creds.Password) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to check user credentials") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "failed to check user credentials") return } if !ok { - rest.SendErrorJSON(w, r, p.L, http.StatusForbidden, nil, "incorrect user or password") + rest.SendErrorJSON(w, logReq, p.L, http.StatusForbidden, nil, "incorrect user or password") return } @@ -108,13 +109,13 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { } u, err = setAvatar(p.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to save avatar to proxy") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "failed to save avatar to proxy") return } cid, err := randToken() if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "can't make token id") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "can't make token id") return } @@ -132,12 +133,26 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { } if _, err = p.TokenService.Set(w, claims); err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to set token") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "failed to set token") return } rest.RenderJSON(w, claims.User) } +func (p DirectHandler) scrubPasswordFromRequest(r *http.Request) *http.Request { + if r == nil || r.URL == nil { + return r + } + if _, ok := r.URL.Query()["passwd"]; !ok { + return r + } + rc := r.Clone(r.Context()) + q := rc.URL.Query() + q.Set("passwd", "") + rc.URL.RawQuery = q.Encode() + return rc +} + // getCredentials extracts user and password from request func (p DirectHandler) getCredentials(w http.ResponseWriter, r *http.Request) (credentials, error) { diff --git a/provider/direct_test.go b/provider/direct_test.go index bc05f87..413bb97 100644 --- a/provider/direct_test.go +++ b/provider/direct_test.go @@ -209,6 +209,34 @@ func TestDirect_LoginHandlerFailed(t *testing.T) { } } +func TestDirect_LoginHandlerRedactsQueryPasswordInLogs(t *testing.T) { + logBuf := strings.Builder{} + d := DirectHandler{ + ProviderName: "test", + CredChecker: &mockCredsChecker{ok: false}, + TokenService: token.NewService(token.Opts{ + SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }), + TokenDuration: time.Hour, + CookieDuration: time.Hour * 24 * 31, + }), + Issuer: "iss-test", + L: logger.Func(func(format string, args ...any) { + fmt.Fprintf(&logBuf, format, args...) + }), + } + + rr := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/login?user=myuser&passwd=secret-password&aud=xyz123", http.NoBody) + require.NoError(t, err) + + d.LoginHandler(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Equal(t, "secret-password", req.URL.Query().Get("passwd"), "original request must not be mutated") + assert.NotContains(t, logBuf.String(), "secret-password") + assert.Contains(t, logBuf.String(), "passwd=") +} + func TestDirect_Logout(t *testing.T) { d := DirectHandler{ ProviderName: "test", diff --git a/provider/oauth1.go b/provider/oauth1.go index 96d4d7a..d8fdefe 100644 --- a/provider/oauth1.go +++ b/provider/oauth1.go @@ -127,8 +127,6 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } - h.Logf("[DEBUG] got raw user info %+v", jData) - u := h.mapUser(jData, data) u, err = setAvatar(h.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) if err != nil { @@ -159,8 +157,6 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } - h.Logf("[DEBUG] user info %+v", u) - // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, h.URL, h.AllowedRedirectHosts) { diff --git a/provider/oauth1_test.go b/provider/oauth1_test.go index 8668f8a..9c16291 100644 --- a/provider/oauth1_test.go +++ b/provider/oauth1_test.go @@ -217,6 +217,30 @@ func TestOauth1LoginFromRejectsExternalHost(t *testing.T) { assert.NotContains(t, string(body), "evil.example.com") } +func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { + logBuf := strings.Builder{} + captureLog := func(p *Params) { + p.L = logger.Func(func(format string, args ...any) { + fmt.Fprintf(&logBuf, format, args...) + }) + } + teardown := prepOauth1Test(t, 8993, 8994, captureLog) + defer teardown() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + client := &http.Client{Jar: jar, Timeout: timeout * time.Second} + + resp, err := client.Get("http://localhost:8993/login?site=remark") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logged := logBuf.String() + assert.NotContains(t, logged, "oauth-sensitive@example.com") + assert.NotContains(t, logged, "mock_myuser1") +} + func prepOauth1Test(t *testing.T, loginPort, authPort int, paramOpts ...func(*Params)) func() { //nolint provider := Oauth1Handler{ @@ -305,6 +329,7 @@ func prepOauth1Test(t *testing.T, loginPort, authPort int, paramOpts ...func(*Pa res := fmt.Sprintf(`{ "id": "%s", "name":"blah", + "email":"oauth-sensitive@example.com", "picture":"http://exmple.com/pic1.png" }`, useIDs[count]) count++ diff --git a/provider/oauth2.go b/provider/oauth2.go index 837e211..e717ce2 100644 --- a/provider/oauth2.go +++ b/provider/oauth2.go @@ -202,8 +202,6 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } - p.Logf("[DEBUG] got raw user info %+v", jData) - u := p.mapUser(jData, data) if oauthClaims.NoAva { u.Picture = "" // reset picture on no avatar request @@ -243,8 +241,6 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { p.bearerTokenHook(p.Name(), u, *tok) } - p.Logf("[DEBUG] user info %+v", u) - // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, p.URL, p.AllowedRedirectHosts) { diff --git a/provider/oauth2_test.go b/provider/oauth2_test.go index 5da4739..0d897c5 100644 --- a/provider/oauth2_test.go +++ b/provider/oauth2_test.go @@ -318,6 +318,30 @@ func TestOauth2LoginFromAllowsAllowlistedHost(t *testing.T) { assert.Contains(t, lastRedirect, "trusted.example.com") } +func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { + logBuf := strings.Builder{} + captureLog := func(p *Params) { + p.L = logger.Func(func(format string, args ...any) { + fmt.Fprintf(&logBuf, format, args...) + }) + } + teardown := prepOauth2Test(t, 8991, 8992, nil, captureLog) + defer teardown() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + client := &http.Client{Jar: jar, Timeout: 5 * time.Second} + + resp, err := client.Get("http://localhost:8991/login?site=remark") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logged := logBuf.String() + assert.NotContains(t, logged, "oauth-sensitive@example.com") + assert.NotContains(t, logged, "mock_myuser1") +} + func TestMakeRedirURL(t *testing.T) { cases := []struct{ rootURL, route, out string }{ {"localhost:8080/", "/my/auth/path/google", "localhost:8080/my/auth/path/callback"}, @@ -415,6 +439,7 @@ func prepOauth2Test(t *testing.T, loginPort, authPort int, btHook BearerTokenHoo res := fmt.Sprintf(`{ "id": "%s", "name":"blah", + "email":"oauth-sensitive@example.com", "picture":"http://exmple.com/pic1.png" }`, useIDs[count]) count++ diff --git a/v2/provider/apple.go b/v2/provider/apple.go index aaee874..67b21a7 100644 --- a/v2/provider/apple.go +++ b/v2/provider/apple.go @@ -402,8 +402,6 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } - ah.Logf("[DEBUG] user info %+v", u) - // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, ah.URL, ah.AllowedRedirectHosts) { @@ -522,7 +520,7 @@ func (ah *AppleHandler) parseUserData(user *token.User, jUser string) { // catch error for log only. No need break flow if user name doesn't exist if err := json.Unmarshal([]byte(jUser), &userData); err != nil { - ah.Logf("[DEBUG] failed to parse user data %s: %v", user, err) + ah.Logf("[DEBUG] failed to parse apple user data: %v", err) user.Name = "noname_" + user.ID[6:12] // paste noname if user name failed to parse return } diff --git a/v2/provider/direct.go b/v2/provider/direct.go index 9844989..c8cbaf6 100644 --- a/v2/provider/direct.go +++ b/v2/provider/direct.go @@ -76,24 +76,25 @@ func (p DirectHandler) Name() string { return p.ProviderName } // "aud": "bar", // } func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { + logReq := p.scrubPasswordFromRequest(r) creds, err := p.getCredentials(w, r) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusBadRequest, err, "failed to parse credentials") + rest.SendErrorJSON(w, logReq, p.L, http.StatusBadRequest, err, "failed to parse credentials") return } sessOnly := r.URL.Query().Get("sess") == "1" if p.CredChecker == nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, fmt.Errorf("no credential checker"), "no credential checker") return } ok, err := p.CredChecker.Check(creds.User, creds.Password) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to check user credentials") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "failed to check user credentials") return } if !ok { - rest.SendErrorJSON(w, r, p.L, http.StatusForbidden, nil, "incorrect user or password") + rest.SendErrorJSON(w, logReq, p.L, http.StatusForbidden, nil, "incorrect user or password") return } @@ -108,13 +109,13 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { } u, err = setAvatar(p.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to save avatar to proxy") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "failed to save avatar to proxy") return } cid, err := randToken() if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "can't make token id") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "can't make token id") return } @@ -132,12 +133,26 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { } if _, err = p.TokenService.Set(w, claims); err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to set token") + rest.SendErrorJSON(w, logReq, p.L, http.StatusInternalServerError, err, "failed to set token") return } rest.RenderJSON(w, claims.User) } +func (p DirectHandler) scrubPasswordFromRequest(r *http.Request) *http.Request { + if r == nil || r.URL == nil { + return r + } + if _, ok := r.URL.Query()["passwd"]; !ok { + return r + } + rc := r.Clone(r.Context()) + q := rc.URL.Query() + q.Set("passwd", "") + rc.URL.RawQuery = q.Encode() + return rc +} + // getCredentials extracts user and password from request func (p DirectHandler) getCredentials(w http.ResponseWriter, r *http.Request) (credentials, error) { diff --git a/v2/provider/direct_test.go b/v2/provider/direct_test.go index a146b9d..1c9f5d9 100644 --- a/v2/provider/direct_test.go +++ b/v2/provider/direct_test.go @@ -209,6 +209,34 @@ func TestDirect_LoginHandlerFailed(t *testing.T) { } } +func TestDirect_LoginHandlerRedactsQueryPasswordInLogs(t *testing.T) { + logBuf := strings.Builder{} + d := DirectHandler{ + ProviderName: "test", + CredChecker: &mockCredsChecker{ok: false}, + TokenService: token.NewService(token.Opts{ + SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }), + TokenDuration: time.Hour, + CookieDuration: time.Hour * 24 * 31, + }), + Issuer: "iss-test", + L: logger.Func(func(format string, args ...any) { + fmt.Fprintf(&logBuf, format, args...) + }), + } + + rr := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/login?user=myuser&passwd=secret-password&aud=xyz123", http.NoBody) + require.NoError(t, err) + + d.LoginHandler(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Equal(t, "secret-password", req.URL.Query().Get("passwd"), "original request must not be mutated") + assert.NotContains(t, logBuf.String(), "secret-password") + assert.Contains(t, logBuf.String(), "passwd=") +} + func TestDirect_Logout(t *testing.T) { d := DirectHandler{ ProviderName: "test", diff --git a/v2/provider/oauth1.go b/v2/provider/oauth1.go index b97d54a..6c64e1a 100644 --- a/v2/provider/oauth1.go +++ b/v2/provider/oauth1.go @@ -127,8 +127,6 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } - h.Logf("[DEBUG] got raw user info %+v", jData) - u := h.mapUser(jData, data) u, err = setAvatar(h.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) if err != nil { @@ -159,8 +157,6 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } - h.Logf("[DEBUG] user info %+v", u) - // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, h.URL, h.AllowedRedirectHosts) { diff --git a/v2/provider/oauth1_test.go b/v2/provider/oauth1_test.go index e8e417b..90c20e5 100644 --- a/v2/provider/oauth1_test.go +++ b/v2/provider/oauth1_test.go @@ -213,6 +213,30 @@ func TestOauth1LoginFromRejectsExternalHost(t *testing.T) { assert.NotContains(t, string(body), "evil.example.com") } +func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { + logBuf := strings.Builder{} + captureLog := func(p *Params) { + p.L = logger.Func(func(format string, args ...any) { + fmt.Fprintf(&logBuf, format, args...) + }) + } + teardown := prepOauth1Test(t, 8993, 8994, captureLog) + defer teardown() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + client := &http.Client{Jar: jar, Timeout: timeout * time.Second} + + resp, err := client.Get("http://localhost:8993/login?site=remark") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logged := logBuf.String() + assert.NotContains(t, logged, "oauth-sensitive@example.com") + assert.NotContains(t, logged, "mock_myuser1") +} + func prepOauth1Test(t *testing.T, loginPort, authPort int, paramOpts ...func(*Params)) func() { //nolint provider := Oauth1Handler{ @@ -301,6 +325,7 @@ func prepOauth1Test(t *testing.T, loginPort, authPort int, paramOpts ...func(*Pa res := fmt.Sprintf(`{ "id": "%s", "name":"blah", + "email":"oauth-sensitive@example.com", "picture":"http://exmple.com/pic1.png" }`, useIDs[count]) count++ diff --git a/v2/provider/oauth2.go b/v2/provider/oauth2.go index 14bb0bf..22e996b 100644 --- a/v2/provider/oauth2.go +++ b/v2/provider/oauth2.go @@ -202,8 +202,6 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } - p.Logf("[DEBUG] got raw user info %+v", jData) - u := p.mapUser(jData, data) if oauthClaims.NoAva { u.Picture = "" // reset picture on no avatar request @@ -243,8 +241,6 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { p.bearerTokenHook(p.Name(), u, *tok) } - p.Logf("[DEBUG] user info %+v", u) - // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, p.URL, p.AllowedRedirectHosts) { diff --git a/v2/provider/oauth2_test.go b/v2/provider/oauth2_test.go index e10419e..fc8b44a 100644 --- a/v2/provider/oauth2_test.go +++ b/v2/provider/oauth2_test.go @@ -327,6 +327,30 @@ func TestOauth2LoginFromAllowsAllowlistedHost(t *testing.T) { assert.Contains(t, lastRedirect, "trusted.example.com") } +func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { + logBuf := strings.Builder{} + captureLog := func(p *Params) { + p.L = logger.Func(func(format string, args ...any) { + fmt.Fprintf(&logBuf, format, args...) + }) + } + teardown := prepOauth2Test(t, 8991, 8992, nil, captureLog) + defer teardown() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + client := &http.Client{Jar: jar, Timeout: 5 * time.Second} + + resp, err := client.Get("http://localhost:8991/login?site=remark") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logged := logBuf.String() + assert.NotContains(t, logged, "oauth-sensitive@example.com") + assert.NotContains(t, logged, "mock_myuser1") +} + func TestMakeRedirURL(t *testing.T) { cases := []struct{ rootURL, route, out string }{ {"localhost:8080/", "/my/auth/path/google", "localhost:8080/my/auth/path/callback"}, @@ -423,6 +447,7 @@ func prepOauth2Test(t *testing.T, loginPort, authPort int, btHook BearerTokenHoo res := fmt.Sprintf(`{ "id": "%s", "name":"blah", + "email":"oauth-sensitive@example.com", "picture":"http://exmple.com/pic1.png" }`, useIDs[count]) count++ From 449c407a07e282fab533fea41e4d601c997bdb1e Mon Sep 17 00:00:00 2001 From: Umputun Date: Mon, 25 May 2026 11:03:01 -0500 Subject: [PATCH 2/4] docs: add project agent guidance --- AGENTS.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e04ebde --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Project Guidance + +## Commands + +| Command | Purpose | +|---------|---------| +| `go test -p 1 ./...` | Run the root `github.com/go-pkgz/auth` module tests. | +| `cd v2 && go test -p 1 ./...` | Run the `github.com/go-pkgz/auth/v2` module tests. | +| `go test -timeout=60s -v -race -p 1 -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov ./...` | Root-module CI test command from `.github/workflows/ci.yml`. | +| `cd v2 && go test -timeout=60s -v -race -p 1 -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov ./...` | v2-module CI test command from `.github/workflows/ci-v2.yml`. | +| `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run --max-issues-per-linter=0 --max-same-issues=0` | Reproduce root-module CI lint locally. | +| `cd v2 && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run --config ../.golangci.yml --max-issues-per-linter=0 --max-same-issues=0` | Reproduce v2-module CI lint locally. | +| `~/.claude/format.sh` | Format Go code when available; it runs gofmt/goimports. | + +## Architecture + +- root module `github.com/go-pkgz/auth` is the v1 API; `v2/` is a separate Go module `github.com/go-pkgz/auth/v2` with mirrored packages. +- `_example/` is a separate example module that replaces `github.com/go-pkgz/auth/v2 => ../v2`; v2 CI builds it before testing `v2/`. +- `auth.go` wires the public `Service`, providers, middleware, token service, avatar proxy, and security headers. +- `provider/` contains OAuth1/OAuth2, Apple, direct, verify, Telegram, dev, and custom-provider flows; most provider changes need the same change under `v2/provider/`. +- `token/` wraps JWT/cookie/XSRF behavior; v1 uses `github.com/golang-jwt/jwt` v3 claims, v2 uses `github.com/golang-jwt/jwt/v5` registered claims. +- `avatar/` owns avatar proxying/storage and has filesystem, bbolt, GridFS, and no-op stores. +- `middleware/` owns auth, trace, admin-only, RBAC, basic auth, validator, token refresh, and request user context. + +## CI and Lint Notes + +- CI pins golangci-lint through GitHub Actions (`golangci/golangci-lint-action@v7` with `version: v2.6.2` in `.github/workflows/ci*.yml`). Newer local `golangci-lint` versions can report extra `gosec` findings that CI does not enforce. +- Root CI (`.github/workflows/ci.yml`) ignores `v2/**`, `_example/**`, and `ci-v2.yml`; v2 CI (`.github/workflows/ci-v2.yml`) runs only for `v2/**`, `_example/**`, and `ci-v2.yml` changes. +- Mongo-backed tests run in CI with `ENABLE_MONGO_TESTS=true` after `wbari/start-mongoDB@v0.2` starts MongoDB 6.0. +- The workflow coverage step currently installs `github.com/mattn/goveralls@latest` with `COVERALLS_TOKEN=${{ secrets.GITHUB_TOKEN }}`; security reviews should keep checking this supply-chain path. + +## Project Rules + +- For behavior shared by v1 and v2, update root and `v2/` implementations and tests together unless the difference is explicitly version-specific. +- Preserve backward compatibility for public auth flows and documented query parameters. For example, direct login still supports `passwd` in the URL, so sensitive-query fixes must redact rather than remove that input path. +- Avoid logging raw user profiles, mapped `token.User` values, JWTs, OAuth tokens, confirmation tokens, or credential-bearing URLs. Use redacted request copies when passing URLs with secrets to `rest.SendErrorJSON`. +- `AllowedRedirectHosts` hardening is opt-in. Nil keeps legacy permissive `from` redirects; a non-nil getter enables host validation. +- Verify-provider confirmation tokens are one-shot only when `VerifConfirmationStore` is configured; nil installs an in-memory store via `AddVerifProvider`, suitable only for single-instance deployments. +- Avatar handling is security-sensitive: keep content-type validation, image dimension/size caps, and bot-token URL redaction intact in both root and v2. + +## Testing Gotchas + +- Run root and v2 tests separately; `go test ./...` from the root does not test the separate `v2` module. +- When reproducing CI lint, use the pinned `v2.6.2` command above, not the globally installed `golangci-lint` binary unless its version matches CI. +- Many tests bind fixed localhost ports in the 898x/899x range; use `-p 1` for package test runs to reduce port-collision risk. +- `go test ./...` in the root includes Mongo tests only when `ENABLE_MONGO_TESTS=true`; without it, CI-only Mongo coverage may not run locally. From d50c9993f15ef58ec098548b17801629bad79fda Mon Sep 17 00:00:00 2001 From: Umputun Date: Mon, 25 May 2026 11:15:20 -0500 Subject: [PATCH 3/4] fix(auth): restore safe debug logging --- provider/apple.go | 2 ++ provider/oauth1.go | 3 +++ provider/oauth1_test.go | 3 ++- provider/oauth2.go | 3 +++ provider/oauth2_test.go | 3 ++- provider/service.go | 20 ++++++++++++++++++++ provider/service_test.go | 33 +++++++++++++++++++++++++++++++++ v2/provider/apple.go | 2 ++ v2/provider/oauth1.go | 3 +++ v2/provider/oauth1_test.go | 3 ++- v2/provider/oauth2.go | 3 +++ v2/provider/oauth2_test.go | 3 ++- v2/provider/service.go | 20 ++++++++++++++++++++ v2/provider/service_test.go | 33 +++++++++++++++++++++++++++++++++ 14 files changed, 130 insertions(+), 4 deletions(-) diff --git a/provider/apple.go b/provider/apple.go index b1b7187..3618fe9 100644 --- a/provider/apple.go +++ b/provider/apple.go @@ -396,6 +396,8 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } + ah.Logf("[DEBUG] user info %s", userLogSummary(u)) + // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, ah.URL, ah.AllowedRedirectHosts) { diff --git a/provider/oauth1.go b/provider/oauth1.go index d8fdefe..3f688db 100644 --- a/provider/oauth1.go +++ b/provider/oauth1.go @@ -127,6 +127,7 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } + h.Logf("[DEBUG] got raw user info %s", userDataLogSummary(jData)) u := h.mapUser(jData, data) u, err = setAvatar(h.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) if err != nil { @@ -157,6 +158,8 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } + h.Logf("[DEBUG] user info %s", userLogSummary(u)) + // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, h.URL, h.AllowedRedirectHosts) { diff --git a/provider/oauth1_test.go b/provider/oauth1_test.go index 9c16291..b3614ee 100644 --- a/provider/oauth1_test.go +++ b/provider/oauth1_test.go @@ -237,8 +237,9 @@ func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) logged := logBuf.String() + assert.Contains(t, logged, "got raw user info keys=[email id name picture]") + assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") - assert.NotContains(t, logged, "mock_myuser1") } func prepOauth1Test(t *testing.T, loginPort, authPort int, paramOpts ...func(*Params)) func() { //nolint diff --git a/provider/oauth2.go b/provider/oauth2.go index e717ce2..764764d 100644 --- a/provider/oauth2.go +++ b/provider/oauth2.go @@ -202,6 +202,7 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } + p.Logf("[DEBUG] got raw user info %s", userDataLogSummary(jData)) u := p.mapUser(jData, data) if oauthClaims.NoAva { u.Picture = "" // reset picture on no avatar request @@ -241,6 +242,8 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { p.bearerTokenHook(p.Name(), u, *tok) } + p.Logf("[DEBUG] user info %s", userLogSummary(u)) + // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, p.URL, p.AllowedRedirectHosts) { diff --git a/provider/oauth2_test.go b/provider/oauth2_test.go index 0d897c5..89067e6 100644 --- a/provider/oauth2_test.go +++ b/provider/oauth2_test.go @@ -338,8 +338,9 @@ func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) logged := logBuf.String() + assert.Contains(t, logged, "got raw user info keys=[email id name picture]") + assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") - assert.NotContains(t, logged, "mock_myuser1") } func TestMakeRedirURL(t *testing.T) { diff --git a/provider/service.go b/provider/service.go index b44947c..239d512 100644 --- a/provider/service.go +++ b/provider/service.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "slices" "strings" "github.com/go-pkgz/auth/avatar" @@ -85,6 +86,25 @@ func (p Service) Handler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } +func userDataLogSummary(data UserData) string { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + slices.Sort(keys) + return fmt.Sprintf("keys=%v", keys) +} + +func userLogSummary(u token.User) string { + attrs := make([]string, 0, len(u.Attributes)) + for k := range u.Attributes { + attrs = append(attrs, k) + } + slices.Sort(attrs) + return fmt.Sprintf("id=%q name=%q picture=%t email=%t attrs=%v role=%t audience=%t", + u.ID, u.Name, u.Picture != "", u.Email != "", attrs, u.Role != "", u.Audience != "") +} + // setAvatar saves avatar and puts proxied URL to u.Picture func setAvatar(ava AvatarSaver, u token.User, client *http.Client) (token.User, error) { if ava == nil || ava == (*avatar.Proxy)(nil) { diff --git a/provider/service_test.go b/provider/service_test.go index 98643a0..e32d717 100644 --- a/provider/service_test.go +++ b/provider/service_test.go @@ -88,6 +88,39 @@ func TestLocalBindAddr_DefaultIsNotAllInterfaces(t *testing.T) { assert.False(t, strings.HasPrefix(addr, ":"), "default bind must include a hostname, not the bare-port shorthand") } +func TestLogSummaries(t *testing.T) { + assert.Equal(t, "keys=[email id name picture]", userDataLogSummary(UserData{ + "id": "123", + "name": "Jane User", + "email": "secret@example.com", + "picture": "https://example.com/pic.png", + })) + + summary := userLogSummary(token.User{ + ID: "provider_user123", + Name: "Jane User", + Picture: "https://example.com/pic.png?token=secret", + Email: "secret@example.com", + Attributes: map[string]any{ + "tier": "gold", + "admin": true, + }, + Role: "admin", + Audience: "site1", + }) + + assert.Contains(t, summary, `id="provider_user123"`) + assert.Contains(t, summary, `name="Jane User"`) + assert.Contains(t, summary, "picture=true") + assert.Contains(t, summary, "email=true") + assert.Contains(t, summary, "attrs=[admin tier]") + assert.Contains(t, summary, "role=true") + assert.Contains(t, summary, "audience=true") + assert.NotContains(t, summary, "secret@example.com") + assert.NotContains(t, summary, "gold") + assert.NotContains(t, summary, "https://example.com") +} + func TestSetAvatar(t *testing.T) { client := &http.Client{Timeout: time.Second} u, err := setAvatar(nil, token.User{Picture: "http://example.com/pic1.png"}, client) diff --git a/v2/provider/apple.go b/v2/provider/apple.go index 67b21a7..a4fed33 100644 --- a/v2/provider/apple.go +++ b/v2/provider/apple.go @@ -402,6 +402,8 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } + ah.Logf("[DEBUG] user info %s", userLogSummary(u)) + // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, ah.URL, ah.AllowedRedirectHosts) { diff --git a/v2/provider/oauth1.go b/v2/provider/oauth1.go index 6c64e1a..47af078 100644 --- a/v2/provider/oauth1.go +++ b/v2/provider/oauth1.go @@ -127,6 +127,7 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } + h.Logf("[DEBUG] got raw user info %s", userDataLogSummary(jData)) u := h.mapUser(jData, data) u, err = setAvatar(h.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) if err != nil { @@ -157,6 +158,8 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { return } + h.Logf("[DEBUG] user info %s", userLogSummary(u)) + // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, h.URL, h.AllowedRedirectHosts) { diff --git a/v2/provider/oauth1_test.go b/v2/provider/oauth1_test.go index 90c20e5..018b1df 100644 --- a/v2/provider/oauth1_test.go +++ b/v2/provider/oauth1_test.go @@ -233,8 +233,9 @@ func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) logged := logBuf.String() + assert.Contains(t, logged, "got raw user info keys=[email id name picture]") + assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") - assert.NotContains(t, logged, "mock_myuser1") } func prepOauth1Test(t *testing.T, loginPort, authPort int, paramOpts ...func(*Params)) func() { //nolint diff --git a/v2/provider/oauth2.go b/v2/provider/oauth2.go index 22e996b..e75324e 100644 --- a/v2/provider/oauth2.go +++ b/v2/provider/oauth2.go @@ -202,6 +202,7 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info") return } + p.Logf("[DEBUG] got raw user info %s", userDataLogSummary(jData)) u := p.mapUser(jData, data) if oauthClaims.NoAva { u.Picture = "" // reset picture on no avatar request @@ -241,6 +242,8 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { p.bearerTokenHook(p.Name(), u, *tok) } + p.Logf("[DEBUG] user info %s", userLogSummary(u)) + // redirect to back url if presented in login query params if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { if !isAllowedRedirect(oauthClaims.Handshake.From, p.URL, p.AllowedRedirectHosts) { diff --git a/v2/provider/oauth2_test.go b/v2/provider/oauth2_test.go index fc8b44a..d6a9864 100644 --- a/v2/provider/oauth2_test.go +++ b/v2/provider/oauth2_test.go @@ -347,8 +347,9 @@ func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) logged := logBuf.String() + assert.Contains(t, logged, "got raw user info keys=[email id name picture]") + assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") - assert.NotContains(t, logged, "mock_myuser1") } func TestMakeRedirURL(t *testing.T) { diff --git a/v2/provider/service.go b/v2/provider/service.go index 469b497..953f62d 100644 --- a/v2/provider/service.go +++ b/v2/provider/service.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "slices" "strings" "github.com/go-pkgz/auth/v2/avatar" @@ -85,6 +86,25 @@ func (p Service) Handler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } +func userDataLogSummary(data UserData) string { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + slices.Sort(keys) + return fmt.Sprintf("keys=%v", keys) +} + +func userLogSummary(u token.User) string { + attrs := make([]string, 0, len(u.Attributes)) + for k := range u.Attributes { + attrs = append(attrs, k) + } + slices.Sort(attrs) + return fmt.Sprintf("id=%q name=%q picture=%t email=%t attrs=%v role=%t audience=%t", + u.ID, u.Name, u.Picture != "", u.Email != "", attrs, u.Role != "", u.Audience != "") +} + // setAvatar saves avatar and puts proxied URL to u.Picture func setAvatar(ava AvatarSaver, u token.User, client *http.Client) (token.User, error) { if ava == nil || ava == (*avatar.Proxy)(nil) { diff --git a/v2/provider/service_test.go b/v2/provider/service_test.go index 8aae732..848465c 100644 --- a/v2/provider/service_test.go +++ b/v2/provider/service_test.go @@ -85,6 +85,39 @@ func TestLocalBindAddr_DefaultIsNotAllInterfaces(t *testing.T) { assert.False(t, strings.HasPrefix(addr, ":"), "default bind must include a hostname, not the bare-port shorthand") } +func TestLogSummaries(t *testing.T) { + assert.Equal(t, "keys=[email id name picture]", userDataLogSummary(UserData{ + "id": "123", + "name": "Jane User", + "email": "secret@example.com", + "picture": "https://example.com/pic.png", + })) + + summary := userLogSummary(token.User{ + ID: "provider_user123", + Name: "Jane User", + Picture: "https://example.com/pic.png?token=secret", + Email: "secret@example.com", + Attributes: map[string]any{ + "tier": "gold", + "admin": true, + }, + Role: "admin", + Audience: "site1", + }) + + assert.Contains(t, summary, `id="provider_user123"`) + assert.Contains(t, summary, `name="Jane User"`) + assert.Contains(t, summary, "picture=true") + assert.Contains(t, summary, "email=true") + assert.Contains(t, summary, "attrs=[admin tier]") + assert.Contains(t, summary, "role=true") + assert.Contains(t, summary, "audience=true") + assert.NotContains(t, summary, "secret@example.com") + assert.NotContains(t, summary, "gold") + assert.NotContains(t, summary, "https://example.com") +} + func TestSetAvatar(t *testing.T) { client := &http.Client{Timeout: time.Second} u, err := setAvatar(nil, token.User{Picture: "http://example.com/pic1.png"}, client) From a544fd2cf3ad00c7a22bc7a61057a115783299af Mon Sep 17 00:00:00 2001 From: Umputun Date: Mon, 25 May 2026 11:32:49 -0500 Subject: [PATCH 4/4] test(auth): synchronize log capture --- provider/oauth1_test.go | 10 +++++++++- provider/oauth2_test.go | 10 +++++++++- v2/provider/oauth1_test.go | 10 +++++++++- v2/provider/oauth2_test.go | 10 +++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/provider/oauth1_test.go b/provider/oauth1_test.go index b3614ee..a6ca5c7 100644 --- a/provider/oauth1_test.go +++ b/provider/oauth1_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/cookiejar" "strings" + "sync" "testing" "time" @@ -218,9 +219,12 @@ func TestOauth1LoginFromRejectsExternalHost(t *testing.T) { } func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { + var logMu sync.Mutex logBuf := strings.Builder{} captureLog := func(p *Params) { p.L = logger.Func(func(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() fmt.Fprintf(&logBuf, format, args...) }) } @@ -236,7 +240,11 @@ func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - logged := logBuf.String() + logged := func() string { + logMu.Lock() + defer logMu.Unlock() + return logBuf.String() + }() assert.Contains(t, logged, "got raw user info keys=[email id name picture]") assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") diff --git a/provider/oauth2_test.go b/provider/oauth2_test.go index 89067e6..52c090e 100644 --- a/provider/oauth2_test.go +++ b/provider/oauth2_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/cookiejar" "strings" + "sync" "testing" "time" @@ -319,9 +320,12 @@ func TestOauth2LoginFromAllowsAllowlistedHost(t *testing.T) { } func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { + var logMu sync.Mutex logBuf := strings.Builder{} captureLog := func(p *Params) { p.L = logger.Func(func(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() fmt.Fprintf(&logBuf, format, args...) }) } @@ -337,7 +341,11 @@ func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - logged := logBuf.String() + logged := func() string { + logMu.Lock() + defer logMu.Unlock() + return logBuf.String() + }() assert.Contains(t, logged, "got raw user info keys=[email id name picture]") assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") diff --git a/v2/provider/oauth1_test.go b/v2/provider/oauth1_test.go index 018b1df..b4fceb7 100644 --- a/v2/provider/oauth1_test.go +++ b/v2/provider/oauth1_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/cookiejar" "strings" + "sync" "testing" "time" @@ -214,9 +215,12 @@ func TestOauth1LoginFromRejectsExternalHost(t *testing.T) { } func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { + var logMu sync.Mutex logBuf := strings.Builder{} captureLog := func(p *Params) { p.L = logger.Func(func(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() fmt.Fprintf(&logBuf, format, args...) }) } @@ -232,7 +236,11 @@ func TestOauth1LoginDoesNotLogUserProfile(t *testing.T) { defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - logged := logBuf.String() + logged := func() string { + logMu.Lock() + defer logMu.Unlock() + return logBuf.String() + }() assert.Contains(t, logged, "got raw user info keys=[email id name picture]") assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com") diff --git a/v2/provider/oauth2_test.go b/v2/provider/oauth2_test.go index d6a9864..ee0e245 100644 --- a/v2/provider/oauth2_test.go +++ b/v2/provider/oauth2_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/cookiejar" "strings" + "sync" "testing" "time" @@ -328,9 +329,12 @@ func TestOauth2LoginFromAllowsAllowlistedHost(t *testing.T) { } func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { + var logMu sync.Mutex logBuf := strings.Builder{} captureLog := func(p *Params) { p.L = logger.Func(func(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() fmt.Fprintf(&logBuf, format, args...) }) } @@ -346,7 +350,11 @@ func TestOauth2LoginDoesNotLogUserProfile(t *testing.T) { defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - logged := logBuf.String() + logged := func() string { + logMu.Lock() + defer logMu.Unlock() + return logBuf.String() + }() assert.Contains(t, logged, "got raw user info keys=[email id name picture]") assert.Contains(t, logged, `user info id="mock_myuser1" name="blah" picture=true`) assert.NotContains(t, logged, "oauth-sensitive@example.com")