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
4 changes: 3 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
api.oauthServer = oauthserver.NewServer(globalConfig, db, api.tokenService)
}

api.scim = scim.NewServer(globalConfig)
api.scim = scim.NewServer(globalConfig, db, api.extractBearerToken)

if api.config.Password.HIBP.Enabled {
httpClient := &http.Client{
Expand Down Expand Up @@ -458,6 +458,8 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig)
r.Get("/ResourceTypes", api.scim.ResourceTypes)
r.Get("/Schemas", api.scim.Schemas)

r.WithBypass(api.scim.Authenticate).Get("/Users/{id}", api.scim.UserByID)
})
})

Expand Down
9 changes: 2 additions & 7 deletions internal/api/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const (
adminUserKey = contextKey("admin_user")
oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token
oauthVerifierKey = contextKey("oauth_verifier")
ssoProviderKey = contextKey("sso_provider")
externalHostKey = contextKey("external_host")
oauthClientStateKey = contextKey("oauth_client_state_id")
flowStateContextKey = contextKey("flow_state")
Expand Down Expand Up @@ -239,15 +238,11 @@ func getOAuthVerifier(ctx context.Context) string {
}

func withSSOProvider(ctx context.Context, provider *models.SSOProvider) context.Context {
return context.WithValue(ctx, ssoProviderKey, provider)
return shared.WithSSOProvider(ctx, provider)
}

func getSSOProvider(ctx context.Context) *models.SSOProvider {
obj := ctx.Value(ssoProviderKey)
if obj == nil {
return nil
}
return obj.(*models.SSOProvider)
return shared.GetSSOProvider(ctx)
}

func withExternalHost(ctx context.Context, u *url.URL) context.Context {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/samlacs.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ func (a *API) handleSamlAcs(w http.ResponseWriter, r *http.Request) error {
}
}

providerType := "sso:" + ssoProvider.ID.String()
providerType := ssoProvider.ProviderType()
if err := a.triggerBeforeUserCreatedExternal(
r, db, &userProvidedData, providerType); err != nil {
return err
Expand Down
55 changes: 55 additions & 0 deletions internal/api/scim/authenticate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package scim

import (
"context"
"net/http"

"github.com/supabase/auth/internal/api/scim/protocol"
"github.com/supabase/auth/internal/api/shared"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/observability"
)

func (srv *Server) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, ok := srv.authenticate(w, r)
if !ok {
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func (srv *Server) authenticate(w http.ResponseWriter, r *http.Request) (context.Context, bool) {
ctx := r.Context()

token, err := srv.extract(r)
if err != nil {
unauthorized(w)
return nil, false
}

provider, err := models.FindSSOProviderBySCIMToken(srv.db.WithContext(ctx), token)
if err != nil {
if models.IsNotFoundError(err) {
unauthorized(w)
return nil, false
}
srv.internalError(w, r, err)
return nil, false
}

if !provider.IsEnabled() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: LOW

The server returns HTTP 403 for a valid token whose provider is disabled, versus HTTP 401 for any unknown token. This status-code difference lets an attacker use a disabled SCIM provider as an oracle — submitting candidate tokens and distinguishing valid-but-forbidden ones (403) from invalid ones (401), effectively confirming token validity without gaining access.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Replace the HTTP 403 response for a disabled provider with the same unauthorized(w) call (HTTP 401) used for unknown tokens. This eliminates the status-code oracle by returning a uniform 401 response whether the token is unrecognised or resolves to a disabled provider, so an attacker can no longer confirm token validity by observing the response code.

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
if !provider.IsEnabled() {
if !provider.IsEnabled() {
unauthorized(w)
return nil, false
}

protocol.SendError(w, http.StatusForbidden, "", "SCIM is not available for this provider")
return nil, false
}

observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String())

return shared.WithSSOProvider(ctx, provider), true
}

func unauthorized(w http.ResponseWriter) error {
w.Header().Set("WWW-Authenticate", "Bearer")
return protocol.SendError(w, http.StatusUnauthorized, "", "A valid SCIM bearer token is required")
}
29 changes: 29 additions & 0 deletions internal/api/scim/core/authentication_scheme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package core

type AuthenticationSchemeType string

const (
AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken"
)

type AuthenticationScheme struct {
Type AuthenticationSchemeType `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
SpecURI string `json:"specUri,omitempty"`
Primary bool `json:"primary"`
}

func NewOAuthBearerToken() *AuthenticationScheme {
return &AuthenticationScheme{
Type: AuthenticationSchemeOAuthBearerToken,
Name: "OAuth Bearer Token",
Description: "Authentication scheme using the OAuth Bearer Token Standard",
SpecURI: "http://www.rfc-editor.org/info/rfc6750",
}
}

func (scheme *AuthenticationScheme) AsPrimary() *AuthenticationScheme {
scheme.Primary = true
return scheme
}
3 changes: 0 additions & 3 deletions internal/api/scim/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,3 @@ package core

// SchemaURI identifies a SCIM schema
type SchemaURI string

// ResourceTypeName names a resource type
type ResourceTypeName string
6 changes: 0 additions & 6 deletions internal/api/scim/core/endpoints.go

This file was deleted.

16 changes: 16 additions & 0 deletions internal/api/scim/core/feature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package core

type SupportedFeature struct {
Supported bool `json:"supported"`
}

type BulkFeature struct {
Supported bool `json:"supported"`
MaxOperations int `json:"maxOperations"`
MaxPayloadSize int `json:"maxPayloadSize"`
}

type FilterFeature struct {
Supported bool `json:"supported"`
MaxResults int `json:"maxResults"`
}
21 changes: 15 additions & 6 deletions internal/api/scim/core/meta.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package core

// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1.
import "time"

type Meta struct {
ResourceType ResourceTypeName `json:"resourceType"`
Created time.Time `json:"created,omitzero"`
LastModified time.Time `json:"lastModified,omitzero"`
Location string `json:"location,omitempty"`
}

func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta {
return Meta{
ResourceType: resourceType,
Location: baseURL + endpoint,
}
func NewMeta(baseURL string, resourceType ResourceType) Meta {
return resourceType.Meta(baseURL)
}

func (m Meta) For(r Resource) Meta {
created, updated := r.Timestamps()

m.Location += "/" + r.ResourceID()
m.Created, m.LastModified = created.UTC(), updated.UTC()

return m
}
72 changes: 65 additions & 7 deletions internal/api/scim/core/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,81 @@ package core
import (
"encoding/json"
"testing"
"time"

"github.com/gofrs/uuid"
"github.com/stretchr/testify/require"
)

type resource struct {
id string
created, updated time.Time
}

func (s resource) ResourceID() string { return s.id }
func (s resource) ResourceType() ResourceType {
return ResourceType{Name: "Resource", Endpoint: "/Resources"}
}
func (s resource) Timestamps() (created, updated time.Time) { return s.created, s.updated }

func TestNewMeta(t *testing.T) {
t.Run("locates the resource at its endpoint", func(t *testing.T) {
meta := NewMeta("http://localhost:9999/scim/v2", ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig)
baseURL := "http://localhost:9999/scim/v2"
id := uuid.Must(uuid.NewV4()).String()
created := time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC)
updated := time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC)

t.Run("locates a resource that is its own endpoint", func(t *testing.T) {
meta := NewMeta(baseURL, ResourceTypeServiceProviderConfig)

require.Equal(t, ResourceTypeServiceProviderConfig.Name, meta.ResourceType)
require.Equal(t, baseURL+"/ServiceProviderConfig", meta.Location)
require.Zero(t, meta.Created)
require.Zero(t, meta.LastModified)
})

t.Run("locates one resource of a collection", func(t *testing.T) {
meta := NewMeta(baseURL, ResourceTypeUser).For(resource{
id: id,
created: created,
updated: updated,
})

require.Equal(t, ResourceTypeUser.Name, meta.ResourceType)
require.Equal(t, baseURL+"/Users/"+id, meta.Location)
require.Equal(t, created, meta.Created)
require.Equal(t, updated, meta.LastModified)
})

t.Run("normalizes the timestamps to UTC", func(t *testing.T) {
meta := NewMeta(baseURL, ResourceTypeUser).For(resource{
id: id,
created: created.In(time.FixedZone("MDT", -6*60*60)),
updated: updated.In(time.FixedZone("MDT", -6*60*60)),
})

require.Equal(t, time.UTC, meta.Created.Location())
require.Equal(t, time.UTC, meta.LastModified.Location())
require.True(t, meta.Created.Equal(created))
require.True(t, meta.LastModified.Equal(updated))
})

t.Run("leaves the receiver untouched", func(t *testing.T) {
collection := NewMeta(baseURL, ResourceTypeUser)

first := collection.For(resource{id: "first"})
second := collection.For(resource{id: "second"})

require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType)
require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location)
require.Equal(t, baseURL+"/Users/first", first.Location)
require.Equal(t, baseURL+"/Users/second", second.Location)
require.Equal(t, baseURL+"/Users", collection.Location)
require.Zero(t, collection.Created)
})
}

func TestMeta(t *testing.T) {
t.Run("serializes to JSON correctly", func(t *testing.T) {
body, err := json.Marshal(Meta{
ResourceType: ResourceTypeServiceProviderConfig,
ResourceType: "ServiceProviderConfig",
Location: "http://localhost:9999/scim/v2/ServiceProviderConfig",
})

Expand All @@ -31,9 +89,9 @@ func TestMeta(t *testing.T) {
})

t.Run("omits the location when it is empty", func(t *testing.T) {
body, err := json.Marshal(Meta{ResourceType: ResourceTypeServiceProviderConfig})
body, err := json.Marshal(Meta{ResourceType: "Example"})

require.NoError(t, err)
require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body))
require.JSONEq(t, `{"resourceType": "Example"}`, string(body))
})
}
9 changes: 9 additions & 0 deletions internal/api/scim/core/resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package core

import "time"

type Resource interface {
ResourceID() string
ResourceType() ResourceType
Timestamps() (created, updated time.Time)
}
31 changes: 31 additions & 0 deletions internal/api/scim/core/resource_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package core

var (
ResourceTypeServiceProviderConfig = ResourceType{
Name: "ServiceProviderConfig",
Endpoint: "/ServiceProviderConfig",
}

ResourceTypeUser = ResourceType{
Name: "User",
Endpoint: "/Users",
}
)

type ResourceTypeName string

type ResourceType struct {
Name ResourceTypeName
Endpoint string
}

func (r ResourceType) Meta(baseURL string) Meta {
return Meta{
ResourceType: r.Name,
Location: r.Location(baseURL),
}
}

func (r ResourceType) Location(baseURL string) string {
return baseURL + r.Endpoint
}
24 changes: 24 additions & 0 deletions internal/api/scim/core/resource_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package core

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestResourceType(t *testing.T) {
r := ResourceType{Name: "Resource", Endpoint: "/Resources"}

t.Run("Meta", func(t *testing.T) {
baseURL := "http://localhost:9999/scim/v2"

t.Run("locates a resource that is its own endpoint", func(t *testing.T) {
meta := r.Meta(baseURL)

require.Equal(t, ResourceTypeName("Resource"), meta.ResourceType)
require.Equal(t, baseURL+"/Resources", meta.Location)
require.Zero(t, meta.Created)
require.Zero(t, meta.LastModified)
})
})
}
5 changes: 1 addition & 4 deletions internal/api/scim/core/schemas.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,5 @@ const (
schemaCore = schemaRoot + ":core:2.0"

SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig"
)

const (
ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig"
SchemaUser SchemaURI = schemaCore + ":User"
)
Loading
Loading