From cc8d8b1cb37ea3480a1edaaf579a6dcccdf86324 Mon Sep 17 00:00:00 2001 From: shanurwan <176239359+shanurwan@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:12:21 +0800 Subject: [PATCH 1/3] fix(oauthserver): send Cache-Control: no-store on token responses Token responses carry access and refresh tokens, and RFC 6749 section 5.1 and OAuth 2.1 section 3.2.3 require them not to be cached. /oauth/token sent no Cache-Control header. Set it before any response is written, so error responses carry it too. --- internal/api/oauthserver/handlers.go | 4 +++ .../api/oauthserver/handlers_token_test.go | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 internal/api/oauthserver/handlers_token_test.go diff --git a/internal/api/oauthserver/handlers.go b/internal/api/oauthserver/handlers.go index bd93f7801f..0767723eab 100644 --- a/internal/api/oauthserver/handlers.go +++ b/internal/api/oauthserver/handlers.go @@ -267,6 +267,10 @@ type OAuthTokenParams struct { func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() + // Token responses carry credentials and must not be cached (RFC 6749 section 5.1, + // OAuth 2.1 section 3.2.3). Set before any response is written, so errors carry it too. + w.Header().Set("Cache-Control", "no-store") + var params OAuthTokenParams // Handle both JSON and form-encoded requests diff --git a/internal/api/oauthserver/handlers_token_test.go b/internal/api/oauthserver/handlers_token_test.go new file mode 100644 index 0000000000..1e27a10a77 --- /dev/null +++ b/internal/api/oauthserver/handlers_token_test.go @@ -0,0 +1,36 @@ +package oauthserver + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/shared" +) + +func (ts *OAuthClientTestSuite) TestOAuthTokenResponsesAreNotCached() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("no-store@example.com") + + token := func(code string) (*httptest.ResponseRecorder, error) { + body, err := json.Marshal(map[string]string{"grant_type": GrantTypeAuthorizationCode, "code": code}) + require.NoError(ts.T(), err) + req := httptest.NewRequest(http.MethodPost, "/oauth/token", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(shared.WithOAuthServerClient(req.Context(), client)) + w := httptest.NewRecorder() + return w, ts.Server.OAuthToken(w, req) + } + + w, err := token(ts.mintApprovedCode(client.ID, user.ID)) + require.NoError(ts.T(), err) + require.Contains(ts.T(), w.Body.String(), "access_token") + require.Equal(ts.T(), "no-store", w.Header().Get("Cache-Control")) + + // An error response is written to the same ResponseWriter, so it keeps the header. + w, err = token("not-a-real-code") + require.Error(ts.T(), err) + require.Equal(ts.T(), "no-store", w.Header().Get("Cache-Control")) +} From 12e42b03fdb25cba0746a390afcd0be292d3fe5a Mon Sep 17 00:00:00 2001 From: shanurwan <176239359+shanurwan@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:20:53 +0800 Subject: [PATCH 2/3] fix(oauthserver): set no-store in middleware ahead of client authentication requireOAuthClientAuth returns invalid_client before OAuthToken runs, so those responses did not carry Cache-Control: no-store. Set the header in a middleware that runs first on the token route, and test it through the router: a wrong client secret, an unknown client, and an unknown code all carry the header. --- internal/api/api.go | 2 +- internal/api/middleware.go | 9 +++ internal/api/oauthserver/handlers.go | 4 -- .../api/oauthserver/handlers_token_test.go | 36 ---------- internal/api/oauthserver_token_cache_test.go | 71 +++++++++++++++++++ 5 files changed, 81 insertions(+), 41 deletions(-) delete mode 100644 internal/api/oauthserver/handlers_token_test.go create mode 100644 internal/api/oauthserver_token_cache_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 6c26fe5571..3d936203cc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -458,7 +458,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne Post("/clients/register", api.oauthServer.OAuthServerClientDynamicRegister) // OAuth Token endpoint (public, with client authentication) - r.With(api.requireOAuthClientAuth).Post("/token", api.oauthServer.OAuthToken) + r.With(noStore).With(api.requireOAuthClientAuth).Post("/token", api.oauthServer.OAuthToken) // OIDC UserInfo endpoint (requires user authentication via Bearer token) r.With(api.requireAuthenticationAllowOAuthClient).Get("/userinfo", api.oauthServer.OAuthUserInfo) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index ee9590ea32..9dc15a15ae 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -140,6 +140,15 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler { // requireOAuthClientAuth authenticates an OAuth client as middleware // Requires client_id to be present and validates client credentials +// noStore marks a response as uncacheable. Token endpoint responses carry +// credentials and must not be cached (RFC 6749 section 5.1, OAuth 2.1 section +// 3.2.3). As middleware it also covers errors returned before the handler runs, +// such as a failed client authentication. +func noStore(w http.ResponseWriter, r *http.Request) (context.Context, error) { + w.Header().Set("Cache-Control", "no-store") + return nil, nil +} + func (a *API) requireOAuthClientAuth(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context() diff --git a/internal/api/oauthserver/handlers.go b/internal/api/oauthserver/handlers.go index 0767723eab..bd93f7801f 100644 --- a/internal/api/oauthserver/handlers.go +++ b/internal/api/oauthserver/handlers.go @@ -267,10 +267,6 @@ type OAuthTokenParams struct { func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - // Token responses carry credentials and must not be cached (RFC 6749 section 5.1, - // OAuth 2.1 section 3.2.3). Set before any response is written, so errors carry it too. - w.Header().Set("Cache-Control", "no-store") - var params OAuthTokenParams // Handle both JSON and form-encoded requests diff --git a/internal/api/oauthserver/handlers_token_test.go b/internal/api/oauthserver/handlers_token_test.go deleted file mode 100644 index 1e27a10a77..0000000000 --- a/internal/api/oauthserver/handlers_token_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package oauthserver - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/stretchr/testify/require" - "github.com/supabase/auth/internal/api/shared" -) - -func (ts *OAuthClientTestSuite) TestOAuthTokenResponsesAreNotCached() { - client, _ := ts.createTestOAuthClient() - user := ts.createTestUser("no-store@example.com") - - token := func(code string) (*httptest.ResponseRecorder, error) { - body, err := json.Marshal(map[string]string{"grant_type": GrantTypeAuthorizationCode, "code": code}) - require.NoError(ts.T(), err) - req := httptest.NewRequest(http.MethodPost, "/oauth/token", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(shared.WithOAuthServerClient(req.Context(), client)) - w := httptest.NewRecorder() - return w, ts.Server.OAuthToken(w, req) - } - - w, err := token(ts.mintApprovedCode(client.ID, user.ID)) - require.NoError(ts.T(), err) - require.Contains(ts.T(), w.Body.String(), "access_token") - require.Equal(ts.T(), "no-store", w.Header().Get("Cache-Control")) - - // An error response is written to the same ResponseWriter, so it keeps the header. - w, err = token("not-a-real-code") - require.Error(ts.T(), err) - require.Equal(ts.T(), "no-store", w.Header().Get("Cache-Control")) -} diff --git a/internal/api/oauthserver_token_cache_test.go b/internal/api/oauthserver_token_cache_test.go new file mode 100644 index 0000000000..d38201faa9 --- /dev/null +++ b/internal/api/oauthserver_token_cache_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" +) + +// Every response from the OAuth token endpoint must carry Cache-Control: +// no-store, including errors returned by client authentication before the +// handler runs. +func TestOAuthTokenEndpointResponsesAreNotCached(t *testing.T) { + api, _, err := setupAPIForTestWithCallback(func(c *conf.GlobalConfiguration, _ *storage.Connection) { + if c == nil { + return + } + c.OAuthServer.Enabled = true + c.OAuthServer.AllowDynamicRegistration = true + }) + require.NoError(t, err) + defer api.db.Close() + require.NoError(t, models.TruncateAll(api.db)) + + body, err := json.Marshal(map[string]any{ + "client_name": "cache test", + "redirect_uris": []string{"https://example.com/callback"}, + "token_endpoint_auth_method": "client_secret_basic", + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/oauth/clients/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + var client struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &client)) + require.NotEmpty(t, client.ClientSecret) + + cases := []struct { + name, clientID, clientSecret string + }{ + {"wrong client secret, rejected by client authentication", client.ClientID, "wrong-secret"}, + {"unknown client, rejected by client authentication", "00000000-0000-0000-0000-000000000000", "secret"}, + {"valid client and unknown code, rejected by the handler", client.ClientID, client.ClientSecret}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + form := url.Values{"grant_type": {"authorization_code"}, "code": {"not-a-real-code"}} + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.clientID, c.clientSecret) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.GreaterOrEqual(t, w.Code, 400, w.Body.String()) + assert.Equal(t, "no-store", w.Header().Get("Cache-Control")) + }) + } +} From a3e094345754ae700c279a44d182ae77e39b506a Mon Sep 17 00:00:00 2001 From: shanurwan <176239359+shanurwan@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:23:44 +0800 Subject: [PATCH 3/3] fix(api): keep requireOAuthClientAuth doc comment on its function --- internal/api/middleware.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 9dc15a15ae..38f9023347 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -138,8 +138,6 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler { } } -// requireOAuthClientAuth authenticates an OAuth client as middleware -// Requires client_id to be present and validates client credentials // noStore marks a response as uncacheable. Token endpoint responses carry // credentials and must not be cached (RFC 6749 section 5.1, OAuth 2.1 section // 3.2.3). As middleware it also covers errors returned before the handler runs, @@ -149,6 +147,8 @@ func noStore(w http.ResponseWriter, r *http.Request) (context.Context, error) { return nil, nil } +// requireOAuthClientAuth authenticates an OAuth client as middleware +// Requires client_id to be present and validates client credentials func (a *API) requireOAuthClientAuth(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context()