better_auth-scim 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +34 -1
- data/lib/better_auth/plugins/scim.rb +391 -107
- data/lib/better_auth/scim/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8df92e031a036d624d2ecf54d316007b428e789bc991f6bff2a930d04b11b982
|
|
4
|
+
data.tar.gz: 0c1364c9ac343a17cf235b043ab19c0134a3b468716ee6ae2b7ed927352a7563
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5da964f718229da1b262e9e187f334c29d8770b3f7ab8c6f330d8da4533aedbd09913a854a034edfd9aaaa84e14d6fde5a1bf537214aaeac184a79bf97844205
|
|
7
|
+
data.tar.gz: ad7c9cfaad469f3a6c57e272c4ccb4977fb0575c425009352a2778fd9dc90207429cdb8aab84281dd89efcda293dcc24bfb5cf7b7348fbdf16e9bf35c675c797
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-04-29
|
|
4
|
+
|
|
5
|
+
- Aligned SCIM user and group provisioning behavior with upstream Better Auth v1.6.9, including filtering, patch operations, schema responses, error shapes, and token handling.
|
|
6
|
+
- Expanded SCIM documentation and tests for upstream parity flows.
|
|
7
|
+
|
|
3
8
|
## 0.1.0
|
|
4
9
|
|
|
5
10
|
- Initial package skeleton for Better Auth SCIM.
|
data/README.md
CHANGED
|
@@ -4,13 +4,46 @@ External SCIM provisioning plugin package for `better_auth`.
|
|
|
4
4
|
|
|
5
5
|
SCIM is not login. It is a provisioning API used by identity platforms to create, update, deactivate, and list users. It can be used alongside SSO, but it does not depend on SSO.
|
|
6
6
|
|
|
7
|
+
```ruby
|
|
8
|
+
gem "better_auth-scim"
|
|
9
|
+
```
|
|
10
|
+
|
|
7
11
|
```ruby
|
|
8
12
|
require "better_auth"
|
|
9
13
|
require "better_auth/scim"
|
|
10
14
|
|
|
11
15
|
BetterAuth.auth(
|
|
12
16
|
plugins: [
|
|
13
|
-
BetterAuth::Plugins.scim
|
|
17
|
+
BetterAuth::Plugins.scim(
|
|
18
|
+
store_scim_token: "hashed",
|
|
19
|
+
provider_ownership: { enabled: true }
|
|
20
|
+
)
|
|
14
21
|
]
|
|
15
22
|
)
|
|
16
23
|
```
|
|
24
|
+
|
|
25
|
+
Implemented API methods include token generation, provider connection management, SCIM user CRUD, and SCIM metadata endpoints:
|
|
26
|
+
|
|
27
|
+
- `generate_scim_token`
|
|
28
|
+
- `list_scim_provider_connections`
|
|
29
|
+
- `get_scim_provider_connection`
|
|
30
|
+
- `delete_scim_provider_connection`
|
|
31
|
+
- `create_scim_user`
|
|
32
|
+
- `list_scim_users`
|
|
33
|
+
- `get_scim_user`
|
|
34
|
+
- `update_scim_user`
|
|
35
|
+
- `patch_scim_user`
|
|
36
|
+
- `delete_scim_user`
|
|
37
|
+
- `get_scim_service_provider_config`
|
|
38
|
+
- `get_scim_schemas`
|
|
39
|
+
- `get_scim_schema`
|
|
40
|
+
- `get_scim_resource_types`
|
|
41
|
+
- `get_scim_resource_type`
|
|
42
|
+
|
|
43
|
+
Options use Ruby snake_case names: `store_scim_token`, `default_scim`, `provider_ownership`, `required_role`, `before_scim_token_generated`, and `after_scim_token_generated`.
|
|
44
|
+
|
|
45
|
+
The plugin exposes upstream-style surface metadata:
|
|
46
|
+
|
|
47
|
+
- `BetterAuth::Plugins.scim.version` returns the gem SCIM version.
|
|
48
|
+
- `BetterAuth::Plugins.scim.client` returns the Ruby client-plugin descriptor (`scim-client`) for integrations that inspect plugin parity metadata.
|
|
49
|
+
- SCIM protocol routes are hidden from generated OpenAPI output, matching upstream `HIDE_METADATA`; provider management routes remain visible.
|
|
@@ -5,6 +5,11 @@ require "securerandom"
|
|
|
5
5
|
|
|
6
6
|
module BetterAuth
|
|
7
7
|
module Plugins
|
|
8
|
+
SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
|
|
9
|
+
SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
10
|
+
SCIM_USER_SCHEMA_ID = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
11
|
+
SCIM_SUPPORTED_MEDIA_TYPES = ["application/json", "application/scim+json"].freeze
|
|
12
|
+
|
|
8
13
|
module_function
|
|
9
14
|
|
|
10
15
|
remove_method :scim if method_defined?(:scim) || private_method_defined?(:scim)
|
|
@@ -12,11 +17,17 @@ module BetterAuth
|
|
|
12
17
|
|
|
13
18
|
def scim(options = {})
|
|
14
19
|
config = {store_scim_token: "plain"}.merge(normalize_hash(options))
|
|
20
|
+
schema = scim_schema(config)
|
|
15
21
|
Plugin.new(
|
|
16
22
|
id: "scim",
|
|
17
|
-
|
|
23
|
+
version: BetterAuth::SCIM::VERSION,
|
|
24
|
+
client: scim_client,
|
|
25
|
+
schema: schema,
|
|
18
26
|
endpoints: {
|
|
19
27
|
generate_scim_token: scim_generate_token_endpoint(config),
|
|
28
|
+
list_scim_provider_connections: scim_list_provider_connections_endpoint(config),
|
|
29
|
+
get_scim_provider_connection: scim_get_provider_connection_endpoint(config),
|
|
30
|
+
delete_scim_provider_connection: scim_delete_provider_connection_endpoint(config),
|
|
20
31
|
create_scim_user: scim_create_user_endpoint(config),
|
|
21
32
|
update_scim_user: scim_update_user_endpoint(config),
|
|
22
33
|
patch_scim_user: scim_patch_user_endpoint(config),
|
|
@@ -33,126 +44,214 @@ module BetterAuth
|
|
|
33
44
|
)
|
|
34
45
|
end
|
|
35
46
|
|
|
36
|
-
def
|
|
47
|
+
def scim_client
|
|
48
|
+
{
|
|
49
|
+
"id" => "scim-client",
|
|
50
|
+
"version" => BetterAuth::SCIM::VERSION,
|
|
51
|
+
"serverPluginId" => "scim"
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scim_hidden_metadata(summary, allowed_media_types)
|
|
56
|
+
{
|
|
57
|
+
hide: true,
|
|
58
|
+
allowed_media_types: allowed_media_types,
|
|
59
|
+
openapi: {
|
|
60
|
+
summary: summary,
|
|
61
|
+
responses: scim_openapi_responses
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scim_openapi_metadata(summary)
|
|
67
|
+
{
|
|
68
|
+
openapi: {
|
|
69
|
+
summary: summary,
|
|
70
|
+
responses: scim_openapi_responses
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scim_openapi_responses
|
|
76
|
+
{
|
|
77
|
+
"200" => {description: "Success"},
|
|
78
|
+
"400" => {description: "Bad Request"},
|
|
79
|
+
"401" => {description: "Unauthorized"},
|
|
80
|
+
"403" => {description: "Forbidden"},
|
|
81
|
+
"404" => {description: "Not Found"}
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def scim_schema(config = {})
|
|
86
|
+
scim_provider_fields = {
|
|
87
|
+
providerId: {type: "string", required: true, unique: true},
|
|
88
|
+
scimToken: {type: "string", required: true, unique: true},
|
|
89
|
+
organizationId: {type: "string", required: false}
|
|
90
|
+
}
|
|
91
|
+
scim_provider_fields[:userId] = {type: "string", required: false} if scim_provider_ownership_enabled?(config)
|
|
92
|
+
|
|
37
93
|
{
|
|
38
94
|
scimProvider: {
|
|
39
|
-
fields:
|
|
40
|
-
providerId: {type: "string", required: true, unique: true},
|
|
41
|
-
scimToken: {type: "string", required: true, unique: true},
|
|
42
|
-
organizationId: {type: "string", required: false}
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
user: {
|
|
46
|
-
fields: {
|
|
47
|
-
active: {type: "boolean", required: false, default_value: true},
|
|
48
|
-
externalId: {type: "string", required: false}
|
|
49
|
-
}
|
|
95
|
+
fields: scim_provider_fields
|
|
50
96
|
}
|
|
51
97
|
}
|
|
52
98
|
end
|
|
53
99
|
|
|
54
100
|
def scim_generate_token_endpoint(config)
|
|
55
|
-
Endpoint.new(path: "/scim/generate-token", method: "POST") do |ctx|
|
|
101
|
+
Endpoint.new(path: "/scim/generate-token", method: "POST", metadata: scim_openapi_metadata("Generates a new SCIM token for the given provider")) do |ctx|
|
|
56
102
|
session = Routes.current_session(ctx)
|
|
57
103
|
body = normalize_hash(ctx.body)
|
|
58
104
|
provider_id = body[:provider_id].to_s
|
|
59
105
|
organization_id = body[:organization_id]
|
|
106
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["MISSING_FIELD"]) if provider_id.empty?
|
|
60
107
|
raise APIError.new("BAD_REQUEST", message: "Provider id contains forbidden characters") if provider_id.include?(":")
|
|
108
|
+
required_roles = scim_required_roles(ctx, config)
|
|
61
109
|
if organization_id && !scim_has_organization_plugin?(ctx)
|
|
62
110
|
raise APIError.new("BAD_REQUEST", message: "Restricting a token to an organization requires the organization plugin")
|
|
63
111
|
end
|
|
64
112
|
|
|
113
|
+
member = nil
|
|
65
114
|
if organization_id
|
|
66
|
-
member = ctx.
|
|
67
|
-
model: "member",
|
|
68
|
-
where: [
|
|
69
|
-
{field: "userId", value: session.fetch(:user).fetch("id")},
|
|
70
|
-
{field: "organizationId", value: organization_id}
|
|
71
|
-
]
|
|
72
|
-
)
|
|
115
|
+
member = scim_find_organization_member(ctx, session.fetch(:user).fetch("id"), organization_id)
|
|
73
116
|
raise APIError.new("FORBIDDEN", message: "You are not a member of the organization") unless member
|
|
117
|
+
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
74
118
|
end
|
|
75
119
|
|
|
76
|
-
base_token = Crypto.random_string(24)
|
|
77
|
-
token = Base64.urlsafe_encode64([base_token, provider_id, organization_id].compact.join(":"), padding: false)
|
|
78
|
-
stored = scim_store_token(ctx, config, base_token)
|
|
79
120
|
where = [{field: "providerId", value: provider_id}]
|
|
80
121
|
where << {field: "organizationId", value: organization_id} if organization_id
|
|
81
122
|
existing = ctx.context.adapter.find_one(model: "scimProvider", where: where)
|
|
123
|
+
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), existing, required_roles) if existing
|
|
124
|
+
|
|
125
|
+
base_token = Crypto.random_string(24)
|
|
126
|
+
token = Base64.urlsafe_encode64([base_token, provider_id, organization_id].compact.join(":"), padding: false)
|
|
127
|
+
scim_call_token_hook(config[:before_scim_token_generated], user: session.fetch(:user), member: member, scim_token: token)
|
|
128
|
+
stored = scim_store_token(ctx, config, base_token)
|
|
82
129
|
data = {providerId: provider_id, scimToken: stored, organizationId: organization_id}
|
|
130
|
+
data[:userId] = session.fetch(:user).fetch("id") if scim_provider_ownership_enabled?(config)
|
|
83
131
|
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "id", value: existing.fetch("id")}]) if existing
|
|
84
|
-
ctx.context.adapter.create(model: "scimProvider", data: data)
|
|
132
|
+
provider = ctx.context.adapter.create(model: "scimProvider", data: data)
|
|
133
|
+
scim_call_token_hook(config[:after_scim_token_generated], user: session.fetch(:user), member: member, scim_token: token, scim_provider: provider)
|
|
85
134
|
ctx.json({scimToken: token}, status: 201)
|
|
86
135
|
end
|
|
87
136
|
end
|
|
88
137
|
|
|
138
|
+
def scim_list_provider_connections_endpoint(config)
|
|
139
|
+
Endpoint.new(path: "/scim/list-provider-connections", method: "GET", metadata: scim_openapi_metadata("List SCIM provider connections.")) do |ctx|
|
|
140
|
+
session = Routes.current_session(ctx)
|
|
141
|
+
user_id = session.fetch(:user).fetch("id")
|
|
142
|
+
required_roles = scim_required_roles(ctx, config)
|
|
143
|
+
org_memberships = scim_has_organization_plugin?(ctx) ? scim_user_org_memberships(ctx, user_id) : {}
|
|
144
|
+
providers = ctx.context.adapter.find_many(model: "scimProvider").select do |provider|
|
|
145
|
+
organization_id = provider["organizationId"]
|
|
146
|
+
if organization_id
|
|
147
|
+
member = org_memberships[organization_id]
|
|
148
|
+
member && scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
149
|
+
else
|
|
150
|
+
!provider.key?("userId") || provider["userId"].nil? || provider["userId"] == user_id
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
ctx.json({providers: providers.map { |provider| scim_normalized_provider(provider) }})
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def scim_get_provider_connection_endpoint(config)
|
|
158
|
+
Endpoint.new(path: "/scim/get-provider-connection", method: "GET", metadata: scim_openapi_metadata("Get SCIM provider connection.")) do |ctx|
|
|
159
|
+
session = Routes.current_session(ctx)
|
|
160
|
+
provider = scim_provider_by_provider_id!(ctx, ctx.query[:provider_id] || ctx.query["providerId"] || ctx.query["provider_id"])
|
|
161
|
+
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), provider, scim_required_roles(ctx, config))
|
|
162
|
+
ctx.json(scim_normalized_provider(provider))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def scim_delete_provider_connection_endpoint(config)
|
|
167
|
+
Endpoint.new(path: "/scim/delete-provider-connection", method: "POST", metadata: scim_openapi_metadata("Delete SCIM provider connection.")) do |ctx|
|
|
168
|
+
session = Routes.current_session(ctx)
|
|
169
|
+
body = normalize_hash(ctx.body)
|
|
170
|
+
provider = scim_provider_by_provider_id!(ctx, body[:provider_id])
|
|
171
|
+
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), provider, scim_required_roles(ctx, config))
|
|
172
|
+
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
173
|
+
ctx.json({success: true})
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
89
177
|
def scim_create_user_endpoint(config)
|
|
90
|
-
Endpoint.new(path: "/scim/v2/Users", method: "POST", use: [scim_auth_middleware(config)]) do |ctx|
|
|
178
|
+
Endpoint.new(path: "/scim/v2/Users", method: "POST", metadata: scim_hidden_metadata("Create SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
91
179
|
body = normalize_hash(ctx.body)
|
|
180
|
+
scim_validate_user_body!(body)
|
|
92
181
|
provider = ctx.context.scim_provider
|
|
93
182
|
provider_id = provider.fetch("providerId")
|
|
94
183
|
email = scim_primary_email(body).downcase
|
|
95
184
|
account_id = scim_account_id(body)
|
|
96
185
|
existing_account = ctx.context.adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
|
|
97
|
-
raise
|
|
98
|
-
|
|
99
|
-
user = ctx.context.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
186
|
+
raise scim_error("CONFLICT", "User already exists", scim_type: "uniqueness") if existing_account
|
|
187
|
+
|
|
188
|
+
user, account = ctx.context.adapter.transaction do
|
|
189
|
+
user = ctx.context.internal_adapter.find_user_by_email(email)&.fetch(:user)
|
|
190
|
+
user ||= ctx.context.internal_adapter.create_user(
|
|
191
|
+
email: email,
|
|
192
|
+
name: scim_display_name(body, email),
|
|
193
|
+
emailVerified: true
|
|
194
|
+
)
|
|
195
|
+
account = ctx.context.internal_adapter.create_account(
|
|
196
|
+
userId: user.fetch("id"),
|
|
197
|
+
providerId: provider_id,
|
|
198
|
+
accountId: account_id,
|
|
199
|
+
accessToken: "",
|
|
200
|
+
refreshToken: ""
|
|
201
|
+
)
|
|
202
|
+
scim_create_org_membership(ctx, user.fetch("id"), provider["organizationId"])
|
|
203
|
+
[user, account]
|
|
204
|
+
end
|
|
205
|
+
resource = scim_user_resource(user, account, ctx.context.base_url)
|
|
206
|
+
ctx.json(resource, status: 201, headers: {location: resource.fetch(:meta).fetch(:location)})
|
|
116
207
|
end
|
|
117
208
|
end
|
|
118
209
|
|
|
119
210
|
def scim_update_user_endpoint(config)
|
|
120
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "PUT", use: [scim_auth_middleware(config)]) do |ctx|
|
|
211
|
+
Endpoint.new(path: "/scim/v2/Users/:userId", method: "PUT", metadata: scim_hidden_metadata("Update SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
121
212
|
user, account = scim_find_user_with_account!(ctx)
|
|
122
213
|
body = normalize_hash(ctx.body)
|
|
123
|
-
|
|
124
|
-
updated_account = ctx.context.
|
|
214
|
+
scim_validate_user_body!(body)
|
|
215
|
+
updated, updated_account = ctx.context.adapter.transaction do
|
|
216
|
+
[
|
|
217
|
+
ctx.context.internal_adapter.update_user(user.fetch("id"), scim_user_update(body)),
|
|
218
|
+
ctx.context.internal_adapter.update_account(account.fetch("id"), accountId: scim_account_id(body), updatedAt: Time.now)
|
|
219
|
+
]
|
|
220
|
+
end
|
|
125
221
|
ctx.json(scim_user_resource(updated, updated_account, ctx.context.base_url))
|
|
126
222
|
end
|
|
127
223
|
end
|
|
128
224
|
|
|
129
225
|
def scim_patch_user_endpoint(config)
|
|
130
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "PATCH", use: [scim_auth_middleware(config)]) do |ctx|
|
|
226
|
+
Endpoint.new(path: "/scim/v2/Users/:userId", method: "PATCH", metadata: scim_hidden_metadata("Patch SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
131
227
|
user, account = scim_find_user_with_account!(ctx)
|
|
228
|
+
body = normalize_hash(ctx.body)
|
|
229
|
+
scim_validate_patch_body!(body)
|
|
132
230
|
update = {}
|
|
133
231
|
account_update = {}
|
|
134
|
-
Array(
|
|
232
|
+
Array(body[:operations] || ctx.body["Operations"]).each do |operation|
|
|
135
233
|
op = normalize_hash(operation)
|
|
136
|
-
operation_name = op[:op].to_s.downcase
|
|
137
|
-
raise
|
|
234
|
+
operation_name = op[:op].to_s.empty? ? "replace" : op[:op].to_s.downcase
|
|
235
|
+
raise scim_error("BAD_REQUEST", "Invalid SCIM patch operation") unless %w[replace add remove].include?(operation_name)
|
|
138
236
|
|
|
139
|
-
if op[:
|
|
140
|
-
|
|
237
|
+
if op[:value].is_a?(Hash)
|
|
238
|
+
patch_path = op[:path].to_s.empty? ? nil : op[:path]
|
|
239
|
+
scim_apply_patch_value!(user, update, account_update, normalize_hash(op[:value]), operation_name, patch_path)
|
|
141
240
|
next
|
|
142
241
|
end
|
|
143
242
|
|
|
144
243
|
scim_apply_patch_path!(user, update, account_update, op[:path], op[:value], operation_name)
|
|
145
244
|
end
|
|
146
|
-
raise
|
|
245
|
+
raise scim_error("BAD_REQUEST", "No valid fields to update") if update.empty? && account_update.empty?
|
|
147
246
|
|
|
148
|
-
ctx.context.internal_adapter.update_user(user.fetch("id"), update) unless update.empty?
|
|
149
|
-
ctx.context.internal_adapter.update_account(account.fetch("id"), account_update) unless account_update.empty?
|
|
247
|
+
ctx.context.internal_adapter.update_user(user.fetch("id"), update.merge(updatedAt: Time.now)) unless update.empty?
|
|
248
|
+
ctx.context.internal_adapter.update_account(account.fetch("id"), account_update.merge(updatedAt: Time.now)) unless account_update.empty?
|
|
150
249
|
ctx.json(nil, status: 204)
|
|
151
250
|
end
|
|
152
251
|
end
|
|
153
252
|
|
|
154
253
|
def scim_delete_user_endpoint(config)
|
|
155
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "DELETE", metadata:
|
|
254
|
+
Endpoint.new(path: "/scim/v2/Users/:userId", method: "DELETE", metadata: scim_hidden_metadata("Delete SCIM user.", [*SCIM_SUPPORTED_MEDIA_TYPES, ""]), use: [scim_auth_middleware(config)]) do |ctx|
|
|
156
255
|
user, = scim_find_user_with_account!(ctx)
|
|
157
256
|
ctx.context.internal_adapter.delete_user(user.fetch("id"))
|
|
158
257
|
ctx.json(nil, status: 204)
|
|
@@ -160,7 +259,7 @@ module BetterAuth
|
|
|
160
259
|
end
|
|
161
260
|
|
|
162
261
|
def scim_list_users_endpoint(config)
|
|
163
|
-
Endpoint.new(path: "/scim/v2/Users", method: "GET", use: [scim_auth_middleware(config)]) do |ctx|
|
|
262
|
+
Endpoint.new(path: "/scim/v2/Users", method: "GET", metadata: scim_hidden_metadata("List SCIM users.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
164
263
|
provider = ctx.context.scim_provider
|
|
165
264
|
accounts = ctx.context.adapter.find_many(model: "account", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
166
265
|
users_by_id = ctx.context.internal_adapter.list_users.each_with_object({}) { |user, result| result[user.fetch("id")] = user }
|
|
@@ -180,72 +279,86 @@ module BetterAuth
|
|
|
180
279
|
|
|
181
280
|
(resource[filter_field.to_sym].to_s.downcase == filter_value.to_s.downcase) ? resource : nil
|
|
182
281
|
end
|
|
183
|
-
ctx.json({schemas: [
|
|
282
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
|
|
184
283
|
end
|
|
185
284
|
end
|
|
186
285
|
|
|
187
286
|
def scim_get_user_endpoint(config)
|
|
188
|
-
Endpoint.new(path: "/scim/v2/Users/:userId", method: "GET", use: [scim_auth_middleware(config)]) do |ctx|
|
|
287
|
+
Endpoint.new(path: "/scim/v2/Users/:userId", method: "GET", metadata: scim_hidden_metadata("Get SCIM user.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
189
288
|
user, account = scim_find_user_with_account!(ctx)
|
|
190
289
|
ctx.json(scim_user_resource(user, account, ctx.context.base_url))
|
|
191
290
|
end
|
|
192
291
|
end
|
|
193
292
|
|
|
194
293
|
def scim_service_provider_config_endpoint
|
|
195
|
-
Endpoint.new(path: "/scim/v2/ServiceProviderConfig", method: "GET") do |ctx|
|
|
294
|
+
Endpoint.new(path: "/scim/v2/ServiceProviderConfig", method: "GET", metadata: scim_hidden_metadata("SCIM Service Provider Configuration", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
196
295
|
ctx.json({
|
|
197
296
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
198
297
|
patch: {supported: true},
|
|
199
|
-
|
|
298
|
+
bulk: {supported: false},
|
|
299
|
+
filter: {supported: true},
|
|
200
300
|
changePassword: {supported: false},
|
|
201
301
|
sort: {supported: false},
|
|
202
302
|
etag: {supported: false},
|
|
203
|
-
authenticationSchemes: [{
|
|
303
|
+
authenticationSchemes: [{
|
|
304
|
+
type: "oauthbearertoken",
|
|
305
|
+
name: "OAuth Bearer Token",
|
|
306
|
+
description: "Authentication scheme using the Authorization header with a bearer token tied to an organization.",
|
|
307
|
+
specUri: "http://www.rfc-editor.org/info/rfc6750",
|
|
308
|
+
primary: true
|
|
309
|
+
}],
|
|
310
|
+
meta: {resourceType: "ServiceProviderConfig"}
|
|
204
311
|
})
|
|
205
312
|
end
|
|
206
313
|
end
|
|
207
314
|
|
|
208
315
|
def scim_schemas_endpoint
|
|
209
|
-
Endpoint.new(path: "/scim/v2/Schemas", method: "GET") do |ctx|
|
|
210
|
-
ctx.
|
|
316
|
+
Endpoint.new(path: "/scim/v2/Schemas", method: "GET", metadata: scim_hidden_metadata("List SCIM schemas.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
317
|
+
resource = scim_user_schema(ctx.context.base_url)
|
|
318
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], Resources: [resource], totalResults: 1, itemsPerPage: 1, startIndex: 1})
|
|
211
319
|
end
|
|
212
320
|
end
|
|
213
321
|
|
|
214
322
|
def scim_schema_endpoint
|
|
215
|
-
Endpoint.new(path: "/scim/v2/Schemas/:schemaId", method: "GET") do |ctx|
|
|
216
|
-
raise
|
|
323
|
+
Endpoint.new(path: "/scim/v2/Schemas/:schemaId", method: "GET", metadata: scim_hidden_metadata("Get SCIM schema.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
324
|
+
raise scim_error("NOT_FOUND", "Schema not found") unless scim_param(ctx, :schema_id).to_s == SCIM_USER_SCHEMA_ID
|
|
217
325
|
|
|
218
|
-
ctx.json(scim_user_schema)
|
|
326
|
+
ctx.json(scim_user_schema(ctx.context.base_url))
|
|
219
327
|
end
|
|
220
328
|
end
|
|
221
329
|
|
|
222
330
|
def scim_resource_types_endpoint
|
|
223
|
-
Endpoint.new(path: "/scim/v2/ResourceTypes", method: "GET") do |ctx|
|
|
224
|
-
ctx.
|
|
331
|
+
Endpoint.new(path: "/scim/v2/ResourceTypes", method: "GET", metadata: scim_hidden_metadata("List SCIM resource types.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
332
|
+
resource = scim_user_resource_type(ctx.context.base_url)
|
|
333
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], Resources: [resource], totalResults: 1, itemsPerPage: 1, startIndex: 1})
|
|
225
334
|
end
|
|
226
335
|
end
|
|
227
336
|
|
|
228
337
|
def scim_resource_type_endpoint
|
|
229
|
-
Endpoint.new(path: "/scim/v2/ResourceTypes/:resourceTypeId", method: "GET") do |ctx|
|
|
230
|
-
raise
|
|
338
|
+
Endpoint.new(path: "/scim/v2/ResourceTypes/:resourceTypeId", method: "GET", metadata: scim_hidden_metadata("Get SCIM resource type.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
339
|
+
raise scim_error("NOT_FOUND", "Resource type not found") unless scim_param(ctx, :resource_type_id) == "User"
|
|
231
340
|
|
|
232
|
-
ctx.json(scim_user_resource_type)
|
|
341
|
+
ctx.json(scim_user_resource_type(ctx.context.base_url))
|
|
233
342
|
end
|
|
234
343
|
end
|
|
235
344
|
|
|
236
345
|
def scim_auth_middleware(config)
|
|
237
346
|
lambda do |ctx|
|
|
238
347
|
encoded = ctx.headers["authorization"].to_s.sub(/\ABearer\s+/i, "")
|
|
239
|
-
raise
|
|
348
|
+
raise scim_error("UNAUTHORIZED", "SCIM token is required") if encoded.empty?
|
|
240
349
|
|
|
241
350
|
token, provider_id, organization_id = scim_decode_token(encoded)
|
|
242
|
-
provider = scim_default_provider(config,
|
|
243
|
-
|
|
351
|
+
provider = scim_default_provider(config, provider_id, organization_id)
|
|
352
|
+
if provider
|
|
353
|
+
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider.fetch("scimToken") == token
|
|
354
|
+
else
|
|
355
|
+
provider = ctx.context.adapter.find_one(
|
|
244
356
|
model: "scimProvider",
|
|
245
357
|
where: [{field: "providerId", value: provider_id}].tap { |where| where << {field: "organizationId", value: organization_id} if organization_id }
|
|
246
358
|
)
|
|
247
|
-
|
|
248
|
-
|
|
359
|
+
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider
|
|
360
|
+
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless scim_token_matches?(ctx, config, token, provider.fetch("scimToken"))
|
|
361
|
+
end
|
|
249
362
|
|
|
250
363
|
ctx.context.apply_plugin_context!(scim_provider: provider)
|
|
251
364
|
nil
|
|
@@ -255,7 +368,7 @@ module BetterAuth
|
|
|
255
368
|
def scim_store_token(ctx, config, token)
|
|
256
369
|
storage = config[:store_scim_token]
|
|
257
370
|
if storage == "hashed"
|
|
258
|
-
Crypto.sha256(token)
|
|
371
|
+
Crypto.sha256(token, encoding: :base64url)
|
|
259
372
|
elsif storage == "encrypted"
|
|
260
373
|
Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
|
|
261
374
|
elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
|
|
@@ -278,18 +391,20 @@ module BetterAuth
|
|
|
278
391
|
def scim_decode_token(encoded)
|
|
279
392
|
decoded = Base64.urlsafe_decode64(encoded.to_s)
|
|
280
393
|
token, provider_id, *organization_parts = decoded.split(":")
|
|
281
|
-
raise
|
|
394
|
+
raise scim_error("UNAUTHORIZED", "Invalid SCIM token") if token.to_s.empty? || provider_id.to_s.empty?
|
|
282
395
|
|
|
283
396
|
[token, provider_id, organization_parts.join(":").then { |value| value.empty? ? nil : value }]
|
|
284
397
|
rescue ArgumentError
|
|
285
|
-
raise
|
|
398
|
+
raise scim_error("UNAUTHORIZED", "Invalid SCIM token")
|
|
286
399
|
end
|
|
287
400
|
|
|
288
|
-
def scim_default_provider(config,
|
|
401
|
+
def scim_default_provider(config, provider_id, organization_id)
|
|
289
402
|
Array(config[:default_scim]).find do |provider|
|
|
290
403
|
candidate = normalize_hash(provider)
|
|
404
|
+
next true if candidate[:provider_id].to_s == provider_id.to_s && organization_id.to_s.empty?
|
|
405
|
+
|
|
291
406
|
candidate[:provider_id].to_s == provider_id.to_s &&
|
|
292
|
-
|
|
407
|
+
!organization_id.to_s.empty? &&
|
|
293
408
|
candidate[:organization_id].to_s == organization_id.to_s
|
|
294
409
|
end&.then do |provider|
|
|
295
410
|
data = normalize_hash(provider)
|
|
@@ -315,7 +430,7 @@ module BetterAuth
|
|
|
315
430
|
)
|
|
316
431
|
user = nil unless member
|
|
317
432
|
end
|
|
318
|
-
raise
|
|
433
|
+
raise scim_error("NOT_FOUND", "User not found") unless user && account
|
|
319
434
|
|
|
320
435
|
[user, account]
|
|
321
436
|
end
|
|
@@ -324,11 +439,31 @@ module BetterAuth
|
|
|
324
439
|
{
|
|
325
440
|
email: scim_primary_email(body)&.downcase,
|
|
326
441
|
name: scim_display_name(body, body[:user_name].to_s),
|
|
327
|
-
|
|
328
|
-
externalId: body[:external_id]
|
|
442
|
+
updatedAt: Time.now
|
|
329
443
|
}.compact
|
|
330
444
|
end
|
|
331
445
|
|
|
446
|
+
def scim_validate_user_body!(body)
|
|
447
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body[:user_name].to_s.empty?
|
|
448
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:external_id) && !body[:external_id].is_a?(String)
|
|
449
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:name) && !body[:name].is_a?(Hash)
|
|
450
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:emails) && !body[:emails].is_a?(Array)
|
|
451
|
+
|
|
452
|
+
Array(body[:emails]).each do |email|
|
|
453
|
+
email = normalize_hash(email)
|
|
454
|
+
value = email[:value]
|
|
455
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if email.key?(:primary) && ![true, false].include?(email[:primary])
|
|
456
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def scim_validate_patch_body!(body)
|
|
461
|
+
schemas = Array(body[:schemas])
|
|
462
|
+
return if schemas.include?("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
|
463
|
+
|
|
464
|
+
raise scim_error("BAD_REQUEST", "Invalid schemas for PatchOp")
|
|
465
|
+
end
|
|
466
|
+
|
|
332
467
|
def scim_apply_patch_value!(user, update, account_update, value, operation_name, path = nil)
|
|
333
468
|
value.each do |key, nested_value|
|
|
334
469
|
nested_key = Schema.storage_key(key)
|
|
@@ -345,22 +480,29 @@ module BetterAuth
|
|
|
345
480
|
remove = operation_name == "remove"
|
|
346
481
|
normalized = "/" + path.to_s.sub(%r{\A/+}, "").tr(".", "/")
|
|
347
482
|
case normalized
|
|
348
|
-
when "/active"
|
|
349
|
-
update[:active] = remove ? nil : value
|
|
350
483
|
when "/userName"
|
|
351
|
-
|
|
484
|
+
new_value = value.to_s.downcase
|
|
485
|
+
update[:email] = new_value if scim_patch_should_apply?(user["email"], new_value, operation_name)
|
|
352
486
|
when "/externalId"
|
|
353
|
-
account_update[:accountId] =
|
|
354
|
-
update[:externalId] = remove ? nil : value
|
|
487
|
+
account_update[:accountId] = value unless remove
|
|
355
488
|
when "/name/formatted"
|
|
356
|
-
update[:name] = value
|
|
489
|
+
update[:name] = value if scim_patch_should_apply?(user["name"], value, operation_name)
|
|
357
490
|
when "/name/givenName"
|
|
358
|
-
|
|
491
|
+
new_value = scim_full_name(user.fetch("email"), given_name: value, family_name: scim_family_name(update[:name] || user["name"]))
|
|
492
|
+
update[:name] = new_value if scim_patch_should_apply?(user["name"], new_value, operation_name)
|
|
359
493
|
when "/name/familyName"
|
|
360
|
-
|
|
494
|
+
new_value = scim_full_name(user.fetch("email"), given_name: scim_given_name(update[:name] || user["name"]), family_name: value)
|
|
495
|
+
update[:name] = new_value if scim_patch_should_apply?(user["name"], new_value, operation_name)
|
|
361
496
|
end
|
|
362
497
|
end
|
|
363
498
|
|
|
499
|
+
def scim_patch_should_apply?(current_value, new_value, operation_name)
|
|
500
|
+
return false if operation_name == "remove"
|
|
501
|
+
return false if operation_name == "add" && current_value == new_value
|
|
502
|
+
|
|
503
|
+
true
|
|
504
|
+
end
|
|
505
|
+
|
|
364
506
|
def scim_display_name(body, fallback = nil)
|
|
365
507
|
name = normalize_hash(body[:name] || {})
|
|
366
508
|
return name[:formatted].to_s.strip unless name[:formatted].to_s.strip.empty?
|
|
@@ -370,36 +512,87 @@ module BetterAuth
|
|
|
370
512
|
|
|
371
513
|
def scim_user_resource(user, account = nil, base_url = nil)
|
|
372
514
|
{
|
|
373
|
-
schemas: [
|
|
515
|
+
schemas: [SCIM_USER_SCHEMA_ID],
|
|
374
516
|
id: user.fetch("id"),
|
|
375
517
|
userName: user.fetch("email"),
|
|
376
|
-
externalId: account&.fetch("accountId", nil)
|
|
518
|
+
externalId: account&.fetch("accountId", nil),
|
|
377
519
|
displayName: user["name"],
|
|
378
|
-
active:
|
|
520
|
+
active: true,
|
|
379
521
|
name: {formatted: user["name"]},
|
|
380
522
|
emails: [{primary: true, value: user.fetch("email")}],
|
|
381
|
-
meta: {
|
|
523
|
+
meta: {
|
|
524
|
+
resourceType: "User",
|
|
525
|
+
created: user["createdAt"],
|
|
526
|
+
lastModified: user["updatedAt"],
|
|
527
|
+
location: base_url ? "#{base_url}/scim/v2/Users/#{user.fetch("id")}" : nil
|
|
528
|
+
}.compact
|
|
382
529
|
}.compact
|
|
383
530
|
end
|
|
384
531
|
|
|
385
532
|
def scim_parse_filter(filter)
|
|
386
533
|
match = filter.to_s.match(/\A\s*([^\s]+)\s+(eq|ne|co|sw|ew|pr)\s*(?:"([^"]*)"|([^\s]+))?\s*\z/i)
|
|
387
|
-
raise
|
|
534
|
+
raise scim_error("BAD_REQUEST", "Invalid SCIM filter", scim_type: "invalidFilter") unless match
|
|
388
535
|
|
|
389
536
|
field = match[1]
|
|
390
537
|
operator = match[2].downcase
|
|
391
|
-
raise
|
|
392
|
-
raise
|
|
538
|
+
raise scim_error("BAD_REQUEST", "The operator \"#{operator}\" is not supported", scim_type: "invalidFilter") unless operator == "eq"
|
|
539
|
+
raise scim_error("BAD_REQUEST", "The attribute \"#{field}\" is not supported", scim_type: "invalidFilter") unless field == "userName"
|
|
393
540
|
|
|
394
541
|
[field, match[3] || match[4]]
|
|
395
542
|
end
|
|
396
543
|
|
|
397
|
-
def scim_user_schema
|
|
398
|
-
{
|
|
544
|
+
def scim_user_schema(base_url = nil)
|
|
545
|
+
{
|
|
546
|
+
id: SCIM_USER_SCHEMA_ID,
|
|
547
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
|
548
|
+
name: "User",
|
|
549
|
+
description: "User Account",
|
|
550
|
+
attributes: [
|
|
551
|
+
{name: "id", type: "string", multiValued: false, description: "Unique opaque identifier for the User", required: false, caseExact: true, mutability: "readOnly", returned: "default", uniqueness: "server"},
|
|
552
|
+
{name: "userName", type: "string", multiValued: false, description: "Unique identifier for the User, typically used by the user to directly authenticate to the service provider", required: true, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server"},
|
|
553
|
+
{name: "displayName", type: "string", multiValued: false, description: "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described, if known.", required: false, caseExact: true, mutability: "readOnly", returned: "default", uniqueness: "none"},
|
|
554
|
+
{name: "active", type: "boolean", multiValued: false, description: "A Boolean value indicating the User's administrative status.", required: false, mutability: "readOnly", returned: "default"},
|
|
555
|
+
{
|
|
556
|
+
name: "name",
|
|
557
|
+
type: "complex",
|
|
558
|
+
multiValued: false,
|
|
559
|
+
description: "The components of the user's real name.",
|
|
560
|
+
required: false,
|
|
561
|
+
subAttributes: [
|
|
562
|
+
{name: "formatted", type: "string", multiValued: false, description: "The full name, including all middlenames, titles, and suffixes as appropriate, formatted for display(e.g., 'Ms. Barbara J Jensen, III').", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "none"},
|
|
563
|
+
{name: "familyName", type: "string", multiValued: false, description: "The family name of the User, or last name in most Western languages (e.g., 'Jensen' given the fullname 'Ms. Barbara J Jensen, III').", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "none"},
|
|
564
|
+
{name: "givenName", type: "string", multiValued: false, description: "The given name of the User, or first name in most Western languages (e.g., 'Barbara' given the full name 'Ms. Barbara J Jensen, III').", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "none"}
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "emails",
|
|
569
|
+
type: "complex",
|
|
570
|
+
multiValued: true,
|
|
571
|
+
description: "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.",
|
|
572
|
+
required: false,
|
|
573
|
+
mutability: "readWrite",
|
|
574
|
+
returned: "default",
|
|
575
|
+
uniqueness: "none",
|
|
576
|
+
subAttributes: [
|
|
577
|
+
{name: "value", type: "string", multiValued: false, description: "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.", required: false, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server"},
|
|
578
|
+
{name: "primary", type: "boolean", multiValued: false, description: "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary email address. The primary attribute value 'true' MUST appear no more than once.", required: false, mutability: "readWrite", returned: "default"}
|
|
579
|
+
]
|
|
580
|
+
}
|
|
581
|
+
],
|
|
582
|
+
meta: {resourceType: "Schema", location: scim_resource_url(base_url, "/scim/v2/Schemas/#{SCIM_USER_SCHEMA_ID}")}
|
|
583
|
+
}
|
|
399
584
|
end
|
|
400
585
|
|
|
401
|
-
def scim_user_resource_type
|
|
402
|
-
{
|
|
586
|
+
def scim_user_resource_type(base_url = nil)
|
|
587
|
+
{
|
|
588
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
589
|
+
id: "User",
|
|
590
|
+
name: "User",
|
|
591
|
+
endpoint: "/Users",
|
|
592
|
+
description: "User Account",
|
|
593
|
+
schema: SCIM_USER_SCHEMA_ID,
|
|
594
|
+
meta: {resourceType: "ResourceType", location: scim_resource_url(base_url, "/scim/v2/ResourceTypes/User")}
|
|
595
|
+
}
|
|
403
596
|
end
|
|
404
597
|
|
|
405
598
|
def scim_param(ctx, key)
|
|
@@ -410,6 +603,97 @@ module BetterAuth
|
|
|
410
603
|
Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
|
|
411
604
|
end
|
|
412
605
|
|
|
606
|
+
def scim_organization_plugin(ctx)
|
|
607
|
+
Array(ctx.context.options.plugins).find { |plugin| plugin.id == "organization" }
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def scim_required_roles(ctx, config)
|
|
611
|
+
configured = config[:required_role] || config[:required_roles]
|
|
612
|
+
return Array(configured).map(&:to_s) if configured
|
|
613
|
+
|
|
614
|
+
creator_role = scim_organization_plugin(ctx)&.options&.fetch(:creator_role, nil)
|
|
615
|
+
["admin", creator_role || "owner"].uniq
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def scim_provider_ownership_enabled?(config)
|
|
619
|
+
normalize_hash(config[:provider_ownership] || {})[:enabled] == true
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def scim_find_organization_member(ctx, user_id, organization_id)
|
|
623
|
+
ctx.context.adapter.find_one(
|
|
624
|
+
model: "member",
|
|
625
|
+
where: [
|
|
626
|
+
{field: "userId", value: user_id},
|
|
627
|
+
{field: "organizationId", value: organization_id}
|
|
628
|
+
]
|
|
629
|
+
)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def scim_parse_roles(role)
|
|
633
|
+
Array(role).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def scim_has_required_role?(role, required_roles)
|
|
637
|
+
required = Array(required_roles).map(&:to_s)
|
|
638
|
+
required.empty? || scim_parse_roles(role).any? { |candidate| required.include?(candidate) }
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def scim_user_org_memberships(ctx, user_id)
|
|
642
|
+
ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}]).each_with_object({}) do |member, result|
|
|
643
|
+
result[member.fetch("organizationId")] = member
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def scim_assert_provider_access!(ctx, user_id, provider, required_roles)
|
|
648
|
+
return unless provider
|
|
649
|
+
|
|
650
|
+
organization_id = provider["organizationId"]
|
|
651
|
+
if organization_id
|
|
652
|
+
raise APIError.new("FORBIDDEN", message: "Organization plugin is required to access this SCIM provider") unless scim_has_organization_plugin?(ctx)
|
|
653
|
+
|
|
654
|
+
member = scim_find_organization_member(ctx, user_id, organization_id)
|
|
655
|
+
raise APIError.new("FORBIDDEN", message: "You must be a member of the organization to access this provider") unless member
|
|
656
|
+
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
657
|
+
elsif provider.key?("userId") && provider["userId"] && provider["userId"] != user_id
|
|
658
|
+
raise APIError.new("FORBIDDEN", message: "You must be the owner to access this provider")
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def scim_provider_by_provider_id!(ctx, provider_id)
|
|
663
|
+
provider = ctx.context.adapter.find_one(model: "scimProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
664
|
+
raise APIError.new("NOT_FOUND", message: "SCIM provider not found") unless provider
|
|
665
|
+
|
|
666
|
+
provider
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def scim_normalized_provider(provider)
|
|
670
|
+
{
|
|
671
|
+
id: provider.fetch("id"),
|
|
672
|
+
providerId: provider.fetch("providerId"),
|
|
673
|
+
organizationId: provider["organizationId"]
|
|
674
|
+
}
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def scim_call_token_hook(callback, payload)
|
|
678
|
+
callback.call(payload) if callback.respond_to?(:call)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def scim_error(status, detail, scim_type: nil)
|
|
682
|
+
body = {
|
|
683
|
+
schemas: [SCIM_ERROR_SCHEMA],
|
|
684
|
+
status: APIError::STATUS_CODES.fetch(status.to_s.upcase, 500).to_s,
|
|
685
|
+
detail: detail
|
|
686
|
+
}
|
|
687
|
+
body[:scimType] = scim_type if scim_type
|
|
688
|
+
APIError.new(status, message: detail, body: body)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def scim_resource_url(base_url, path)
|
|
692
|
+
return path unless base_url
|
|
693
|
+
|
|
694
|
+
"#{base_url}#{path}"
|
|
695
|
+
end
|
|
696
|
+
|
|
413
697
|
def scim_create_org_membership(ctx, user_id, organization_id)
|
|
414
698
|
return unless organization_id
|
|
415
699
|
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])
|
|
@@ -418,7 +702,7 @@ module BetterAuth
|
|
|
418
702
|
end
|
|
419
703
|
|
|
420
704
|
def scim_account_id(body)
|
|
421
|
-
body[:external_id] || body[:user_name]
|
|
705
|
+
body[:external_id] || body[:user_name].to_s.downcase
|
|
422
706
|
end
|
|
423
707
|
|
|
424
708
|
def scim_primary_email(body)
|