Skip to content

Commit d01a1ec

Browse files
committed
feat(scim): store a per-provider SCIM token hash
1 parent dc5bf8e commit d01a1ec

20 files changed

Lines changed: 566 additions & 24 deletions

internal/api/scim/core/core.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Package core implements the SCIM 2.0 core schema defined in RFC 7643.
2+
package core
3+
4+
// SchemaURI identifies a SCIM schema
5+
type SchemaURI string
6+
7+
// ResourceTypeName names a resource type
8+
type ResourceTypeName string
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package core
2+
3+
// The resource endpoints of RFC 7644, Section 3.2, relative to the base URL
4+
const (
5+
EndpointServiceProviderConfig = "/ServiceProviderConfig"
6+
)

internal/api/scim/core/meta.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package core
2+
3+
// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1.
4+
type Meta struct {
5+
ResourceType ResourceTypeName `json:"resourceType"`
6+
Location string `json:"location,omitempty"`
7+
}
8+
9+
func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta {
10+
return Meta{
11+
ResourceType: resourceType,
12+
Location: baseURL + endpoint,
13+
}
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package core
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestNewMeta(t *testing.T) {
11+
t.Run("locates the resource at its endpoint", func(t *testing.T) {
12+
meta := NewMeta("http://localhost:9999/scim/v2", ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig)
13+
14+
require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType)
15+
require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location)
16+
})
17+
}
18+
19+
func TestMeta(t *testing.T) {
20+
t.Run("serializes to JSON correctly", func(t *testing.T) {
21+
body, err := json.Marshal(Meta{
22+
ResourceType: ResourceTypeServiceProviderConfig,
23+
Location: "http://localhost:9999/scim/v2/ServiceProviderConfig",
24+
})
25+
26+
require.NoError(t, err)
27+
require.JSONEq(t, `{
28+
"resourceType": "ServiceProviderConfig",
29+
"location": "http://localhost:9999/scim/v2/ServiceProviderConfig"
30+
}`, string(body))
31+
})
32+
33+
t.Run("omits the location when it is empty", func(t *testing.T) {
34+
body, err := json.Marshal(Meta{ResourceType: ResourceTypeServiceProviderConfig})
35+
36+
require.NoError(t, err)
37+
require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body))
38+
})
39+
}

