From 98efb884948566e0af77e7d5e93e1d48d702f18b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 13 Aug 2026 17:50:40 +0000 Subject: [PATCH 1/6] fix(coderd): set Cache-Control: no-store on OAuth2 responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No response from the /oauth2 route tree set Cache-Control at all, so an intermediary cache or customer-operated reverse proxy was free to apply a heuristic freshness lifetime to a response carrying a live credential. RFC 6749 §5.1 and OAuth 2.1 §3.2.3 both make an affirmative no-store directive a MUST for the authorization server. Add httpmw.NoStore, mounted once on the /oauth2 tree, setting Cache-Control: no-store and Pragma: no-cache on every response from it. RFC 6749 §5.1 makes both headers a MUST in a single sentence; OAuth 2.1 drops Pragma because RFC 9111 §5.4 deprecates it as a request-only field, so sending both is conformant under either reading. The scope is the whole tree rather than only POST /oauth2/tokens. With a middleware the mount point is one line either way, and the wider scope also covers DCR registration (returns client_secret and registration_access_token), client configuration read and update, and the authorize endpoint's 302 whose Location query carries the authorization code. A route added to the tree later inherits the headers with no action from its author. Three write paths in the tree never call httpapi.Write, so a fix centralized there would have missed POST /oauth2/revoke, DELETE /oauth2/clients/{client_id}, and writeOAuth2RegistrationError. The two /.well-known/* metadata endpoints are deliberately excluded. They sit on their own route trees, their content is public discovery metadata, and RFC 9728 §5 asks for the opposite treatment. A negative assertion pins the exclusion so a later move to a higher router fails CI. Note for PLAT-498: DELETE /oauth2/tokens now carries no-store and is wrapped in apiKeyMiddleware, which runs after this middleware. A Cache-Control write there must not replace no-store with a weaker directive such as private. --- coderd/coderd.go | 4 + coderd/httpmw/nostore.go | 29 ++ coderd/httpmw/nostore_test.go | 131 +++++++++ coderd/oauth2provider/nostore_test.go | 380 ++++++++++++++++++++++++++ 4 files changed, 544 insertions(+) create mode 100644 coderd/httpmw/nostore.go create mode 100644 coderd/httpmw/nostore_test.go create mode 100644 coderd/oauth2provider/nostore_test.go diff --git a/coderd/coderd.go b/coderd/coderd.go index fad338d39d4..d504f97a7ad 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1243,6 +1243,10 @@ func New(options *Options) *API { r.Route("/oauth2", func(r chi.Router) { r.Use( httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), + // Every response from this tree may carry a credential, so none of + // them may be retained by an intermediary cache. NoStore takes no + // configuration, so it is passed uncalled. + httpmw.NoStore, ) r.Route("/authorize", func(r chi.Router) { r.Use( diff --git a/coderd/httpmw/nostore.go b/coderd/httpmw/nostore.go new file mode 100644 index 00000000000..e9f64cc3c56 --- /dev/null +++ b/coderd/httpmw/nostore.go @@ -0,0 +1,29 @@ +package httpmw + +import "net/http" + +const ( + cacheControlHeader = "Cache-Control" + pragmaHeader = "Pragma" +) + +// NoStore sets the response caching headers that OAuth2 requires on any +// response that may contain a credential. RFC 6749 §5.1 makes both headers a +// MUST for the authorization server; OAuth 2.1 §3.2.3 keeps only no-store, +// because RFC 9111 §5.4 deprecates Pragma as a request-only field. Both are +// sent so that a client or auditor reading either specification sees a +// conformant response. +// +// The headers are set before the wrapped handler runs, so a handler that +// writes its own Cache-Control would win. No handler under /oauth2 does; the +// test suite pins that. +// +// NoStore carries no configuration, so it is the middleware itself rather +// than a constructor for one. Pass it to chi's r.Use without calling it. +func NoStore(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set(cacheControlHeader, "no-store") + rw.Header().Set(pragmaHeader, "no-cache") + next.ServeHTTP(rw, r) + }) +} diff --git a/coderd/httpmw/nostore_test.go b/coderd/httpmw/nostore_test.go new file mode 100644 index 00000000000..b20b01fbb15 --- /dev/null +++ b/coderd/httpmw/nostore_test.go @@ -0,0 +1,131 @@ +package httpmw_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +func TestNoStore(t *testing.T) { + t.Parallel() + + tests := []struct { + Name string + Handler http.HandlerFunc + + expectStatus int + expectCacheControl string + expectPragma string + // assert runs extra checks against the recorded response. + assert func(t *testing.T, res *httptest.ResponseRecorder) + }{ + { + // The POST /oauth2/tokens shape. + Name: "OK", + Handler: func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write([]byte(`{"access_token":"secret"}`)) + }, + expectStatus: http.StatusOK, + expectCacheControl: "no-store", + expectPragma: "no-cache", + }, + { + // The DELETE /oauth2/clients/{client_id} shape: a response with + // no body still carries headers. + Name: "NoContent", + Handler: func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusNoContent) + }, + expectStatus: http.StatusNoContent, + expectCacheControl: "no-store", + expectPragma: "no-cache", + }, + { + // The POST /oauth2/authorize shape: http.Redirect adds Location + // and calls WriteHeader without clearing the header map. + Name: "Redirect", + Handler: func(rw http.ResponseWriter, r *http.Request) { + http.Redirect(rw, r, "https://example.com/callback?code=abc", http.StatusFound) + }, + expectStatus: http.StatusFound, + expectCacheControl: "no-store", + expectPragma: "no-cache", + assert: func(t *testing.T, res *httptest.ResponseRecorder) { + require.Equal(t, "https://example.com/callback?code=abc", res.Header().Get("Location")) + }, + }, + { + // The revoke.go and registration.go shape: a bare WriteHeader + // that never reaches httpapi.Write. + Name: "BareWriteHeaderError", + Handler: func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusBadRequest) + }, + expectStatus: http.StatusBadRequest, + expectCacheControl: "no-store", + expectPragma: "no-cache", + }, + { + // The headers are advisory: a handler that writes its own + // Cache-Control wins. No handler under /oauth2 does, which the + // integration tests pin, but the contract belongs in a test + // rather than only in prose. + Name: "HandlerOverwrites", + Handler: func(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set("Cache-Control", "private") + rw.WriteHeader(http.StatusOK) + }, + expectStatus: http.StatusOK, + expectCacheControl: "private", + expectPragma: "no-cache", + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + res := httptest.NewRecorder() + httpmw.NoStore(tt.Handler).ServeHTTP(res, req) + + require.Equal(t, tt.expectStatus, res.Code) + require.Equal(t, tt.expectCacheControl, res.Header().Get("Cache-Control")) + require.Equal(t, tt.expectPragma, res.Header().Get("Pragma")) + if tt.assert != nil { + tt.assert(t, res) + } + }) + } +} + +// TestNoStoreAfterExperimentGate pins the ordering consequence of mounting +// NoStore after the experiment gate on the /oauth2 tree: a request the gate +// rejects never reaches NoStore, so its response carries neither header. That +// is correct, since the rejection contains no credential. The gate that runs +// in production, RequireExperimentWithDevBypass, cannot be exercised from a +// test binary because buildinfo.IsDev() is true there and bypasses the check, +// so this asserts against the RequireExperiment it delegates to. +func TestNoStoreAfterExperimentGate(t *testing.T) { + t.Parallel() + + handler := httpmw.RequireExperiment(codersdk.Experiments{}, codersdk.ExperimentOAuth2)( + httpmw.NoStore(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + })), + ) + + req := httptest.NewRequest(http.MethodGet, "/oauth2/tokens", nil) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + + require.Equal(t, http.StatusForbidden, res.Code) + require.Empty(t, res.Header().Get("Cache-Control")) + require.Empty(t, res.Header().Get("Pragma")) +} diff --git a/coderd/oauth2provider/nostore_test.go b/coderd/oauth2provider/nostore_test.go new file mode 100644 index 00000000000..1f401852808 --- /dev/null +++ b/coderd/oauth2provider/nostore_test.go @@ -0,0 +1,380 @@ +package oauth2provider_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// TestOAuth2NoStoreHeaders asserts that every response from the /oauth2 route +// tree carries Cache-Control: no-store and Pragma: no-cache, on the routes +// that return a credential and on the routes that do not. Three of the tree's +// write paths never call httpapi.Write, so this is what proves the middleware +// reaches them: POST /oauth2/revoke and DELETE /oauth2/clients/{client_id} +// both write a bare status, and a rejected registration is written by +// writeOAuth2RegistrationError. +// +// The negative case at the end pins the exclusion of the /.well-known/* +// metadata endpoints, which RFC 9728 §5 asks to be cacheable. It fails if the +// middleware is ever hoisted onto a higher route tree. +func TestOAuth2NoStoreHeaders(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + baseURL := client.URL.String() + + t.Run("TokenExchange", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, secret := oauth2providertest.CreateTestOAuth2App(t, client) + verifier, challenge := oauth2providertest.GeneratePKCE(t) + code := authorizationCode(t, client, baseURL, app.ID.String(), challenge) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", app.ID.String()) + form.Set("client_secret", secret) + form.Set("code_verifier", verifier) + form.Set("redirect_uri", oauth2providertest.TestRedirectURI) + + resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/tokens", strings.NewReader(form.Encode()), formContentType) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("TokenExchangeInvalidClient", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, _ := oauth2providertest.CreateTestOAuth2App(t, client) + verifier, challenge := oauth2providertest.GeneratePKCE(t) + code := authorizationCode(t, client, baseURL, app.ID.String(), challenge) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", app.ID.String()) + form.Set("client_secret", "not-the-client-secret") + form.Set("code_verifier", verifier) + form.Set("redirect_uri", oauth2providertest.TestRedirectURI) + + resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/tokens", strings.NewReader(form.Encode()), formContentType) + defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + requireNoStore(t, resp) + // The two header writers coexist: WriteOAuth2Error sets this one + // itself, after the middleware has already written its own. + require.Equal(t, `Basic realm="coder"`, resp.Header.Get("WWW-Authenticate")) + }) + + t.Run("AuthorizeRedirect", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, _ := oauth2providertest.CreateTestOAuth2App(t, client) + _, challenge := oauth2providertest.GeneratePKCE(t) + + resp := doRequest(ctx, t, http.MethodPost, authorizeURL(baseURL, app.ID.String(), challenge), nil, sessionToken(client)) + defer resp.Body.Close() + require.Equal(t, http.StatusFound, resp.StatusCode) + requireNoStore(t, resp) + + // The credential this response carries is in the Location query. + location, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + require.NotEmpty(t, location.Query().Get("code")) + }) + + t.Run("AuthorizeConsentPage", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, _ := oauth2providertest.CreateTestOAuth2App(t, client) + _, challenge := oauth2providertest.GeneratePKCE(t) + + resp := doRequest(ctx, t, http.MethodGet, authorizeURL(baseURL, app.ID.String(), challenge), nil, sessionToken(client)) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("AuthorizeStaticErrorPage", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, _ := oauth2providertest.CreateTestOAuth2App(t, client) + _, challenge := oauth2providertest.GeneratePKCE(t) + + // An unsupported response_type is rendered as a static error page + // rather than written through httpapi. + uri := strings.Replace(authorizeURL(baseURL, app.ID.String(), challenge), "response_type=code", "response_type=token", 1) + resp := doRequest(ctx, t, http.MethodGet, uri, nil, sessionToken(client)) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("Revoke", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, secret := oauth2providertest.CreateTestOAuth2App(t, client) + verifier, challenge := oauth2providertest.GeneratePKCE(t) + code := authorizationCode(t, client, baseURL, app.ID.String(), challenge) + token := oauth2providertest.ExchangeCodeForToken(t, baseURL, oauth2providertest.TokenExchangeParams{ + GrantType: "authorization_code", + Code: code, + ClientID: app.ID.String(), + ClientSecret: secret, + CodeVerifier: verifier, + RedirectURI: oauth2providertest.TestRedirectURI, + }) + + form := url.Values{} + form.Set("token", token.RefreshToken) + form.Set("client_id", app.ID.String()) + + // RFC 7009 success is a bare rw.WriteHeader(200) that never reaches + // httpapi.Write. + resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("DeleteTokens", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, _ := oauth2providertest.CreateTestOAuth2App(t, client) + + uri := fmt.Sprintf("%s/oauth2/tokens?client_id=%s", baseURL, app.ID.String()) + resp := doRequest(ctx, t, http.MethodDelete, uri, nil, sessionToken(client)) + defer resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("Register", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + body := registrationBody(t, codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"https://example.com/callback"}, + ClientName: fmt.Sprintf("nostore-register-%s", testutil.MustRandString(t, 10)), + }) + + resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/register", strings.NewReader(body), jsonContentType) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("RegisterInvalidMetadata", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + body := registrationBody(t, codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"not-a-url"}, + }) + + // Rejected by writeOAuth2RegistrationError, which encodes its own JSON + // and never reaches httpapi.Write. + resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/register", strings.NewReader(body), jsonContentType) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("GetClientConfiguration", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + registration := registerClient(ctx, t, client) + + uri := fmt.Sprintf("%s/oauth2/clients/%s", baseURL, registration.ClientID) + resp := doRequest(ctx, t, http.MethodGet, uri, nil, bearer(registration.RegistrationAccessToken)) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("PutClientConfiguration", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + registration := registerClient(ctx, t, client) + body := registrationBody(t, codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"https://example.com/updated-callback"}, + ClientName: fmt.Sprintf("nostore-updated-%s", testutil.MustRandString(t, 10)), + }) + + uri := fmt.Sprintf("%s/oauth2/clients/%s", baseURL, registration.ClientID) + resp := doRequest(ctx, t, http.MethodPut, uri, strings.NewReader(body), jsonContentType, bearer(registration.RegistrationAccessToken)) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("DeleteClientConfiguration", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + registration := registerClient(ctx, t, client) + + // A 204 carries headers even with no body, and RFC 7592 §2.3's own + // example shows no-store on exactly this response. + uri := fmt.Sprintf("%s/oauth2/clients/%s", baseURL, registration.ClientID) + resp := doRequest(ctx, t, http.MethodDelete, uri, nil, bearer(registration.RegistrationAccessToken)) + defer resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + requireNoStore(t, resp) + }) + + t.Run("UnmatchedPath", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Chi runs a subrouter's middleware chain around its unmatched-path + // handling, so a path with no route under the tree still carries the + // headers. The status is not asserted: the request falls through to + // the root router's handler, which serves the single-page app rather + // than a 404, and that is the site handler's business, not this + // middleware's. + resp := doRequest(ctx, t, http.MethodGet, baseURL+"/oauth2/does-not-exist", nil) + defer resp.Body.Close() + requireNoStore(t, resp) + }) + + // Discovery metadata is public and RFC 9728 §5 asks for it to be cacheable. + // These endpoints sit outside the /oauth2 tree, and these assertions are + // what keep them there: they fail if the middleware is ever mounted on a + // router that reaches them. + for _, path := range []string{ + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource", + } { + t.Run("Cacheable"+path, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp := doRequest(ctx, t, http.MethodGet, baseURL+path, nil) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotContains(t, resp.Header.Get("Cache-Control"), "no-store") + }) + } +} + +func requireNoStore(t *testing.T, resp *http.Response) { + t.Helper() + + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + require.Equal(t, "no-cache", resp.Header.Get("Pragma")) +} + +// doRequest performs a request without following redirects, so a 302's own +// headers can be asserted rather than the redirect target's. +func doRequest(ctx context.Context, t *testing.T, method, uri string, body io.Reader, opts ...func(*http.Request)) *http.Response { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, method, uri, body) + require.NoError(t, err) + for _, opt := range opts { + opt(req) + } + + httpClient := &http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := httpClient.Do(req) + require.NoError(t, err) + + return resp +} + +func formContentType(r *http.Request) { + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") +} + +func jsonContentType(r *http.Request) { + r.Header.Set("Content-Type", "application/json") +} + +func sessionToken(client *codersdk.Client) func(*http.Request) { + return func(r *http.Request) { + r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + } +} + +func bearer(token string) func(*http.Request) { + return func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + } +} + +func authorizeURL(baseURL, clientID, challenge string) string { + query := url.Values{} + query.Set("client_id", clientID) + query.Set("response_type", "code") + query.Set("redirect_uri", oauth2providertest.TestRedirectURI) + query.Set("state", "state") + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + + return baseURL + "/oauth2/authorize?" + query.Encode() +} + +func authorizationCode(t *testing.T, client *codersdk.Client, baseURL, clientID, challenge string) string { + t.Helper() + + state := oauth2providertest.GenerateState(t) + return oauth2providertest.AuthorizeOAuth2App(t, client, baseURL, oauth2providertest.AuthorizeParams{ + ClientID: clientID, + ResponseType: "code", + RedirectURI: oauth2providertest.TestRedirectURI, + State: state, + CodeChallenge: challenge, + CodeChallengeMethod: "S256", + }) +} + +func registrationBody(t *testing.T, req codersdk.OAuth2ClientRegistrationRequest) string { + t.Helper() + + body, err := json.Marshal(req) + require.NoError(t, err) + + return string(body) +} + +func registerClient(ctx context.Context, t *testing.T, client *codersdk.Client) codersdk.OAuth2ClientRegistrationResponse { + t.Helper() + + registration, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"https://example.com/callback"}, + ClientName: fmt.Sprintf("nostore-client-%s", testutil.MustRandString(t, 10)), + }) + require.NoError(t, err) + + return registration +} From 97cea43bb035d89f5bcc3eda8e5b54b3de559220 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 13 Aug 2026 20:58:40 +0000 Subject: [PATCH 2/6] fix(coderd): extend no-store to the oauth2-provider tree POST /api/v2/oauth2-provider/apps/{app}/secrets returns OAuth2ProviderAppSecretFull.ClientSecretFull in plaintext, and nothing on that tree set a cache directive. It meets RFC 6749 5.1's predicate as squarely as anything under /oauth2, so mount httpmw.NoStore there too rather than leaving the rule a description of one URL prefix. Mounted after apiKeyMiddleware, so a 401 that carries no credential gets no headers, matching the property the /oauth2 tree already has with the experiment gate. Also address review comments on the original change. Drop the two single-use header-name constants in favor of the package's existing string literals at the call site. Delete the comments that restate the syntax beside them, in the mount chain, the NoStore godoc, and the test table. Note why chi's middleware.NoCache is not used: it strips ETag-family headers from the request, and it sends directives neither specification asks for while OAuth 2.1 narrows the requirement. Narrow two overclaiming docstrings. TestNoStoreAfterExperimentGate builds its chain in the test body, so it does not detect a reordering in coderd.go, and the docstring no longer says it pins one. The .well-known assertions prove the exclusion of this middleware, not that the metadata is cacheable, since those endpoints advertise no freshness lifetime at all. --- coderd/coderd.go | 7 ++-- coderd/httpmw/nostore.go | 21 ++++++----- coderd/httpmw/nostore_test.go | 23 ++++++------ coderd/oauth2provider/nostore_test.go | 51 ++++++++++++++++++++++++--- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index d504f97a7ad..0a3134852f9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1244,8 +1244,7 @@ func New(options *Options) *API { r.Use( httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), // Every response from this tree may carry a credential, so none of - // them may be retained by an intermediary cache. NoStore takes no - // configuration, so it is passed uncalled. + // them may be retained by an intermediary cache. httpmw.NoStore, ) r.Route("/authorize", func(r chi.Router) { @@ -2112,6 +2111,10 @@ func New(options *Options) *API { r.Use( apiKeyMiddleware, httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), + // POST /apps/{app}/secrets returns a plaintext client secret, + // so this tree falls under the same RFC 6749 §5.1 requirement + // as /oauth2. + httpmw.NoStore, ) r.Route("/apps", func(r chi.Router) { r.Get("/", api.oAuth2ProviderApps()) diff --git a/coderd/httpmw/nostore.go b/coderd/httpmw/nostore.go index e9f64cc3c56..708c7d8e70c 100644 --- a/coderd/httpmw/nostore.go +++ b/coderd/httpmw/nostore.go @@ -2,11 +2,6 @@ package httpmw import "net/http" -const ( - cacheControlHeader = "Cache-Control" - pragmaHeader = "Pragma" -) - // NoStore sets the response caching headers that OAuth2 requires on any // response that may contain a credential. RFC 6749 §5.1 makes both headers a // MUST for the authorization server; OAuth 2.1 §3.2.3 keeps only no-store, @@ -15,15 +10,19 @@ const ( // conformant response. // // The headers are set before the wrapped handler runs, so a handler that -// writes its own Cache-Control would win. No handler under /oauth2 does; the -// test suite pins that. +// writes its own Cache-Control would win. No handler under the trees this is +// mounted on does; the test suite pins that. Pragma is written unconditionally, +// so such a handler's Cache-Control ships alongside Pragma: no-cache. // -// NoStore carries no configuration, so it is the middleware itself rather -// than a constructor for one. Pass it to chi's r.Use without calling it. +// chi's middleware.NoCache is not used, though that package is already +// imported at the mount site. It strips the ETag-family headers from the +// request, which an authorization server has no business doing, and it sends +// directives neither specification asks for, where OAuth 2.1 narrows the +// requirement rather than widening it. func NoStore(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set(cacheControlHeader, "no-store") - rw.Header().Set(pragmaHeader, "no-cache") + rw.Header().Set("Cache-Control", "no-store") + rw.Header().Set("Pragma", "no-cache") next.ServeHTTP(rw, r) }) } diff --git a/coderd/httpmw/nostore_test.go b/coderd/httpmw/nostore_test.go index b20b01fbb15..4c4a7fa057c 100644 --- a/coderd/httpmw/nostore_test.go +++ b/coderd/httpmw/nostore_test.go @@ -21,8 +21,7 @@ func TestNoStore(t *testing.T) { expectStatus int expectCacheControl string expectPragma string - // assert runs extra checks against the recorded response. - assert func(t *testing.T, res *httptest.ResponseRecorder) + assert func(t *testing.T, res *httptest.ResponseRecorder) }{ { // The POST /oauth2/tokens shape. @@ -73,9 +72,7 @@ func TestNoStore(t *testing.T) { }, { // The headers are advisory: a handler that writes its own - // Cache-Control wins. No handler under /oauth2 does, which the - // integration tests pin, but the contract belongs in a test - // rather than only in prose. + // Cache-Control wins, and Pragma survives alongside it. Name: "HandlerOverwrites", Handler: func(rw http.ResponseWriter, _ *http.Request) { rw.Header().Set("Cache-Control", "private") @@ -105,13 +102,15 @@ func TestNoStore(t *testing.T) { } } -// TestNoStoreAfterExperimentGate pins the ordering consequence of mounting -// NoStore after the experiment gate on the /oauth2 tree: a request the gate -// rejects never reaches NoStore, so its response carries neither header. That -// is correct, since the rejection contains no credential. The gate that runs -// in production, RequireExperimentWithDevBypass, cannot be exercised from a -// test binary because buildinfo.IsDev() is true there and bypasses the check, -// so this asserts against the RequireExperiment it delegates to. +// TestNoStoreAfterExperimentGate shows what an experiment gate ahead of +// NoStore costs: a request the gate rejects never reaches NoStore, so its +// response carries neither header. That is why mounting NoStore last is +// harmless, since an experiment rejection contains no credential. The chain +// here is built in the test body rather than read off the router, so it does +// not detect a reordering in coderd.go. The gate that runs in production, +// RequireExperimentWithDevBypass, cannot be exercised from a test binary +// because buildinfo.IsDev() is true there and bypasses the check, so this +// asserts against the RequireExperiment it delegates to. func TestNoStoreAfterExperimentGate(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/nostore_test.go b/coderd/oauth2provider/nostore_test.go index 1f401852808..8eab3415296 100644 --- a/coderd/oauth2provider/nostore_test.go +++ b/coderd/oauth2provider/nostore_test.go @@ -263,10 +263,11 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { requireNoStore(t, resp) }) - // Discovery metadata is public and RFC 9728 §5 asks for it to be cacheable. - // These endpoints sit outside the /oauth2 tree, and these assertions are - // what keep them there: they fail if the middleware is ever mounted on a - // router that reaches them. + // Discovery metadata is public, and RFC 9728 §5 asks for it to be + // cacheable. These assertions do not prove it is: the endpoints advertise + // no freshness lifetime at all. What they pin is the exclusion of this + // middleware, so they fail if it is ever mounted on a router that reaches + // them. for _, path := range []string{ "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -283,6 +284,48 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { } } +// TestOAuth2ProviderNoStoreHeaders asserts the same headers on the +// /api/v2/oauth2-provider tree, where POST /apps/{app}/secrets returns +// OAuth2ProviderAppSecretFull.ClientSecretFull in plaintext. That response +// meets RFC 6749 §5.1's predicate as squarely as anything under /oauth2, so +// the two trees are treated alike. +func TestOAuth2ProviderNoStoreHeaders(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + baseURL := client.URL.String() + + t.Run("CreateAppSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app, _ := oauth2providertest.CreateTestOAuth2App(t, client) + + uri := fmt.Sprintf("%s/api/v2/oauth2-provider/apps/%s/secrets", baseURL, app.ID) + resp := doRequest(ctx, t, http.MethodPost, uri, nil, sessionToken(client)) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + requireNoStore(t, resp) + + var secret codersdk.OAuth2ProviderAppSecretFull + require.NoError(t, json.NewDecoder(resp.Body).Decode(&secret)) + require.NotEmpty(t, secret.ClientSecretFull) + }) + + t.Run("ListApps", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // A route that returns no credential still carries the headers, which + // is what shows the mount is on the tree rather than on one handler. + resp := doRequest(ctx, t, http.MethodGet, baseURL+"/api/v2/oauth2-provider/apps", nil, sessionToken(client)) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + requireNoStore(t, resp) + }) +} + func requireNoStore(t *testing.T, resp *http.Response) { t.Helper() From e9c1cc244073646a49c516af437a62c540f74e4a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 13 Aug 2026 21:30:45 +0000 Subject: [PATCH 3/6] test(coderd): tighten the no-store test comments The shape annotations on the table entries and subtests ran two to three sentences where a phrase carried the same fact. Trim them, and trim the two test docstrings, keeping the clause that says why each shape is under test: which handlers bypass httpapi.Write, what http.Redirect does to the header map, why the unmatched-path case does not assert a status. Case names are unchanged. Renaming them to absorb the comments was considered and declined, since a name cannot carry the mapping from a generic handler shape to the real endpoint it models. --- coderd/httpmw/nostore_test.go | 26 ++++++------ coderd/oauth2provider/nostore_test.go | 61 ++++++++++++--------------- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/coderd/httpmw/nostore_test.go b/coderd/httpmw/nostore_test.go index 4c4a7fa057c..36910c27dff 100644 --- a/coderd/httpmw/nostore_test.go +++ b/coderd/httpmw/nostore_test.go @@ -35,8 +35,8 @@ func TestNoStore(t *testing.T) { expectPragma: "no-cache", }, { - // The DELETE /oauth2/clients/{client_id} shape: a response with - // no body still carries headers. + // The DELETE /oauth2/clients/{client_id} shape: headers on a + // response with no body. Name: "NoContent", Handler: func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) @@ -46,8 +46,8 @@ func TestNoStore(t *testing.T) { expectPragma: "no-cache", }, { - // The POST /oauth2/authorize shape: http.Redirect adds Location - // and calls WriteHeader without clearing the header map. + // The POST /oauth2/authorize shape: http.Redirect writes its own + // headers without clearing the map. Name: "Redirect", Handler: func(rw http.ResponseWriter, r *http.Request) { http.Redirect(rw, r, "https://example.com/callback?code=abc", http.StatusFound) @@ -60,8 +60,8 @@ func TestNoStore(t *testing.T) { }, }, { - // The revoke.go and registration.go shape: a bare WriteHeader - // that never reaches httpapi.Write. + // The revoke.go and registration.go shape: a bare WriteHeader, + // never httpapi.Write. Name: "BareWriteHeaderError", Handler: func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusBadRequest) @@ -103,14 +103,12 @@ func TestNoStore(t *testing.T) { } // TestNoStoreAfterExperimentGate shows what an experiment gate ahead of -// NoStore costs: a request the gate rejects never reaches NoStore, so its -// response carries neither header. That is why mounting NoStore last is -// harmless, since an experiment rejection contains no credential. The chain -// here is built in the test body rather than read off the router, so it does -// not detect a reordering in coderd.go. The gate that runs in production, -// RequireExperimentWithDevBypass, cannot be exercised from a test binary -// because buildinfo.IsDev() is true there and bypasses the check, so this -// asserts against the RequireExperiment it delegates to. +// NoStore costs: a rejected request never reaches it, so the response carries +// neither header. That is harmless, since the rejection carries no credential. +// The chain is built here rather than read off the router, so a reordering in +// coderd.go would go undetected. It asserts against RequireExperiment because +// the production RequireExperimentWithDevBypass short-circuits on +// buildinfo.IsDev() in a test binary. func TestNoStoreAfterExperimentGate(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/nostore_test.go b/coderd/oauth2provider/nostore_test.go index 8eab3415296..8e1d714587b 100644 --- a/coderd/oauth2provider/nostore_test.go +++ b/coderd/oauth2provider/nostore_test.go @@ -18,17 +18,15 @@ import ( "github.com/coder/coder/v2/testutil" ) -// TestOAuth2NoStoreHeaders asserts that every response from the /oauth2 route -// tree carries Cache-Control: no-store and Pragma: no-cache, on the routes -// that return a credential and on the routes that do not. Three of the tree's -// write paths never call httpapi.Write, so this is what proves the middleware -// reaches them: POST /oauth2/revoke and DELETE /oauth2/clients/{client_id} -// both write a bare status, and a rejected registration is written by -// writeOAuth2RegistrationError. +// TestOAuth2NoStoreHeaders asserts Cache-Control: no-store and Pragma: +// no-cache on every response from the /oauth2 tree, credential-bearing or not. +// Three write paths never call httpapi.Write, so these are what prove the +// middleware reaches them: POST /oauth2/revoke and DELETE +// /oauth2/clients/{client_id} write a bare status, and +// writeOAuth2RegistrationError encodes its own JSON. // -// The negative case at the end pins the exclusion of the /.well-known/* -// metadata endpoints, which RFC 9728 §5 asks to be cacheable. It fails if the -// middleware is ever hoisted onto a higher route tree. +// The cases at the end pin the exclusion of the /.well-known/* metadata +// endpoints, failing if the middleware is hoisted onto a higher route tree. func TestOAuth2NoStoreHeaders(t *testing.T) { t.Parallel() @@ -79,8 +77,8 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { defer resp.Body.Close() require.Equal(t, http.StatusUnauthorized, resp.StatusCode) requireNoStore(t, resp) - // The two header writers coexist: WriteOAuth2Error sets this one - // itself, after the middleware has already written its own. + // Both writers coexist: WriteOAuth2Error sets this one after the + // middleware has set its own. require.Equal(t, `Basic realm="coder"`, resp.Header.Get("WWW-Authenticate")) }) @@ -122,8 +120,8 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { app, _ := oauth2providertest.CreateTestOAuth2App(t, client) _, challenge := oauth2providertest.GeneratePKCE(t) - // An unsupported response_type is rendered as a static error page - // rather than written through httpapi. + // An unsupported response_type renders a static error page rather + // than going through httpapi. uri := strings.Replace(authorizeURL(baseURL, app.ID.String(), challenge), "response_type=code", "response_type=token", 1) resp := doRequest(ctx, t, http.MethodGet, uri, nil, sessionToken(client)) defer resp.Body.Close() @@ -151,8 +149,7 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { form.Set("token", token.RefreshToken) form.Set("client_id", app.ID.String()) - // RFC 7009 success is a bare rw.WriteHeader(200) that never reaches - // httpapi.Write. + // RFC 7009 success is a bare WriteHeader(200), never httpapi.Write. resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) @@ -195,8 +192,8 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { RedirectURIs: []string{"not-a-url"}, }) - // Rejected by writeOAuth2RegistrationError, which encodes its own JSON - // and never reaches httpapi.Write. + // Rejected by writeOAuth2RegistrationError, which encodes its own + // JSON rather than calling httpapi.Write. resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/register", strings.NewReader(body), jsonContentType) defer resp.Body.Close() require.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -239,8 +236,7 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { registration := registerClient(ctx, t, client) - // A 204 carries headers even with no body, and RFC 7592 §2.3's own - // example shows no-store on exactly this response. + // RFC 7592 §2.3's own example shows no-store on exactly this 204. uri := fmt.Sprintf("%s/oauth2/clients/%s", baseURL, registration.ClientID) resp := doRequest(ctx, t, http.MethodDelete, uri, nil, bearer(registration.RegistrationAccessToken)) defer resp.Body.Close() @@ -253,21 +249,17 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Chi runs a subrouter's middleware chain around its unmatched-path - // handling, so a path with no route under the tree still carries the - // headers. The status is not asserted: the request falls through to - // the root router's handler, which serves the single-page app rather - // than a 404, and that is the site handler's business, not this - // middleware's. + // handling, so a path with no route still carries the headers. The + // status is not asserted: the request falls through to the root + // router's SPA handler, which is not this middleware's business. resp := doRequest(ctx, t, http.MethodGet, baseURL+"/oauth2/does-not-exist", nil) defer resp.Body.Close() requireNoStore(t, resp) }) - // Discovery metadata is public, and RFC 9728 §5 asks for it to be - // cacheable. These assertions do not prove it is: the endpoints advertise - // no freshness lifetime at all. What they pin is the exclusion of this - // middleware, so they fail if it is ever mounted on a router that reaches - // them. + // Discovery metadata is public and RFC 9728 §5 asks for it to be + // cacheable. These do not prove it is, since the endpoints advertise no + // freshness lifetime; they pin the exclusion of this middleware. for _, path := range []string{ "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -286,9 +278,8 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { // TestOAuth2ProviderNoStoreHeaders asserts the same headers on the // /api/v2/oauth2-provider tree, where POST /apps/{app}/secrets returns -// OAuth2ProviderAppSecretFull.ClientSecretFull in plaintext. That response -// meets RFC 6749 §5.1's predicate as squarely as anything under /oauth2, so -// the two trees are treated alike. +// ClientSecretFull in plaintext and so meets RFC 6749 §5.1's predicate as +// squarely as anything under /oauth2. func TestOAuth2ProviderNoStoreHeaders(t *testing.T) { t.Parallel() @@ -317,8 +308,8 @@ func TestOAuth2ProviderNoStoreHeaders(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - // A route that returns no credential still carries the headers, which - // is what shows the mount is on the tree rather than on one handler. + // A credential-free route still carries the headers, which shows the + // mount is on the tree rather than on one handler. resp := doRequest(ctx, t, http.MethodGet, baseURL+"/api/v2/oauth2-provider/apps", nil, sessionToken(client)) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) From c4b134252f9f4439a58b1aaf5246133ae42eeb3b Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 13 Aug 2026 21:38:27 +0000 Subject: [PATCH 4/6] test(coderd/oauth2provider): widen the metadata cacheability assertion NotContains "no-store" caught this middleware being hoisted onto a router that reaches the discovery endpoints, but not the wider class of blanket middleware that would contradict RFC 9728 5 the same way. Chi's NoCache happens to include no-store and so was already caught; a middleware stamping private or max-age=0 on authenticated API responses was not. Assert the property instead: no directive that stops a shared cache from storing the response. An explicit freshness lifetime added to these endpoints later, which RFC 9728 5 encourages, still passes, which is why this is not require.Empty on the whole header. --- coderd/oauth2provider/nostore_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/coderd/oauth2provider/nostore_test.go b/coderd/oauth2provider/nostore_test.go index 8e1d714587b..b9ad8345eb2 100644 --- a/coderd/oauth2provider/nostore_test.go +++ b/coderd/oauth2provider/nostore_test.go @@ -259,7 +259,11 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { // Discovery metadata is public and RFC 9728 §5 asks for it to be // cacheable. These do not prove it is, since the endpoints advertise no - // freshness lifetime; they pin the exclusion of this middleware. + // freshness lifetime at all. What they pin is that no blanket middleware + // has landed on a router reaching them: this one would stamp no-store, + // and an "authenticated responses are not shared-cacheable" middleware + // would stamp private or max-age=0. An explicit freshness lifetime added + // here later, which RFC 9728 §5 encourages, still passes. for _, path := range []string{ "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -271,7 +275,9 @@ func TestOAuth2NoStoreHeaders(t *testing.T) { resp := doRequest(ctx, t, http.MethodGet, baseURL+path, nil) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) - require.NotContains(t, resp.Header.Get("Cache-Control"), "no-store") + for _, directive := range []string{"no-store", "private", "no-cache", "max-age=0"} { + require.NotContains(t, resp.Header.Get("Cache-Control"), directive) + } }) } } From 19e15d9a7ce263a77853895deac7d502515a98c2 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 13 Aug 2026 23:46:48 +0000 Subject: [PATCH 5/6] test(coderd): drop TestNoStoreAfterExperimentGate The test asserted that RequireExperiment returns 403 and that a short-circuiting outer middleware stops the inner one from running. The first belongs to experiments.go and the second is net/http handler chaining, so nothing in it could fail for a reason involving NoStore. Reordering the production chain, or replacing NoStore in it with any no-op, left the test green. No coverage is lost, because there was none to lose. The experiment gate that runs in production is RequireExperimentWithDevBypass, which short-circuits on buildinfo.IsDev(), so the disabled path is not reachable from a test binary at any level. The design fact the test was standing in for, that mounting after the gate is deliberate and harmless, moves to a comment at the mount site. --- coderd/coderd.go | 4 +++- coderd/httpmw/nostore_test.go | 26 -------------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 0a3134852f9..69a6abe1d5c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1244,7 +1244,9 @@ func New(options *Options) *API { r.Use( httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), // Every response from this tree may carry a credential, so none of - // them may be retained by an intermediary cache. + // them may be retained by an intermediary cache. Mounted after + // the gate, so a request the gate rejects gets no headers. That + // rejection carries no credential, so it needs none. httpmw.NoStore, ) r.Route("/authorize", func(r chi.Router) { diff --git a/coderd/httpmw/nostore_test.go b/coderd/httpmw/nostore_test.go index 36910c27dff..4922da07b4f 100644 --- a/coderd/httpmw/nostore_test.go +++ b/coderd/httpmw/nostore_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/codersdk" ) func TestNoStore(t *testing.T) { @@ -101,28 +100,3 @@ func TestNoStore(t *testing.T) { }) } } - -// TestNoStoreAfterExperimentGate shows what an experiment gate ahead of -// NoStore costs: a rejected request never reaches it, so the response carries -// neither header. That is harmless, since the rejection carries no credential. -// The chain is built here rather than read off the router, so a reordering in -// coderd.go would go undetected. It asserts against RequireExperiment because -// the production RequireExperimentWithDevBypass short-circuits on -// buildinfo.IsDev() in a test binary. -func TestNoStoreAfterExperimentGate(t *testing.T) { - t.Parallel() - - handler := httpmw.RequireExperiment(codersdk.Experiments{}, codersdk.ExperimentOAuth2)( - httpmw.NoStore(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { - rw.WriteHeader(http.StatusOK) - })), - ) - - req := httptest.NewRequest(http.MethodGet, "/oauth2/tokens", nil) - res := httptest.NewRecorder() - handler.ServeHTTP(res, req) - - require.Equal(t, http.StatusForbidden, res.Code) - require.Empty(t, res.Header().Get("Cache-Control")) - require.Empty(t, res.Header().Get("Pragma")) -} From f3dac8875998a80bf532f1f1407066c502768824 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 13 Aug 2026 23:57:02 +0000 Subject: [PATCH 6/6] docs(coderd/httpmw): scope the NoStore coverage claim to what pins it The godoc said the test suite pins that no handler writes its own Cache-Control. That holds for /oauth2, whose nine routes are all asserted, but /api/v2/oauth2-provider has ten routes and two of them are covered. The fact itself is unchanged, since the only Cache-Control writers in coderd are the SSE helper, exp_chats, and the site handler, none of them reachable from either tree. --- coderd/httpmw/nostore.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/httpmw/nostore.go b/coderd/httpmw/nostore.go index 708c7d8e70c..cdf55036e73 100644 --- a/coderd/httpmw/nostore.go +++ b/coderd/httpmw/nostore.go @@ -10,9 +10,10 @@ import "net/http" // conformant response. // // The headers are set before the wrapped handler runs, so a handler that -// writes its own Cache-Control would win. No handler under the trees this is -// mounted on does; the test suite pins that. Pragma is written unconditionally, -// so such a handler's Cache-Control ships alongside Pragma: no-cache. +// writes its own Cache-Control would win. None does today; the integration +// tests pin that across the /oauth2 tree and spot-check +// /api/v2/oauth2-provider. Pragma is written unconditionally, so such a +// handler's Cache-Control ships alongside Pragma: no-cache. // // chi's middleware.NoCache is not used, though that package is already // imported at the mount site. It strips the ETag-family headers from the