Skip to content
Draft
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
6 changes: 5 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)

if api.config.Password.HIBP.Enabled {
httpClient := &http.Client{
Expand Down Expand Up @@ -457,7 +457,11 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne

r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig)
r.Get("/ResourceTypes", api.scim.ResourceTypes)
r.Get("/ResourceTypes/{id}", api.scim.ResourceTypeByID)
r.Get("/Schemas", api.scim.Schemas)
r.Get("/Schemas/{id}", api.scim.SchemaByID)

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

Expand Down
71 changes: 71 additions & 0 deletions internal/api/scim/authenticate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package scim

import (
"context"
"net/http"
"strings"

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

var providerKey = NewKey[*models.SSOProvider]("sso_provider")

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, ok := parseBearerToken(r.Header.Get("Authorization"))
if !ok {
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() {
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 providerKey.With(ctx, provider), true
}

func parseBearerToken(header string) (string, bool) {
scheme, rest, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") {
return "", false
}

token := strings.TrimSpace(rest)
if token == "" || strings.ContainsAny(token, " \t\r\n\v\f") {
return "", false
}

return token, 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")
}
48 changes: 48 additions & 0 deletions internal/api/scim/authenticate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package scim

import (
"testing"

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

func TestParseBearerToken(t *testing.T) {
// RFC 7235, Section 2.1: credentials = auth-scheme 1*SP token68.
// The scheme is case insensitive and one or more spaces may separate it
// from the token.
accepted := map[string]string{
"Bearer tok": "tok",
"bearer tok": "tok",
"BEARER tok": "tok",
"Bearer tok": "tok",
"Bearer tok ": "tok",
"Bearer \ttok": "tok",
}

for header, want := range accepted {
t.Run(header, func(t *testing.T) {
got, ok := parseBearerToken(header)

require.True(t, ok)
require.Equal(t, want, got)
})
}

rejected := []string{
"",
"Bearer",
"Bearer ",
"Basic tok",
"Bearertok",
"Bearer tok extra",
}

for _, header := range rejected {
t.Run("rejects "+header, func(t *testing.T) {
got, ok := parseBearerToken(header)

require.False(t, ok)
require.Empty(t, got)
})
}
}
24 changes: 24 additions & 0 deletions internal/api/scim/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package scim

import "context"

type Key[T any] struct {
name string
}

func NewKey[T any](name string) Key[T] {
return Key[T]{name: name}
}

func (k Key[T]) String() string {
return k.name
}

func (k Key[T]) With(ctx context.Context, value T) context.Context {
return context.WithValue(ctx, k, value)
}

func (k Key[T]) From(ctx context.Context) T {
value, _ := ctx.Value(k).(T)
return value
}
60 changes: 60 additions & 0 deletions internal/api/scim/context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package scim

import (
"context"
"fmt"
"testing"

"github.com/gofrs/uuid"
"github.com/stretchr/testify/require"
"github.com/supabase/auth/internal/models"
)

func TestKey(t *testing.T) {
key := NewKey[*models.SSOProvider]("sso_provider")

t.Run("round trips a typed value", func(t *testing.T) {
provider := &models.SSOProvider{ID: uuid.Must(uuid.NewV4())}

ctx := key.With(context.Background(), provider)

require.Equal(t, provider, key.From(ctx))
})

t.Run("returns the zero value when absent", func(t *testing.T) {
require.Nil(t, key.From(context.Background()))
})

t.Run("keys of different types do not collide", func(t *testing.T) {
other := NewKey[string]("sso_provider")

ctx := other.With(context.Background(), "not a provider")

require.Nil(t, key.From(ctx))
require.Equal(t, "not a provider", other.From(ctx))
})

t.Run("keys of the same type and name share a slot", func(t *testing.T) {
provider := &models.SSOProvider{ID: uuid.Must(uuid.NewV4())}

ctx := NewKey[*models.SSOProvider]("sso_provider").With(context.Background(), provider)

require.Equal(t, provider, key.From(ctx))
})

t.Run("keys of the same type but a different name do not collide", func(t *testing.T) {
first, second := NewKey[string]("first"), NewKey[string]("second")

ctx := first.With(context.Background(), "one")
ctx = second.With(ctx, "two")

require.Equal(t, "one", first.From(ctx))
require.Equal(t, "two", second.From(ctx))
})

t.Run("names the key when a context is printed", func(t *testing.T) {
ctx := NewKey[string]("first").With(context.Background(), "one")

require.Contains(t, fmt.Sprint(ctx), "first")
})
}
96 changes: 96 additions & 0 deletions internal/api/scim/core/attribute.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package core

// AttributeType is the data type of an attribute, per RFC 7643, Section 7.
type AttributeType string

const (
TypeString AttributeType = "string"
TypeBoolean AttributeType = "boolean"
TypeDecimal AttributeType = "decimal"
TypeInteger AttributeType = "integer"
TypeDateTime AttributeType = "dateTime"
TypeReference AttributeType = "reference"
TypeComplex AttributeType = "complex"
)

// Mutability states when an attribute may be (re)defined.
type Mutability string

const (
MutabilityReadOnly Mutability = "readOnly"
MutabilityReadWrite Mutability = "readWrite"
MutabilityImmutable Mutability = "immutable"
MutabilityWriteOnly Mutability = "writeOnly"
)

// Returned states when an attribute is included in a response.
type Returned string

const (
ReturnedAlways Returned = "always"
ReturnedNever Returned = "never"
ReturnedDefault Returned = "default"
ReturnedRequest Returned = "request"
)

// Uniqueness states how the service provider enforces uniqueness.
type Uniqueness string

const (
UniquenessNone Uniqueness = "none"
UniquenessServer Uniqueness = "server"
UniquenessGlobal Uniqueness = "global"
)

// Attribute describes one attribute of a schema, per RFC 7643, Section 7.
type Attribute struct {
Name string `json:"name"`
Type AttributeType `json:"type"`
MultiValued bool `json:"multiValued"`
Description string `json:"description"`
Required bool `json:"required"`
CaseExact bool `json:"caseExact"`
Mutability Mutability `json:"mutability"`
Returned Returned `json:"returned"`
Uniqueness Uniqueness `json:"uniqueness"`
SubAttributes []*Attribute `json:"subAttributes,omitempty"`
}

// NewAttribute returns an attribute carrying the characteristic defaults
// RFC 7643, Section 7 declares: readWrite mutability, default returnability
// and no uniqueness. The As* and With modifiers state the deviations.
func NewAttribute(name string, attributeType AttributeType, description string) *Attribute {
return &Attribute{
Name: name,
Type: attributeType,
Description: description,
Mutability: MutabilityReadWrite,
Returned: ReturnedDefault,
Uniqueness: UniquenessNone,
}
}

func (a *Attribute) AsRequired() *Attribute {
a.Required = true
return a
}

func (a *Attribute) AsMultiValued() *Attribute {
a.MultiValued = true
return a
}

func (a *Attribute) AsCaseExact() *Attribute {
a.CaseExact = true
return a
}

func (a *Attribute) UniqueOn(uniqueness Uniqueness) *Attribute {
a.Uniqueness = uniqueness
return a
}

func (a *Attribute) With(subAttributes ...*Attribute) *Attribute {
a.SubAttributes = subAttributes
return a
}
4 changes: 4 additions & 0 deletions internal/api/scim/core/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ package core

// The resource endpoints of RFC 7644, Section 3.2, relative to the base URL
const (
EndpointGroups = "/Groups"
EndpointResourceTypes = "/ResourceTypes"
EndpointSchemas = "/Schemas"
EndpointServiceProviderConfig = "/ServiceProviderConfig"
EndpointUsers = "/Users"
)
13 changes: 11 additions & 2 deletions internal/api/scim/core/meta.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package core

import "time"

// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1.
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 {
func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint, id string) Meta {
location := baseURL + endpoint
if id != "" {
location += "/" + id
}

return Meta{
ResourceType: resourceType,
Location: baseURL + endpoint,
Location: location,
}
}
15 changes: 12 additions & 3 deletions internal/api/scim/core/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import (
)

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"

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

require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType)
require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location)
require.Equal(t, baseURL+"/ServiceProviderConfig", meta.Location)
})

t.Run("locates one resource of a collection", func(t *testing.T) {
meta := NewMeta(baseURL, ResourceTypeUser, EndpointUsers, "2819c223-7f76-453a-919d-413861904646")

require.Equal(t, ResourceTypeUser, meta.ResourceType)
require.Equal(t, baseURL+"/Users/2819c223-7f76-453a-919d-413861904646", meta.Location)
})
}

Expand Down
Loading
Loading