Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions internal/api/oauthserver_token_cache_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
}
}