Skip to content

Commit 8fae015

Browse files
authored
feat: add oauth2 client support (#2098)
## Summary This PR implements OAuth2 client support in Supabase Auth, enabling applications to register OAuth clients programmatically. This is a foundational step toward full OAuth 2.1 server compliance. ## Features Added ### Client Registration Endpoints - Manual Registration (POST /admin/oauth/clients) - Admin-only endpoint for manual client registration - Dynamic Registration (POST /oauth/clients/register) - OAuth 2 Dynamic Client Registration compliant endpoint (configurable by env variable) ### Client Management Endpoints - List Clients (GET /admin/oauth/clients) - List all registered OAuth - Get Client (GET /admin/oauth/clients/{client_id}) - Retrieve specific client - Delete Client (DELETE /admin/oauth/clients/{client_id}) - Soft-delete OAuth clients ## Notes on Technical Implementation ### Database Schema - New `oauth_clients` table - Indexing & soft-delete support ### Code Organization - New `internal/api/oauthserver` package for OAuth server functionality. This package aimed to include all oauth server code. Note that Supabase Auth as of today is already a OAuth client to other OAuth Providers (i.e google) - Shared utilities in `internal/api/shared` to avoid circular dependencies. Planning to move the necessary code as we go. Started with `sendJSON` function. - Comprehensive test coverage with both unit and integration tests ## Quick Test Register a new OAuth client: ```sh curl -X POST http://localhost:9999/oauth/clients/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "My App", "redirect_uris": ["https://myapp.example.com/callback"] }' ``` ## Important Note There is no breaking change in this PR. This is purely additive functionality that doesn't affect existing authentication flows.
1 parent dbaccd4 commit 8fae015

20 files changed

+1746
-40
lines changed

internal/api/api.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/sebest/xff"
1010
"github.com/sirupsen/logrus"
1111
"github.com/supabase/auth/internal/api/apierrors"
12+
"github.com/supabase/auth/internal/api/oauthserver"
1213
"github.com/supabase/auth/internal/conf"
1314
"github.com/supabase/auth/internal/hooks/hookshttp"
1415
"github.com/supabase/auth/internal/hooks/hookspgfunc"
@@ -35,8 +36,9 @@ type API struct {
3536
config *conf.GlobalConfiguration
3637
version string
3738

38-
hooksMgr *v0hooks.Manager
39-
hibpClient *hibp.PwnedClient
39+
hooksMgr *v0hooks.Manager
40+
hibpClient *hibp.PwnedClient
41+
oauthServer *oauthserver.Server
4042

4143
// overrideTime can be used to override the clock used by handlers. Should only be used in tests!
4244
overrideTime func() time.Time
@@ -80,7 +82,12 @@ func (a *API) deprecationNotices() {
8082

8183
// NewAPIWithVersion creates a new REST API using the specified version
8284
func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Connection, version string, opt ...Option) *API {
83-
api := &API{config: globalConfig, db: db, version: version}
85+
api := &API{
86+
config: globalConfig,
87+
db: db,
88+
version: version,
89+
oauthServer: oauthserver.NewServer(globalConfig, db),
90+
}
8491

8592
for _, o := range opt {
8693
o.apply(api)
@@ -197,7 +204,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
197204
With(api.verifyCaptcha).Post("/otp", api.Otp)
198205

199206
// rate limiting applied in handler
200-
r.With(api.verifyCaptcha).Post("/token", api.Token)
207+
r.With(api.verifyCaptcha).With(api.oauthClientAuth).Post("/token", api.Token)
201208

202209
r.With(api.limitHandler(api.limiterOpts.Verify)).Route("/verify", func(r *router) {
203210
r.Get("/", api.Verify)
@@ -293,6 +300,28 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
293300
})
294301
})
295302
})
303+
304+
// Admin only oauth client management endpoints
305+
r.Route("/oauth", func(r *router) {
306+
r.Route("/clients", func(r *router) {
307+
// Manual client registration
308+
r.Post("/", api.oauthServer.AdminOAuthServerClientRegister)
309+
310+
r.Get("/", api.oauthServer.OAuthServerClientList)
311+
312+
r.Route("/{client_id}", func(r *router) {
313+
r.Use(api.oauthServer.LoadOAuthServerClient)
314+
r.Get("/", api.oauthServer.OAuthServerClientGet)
315+
r.Delete("/", api.oauthServer.OAuthServerClientDelete)
316+
})
317+
})
318+
})
319+
})
320+
321+
// OAuth Dynamic Client Registration endpoint (public, rate limited)
322+
r.Route("/oauth", func(r *router) {
323+
r.With(api.limitHandler(api.limiterOpts.OAuthClientRegister)).
324+
Post("/clients/register", api.oauthServer.OAuthServerClientDynamicRegister)
296325
})
297326
})
298327

