Skip to content

Commit 6e4416d

Browse files
committed
feat(scim): SCIM /Users endpoints and bearer-token middleware
1 parent ee648cd commit 6e4416d

29 files changed

Lines changed: 2688 additions & 124 deletions

‎internal/api/api.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
138138
api.oauthServer = oauthserver.NewServer(globalConfig, db, api.tokenService)
139139
}
140140

141-
api.scim = scim.NewServer(globalConfig)
141+
api.scim = scim.NewServer(db, globalConfig.API.ExternalURL)
142142

143143
if api.config.Password.HIBP.Enabled {
144144
httpClient := &http.Client{
@@ -457,7 +457,16 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
457457

458458
r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig)
459459
r.Get("/ResourceTypes", api.scim.ResourceTypes)
460+
r.Get("/ResourceTypes/{id}", api.scim.ResourceTypeByID)
460461
r.Get("/Schemas", api.scim.Schemas)
462+
r.Get("/Schemas/{id}", api.scim.SchemaByID)
463+
464+
tenant := r.WithBypass(api.scim.Tenant)
465+
tenant.Get("/Users", api.scim.Users)
466+
tenant.Post("/Users", api.scim.CreateUser)
467+
tenant.Get("/Users/{id}", api.scim.UserByID)
468+
tenant.Put("/Users/{id}", api.scim.ReplaceUser)
469+
tenant.Delete("/Users/{id}", api.scim.DeleteUser)
461470
})
462471
})
463472

‎internal/api/scim/helpers_test.go‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package scim
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/gofrs/uuid"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/supabase/auth/internal/api/scim/core"
12+
"github.com/supabase/auth/internal/conf/confload"
13+
"github.com/supabase/auth/internal/storage"
14+
"github.com/supabase/auth/internal/storage/test"
15+
)
16+
17+
const scimTestConfig = "../../../hack/test.env"
18+
19+
const testExternalURL = "http://localhost:9999"
20+
21+
func newTestDB(t *testing.T) *storage.Connection {
22+
t.Helper()
23+
24+
globalConfig, err := confload.LoadGlobal(scimTestConfig)
25+
require.NoError(t, err)
26+
27+
conn, err := test.SetupDBConnection(globalConfig)
28+
require.NoError(t, err)
29+
30+
return conn
31+
}
32+
33+
func newTenant(t *testing.T, db *storage.Connection) string {
34+
t.Helper()
35+
36+
id := uuid.Must(uuid.NewV4()).String()
37+
require.NoError(t, db.RawQuery("INSERT INTO sso_providers (id, resource_id, created_at, updated_at) VALUES (?, ?, now(), now())", id, "scim-test-"+id).Exec())
38+
39+
t.Cleanup(func() {
40+
_ = db.RawQuery("DELETE FROM sso_providers WHERE id = ?", id).Exec()
41+
})
42+
43+
return id
44+
}
45+
46+
func putUser(t *testing.T, db *storage.Connection, tenant string, user *core.User) {
47+
t.Helper()
48+
49+
stored := *user
50+
stored.ID, stored.Meta = "", core.Meta{}
51+
52+
document, err := json.Marshal(&stored)
53+
require.NoError(t, err)
54+
55+
require.NoError(t, db.RawQuery(
56+
"INSERT INTO scim_users (id, sso_provider_id, resource, created_at, updated_at)"+
57+
" VALUES (?, ?, ?, ?, ?)",
58+
user.ID, tenant, string(document), user.Meta.Created, user.Meta.LastModified,
59+
).Exec())
60+
}
61+
62+
func newStoredUser(t *testing.T, db *storage.Connection, tenant string, user *core.User) *core.User {
63+
t.Helper()
64+
65+
if user.ID == "" {
66+
user.ID = uuid.Must(uuid.NewV4()).String()
67+
}
68+
putUser(t, db, tenant, user)
69+
return user
70+
}
71+
72+
func newTestRepo(db *storage.Connection) *userRepository {
73+
return &userRepository{db: db, baseURL: core.Join(testExternalURL, BasePath)}
74+
}
75+
76+
func ctxFor(tenant string) context.Context {
77+
return withTenant(context.Background(), tenant)
78+
}
79+
80+
func seedPostgres(t *testing.T, users []*core.User) (Repository[*core.User], context.Context) {
81+
db := newTestDB(t)
82+
owner := newTenant(t, db)
83+
for _, user := range users {
84+
putUser(t, db, owner, user)
85+
}
86+
return newTestRepo(db), ctxFor(owner)
87+
}
88+
89+
func grantToken(t *testing.T, db *storage.Connection, provider string) string {
90+
t.Helper()
91+
92+
token, digest := NewToken()
93+
require.NoError(t, db.RawQuery(
94+
"INSERT INTO scim_tokens (sso_provider_id, token_hash, prefix) VALUES (?, ?, ?)",
95+
provider, digest, token[:12],
96+
).Exec())
97+
98+
return token
99+
}
100+
101+
func userNamesOf(users []*core.User) []string {
102+
names := make([]string, 0, len(users))
103+
for _, user := range users {
104+
names = append(names, user.UserName)
105+
}
106+
return names
107+
}

