Skip to content

Commit a89a0b0

Browse files
authored
feat(oauth2): add /oauth/token endpoint (#2159)
## Summary This PR completes the OAuth2 server implementation by adding the `/token` endpoint, enabling full OAuth2 authorization code flow & refresh token support. ## Key Features Added: ### OAuth Token Endpoint (POST /oauth/token) supporting: - `authorization_code` grant type for exchanging authorization codes for access - refresh_token grant type for token refresh - Both JSON and form-encoded request bodies - OAuth Client authentication via Basic auth or request body parameters (form params and JSON body) ### Token Service Integration: - Integrated OAuth server with the existing token service - Added OAuth-specific authentication method (`oauth_provider/authorization_code`) - Enhanced token generation to include OAuth client context in JWT claims. ## Database Changes: - Added `oauth_client_id` field to `sessions` table for OAuth client tracking. So an OAuth clients can use a refresh token only if the session is issued for them. Similarly, a session issued to a client can only be refreshed by that client (i.e user can't use `/token?grant_type=refresh_token` endpoint with a refresh token obtained through `/oauth/token` endpoint.) ## Next Steps - Adding ratelimit for the `/token` endpoint - Store token auth method for oauth clients in the database
1 parent 86b7de4 commit a89a0b0

19 files changed

+481
-114
lines changed

internal/api/api.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,6 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
9393
version: version,
9494
}
9595

96-
// Only initialize OAuth server if enabled
97-
if globalConfig.OAuthServer.Enabled {
98-
api.oauthServer = oauthserver.NewServer(globalConfig, db)
99-
}
100-
10196
for _, o := range opt {
10297
o.apply(api)
10398
}
@@ -123,6 +118,11 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
123118
// Connect token service to API's time function (supports test overrides)
124119
api.tokenService.SetTimeFunc(api.Now)
125120