internal/api/scim/core/schemas.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package core
2+
3+
// The schema URIs of RFC 7643
4+
const (
5+
schemaRoot = "urn:ietf:params:scim:schemas"
6+
schemaCore = schemaRoot + ":core:2.0"
7+
8+
SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig"
9+
)
10+
11+
const (
12+
ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig"
13+
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package core
2+
3+
type SupportedFeature struct {
4+
Supported bool `json:"supported"`
5+
}
6+
7+
type BulkFeature struct {
8+
Supported bool `json:"supported"`
9+
MaxOperations int `json:"maxOperations"`
10+
MaxPayloadSize int `json:"maxPayloadSize"`
11+
}
12+
13+
type FilterFeature struct {
14+
Supported bool `json:"supported"`
15+
MaxResults int `json:"maxResults"`
16+
}
17+
18+
type AuthenticationSchemeType string
19+
20+
const (
21+
AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken"
22+
)
23+
24+
// AuthenticationScheme is the authentication scheme of RFC 7643, Section 5.
25+
type AuthenticationScheme struct {
26+
Type AuthenticationSchemeType `json:"type"`
27+
Name string `json:"name"`
28+
Description string `json:"description"`
29+
SpecURI string `json:"specUri,omitempty"`
30+
Primary bool `json:"primary"`
31+
}
32+
33+
func NewOAuthBearerToken() *AuthenticationScheme {
34+
return &AuthenticationScheme{
35+
Type: AuthenticationSchemeOAuthBearerToken,
36+
Name: "OAuth Bearer Token",
37+
Description: "Authentication scheme using the OAuth Bearer Token Standard",
38+
SpecURI: "http://www.rfc-editor.org/info/rfc6750",
39+
}
40+
}
41+
42+
func (scheme *AuthenticationScheme) AsPrimary() *AuthenticationScheme {
43+
scheme.Primary = true
44+
return scheme
45+
}
46+
47+
// ServiceProviderConfig is the schema defined in RFC 7643, Section 5.
48+
type ServiceProviderConfig struct {
49+
Schemas []SchemaURI `json:"schemas"`
50+
Patch SupportedFeature `json:"patch"`
51+
Bulk BulkFeature `json:"bulk"`
52+
Filter FilterFeature `json:"filter"`
53+
ChangePassword SupportedFeature `json:"changePassword"`
54+
Sort SupportedFeature `json:"sort"`
55+
ETag SupportedFeature `json:"etag"`
56+
AuthenticationSchemes []*AuthenticationScheme `json:"authenticationSchemes"`
57+
Meta Meta `json:"meta"`
58+
}
59+
60+
func NewServiceProviderConfig(baseURL string, schemes ...*AuthenticationScheme) *ServiceProviderConfig {
61+
if schemes == nil {
62+
schemes = []*AuthenticationScheme{}
63+
}
64+
65+
return &ServiceProviderConfig{
66+
Schemas: []SchemaURI{SchemaServiceProviderConfig},
67+
AuthenticationSchemes: schemes,
68+
Meta: NewMeta(baseURL, ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig),
69+
}
70+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package core
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestNewServiceProviderConfig(t *testing.T) {
12+
t.Run("advertises the schemes the caller declares", func(t *testing.T) {
13+
scheme := NewOAuthBearerToken().AsPrimary()
14+
15+
config := NewServiceProviderConfig("", scheme)
16+
17+
require.Equal(t, []SchemaURI{SchemaServiceProviderConfig}, config.Schemas)
18+
require.Equal(t, []*AuthenticationScheme{scheme}, config.AuthenticationSchemes)
19+
})
20+
21+
t.Run("identifies itself with resource metadata", func(t *testing.T) {
22+
baseURL := "http://localhost:9999/scim/v2"
23+
24+
config := NewServiceProviderConfig(baseURL)
25+
26+
require.Equal(t, ResourceTypeServiceProviderConfig, config.Meta.ResourceType)
27+
require.Equal(t, baseURL+EndpointServiceProviderConfig, config.Meta.Location)
28+
})
29+
30+
t.Run("supports none of the optional protocol features", func(t *testing.T) {
31+
config := NewServiceProviderConfig("")
32+
33+
assert.False(t, config.Patch.Supported)
34+
assert.False(t, config.Bulk.Supported)
35+
assert.False(t, config.Filter.Supported)
36+
assert.False(t, config.ChangePassword.Supported)
37+
assert.False(t, config.Sort.Supported)
38+
assert.False(t, config.ETag.Supported)
39+
})
40+
41+
t.Run("serializes authenticationSchemes as an array", func(t *testing.T) {
42+
body, err := json.Marshal(NewServiceProviderConfig(""))
43+
44+
require.NoError(t, err)
45+
require.Contains(t, string(body), `"authenticationSchemes":[]`)
46+
})
47+
}
48+
49+
func TestAuthenticationScheme(t *testing.T) {
50+
t.Run("NewOAuthBearerToken", func(t *testing.T) {
51+
scheme := NewOAuthBearerToken()
52+
53+
assert.Equal(t, AuthenticationSchemeOAuthBearerToken, scheme.Type)
54+
assert.Equal(t, "OAuth Bearer Token", scheme.Name)
55+
assert.Equal(t, "Authentication scheme using the OAuth Bearer Token Standard", scheme.Description)
56+
assert.Equal(t, "http://www.rfc-editor.org/info/rfc6750", scheme.SpecURI)
57+
assert.False(t, scheme.Primary)
58+
})
59+
60+
t.Run("AsPrimary marks the scheme primary", func(t *testing.T) {
61+
scheme := NewOAuthBearerToken()
62+
63+
require.Same(t, scheme, scheme.AsPrimary())
64+
assert.True(t, scheme.Primary)
65+
})
66+
}

internal/api/scim/protocol/error_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ import (
99
"github.com/stretchr/testify/require"
1010
)
1111

12+
const notFoundError = `{
13+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
14+
"status": "404",
15+
"detail": "Endpoint or resource does not exist"
16+
}`
17+
1218
func TestNewError(t *testing.T) {
1319
t.Run("serializes to JSON correctly", func(t *testing.T) {
1420
body, err := json.Marshal(NewError(http.StatusNotFound, "", "Endpoint or resource does not exist"))
1521

1622
require.NoError(t, err)
17-
assert.JSONEq(t, `{
18-
"schemas": [
19-
"urn:ietf:params:scim:api:messages:2.0:Error"
20-
],
21-
"status": "404",
22-
"detail": "Endpoint or resource does not exist"
23-
}`, string(body))
23+
assert.JSONEq(t, notFoundError, string(body))
2424
})
2525

2626
t.Run("includes the scimType when one is given", func(t *testing.T) {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package protocol
2+
3+
const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
4+
5+
type ListResponse[T any] struct {
6+
Schemas []string `json:"schemas"`
7+
TotalResults int `json:"totalResults"`
8+
StartIndex int `json:"startIndex"`
9+
ItemsPerPage int `json:"itemsPerPage"`
10+
Resources []T `json:"Resources"`
11+
}
12+
13+
func NewListResponse[T any](resources []T) *ListResponse[T] {
14+
if resources == nil {
15+
resources = []T{}
16+
}
17+
n := len(resources)
18+
return &ListResponse[T]{
19+
Schemas: []string{SchemaListResponse},
20+
TotalResults: n,
21+
StartIndex: 1,
22+
ItemsPerPage: n,
23+
Resources: resources,
24+
}
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package protocol
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
const emptyListResponse = `{
11+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
12+
"totalResults": 0,
13+
"startIndex": 1,
14+
"itemsPerPage": 0,
15+
"Resources": []
16+
}`
17+
18+
func TestNewListResponse(t *testing.T) {
19+
for _, tc := range []struct {
20+
name string
21+
resources []string
22+
expected string
23+
}{
24+
{
25+
name: "nil resources marshal to an empty array",
26+
resources: nil,
27+
expected: emptyListResponse,
28+
},
29+
{
30+
name: "empty resources marshal to an empty array",
31+
resources: []string{},
32+
expected: emptyListResponse,
33+
},
34+
{
35+
name: "populated resources are counted",
36+
resources: []string{"a", "b"},
37+
expected: `{
38+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
39+
"totalResults": 2,
40+
"startIndex": 1,
41+
"itemsPerPage": 2,
42+
"Resources": ["a", "b"]
43+
}`,
44+
},
45+
} {
46+
t.Run(tc.name, func(t *testing.T) {
47+
body, err := json.Marshal(NewListResponse(tc.resources))
48+
49+
require.NoError(t, err)
50+
require.JSONEq(t, tc.expected, string(body))
51+
})
52+
}
53+
}

0 commit comments

Comments
 (0)