better_auth-scim 0.5.0 → 0.7.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 +7 -0
- data/README.md +6 -1
- data/lib/better_auth/plugins/scim.rb +3 -1
- data/lib/better_auth/scim/middlewares.rb +5 -1
- data/lib/better_auth/scim/provider_management.rb +99 -0
- data/lib/better_auth/scim/routes.rb +35 -159
- data/lib/better_auth/scim/scim_tokens.rb +9 -3
- data/lib/better_auth/scim/validation.rb +51 -0
- data/lib/better_auth/scim/version.rb +1 -1
- data/lib/better_auth/scim.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65e35401e83150cb6dbd98d3b47c19a61d6c7bcca3c1310c40dec3f42e3ecbaa
|
|
4
|
+
data.tar.gz: 66a37c96b0eff2236194fce3d916930c83f555cc73e450681ca9540214154404
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 531454f782e6930da56af377970a36b075b74b878594d79b550e87d750949bc1b856e25301c164c31ee55d476363d0b90ecac6b2d7271aa24fe423f8d42fe822
|
|
7
|
+
data.tar.gz: 0fe6821fab2facb5ebfdd333a7e4eda13205f325ef5235a5d822319cb7ccfba855aea4837943d8379400ad8ea0f1610b3c62df1f3f710589d18446deba67e300
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.7.0 - 2026-05-05
|
|
6
|
+
|
|
7
|
+
- Changed generated SCIM provider tokens to use hashed storage by default. Set `store_scim_token: "plain"` only when plaintext database storage is intentionally required.
|
|
8
|
+
- Split provider management and validation flows and hardened SCIM user listing, patch handling, and auth error responses.
|
|
9
|
+
|
|
3
10
|
## 0.2.0 - 2026-04-29
|
|
4
11
|
|
|
5
12
|
- 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.
|
data/README.md
CHANGED
|
@@ -15,7 +15,6 @@ require "better_auth/scim"
|
|
|
15
15
|
BetterAuth.auth(
|
|
16
16
|
plugins: [
|
|
17
17
|
BetterAuth::Plugins.scim(
|
|
18
|
-
store_scim_token: "hashed",
|
|
19
18
|
provider_ownership: { enabled: true }
|
|
20
19
|
)
|
|
21
20
|
]
|
|
@@ -41,9 +40,15 @@ Implemented API methods include token generation, provider connection management
|
|
|
41
40
|
- `get_scim_resource_type`
|
|
42
41
|
|
|
43
42
|
Options use Ruby snake_case names: `store_scim_token`, `default_scim`, `provider_ownership`, `required_role`, `before_scim_token_generated`, and `after_scim_token_generated`.
|
|
43
|
+
`store_scim_token` defaults to `"hashed"` so generated SCIM provider tokens are
|
|
44
|
+
not stored in plaintext.
|
|
44
45
|
|
|
45
46
|
The plugin exposes upstream-style surface metadata:
|
|
46
47
|
|
|
47
48
|
- `BetterAuth::Plugins.scim.version` returns the gem SCIM version.
|
|
48
49
|
- `BetterAuth::Plugins.scim.client` returns the Ruby client-plugin descriptor (`scim-client`) for integrations that inspect plugin parity metadata.
|
|
49
50
|
- SCIM protocol routes are hidden from generated OpenAPI output, matching upstream `HIDE_METADATA`; provider management routes remain visible.
|
|
51
|
+
|
|
52
|
+
## Production recommendations
|
|
53
|
+
|
|
54
|
+
- In the accounts table (`accounts` or the configured table name), use a unique composite index on `(providerId, accountId)` to prevent duplicate SCIM accounts under concurrent provisioning. The gem does not create this constraint automatically because index syntax and migrations depend on your database adapter and application.
|
|
@@ -12,6 +12,8 @@ require_relative "../scim/scim_filters"
|
|
|
12
12
|
require_relative "../scim/patch_operations"
|
|
13
13
|
require_relative "../scim/scim_tokens"
|
|
14
14
|
require_relative "../scim/middlewares"
|
|
15
|
+
require_relative "../scim/provider_management"
|
|
16
|
+
require_relative "../scim/validation"
|
|
15
17
|
require_relative "../scim/routes"
|
|
16
18
|
|
|
17
19
|
module BetterAuth
|
|
@@ -22,7 +24,7 @@ module BetterAuth
|
|
|
22
24
|
singleton_class.remove_method(:scim) if singleton_class.method_defined?(:scim) || singleton_class.private_method_defined?(:scim)
|
|
23
25
|
|
|
24
26
|
def scim(options = {})
|
|
25
|
-
config = {store_scim_token: "
|
|
27
|
+
config = {store_scim_token: "hashed"}.merge(normalize_hash(options))
|
|
26
28
|
Plugin.new(
|
|
27
29
|
id: "scim",
|
|
28
30
|
version: BetterAuth::SCIM::VERSION,
|
|
@@ -12,7 +12,11 @@ module BetterAuth
|
|
|
12
12
|
token, provider_id, organization_id = scim_decode_token(encoded)
|
|
13
13
|
provider = scim_default_provider(config, provider_id, organization_id)
|
|
14
14
|
if provider
|
|
15
|
-
|
|
15
|
+
stored = provider.fetch("scimToken").to_s
|
|
16
|
+
provided = token.to_s
|
|
17
|
+
unless scim_token_string_matches?(stored, provided)
|
|
18
|
+
raise scim_error("UNAUTHORIZED", "Invalid SCIM token")
|
|
19
|
+
end
|
|
16
20
|
else
|
|
17
21
|
provider = ctx.context.adapter.find_one(
|
|
18
22
|
model: "scimProvider",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def scim_has_organization_plugin?(ctx)
|
|
8
|
+
Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def scim_organization_plugin(ctx)
|
|
12
|
+
Array(ctx.context.options.plugins).find { |plugin| plugin.id == "organization" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scim_required_roles(ctx, config)
|
|
16
|
+
configured = config[:required_role] || config[:required_roles]
|
|
17
|
+
return Array(configured).map(&:to_s) if configured
|
|
18
|
+
|
|
19
|
+
creator_role = scim_organization_plugin(ctx)&.options&.fetch(:creator_role, nil)
|
|
20
|
+
["admin", creator_role || "owner"].uniq
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scim_provider_ownership_enabled?(config)
|
|
24
|
+
normalize_hash(config[:provider_ownership] || {})[:enabled] == true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def scim_find_organization_member(ctx, user_id, organization_id)
|
|
28
|
+
ctx.context.adapter.find_one(
|
|
29
|
+
model: "member",
|
|
30
|
+
where: [
|
|
31
|
+
{field: "userId", value: user_id},
|
|
32
|
+
{field: "organizationId", value: organization_id}
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scim_parse_roles(role)
|
|
38
|
+
Array(role).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scim_has_required_role?(role, required_roles)
|
|
42
|
+
required = Array(required_roles).map(&:to_s)
|
|
43
|
+
required.empty? || scim_parse_roles(role).any? { |candidate| required.include?(candidate) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scim_user_org_memberships(ctx, user_id)
|
|
47
|
+
ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}]).each_with_object({}) do |member, result|
|
|
48
|
+
result[member.fetch("organizationId")] = member
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def scim_assert_provider_access!(ctx, user_id, provider, required_roles)
|
|
53
|
+
return unless provider
|
|
54
|
+
|
|
55
|
+
organization_id = provider["organizationId"]
|
|
56
|
+
if organization_id
|
|
57
|
+
raise APIError.new("FORBIDDEN", message: "Organization plugin is required to access this SCIM provider") unless scim_has_organization_plugin?(ctx)
|
|
58
|
+
|
|
59
|
+
member = scim_find_organization_member(ctx, user_id, organization_id)
|
|
60
|
+
raise APIError.new("FORBIDDEN", message: "You must be a member of the organization to access this provider") unless member
|
|
61
|
+
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
62
|
+
elsif provider.key?("userId") && provider["userId"] && provider["userId"] != user_id
|
|
63
|
+
raise APIError.new("FORBIDDEN", message: "You must be the owner to access this provider")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def scim_provider_by_provider_id!(ctx, provider_id)
|
|
68
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) unless provider_id.is_a?(String)
|
|
69
|
+
|
|
70
|
+
provider = ctx.context.adapter.find_one(model: "scimProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
71
|
+
raise APIError.new("NOT_FOUND", message: "SCIM provider not found") unless provider
|
|
72
|
+
|
|
73
|
+
provider
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scim_provider_id_query(ctx)
|
|
77
|
+
ctx.query[:providerId] || ctx.query[:provider_id] || ctx.query["providerId"] || ctx.query["provider_id"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scim_normalized_provider(provider)
|
|
81
|
+
{
|
|
82
|
+
id: provider.fetch("id"),
|
|
83
|
+
providerId: provider.fetch("providerId"),
|
|
84
|
+
organizationId: provider["organizationId"]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scim_call_token_hook(callback, payload)
|
|
89
|
+
callback.call(payload) if callback.respond_to?(:call)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scim_create_org_membership(ctx, user_id, organization_id)
|
|
93
|
+
return unless organization_id
|
|
94
|
+
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])
|
|
95
|
+
|
|
96
|
+
ctx.context.adapter.create(model: "member", data: {userId: user_id, organizationId: organization_id, role: "member", createdAt: Time.now})
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -32,9 +32,7 @@ module BetterAuth
|
|
|
32
32
|
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
where << {field: "organizationId", value: organization_id} if organization_id
|
|
37
|
-
existing = ctx.context.adapter.find_one(model: "scimProvider", where: where)
|
|
35
|
+
existing = ctx.context.adapter.find_one(model: "scimProvider", where: [{field: "providerId", value: provider_id}])
|
|
38
36
|
if existing
|
|
39
37
|
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), existing, required_roles)
|
|
40
38
|
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "id", value: existing.fetch("id")}])
|
|
@@ -106,8 +104,7 @@ module BetterAuth
|
|
|
106
104
|
user = ctx.context.internal_adapter.find_user_by_email(email)&.fetch(:user)
|
|
107
105
|
user ||= ctx.context.internal_adapter.create_user(
|
|
108
106
|
email: email,
|
|
109
|
-
name: scim_display_name(body, email)
|
|
110
|
-
emailVerified: true
|
|
107
|
+
name: scim_display_name(body, email)
|
|
111
108
|
)
|
|
112
109
|
account = ctx.context.internal_adapter.create_account(
|
|
113
110
|
userId: user.fetch("id"),
|
|
@@ -161,8 +158,10 @@ module BetterAuth
|
|
|
161
158
|
end
|
|
162
159
|
raise scim_error("BAD_REQUEST", "No valid fields to update") if update.empty? && account_update.empty?
|
|
163
160
|
|
|
164
|
-
ctx.context.
|
|
165
|
-
|
|
161
|
+
ctx.context.adapter.transaction do
|
|
162
|
+
ctx.context.internal_adapter.update_user(user.fetch("id"), update.merge(updatedAt: Time.now)) unless update.empty?
|
|
163
|
+
ctx.context.internal_adapter.update_account(account.fetch("id"), account_update.merge(updatedAt: Time.now)) unless account_update.empty?
|
|
164
|
+
end
|
|
166
165
|
ctx.json(nil, status: 204)
|
|
167
166
|
end
|
|
168
167
|
end
|
|
@@ -179,24 +178,37 @@ module BetterAuth
|
|
|
179
178
|
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|
|
|
180
179
|
provider = ctx.context.scim_provider
|
|
181
180
|
accounts = ctx.context.adapter.find_many(model: "account", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
181
|
+
account_user_ids = accounts.map { |account| account.fetch("userId") }.uniq
|
|
182
|
+
empty_list = {schemas: [SCIM_LIST_RESPONSE_SCHEMA], totalResults: 0, itemsPerPage: 0, startIndex: 1, Resources: []}
|
|
183
|
+
if account_user_ids.empty?
|
|
184
|
+
ctx.json(empty_list)
|
|
185
|
+
else
|
|
186
|
+
user_ids = account_user_ids
|
|
187
|
+
if provider["organizationId"]
|
|
188
|
+
user_ids = ctx.context.adapter.find_many(
|
|
189
|
+
model: "member",
|
|
190
|
+
where: [
|
|
191
|
+
{field: "organizationId", value: provider.fetch("organizationId")},
|
|
192
|
+
{field: "userId", value: account_user_ids, operator: "in"}
|
|
193
|
+
]
|
|
194
|
+
).map { |member| member.fetch("userId") }.uniq
|
|
195
|
+
end
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
if user_ids.empty?
|
|
198
|
+
ctx.json(empty_list)
|
|
199
|
+
else
|
|
200
|
+
where = [{field: "id", value: user_ids, operator: "in"}]
|
|
201
|
+
if ctx.query[:filter] || ctx.query["filter"]
|
|
202
|
+
_filter_field, filter_value = scim_parse_filter(ctx.query[:filter] || ctx.query["filter"])
|
|
203
|
+
where << {field: "email", value: filter_value.to_s.downcase, operator: "eq"}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
users = ctx.context.internal_adapter.list_users(where: where, sort_by: {field: "email", direction: "asc"})
|
|
207
|
+
accounts_by_user = accounts.each_with_object({}) { |account, result| result[account.fetch("userId")] ||= account }
|
|
208
|
+
resources = users.map { |user| scim_user_resource(user, accounts_by_user[user.fetch("id")], ctx.context.base_url) }
|
|
209
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
|
|
210
|
+
end
|
|
198
211
|
end
|
|
199
|
-
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
|
|
200
212
|
end
|
|
201
213
|
end
|
|
202
214
|
|
|
@@ -281,141 +293,5 @@ module BetterAuth
|
|
|
281
293
|
|
|
282
294
|
[user, account]
|
|
283
295
|
end
|
|
284
|
-
|
|
285
|
-
def scim_validate_user_body!(body)
|
|
286
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless body[:user_name].is_a?(String)
|
|
287
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body[:user_name].empty?
|
|
288
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:external_id) && !body[:external_id].is_a?(String)
|
|
289
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:name) && !body[:name].is_a?(Hash)
|
|
290
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:emails) && !body[:emails].is_a?(Array)
|
|
291
|
-
normalize_hash(body[:name] || {}).each_value do |value|
|
|
292
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.is_a?(String)
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
Array(body[:emails]).each do |email|
|
|
296
|
-
email = normalize_hash(email)
|
|
297
|
-
value = email[:value]
|
|
298
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if email.key?(:primary) && ![true, false].include?(email[:primary])
|
|
299
|
-
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def scim_validate_patch_body!(body)
|
|
304
|
-
schemas = Array(body[:schemas])
|
|
305
|
-
raise scim_error("BAD_REQUEST", "Invalid schemas for PatchOp") unless schemas.include?("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
|
306
|
-
|
|
307
|
-
Array(body[:operations]).each_with_index do |operation, index|
|
|
308
|
-
op = normalize_hash(operation)[:op]
|
|
309
|
-
next if op.nil? || op.to_s.empty?
|
|
310
|
-
|
|
311
|
-
unless op.is_a?(String)
|
|
312
|
-
raise scim_patch_validation_error("[body.Operations.#{index}.op] Invalid input: expected string")
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
next if %w[replace add remove].include?(op.downcase)
|
|
316
|
-
|
|
317
|
-
raise scim_patch_validation_error("[body.Operations.#{index}.op] Invalid option: expected one of \"replace\"|\"add\"|\"remove\"")
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def scim_patch_validation_error(message)
|
|
322
|
-
APIError.new(
|
|
323
|
-
"BAD_REQUEST",
|
|
324
|
-
message: BASE_ERROR_CODES["VALIDATION_ERROR"],
|
|
325
|
-
body: {code: "VALIDATION_ERROR", message: message}
|
|
326
|
-
)
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def scim_has_organization_plugin?(ctx)
|
|
330
|
-
Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def scim_organization_plugin(ctx)
|
|
334
|
-
Array(ctx.context.options.plugins).find { |plugin| plugin.id == "organization" }
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def scim_required_roles(ctx, config)
|
|
338
|
-
configured = config[:required_role] || config[:required_roles]
|
|
339
|
-
return Array(configured).map(&:to_s) if configured
|
|
340
|
-
|
|
341
|
-
creator_role = scim_organization_plugin(ctx)&.options&.fetch(:creator_role, nil)
|
|
342
|
-
["admin", creator_role || "owner"].uniq
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def scim_provider_ownership_enabled?(config)
|
|
346
|
-
normalize_hash(config[:provider_ownership] || {})[:enabled] == true
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
def scim_find_organization_member(ctx, user_id, organization_id)
|
|
350
|
-
ctx.context.adapter.find_one(
|
|
351
|
-
model: "member",
|
|
352
|
-
where: [
|
|
353
|
-
{field: "userId", value: user_id},
|
|
354
|
-
{field: "organizationId", value: organization_id}
|
|
355
|
-
]
|
|
356
|
-
)
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
def scim_parse_roles(role)
|
|
360
|
-
Array(role).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
def scim_has_required_role?(role, required_roles)
|
|
364
|
-
required = Array(required_roles).map(&:to_s)
|
|
365
|
-
required.empty? || scim_parse_roles(role).any? { |candidate| required.include?(candidate) }
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def scim_user_org_memberships(ctx, user_id)
|
|
369
|
-
ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}]).each_with_object({}) do |member, result|
|
|
370
|
-
result[member.fetch("organizationId")] = member
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
def scim_assert_provider_access!(ctx, user_id, provider, required_roles)
|
|
375
|
-
return unless provider
|
|
376
|
-
|
|
377
|
-
organization_id = provider["organizationId"]
|
|
378
|
-
if organization_id
|
|
379
|
-
raise APIError.new("FORBIDDEN", message: "Organization plugin is required to access this SCIM provider") unless scim_has_organization_plugin?(ctx)
|
|
380
|
-
|
|
381
|
-
member = scim_find_organization_member(ctx, user_id, organization_id)
|
|
382
|
-
raise APIError.new("FORBIDDEN", message: "You must be a member of the organization to access this provider") unless member
|
|
383
|
-
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
384
|
-
elsif provider.key?("userId") && provider["userId"] && provider["userId"] != user_id
|
|
385
|
-
raise APIError.new("FORBIDDEN", message: "You must be the owner to access this provider")
|
|
386
|
-
end
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
def scim_provider_by_provider_id!(ctx, provider_id)
|
|
390
|
-
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) unless provider_id.is_a?(String)
|
|
391
|
-
|
|
392
|
-
provider = ctx.context.adapter.find_one(model: "scimProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
393
|
-
raise APIError.new("NOT_FOUND", message: "SCIM provider not found") unless provider
|
|
394
|
-
|
|
395
|
-
provider
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
def scim_provider_id_query(ctx)
|
|
399
|
-
ctx.query[:providerId] || ctx.query[:provider_id] || ctx.query["providerId"] || ctx.query["provider_id"]
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
def scim_normalized_provider(provider)
|
|
403
|
-
{
|
|
404
|
-
id: provider.fetch("id"),
|
|
405
|
-
providerId: provider.fetch("providerId"),
|
|
406
|
-
organizationId: provider["organizationId"]
|
|
407
|
-
}
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
def scim_call_token_hook(callback, payload)
|
|
411
|
-
callback.call(payload) if callback.respond_to?(:call)
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
def scim_create_org_membership(ctx, user_id, organization_id)
|
|
415
|
-
return unless organization_id
|
|
416
|
-
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])
|
|
417
|
-
|
|
418
|
-
ctx.context.adapter.create(model: "member", data: {userId: user_id, organizationId: organization_id, role: "member", createdAt: Time.now})
|
|
419
|
-
end
|
|
420
296
|
end
|
|
421
297
|
end
|
|
@@ -23,10 +23,16 @@ module BetterAuth
|
|
|
23
23
|
|
|
24
24
|
def scim_token_matches?(ctx, config, token, stored)
|
|
25
25
|
storage = config[:store_scim_token]
|
|
26
|
-
return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored)
|
|
27
|
-
return storage[:decrypt].call(stored)
|
|
26
|
+
return scim_token_string_matches?(Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored), token) if storage == "encrypted"
|
|
27
|
+
return scim_token_string_matches?(storage[:decrypt].call(stored), token) if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
scim_token_string_matches?(scim_store_token(ctx, config, token), stored)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def scim_token_string_matches?(expected, provided)
|
|
33
|
+
expected = expected.to_s
|
|
34
|
+
provided = provided.to_s
|
|
35
|
+
!provided.empty? && expected.bytesize == provided.bytesize && Crypto.constant_time_compare(expected, provided)
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
def scim_decode_token(encoded)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def scim_validate_user_body!(body)
|
|
8
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless body[:user_name].is_a?(String)
|
|
9
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body[:user_name].empty?
|
|
10
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:external_id) && !body[:external_id].is_a?(String)
|
|
11
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:name) && !body[:name].is_a?(Hash)
|
|
12
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:emails) && !body[:emails].is_a?(Array)
|
|
13
|
+
normalize_hash(body[:name] || {}).each_value do |value|
|
|
14
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.is_a?(String)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Array(body[:emails]).each do |email|
|
|
18
|
+
email = normalize_hash(email)
|
|
19
|
+
value = email[:value]
|
|
20
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if email.key?(:primary) && ![true, false].include?(email[:primary])
|
|
21
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def scim_validate_patch_body!(body)
|
|
26
|
+
schemas = Array(body[:schemas])
|
|
27
|
+
raise scim_error("BAD_REQUEST", "Invalid schemas for PatchOp") unless schemas.include?("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
|
28
|
+
|
|
29
|
+
Array(body[:operations]).each_with_index do |operation, index|
|
|
30
|
+
op = normalize_hash(operation)[:op]
|
|
31
|
+
next if op.nil? || op.to_s.empty?
|
|
32
|
+
|
|
33
|
+
unless op.is_a?(String)
|
|
34
|
+
raise scim_patch_validation_error("[body.Operations.#{index}.op] Invalid input: expected string")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
next if %w[replace add remove].include?(op.downcase)
|
|
38
|
+
|
|
39
|
+
raise scim_patch_validation_error("[body.Operations.#{index}.op] Invalid option: expected one of \"replace\"|\"add\"|\"remove\"")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scim_patch_validation_error(message)
|
|
44
|
+
APIError.new(
|
|
45
|
+
"BAD_REQUEST",
|
|
46
|
+
message: BASE_ERROR_CODES["VALIDATION_ERROR"],
|
|
47
|
+
body: {code: "VALIDATION_ERROR", message: message}
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/better_auth/scim.rb
CHANGED
|
@@ -13,6 +13,8 @@ require_relative "scim/scim_filters"
|
|
|
13
13
|
require_relative "scim/patch_operations"
|
|
14
14
|
require_relative "scim/scim_tokens"
|
|
15
15
|
require_relative "scim/middlewares"
|
|
16
|
+
require_relative "scim/provider_management"
|
|
17
|
+
require_relative "scim/validation"
|
|
16
18
|
require_relative "scim/routes"
|
|
17
19
|
require_relative "plugins/scim"
|
|
18
20
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: better_auth-scim
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Sala
|
|
@@ -115,6 +115,7 @@ files:
|
|
|
115
115
|
- lib/better_auth/scim/mappings.rb
|
|
116
116
|
- lib/better_auth/scim/middlewares.rb
|
|
117
117
|
- lib/better_auth/scim/patch_operations.rb
|
|
118
|
+
- lib/better_auth/scim/provider_management.rb
|
|
118
119
|
- lib/better_auth/scim/routes.rb
|
|
119
120
|
- lib/better_auth/scim/scim_error.rb
|
|
120
121
|
- lib/better_auth/scim/scim_filters.rb
|
|
@@ -123,6 +124,7 @@ files:
|
|
|
123
124
|
- lib/better_auth/scim/scim_tokens.rb
|
|
124
125
|
- lib/better_auth/scim/user_schemas.rb
|
|
125
126
|
- lib/better_auth/scim/utils.rb
|
|
127
|
+
- lib/better_auth/scim/validation.rb
|
|
126
128
|
- lib/better_auth/scim/version.rb
|
|
127
129
|
homepage: https://github.com/sebasxsala/better-auth
|
|
128
130
|
licenses:
|