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..38f9023347 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -138,6 +138,15 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler { } } +// 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 +} + // 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) { 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")) + }) + } +}