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
8 changes: 8 additions & 0 deletions internal/api/scim/core/core.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Package core implements the SCIM 2.0 core schema defined in RFC 7643.
package core

// SchemaURI identifies a SCIM schema
type SchemaURI string

// ResourceTypeName names a resource type
type ResourceTypeName string
6 changes: 6 additions & 0 deletions internal/api/scim/core/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package core

// The resource endpoints of RFC 7644, Section 3.2, relative to the base URL
const (
EndpointServiceProviderConfig = "/ServiceProviderConfig"
)
14 changes: 14 additions & 0 deletions internal/api/scim/core/meta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package core

// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1.
type Meta struct {
ResourceType ResourceTypeName `json:"resourceType"`
Location string `json:"location,omitempty"`
}

func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta {
return Meta{
ResourceType: resourceType,
Location: baseURL + endpoint,
}
}
39 changes: 39 additions & 0 deletions internal/api/scim/core/meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package core

import (
"encoding/json"
"testing"

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

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)

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

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

require.NoError(t, err)
require.JSONEq(t, `{
"resourceType": "ServiceProviderConfig",
"location": "http://localhost:9999/scim/v2/ServiceProviderConfig"
}`, string(body))
})

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

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

// The schema URIs of RFC 7643
const (
schemaRoot = "urn:ietf:params:scim:schemas"
schemaCore = schemaRoot + ":core:2.0"

SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig"
)

const (
ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig"
)
70 changes: 70 additions & 0 deletions internal/api/scim/core/service_provider_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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"`
}

type AuthenticationSchemeType string

const (
AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken"
)

// AuthenticationScheme is the authentication scheme of RFC 7643, Section 5.
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
}

// ServiceProviderConfig is the schema defined in RFC 7643, Section 5.
type ServiceProviderConfig struct {
Schemas []SchemaURI `json:"schemas"`
Patch SupportedFeature `json:"patch"`
Bulk BulkFeature `json:"bulk"`
Filter FilterFeature `json:"filter"`
ChangePassword SupportedFeature `json:"changePassword"`
Sort SupportedFeature `json:"sort"`
ETag SupportedFeature `json:"etag"`
AuthenticationSchemes []*AuthenticationScheme `json:"authenticationSchemes"`
Meta Meta `json:"meta"`
}

func NewServiceProviderConfig(baseURL string, schemes ...*AuthenticationScheme) *ServiceProviderConfig {
if schemes == nil {
schemes = []*AuthenticationScheme{}
}

return &ServiceProviderConfig{
Schemas: []SchemaURI{SchemaServiceProviderConfig},
AuthenticationSchemes: schemes,
Meta: NewMeta(baseURL, ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig),
}
}
66 changes: 66 additions & 0 deletions internal/api/scim/core/service_provider_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package core

import (
"encoding/json"
"testing"

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

func TestNewServiceProviderConfig(t *testing.T) {
t.Run("advertises the schemes the caller declares", func(t *testing.T) {
scheme := NewOAuthBearerToken().AsPrimary()

config := NewServiceProviderConfig("", scheme)

require.Equal(t, []SchemaURI{SchemaServiceProviderConfig}, config.Schemas)
require.Equal(t, []*AuthenticationScheme{scheme}, config.AuthenticationSchemes)
})

t.Run("identifies itself with resource metadata", func(t *testing.T) {
baseURL := "http://localhost:9999/scim/v2"

config := NewServiceProviderConfig(baseURL)

require.Equal(t, ResourceTypeServiceProviderConfig, config.Meta.ResourceType)
require.Equal(t, baseURL+EndpointServiceProviderConfig, config.Meta.Location)
})

t.Run("supports none of the optional protocol features", func(t *testing.T) {
config := NewServiceProviderConfig("")

assert.False(t, config.Patch.Supported)
assert.False(t, config.Bulk.Supported)
assert.False(t, config.Filter.Supported)
assert.False(t, config.ChangePassword.Supported)
assert.False(t, config.Sort.Supported)
assert.False(t, config.ETag.Supported)
})

t.Run("serializes authenticationSchemes as an array", func(t *testing.T) {
body, err := json.Marshal(NewServiceProviderConfig(""))

require.NoError(t, err)
require.Contains(t, string(body), `"authenticationSchemes":[]`)
})
}

func TestAuthenticationScheme(t *testing.T) {
t.Run("NewOAuthBearerToken", func(t *testing.T) {
scheme := NewOAuthBearerToken()

assert.Equal(t, AuthenticationSchemeOAuthBearerToken, scheme.Type)
assert.Equal(t, "OAuth Bearer Token", scheme.Name)
assert.Equal(t, "Authentication scheme using the OAuth Bearer Token Standard", scheme.Description)
assert.Equal(t, "http://www.rfc-editor.org/info/rfc6750", scheme.SpecURI)
assert.False(t, scheme.Primary)
})

t.Run("AsPrimary marks the scheme primary", func(t *testing.T) {
scheme := NewOAuthBearerToken()

require.Same(t, scheme, scheme.AsPrimary())
assert.True(t, scheme.Primary)
})
}
11 changes: 8 additions & 3 deletions internal/api/scim/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@ package scim

import (
"net/http"
"strings"

"github.com/supabase/auth/internal/api/scim/core"
"github.com/supabase/auth/internal/api/scim/protocol"
"github.com/supabase/auth/internal/conf"
)

const BasePath = "/scim/v2"

type Server struct {
config *conf.GlobalConfiguration
serviceProviderConfig *core.ServiceProviderConfig
}

func NewServer(config *conf.GlobalConfiguration) *Server {
return &Server{
config: config,
serviceProviderConfig: core.NewServiceProviderConfig(
strings.TrimRight(config.API.ExternalURL, "/")+BasePath,
core.NewOAuthBearerToken().AsPrimary(),
),
}
}

func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) error {
return srv.notImplemented(w)
return protocol.Send(w, http.StatusOK, srv.serviceProviderConfig)
}