‎internal/api/scim/middleware.go‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package scim
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/supabase/auth/internal/api/scim/protocol"
11+
)
12+
13+
const bearerScheme = "bearer "
14+
15+
func (srv *Server) Tenant(next http.Handler) http.Handler {
16+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
ctx, ok := srv.tenant(w, r)
18+
if !ok {
19+
return
20+
}
21+
next.ServeHTTP(w, r.WithContext(ctx))
22+
})
23+
}
24+
25+
func (srv *Server) tenant(w http.ResponseWriter, r *http.Request) (context.Context, bool) {
26+
ctx := r.Context()
27+
28+
tenant, err := srv.lookup(ctx, credential(r))
29+
if err != nil {
30+
if errors.Is(err, ErrNotFound) {
31+
_ = srv.unauthorized(w)
32+
} else {
33+
_ = srv.internalError(w, r, err)
34+
}
35+
return nil, false
36+
}
37+
38+
return withTenant(ctx, tenant), true
39+
}
40+
41+
// unauthorized answers a request with no valid tenant, per RFC 7644, Section 3.12 and RFC 6750, Section 3.
42+
func (srv *Server) unauthorized(w http.ResponseWriter) error {
43+
w.Header().Set("WWW-Authenticate", `Bearer realm="SCIM"`)
44+
return protocol.WriteError(w, protocol.ErrUnauthorized("Bearer token is missing or invalid"))
45+
}
46+
47+
// credential is the bearer token a SCIM client authenticates with, per RFC 7644,
48+
// Section 2 and RFC 6750, Section 2.1.
49+
func credential(r *http.Request) string {
50+
header := r.Header.Get("Authorization")
51+
52+
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
53+
return ""
54+
}
55+
return strings.TrimSpace(header[len(bearerScheme):])
56+
}
57+
58+
func (srv *Server) lookup(ctx context.Context, credential string) (string, error) {
59+
if !strings.HasPrefix(credential, TokenPrefix) {
60+
return "", ErrNotFound
61+
}
62+
63+
var rows []scimToken
64+
tokenQuery := `
65+
SELECT t.id, t.sso_provider_id
66+
FROM scim_tokens t
67+
INNER JOIN sso_providers p ON p.id = t.sso_provider_id
68+
WHERE t.token_hash = ?
69+
AND t.revoked_at IS NULL
70+
AND (t.expires_at IS NULL OR t.expires_at > now())
71+
AND (p.disabled IS NULL OR p.disabled = false)
72+
`
73+
74+
err := srv.db.WithContext(ctx).RawQuery(tokenQuery, hashToken(credential)).All(&rows)
75+
if err != nil {
76+
return "", fmt.Errorf("scim: looking up token: %w", err)
77+
}
78+
79+
if len(rows) == 0 {
80+
return "", ErrNotFound
81+
}
82+
83+
return rows[0].SSOProviderID, nil
84+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package scim
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestCredential(t *testing.T) {
12+
for _, tc := range []struct{ name, header, expected string }{
13+
{"a bearer token", "Bearer scim_abc", "scim_abc"},
14+
{"a lowercase scheme, per RFC 7235", "bearer scim_abc", "scim_abc"},
15+
{"a mixed case scheme", "BeArEr scim_abc", "scim_abc"},
16+
{"surrounding whitespace", "Bearer scim_abc ", "scim_abc"},
17+
{"no header at all", "", ""},
18+
{"another scheme", "Basic dXNlcjpwYXNzd29yZA==", ""},
19+
{"the scheme with nothing after it", "Bearer ", ""},
20+
{"the scheme alone", "Bearer", ""},
21+
} {
22+
t.Run(tc.name, func(t *testing.T) {
23+
r := httptest.NewRequest(http.MethodGet, BasePath+"/Users", nil)
24+
if tc.header != "" {
25+
r.Header.Set("Authorization", tc.header)
26+
}
27+
28+
assert.Equal(t, tc.expected, credential(r))
29+
})
30+
}
31+
}

‎internal/api/scim/models.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package scim
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
type scimUser struct {
9+
ID string `db:"id"`
10+
Resource []byte `db:"resource"`
11+
Active bool `db:"active"`
12+
CreatedAt time.Time `db:"created_at"`
13+
UpdatedAt time.Time `db:"updated_at"`
14+
}
15+
16+
func (u *scimUser) ResourceID() string { return u.ID }
17+
18+
func (u *scimUser) Timestamps() (created, updated time.Time) {
19+
return u.CreatedAt, u.UpdatedAt
20+
}
21+
22+
type scimToken struct {
23+
ID string `db:"id"`
24+
SSOProviderID string `db:"sso_provider_id"`
25+
}
26+
27+
// TODO:: Replace with https://github.com/supabase/auth/pull/2677
28+
type tenantKey struct{}
29+
30+
func withTenant(ctx context.Context, tenant string) context.Context {
31+
return context.WithValue(ctx, tenantKey{}, tenant)
32+
}
33+
34+
func tenantFrom(ctx context.Context) (string, bool) {
35+
tenant, ok := ctx.Value(tenantKey{}).(string)
36+
return tenant, ok && tenant != ""
37+
}

0 commit comments

Comments
 (0)