Skip to content

Commit 5318552

Browse files
authored
feat: implement OAuth2 authorization endpoint (#2107)
# Summary This PR implements the OAuth 2.1 authorization endpoint in Supabase Auth, completing the server-side OAuth flow by adding user authorization and consent management. Building on the OAuth client registration foundation (#2098), this enables Supabase Auth to function as an OAuth 2.1 authorization server. # Features Added ## Authorization Flow Endpoints - **Authorization Initiation** (`GET /oauth/authorize`) - Initiates OAuth 2.1 authorization code flow with PKCE support and redirects user to (for now) pre-configured url - **Authorization Details** (`GET /oauth/authorizations/{authorization_id}`) - Retrieves authorization request details for consent UI - **Consent Processing** (`POST /oauth/authorizations/{authorization_id}/consent`) - Handles user consent decisions (approve/deny) ## Authorization Management - **PKCE Enforcement** - Mandatory PKCE (RFC 7636) with S256/Plain support for OAuth 2.1 compliance - **User Consent Tracking** - Persistent consent storage with scope-based auto-approval for trusted clients - **State Management** - Complete authorization lifecycle management (pending → approved/denied/expired) - **Security Controls** - Authorization expiration, redirect URI validation # Technical Implementation ## Database Schema - New `oauth_authorizations` table for authorization requests with status tracking - New `oauth_consents` table for persistent user consent management - Enhanced enums for authorization status and response types - Comprehensive indexing for performance and cleanup operations ## Code Organization - Extended `internal/api/oauthserver` package with authorization flow handlers - New models: `OAuthServerAuthorization`, `OAuthServerConsent`, and scope utilities - Shared PKCE utilities extracted to `internal/models/pkce.go` for reuse - Context utilities moved to `internal/api/shared` to avoid circular dependencies # Future Work - **Integration Tests** - Add comprehensive integration tests for authorization flow handlers - **Audit Logging** - Enhanced audit logging for authorization decisions and consent management - **Scope Enforcement** - Currently scope handling provides future extensibility without active enforcement/utilization
1 parent bb360a6 commit 5318552

25 files changed

+2252
-88
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ require (
173173
golang.org/x/net v0.38.0 // indirect
174174
golang.org/x/sync v0.12.0
175175
golang.org/x/sys v0.31.0
176-
golang.org/x/text v0.23.0 // indirect
176+
golang.org/x/text v0.23.0
177177
golang.org/x/time v0.9.0
178178
google.golang.org/appengine v1.6.8 // indirect
179179
google.golang.org/grpc v1.63.2 // indirect

internal/api/api.go

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,14 @@ func (a *API) deprecationNotices() {
8888
// NewAPIWithVersion creates a new REST API using the specified version
8989
func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Connection, version string, opt ...Option) *API {
9090
api := &API{
91-
config: globalConfig,
92-
db: db,
93-
version: version,
94-
oauthServer: oauthserver.NewServer(globalConfig, db),
91+
config: globalConfig,
92+
db: db,
93+
version: version,
94+
}
95+
96+
// Only initialize OAuth server if enabled
97+
if globalConfig.OAuthServer.Enabled {
98+
api.oauthServer = oauthserver.NewServer(globalConfig, db)
9599
}
96100

97101
for _, o := range opt {
@@ -171,6 +175,10 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
171175
r.Get("/health", api.HealthCheck)
172176
r.Get("/.well-known/jwks.json", api.Jwks)
173177

178+
if globalConfig.OAuthServer.Enabled {
179+
r.Get("/.well-known/oauth-authorization-server", api.oauthServer.OAuthServerMetadata)
180+
}
181+
174182
r.Route("/callback", func(r *router) {
175183
r.Use(api.isValidExternalHost)
176184
r.Use(api.loadFlowState)
@@ -185,6 +193,8 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
185193

186194
r.Get("/settings", api.Settings)
187195

196+
// `/authorize` to initiate OAuth2 authorization flow with the external providers
197+
// where Supabase Auth is an OAuth2 Client
188198
r.Get("/authorize", api.ExternalProviderRedirect)
189199

190200
r.With(api.requireAdminCredentials).Post("/invite", api.Invite)
@@ -325,27 +335,37 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
325335
})
326336

327337
// Admin only oauth client management endpoints
328-
r.Route("/oauth", func(r *router) {
329-
r.Route("/clients", func(r *router) {
330-
// Manual client registration
331-
r.Post("/", api.oauthServer.AdminOAuthServerClientRegister)
332-
333-
r.Get("/", api.oauthServer.OAuthServerClientList)
334-
335-
r.Route("/{client_id}", func(r *router) {
336-
r.Use(api.oauthServer.LoadOAuthServerClient)
337-
r.Get("/", api.oauthServer.OAuthServerClientGet)
338-
r.Delete("/", api.oauthServer.OAuthServerClientDelete)
338+
if globalConfig.OAuthServer.Enabled {
339+
r.Route("/oauth", func(r *router) {
340+
r.Route("/clients", func(r *router) {
341+
// Manual client registration
342+
r.Post("/", api.oauthServer.AdminOAuthServerClientRegister)
343+
344+
r.Get("/", api.oauthServer.OAuthServerClientList)
345+
346+
r.Route("/{client_id}", func(r *router) {
347+
r.Use(api.oauthServer.LoadOAuthServerClient)
348+
r.Get("/", api.oauthServer.OAuthServerClientGet)
349+
r.Delete("/", api.oauthServer.OAuthServerClientDelete)
350+
})
339351
})
340352
})
341-
})
353+
}
342354
})
343355

344356
// OAuth Dynamic Client Registration endpoint (public, rate limited)
345-
r.Route("/oauth", func(r *router) {
346-
r.With(api.limitHandler(api.limiterOpts.OAuthClientRegister)).
347-
Post("/clients/register", api.oauthServer.OAuthServerClientDynamicRegister)
348-
})
357+
if globalConfig.OAuthServer.Enabled {
358+
r.Route("/oauth", func(r *router) {
359+
r.With(api.limitHandler(api.limiterOpts.OAuthClientRegister)).
360+
Post("/clients/register", api.oauthServer.OAuthServerClientDynamicRegister)
361+
362+
// OAuth 2.1 Authorization endpoints
363+
// `/authorize` to initiate OAuth2 authorization code flow where Supabase Auth is the OAuth2 provider
364+
r.Get("/authorize", api.oauthServer.OAuthServerAuthorize)
365+
r.With(api.requireAuthentication).Get("/authorizations/{authorization_id}", api.oauthServer.OAuthServerGetAuthorization)
366+
r.With(api.requireAuthentication).Post("/authorizations/{authorization_id}/consent", api.oauthServer.OAuthServerConsent)
367+
})
368+
}
349369
})
350370

351371
corsHandler := cors.New(cors.Options{

internal/api/api_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,30 @@ func TestEmailEnabledByDefault(t *testing.T) {
5555

5656
require.True(t, api.config.External.Email.Enabled)
5757
}
58+
59+
func TestOAuthServerDisabledByDefault(t *testing.T) {
60+
api, _, err := setupAPIForTest()
61+
require.NoError(t, err)
62+
63+
// OAuth server should be disabled by default
64+
require.False(t, api.config.OAuthServer.Enabled)
65+
66+
// OAuth server instance should not be initialized when disabled
67+
require.Nil(t, api.oauthServer)
68+
}
69+
70+
func TestOAuthServerCanBeEnabled(t *testing.T) {
71+
api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) {
72+
if config != nil {
73+
// Enable OAuth server
74+
config.OAuthServer.Enabled = true
75+
}
76+
})
77+
require.NoError(t, err)
78+
79+
// OAuth server should be enabled
80+
require.True(t, api.config.OAuthServer.Enabled)
81+
82+
// OAuth server instance should be initialized when enabled
83+
require.NotNil(t, api.oauthServer)
84+
}

internal/api/apierrors/errorcode.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,7 @@ const (
9797
ErrorCodeWeb3UnsupportedChain ErrorCode = "web3_unsupported_chain"
9898
ErrorCodeOAuthDynamicClientRegistrationDisabled ErrorCode = "oauth_dynamic_client_registration_disabled"
9999
ErrorCodeEmailAddressNotProvided ErrorCode = "email_address_not_provided"
100+
101+
ErrorCodeOAuthClientNotFound ErrorCode = "oauth_client_not_found"
102+
ErrorCodeOAuthAuthorizationNotFound ErrorCode = "oauth_authorization_not_found"
100103
)

internal/api/context.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/url"
66

77
jwt "github.com/golang-jwt/jwt/v5"
8+
"github.com/supabase/auth/internal/api/shared"
89
"github.com/supabase/auth/internal/models"
910
)
1011

@@ -21,7 +22,6 @@ const (
2122
tokenKey = contextKey("jwt")
2223
inviteTokenKey = contextKey("invite_token")
2324
signatureKey = contextKey("signature")
24-
userKey = contextKey("user")
2525
targetUserKey = contextKey("target_user")
2626
factorKey = contextKey("factor")
2727
sessionKey = contextKey("session")
@@ -60,7 +60,7 @@ func getClaims(ctx context.Context) *AccessTokenClaims {
6060

6161
// withUser adds the user to the context.
6262
func withUser(ctx context.Context, u *models.User) context.Context {
63-
return context.WithValue(ctx, userKey, u)
63+
return shared.WithUser(ctx, u)
6464
}
6565

6666
// withTargetUser adds the target user for linking to the context.
@@ -75,14 +75,7 @@ func withFactor(ctx context.Context, f *models.Factor) context.Context {
7575

7676
// getUser reads the user from the context.
7777
func getUser(ctx context.Context) *models.User {
78-
if ctx == nil {
79-
return nil
80-
}
81-
obj := ctx.Value(userKey)
82-
if obj == nil {
83-
return nil
84-
}
85-
return obj.(*models.User)
78+
return shared.GetUser(ctx)
8679
}
8780

8881
// getTargetUser reads the user from the context.

0 commit comments

Comments
 (0)