func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error {
Expand Down
28 changes: 26 additions & 2 deletions internal/api/scim/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"testing"

"github.com/stretchr/testify/require"
"github.com/supabase/auth/internal/api/scim/protocol"
"github.com/supabase/auth/internal/conf"
)

//go:embed testdata/*
Expand All @@ -18,15 +20,37 @@ func testFixture(t *testing.T, file string) string {
return string(data)
}

func newServerFor(externalURL string) *Server {
return NewServer(&conf.GlobalConfiguration{
API: conf.APIConfiguration{ExternalURL: externalURL},
})
}

func TestServer(t *testing.T) {
srv := NewServer(nil)
srv := newServerFor("http://localhost:9999")
require.NotNil(t, srv)

t.Run("NewServer trims a trailing slash from the external URL", func(t *testing.T) {
location := newServerFor("https://auth.example.com/").serviceProviderConfig.Meta.Location

require.Equal(t, "https://auth.example.com"+BasePath+"/ServiceProviderConfig", location)
})

t.Run("ServiceProviderConfig", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, BasePath+"/ServiceProviderConfig", nil)
w := httptest.NewRecorder()

require.NoError(t, srv.ServiceProviderConfig(w, r))

require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type"))
require.JSONEq(t, testFixture(t, "service_provider_config.json"), w.Body.String())
})

for _, tc := range []struct {
path string
handler func(http.ResponseWriter, *http.Request) error
}{
{"ServiceProviderConfig", srv.ServiceProviderConfig},
{"ResourceTypes", srv.ResourceTypes},
{"Schemas", srv.Schemas},
} {
Expand Down
39 changes: 39 additions & 0 deletions internal/api/scim/testdata/service_provider_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
],
"patch": {
"supported": false
},
"bulk": {
"supported": false,
"maxOperations": 0,
"maxPayloadSize": 0
},
"filter": {
"supported": false,
"maxResults": 0
},
"changePassword": {
"supported": false
},
"sort": {
"supported": false
},
"etag": {
"supported": false
},
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Authentication scheme using the OAuth Bearer Token Standard",
"specUri": "http://www.rfc-editor.org/info/rfc6750",
"primary": true
}
],
"meta": {
"resourceType": "ServiceProviderConfig",
"location": "http://localhost:9999/scim/v2/ServiceProviderConfig"
}
}
32 changes: 28 additions & 4 deletions internal/api/scim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,28 @@ import (
"testing"

"github.com/stretchr/testify/require"
scimCore "github.com/supabase/auth/internal/api/scim/core"
scimProtocol "github.com/supabase/auth/internal/api/scim/protocol"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/storage"
)

const (
scimServiceProviderConfigPath = "/scim/v2/ServiceProviderConfig"
scimResourceTypesPath = "/scim/v2/ResourceTypes"
scimSchemasPath = "/scim/v2/Schemas"
)

var scimPaths = []string{
"/scim/v2/ServiceProviderConfig",
"/scim/v2/ResourceTypes",
"/scim/v2/Schemas",
scimServiceProviderConfigPath,
scimResourceTypesPath,
scimSchemasPath,
}

// scimNotImplementedPaths shrinks to empty as the endpoints land.
var scimNotImplementedPaths = []string{
scimResourceTypesPath,
scimSchemasPath,
}

func TestSCIM(t *testing.T) {
Expand Down Expand Up @@ -56,7 +69,18 @@ func TestSCIM(t *testing.T) {

require.True(t, api.config.Experimental.ScimEnabled)

for _, path := range scimPaths {
t.Run(scimServiceProviderConfigPath, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, scimServiceProviderConfigPath, nil)
w := httptest.NewRecorder()

api.handler.ServeHTTP(w, r)

require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type"))
require.Contains(t, w.Body.String(), scimCore.SchemaServiceProviderConfig)
})

for _, path := range scimNotImplementedPaths {
t.Run(path, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
Expand Down