121+
// Initialize OAuth server (only if enabled)
122+
if globalConfig.OAuthServer.Enabled {
123+
api.oauthServer = oauthserver.NewServer(globalConfig, db, api.tokenService)
124+
}
125+
126126
if api.config.Password.HIBP.Enabled {
127127
httpClient := &http.Client{
128128
// all HIBP API requests should finish quickly to avoid
@@ -237,7 +237,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
237237
With(api.verifyCaptcha).Post("/otp", api.Otp)
238238

239239
// rate limiting applied in handler
240-
r.With(api.verifyCaptcha).With(api.oauthClientAuth).Post("/token", api.Token)
240+
r.With(api.verifyCaptcha).Post("/token", api.Token)
241241

242242
r.With(api.limitHandler(api.limiterOpts.Verify)).Route("/verify", func(r *router) {
243243
r.Get("/", api.Verify)
@@ -359,6 +359,9 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
359359
r.With(api.limitHandler(api.limiterOpts.OAuthClientRegister)).
360360
Post("/clients/register", api.oauthServer.OAuthServerClientDynamicRegister)
361361

362+
// OAuth Token endpoint (public, with client authentication)
363+
r.With(api.requireOAuthClientAuth).Post("/token", api.oauthServer.OAuthToken)
364+
362365
// OAuth 2.1 Authorization endpoints
363366
// `/authorize` to initiate OAuth2 authorization code flow where Supabase Auth is the OAuth2 provider
364367
r.Get("/authorize", api.oauthServer.OAuthServerAuthorize)

internal/api/middleware.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/sirupsen/logrus"
1818
"github.com/supabase/auth/internal/api/apierrors"
1919
"github.com/supabase/auth/internal/api/oauthserver"
20+
"github.com/supabase/auth/internal/api/shared"
2021
"github.com/supabase/auth/internal/models"
2122
"github.com/supabase/auth/internal/observability"
2223
"github.com/supabase/auth/internal/security"
@@ -84,9 +85,9 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler {
8485
}
8586
}
8687

87-
// oauthClientAuth optionally authenticates an OAuth client as middleware
88-
// This doesn't fail if no client credentials are provided, but validates them if present
89-
func (a *API) oauthClientAuth(w http.ResponseWriter, r *http.Request) (context.Context, error) {
88+
// requireOAuthClientAuth authenticates an OAuth client as middleware
89+
// Requires client_id to be present and validates client credentials
90+
func (a *API) requireOAuthClientAuth(w http.ResponseWriter, r *http.Request) (context.Context, error) {
9091
ctx := r.Context()
9192

9293
clientID, clientSecret, err := oauthserver.ExtractClientCredentials(r)
@@ -121,7 +122,7 @@ func (a *API) oauthClientAuth(w http.ResponseWriter, r *http.Request) (context.C
121122
}
122123

123124
// Add authenticated client to context
124-
ctx = oauthserver.WithOAuthServerClient(ctx, client)
125+
ctx = shared.WithOAuthServerClient(ctx, client)
125126
return ctx, nil
126127
}
127128

internal/api/oauthserver/auth.go

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package oauthserver
22

33
import (
4+
"bytes"
45
"encoding/base64"
6+
"encoding/json"
57
"errors"
8+
"io"
69
"net/http"
710
"strings"
811
)
912

1013
// ExtractClientCredentials extracts OAuth client credentials from the request
11-
// Supports both Basic auth header and form body parameters
14+
// Supports Basic auth header, form body parameters, and JSON body parameters
1215
func ExtractClientCredentials(r *http.Request) (clientID, clientSecret string, err error) {
1316
// First, try Basic auth header: Authorization: Basic base64(client_id:client_secret)
1417
authHeader := r.Header.Get("Authorization")
@@ -28,23 +31,38 @@ func ExtractClientCredentials(r *http.Request) (clientID, clientSecret string, e
2831
return parts[0], parts[1], nil
2932
}
3033

31-
// Fall back to form parameters
32-
if err := r.ParseForm(); err != nil {
33-
return "", "", errors.New("failed to parse form")
34-
}
34+
// Check Content-Type to determine how to parse body parameters
35+
contentType := r.Header.Get("Content-Type")
36+
if strings.Contains(contentType, "application/json") {
37+
// Parse JSON body
38+
body, err := io.ReadAll(r.Body)
39+
if err != nil {
40+
return "", "", errors.New("failed to read request body")
41+
}
42+
// Restore the body so other handlers can read it
43+
r.Body = io.NopCloser(bytes.NewBuffer(body))
3544

36-
clientID = r.FormValue("client_id")
37-
clientSecret = r.FormValue("client_secret")
45+
var jsonData struct {
46+
ClientID string `json:"client_id"`
47+
ClientSecret string `json:"client_secret"`
48+
}
49+
if err := json.Unmarshal(body, &jsonData); err != nil {
50+
return "", "", errors.New("failed to parse JSON body")
51+
}
52+
53+
clientID = jsonData.ClientID
54+
clientSecret = jsonData.ClientSecret
55+
} else {
56+
// Fall back to form parameters
57+
if err := r.ParseForm(); err != nil {
58+
return "", "", errors.New("failed to parse form")
59+
}
3860

39-
// Return empty credentials if both are empty (no client auth attempted)
40-
if clientID == "" && clientSecret == "" {
41-
return "", "", nil
61+
clientID = r.FormValue("client_id")
62+
clientSecret = r.FormValue("client_secret")
4263
}
4364

44-
// For public clients, only client_id is required (client_secret should be empty)
45-
// For confidential clients, both client_id and client_secret are required
46-
// We'll validate this based on the client type in the calling handler
47-
// TODO(cemal) :: this will be validated in detail during the `/token` endpoint implementation
65+
// return error if client_id is not provided
4866
if clientID == "" {
4967
return "", "", errors.New("client_id is required")
5068
}

0 commit comments

Comments
 (0)