internal/api/apierrors/errorcode.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,6 @@ const (
9595
ErrorCodeEmailAddressInvalid ErrorCode = "email_address_invalid"
9696
ErrorCodeWeb3ProviderDisabled ErrorCode = "web3_provider_disabled"
9797
ErrorCodeWeb3UnsupportedChain ErrorCode = "web3_unsupported_chain"
98+
99+
ErrorCodeOAuthDynamicClientRegistrationDisabled ErrorCode = "oauth_dynamic_client_registration_disabled"
98100
)

internal/api/helpers.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ package api
33
import (
44
"context"
55
"encoding/json"
6-
"fmt"
76
"net/http"
87

9-
"github.com/pkg/errors"
108
"github.com/supabase/auth/internal/api/apierrors"
9+
"github.com/supabase/auth/internal/api/shared"
1110
"github.com/supabase/auth/internal/conf"
1211
"github.com/supabase/auth/internal/models"
1312
"github.com/supabase/auth/internal/security"
@@ -16,14 +15,7 @@ import (
1615
)
1716

1817
func sendJSON(w http.ResponseWriter, status int, obj interface{}) error {
19-
w.Header().Set("Content-Type", "application/json")
20-
b, err := json.Marshal(obj)
21-
if err != nil {
22-
return errors.Wrap(err, fmt.Sprintf("Error encoding json response: %v", obj))
23-
}
24-
w.WriteHeader(status)
25-
_, err = w.Write(b)
26-
return err
18+
return shared.SendJSON(w, status, obj)
2719
}
2820

2921
func isAdmin(u *models.User, config *conf.GlobalConfiguration) bool {

internal/api/middleware.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
chimiddleware "github.com/go-chi/chi/v5/middleware"
1515
"github.com/sirupsen/logrus"
1616
"github.com/supabase/auth/internal/api/apierrors"
17+
"github.com/supabase/auth/internal/api/oauthserver"
1718
"github.com/supabase/auth/internal/models"
1819
"github.com/supabase/auth/internal/observability"
1920
"github.com/supabase/auth/internal/security"
@@ -81,6 +82,41 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler {
8182
}
8283
}
8384

85+
// oauthClientAuth optionally authenticates an OAuth client as middleware
86+
// This doesn't fail if no client credentials are provided, but validates them if present
87+
func (a *API) oauthClientAuth(w http.ResponseWriter, r *http.Request) (context.Context, error) {
88+
ctx := r.Context()
89+
90+
clientID, clientSecret, err := oauthserver.ExtractClientCredentials(r)
91+
if err != nil {
92+
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeInvalidCredentials, "Invalid client credentials: "+err.Error())
93+
}
94+
95+
// If no client credentials provided, continue without client authentication
96+
if clientID == "" {
97+
return ctx, nil
98+
}
99+
100+
// Validate client credentials
101+
db := a.db.WithContext(ctx)
102+
client, err := models.FindOAuthServerClientByClientID(db, clientID)
103+
if err != nil {
104+
if models.IsNotFoundError(err) {
105+
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeInvalidCredentials, "Invalid client credentials")
106+
}
107+
return nil, apierrors.NewInternalServerError("Error validating client credentials").WithInternalError(err)
108+
}
109+
110+
// Validate client secret
111+
if !oauthserver.ValidateClientSecret(clientSecret, client.ClientSecretHash) {
112+
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeInvalidCredentials, "Invalid client credentials")
113+
}
114+
115+
// Add authenticated client to context
116+
ctx = oauthserver.WithOAuthServerClient(ctx, client)
117+
return ctx, nil
118+
}
119+
84120
func (a *API) requireAdminCredentials(w http.ResponseWriter, req *http.Request) (context.Context, error) {
85121
t, err := a.extractBearerToken(req)
86122
if err != nil || t == "" {

internal/api/oauthserver/auth.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package oauthserver
2+
3+
import (
4+
"encoding/base64"
5+
"errors"
6+
"net/http"
7+
"strings"
8+
)
9+
10+
// ExtractClientCredentials extracts OAuth client credentials from the request
11+
// Supports both Basic auth header and form body parameters
12+
func ExtractClientCredentials(r *http.Request) (clientID, clientSecret string, err error) {
13+
// First, try Basic auth header: Authorization: Basic base64(client_id:client_secret)
14+
authHeader := r.Header.Get("Authorization")
15+
if authHeader != "" && strings.HasPrefix(authHeader, "Basic ") {
16+
encoded := strings.TrimPrefix(authHeader, "Basic ")
17+
decoded, err := base64.StdEncoding.DecodeString(encoded)
18+
if err != nil {
19+
return "", "", errors.New("invalid basic auth encoding")
20+
}
21+
22+
credentials := string(decoded)
23+
parts := strings.SplitN(credentials, ":", 2)
24+
if len(parts) != 2 {
25+
return "", "", errors.New("invalid basic auth format")
26+
}
27+
28+
return parts[0], parts[1], nil
29+
}
30+
31+
// Fall back to form parameters
32+
if err := r.ParseForm(); err != nil {
33+
return "", "", errors.New("failed to parse form")
34+
}
35+
36+
clientID = r.FormValue("client_id")
37+
clientSecret = r.FormValue("client_secret")
38+
39+
// Return empty credentials if both are empty (no client auth attempted)
40+
if clientID == "" && clientSecret == "" {
41+
return "", "", nil
42+
}
43+
44+
// If only one is provided, it's an error
45+
if clientID == "" || clientSecret == "" {
46+
return "", "", errors.New("both client_id and client_secret must be provided")
47+
}
48+
49+
return clientID, clientSecret, nil
50+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package oauthserver
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"time"
8+
9+
"github.com/go-chi/chi/v5"
10+
"github.com/supabase/auth/internal/api/apierrors"
11+
"github.com/supabase/auth/internal/api/shared"
12+
"github.com/supabase/auth/internal/models"
13+
"github.com/supabase/auth/internal/observability"
14+
)
15+
16+
// OAuthServerClientResponse represents the response format for OAuth client operations
17+
type OAuthServerClientResponse struct {
18+
ClientID string `json:"client_id"`
19+
ClientSecret string `json:"client_secret,omitempty"` // only returned on registration
20+
21+
RedirectURIs []string `json:"redirect_uris"`
22+
TokenEndpointAuthMethod []string `json:"token_endpoint_auth_method"`
23+
GrantTypes []string `json:"grant_types"`
24+
ResponseTypes []string `json:"response_types"`
25+
ClientName string `json:"client_name,omitempty"`
26+
ClientURI string `json:"client_uri,omitempty"`
27+
LogoURI string `json:"logo_uri,omitempty"`
28+
29+
// Metadata fields
30+
RegistrationType string `json:"registration_type"`
31+
CreatedAt time.Time `json:"created_at"`
32+
UpdatedAt time.Time `json:"updated_at"`
33+
}
34+
35+
// OAuthServerClientListResponse represents the response for listing OAuth clients
36+
type OAuthServerClientListResponse struct {
37+
Clients []OAuthServerClientResponse `json:"clients"`
38+
}
39+
40+
// oauthServerClientToResponse converts a model to response format
41+
func oauthServerClientToResponse(client *models.OAuthServerClient, includeSecret bool) *OAuthServerClientResponse {
42+
response := &OAuthServerClientResponse{
43+
ClientID: client.ClientID,
44+
45+
// OAuth 2.1 DCR fields
46+
RedirectURIs: client.GetRedirectURIs(),
47+
TokenEndpointAuthMethod: []string{"client_secret_basic", "client_secret_post"}, // Both methods are supported
48+
GrantTypes: client.GetGrantTypes(),
49+
ResponseTypes: []string{"code"}, // Always "code" in OAuth 2.1
50+
ClientName: client.ClientName.String(),
51+
ClientURI: client.ClientURI.String(),
52+
LogoURI: client.LogoURI.String(),
53+
54+
// Metadata fields
55+
RegistrationType: client.RegistrationType,
56+
CreatedAt: client.CreatedAt,
57+
UpdatedAt: client.UpdatedAt,
58+
}
59+
60+
// Only include client_secret during registration
61+
if includeSecret {
62+
// Note: This will be filled in by the handler with the plaintext secret
63+
response.ClientSecret = ""
64+
}
65+
66+
return response
67+
}
68+
69+
// LoadOAuthServerClient is middleware that loads an OAuth server client from the URL parameter
70+
func (s *Server) LoadOAuthServerClient(w http.ResponseWriter, r *http.Request) (context.Context, error) {
71+
ctx := r.Context()
72+
clientID := chi.URLParam(r, "client_id")
73+
74+
if clientID == "" {
75+
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "client_id is required")
76+
}
77+
78+
observability.LogEntrySetField(r, "oauth_client_id", clientID)
79+
80+
client, err := s.getOAuthServerClient(ctx, clientID)
81+
if err != nil {
82+
if models.IsNotFoundError(err) {
83+
return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeUserNotFound, "OAuth client not found")
84+
}
85+
return nil, apierrors.NewInternalServerError("Error loading OAuth client").WithInternalError(err)
86+
}
87+
88+
ctx = WithOAuthServerClient(ctx, client)
89+
return ctx, nil
90+
}
91+
92+
// AdminOAuthServerClientRegister handles POST /admin/oauth/clients (manual registration by admins)
93+
func (s *Server) AdminOAuthServerClientRegister(w http.ResponseWriter, r *http.Request) error {
94+
ctx := r.Context()
95+
96+
var params OAuthServerClientRegisterParams
97+
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
98+
return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Invalid JSON body")
99+
}
100+
101+
// Force registration type to manual for admin endpoint
102+
params.RegistrationType = "manual"
103+
104+
client, plaintextSecret, err := s.registerOAuthServerClient(ctx, &params)
105+
if err != nil {
106+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, err.Error())
107+
}
108+
109+
response := oauthServerClientToResponse(client, true)
110+
response.ClientSecret = plaintextSecret
111+
112+
return shared.SendJSON(w, http.StatusCreated, response)
113+
}
114+
115+
// OAuthServerClientDynamicRegister handles POST /oauth/register (OAuth 2.1 Dynamic Client Registration)
116+
func (s *Server) OAuthServerClientDynamicRegister(w http.ResponseWriter, r *http.Request) error {
117+
ctx := r.Context()
118+
119+
// Check if dynamic registration is enabled
120+
if !s.config.OAuthServer.AllowDynamicRegistration {
121+
return apierrors.NewForbiddenError(apierrors.ErrorCodeOAuthDynamicClientRegistrationDisabled, "Dynamic client registration is not enabled")
122+
}
123+
124+
var params OAuthServerClientRegisterParams
125+
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
126+
return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Invalid JSON body")
127+
}
128+
129+
params.RegistrationType = "dynamic"
130+
131+
client, plaintextSecret, err := s.registerOAuthServerClient(ctx, &params)
132+
if err != nil {
133+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, err.Error())
134+
}
135+
136+
response := oauthServerClientToResponse(client, true)
137+
response.ClientSecret = plaintextSecret
138+
139+
return shared.SendJSON(w, http.StatusCreated, response)
140+
}
141+
142+
// OAuthServerClientGet handles GET /admin/oauth/clients/{client_id}
143+
func (s *Server) OAuthServerClientGet(w http.ResponseWriter, r *http.Request) error {
144+
ctx := r.Context()
145+
client := GetOAuthServerClient(ctx)
146+
147+
response := oauthServerClientToResponse(client, false)
148+
return shared.SendJSON(w, http.StatusOK, response)
149+
}
150+
151+
// OAuthServerClientDelete handles DELETE /admin/oauth/clients/{client_id}
152+
func (s *Server) OAuthServerClientDelete(w http.ResponseWriter, r *http.Request) error {
153+
ctx := r.Context()
154+
client := GetOAuthServerClient(ctx)
155+
156+
if err := s.deleteOAuthServerClient(ctx, client.ClientID); err != nil {
157+
return apierrors.NewInternalServerError("Error deleting OAuth client").WithInternalError(err)
158+
}
159+
160+
w.WriteHeader(http.StatusNoContent)
161+
return nil
162+
}
163+
164+
// OAuthServerClientList handles GET /admin/oauth/clients
165+
func (s *Server) OAuthServerClientList(w http.ResponseWriter, r *http.Request) error {
166+
ctx := r.Context()
167+
db := s.db.WithContext(ctx)
168+
169+
var clients []models.OAuthServerClient
170+
if err := db.Q().Where("deleted_at is null").Order("created_at desc").All(&clients); err != nil {
171+
return apierrors.NewInternalServerError("Error listing OAuth clients").WithInternalError(err)
172+
}
173+
174+
responses := make([]OAuthServerClientResponse, len(clients))
175+
for i, client := range clients {
176+
responses[i] = *oauthServerClientToResponse(&client, false)
177+
}
178+
179+
response := OAuthServerClientListResponse{
180+
Clients: responses,
181+
}
182+
183+
return shared.SendJSON(w, http.StatusOK, response)
184+
}

0 commit comments

Comments
 (0)