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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1201afe86cacf80ee69f816878a2e3e19674ee2e60a73898aa0532f0ada7b644
4
- data.tar.gz: 8677ea4796b8821bb1c1e8def36709d50d6bbdb49a61e2459e816f41a2055ae3
3
+ metadata.gz: 65e35401e83150cb6dbd98d3b47c19a61d6c7bcca3c1310c40dec3f42e3ecbaa
4
+ data.tar.gz: 66a37c96b0eff2236194fce3d916930c83f555cc73e450681ca9540214154404
5
5
  SHA512:
6
- metadata.gz: 7c5ca6ceb7a0327467e2a3ab062d4e93b495cf908a0216c59f564ad170801c71d8e9eed48841cbe219feaeb0720e5bc2d97c57f0e483611e3a4a0a581deae481
7
- data.tar.gz: 76cfb02894397fdc38280f407be6848044f717b0fee2f4907b4edba881cac91693bc96eb75c8106182f6aff382c3bf042ff2fd8ed707c284cdca4f99d0763973
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: "plain"}.merge(normalize_hash(options))
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
- raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider.fetch("scimToken") == token
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
- where = [{field: "providerId", value: provider_id}]
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.internal_adapter.update_user(user.fetch("id"), update.merge(updatedAt: Time.now)) unless update.empty?
165
- ctx.context.internal_adapter.update_account(account.fetch("id"), account_update.merge(updatedAt: Time.now)) unless account_update.empty?
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
- users_by_id = ctx.context.internal_adapter.list_users.each_with_object({}) { |user, result| result[user.fetch("id")] = user }
183
- users = accounts.filter_map { |account| users_by_id[account.fetch("userId")] }
184
- if provider["organizationId"]
185
- member_ids = ctx.context.adapter.find_many(
186
- model: "member",
187
- where: [{field: "organizationId", value: provider.fetch("organizationId")}]
188
- ).map { |member| member.fetch("userId") }
189
- users = users.select { |user| member_ids.include?(user.fetch("id")) }
190
- end
191
- filter_field, filter_value = scim_parse_filter(ctx.query[:filter] || ctx.query["filter"]) if ctx.query[:filter] || ctx.query["filter"]
192
- resources = users.filter_map do |user|
193
- account = accounts.find { |entry| entry.fetch("userId") == user.fetch("id") }
194
- resource = scim_user_resource(user, account, ctx.context.base_url)
195
- next resource unless filter_field
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
- (resource[filter_field.to_sym].to_s.downcase == filter_value.to_s.downcase) ? resource : nil
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) == token if storage == "encrypted"
27
- return storage[:decrypt].call(stored) == token if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
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
- !token.to_s.empty? && scim_store_token(ctx, config, token) == stored
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module SCIM
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -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.5.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: