better_auth-scim 0.1.0 → 0.5.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: a25f1b935926c0b3cd74a888fc9c923ddbb9c7ade8912d2c935ab2f27b747f33
4
- data.tar.gz: 37210f202bee13853b720dbec539db4ba086eb3693a7234d2745ab41f45ceb63
3
+ metadata.gz: 1201afe86cacf80ee69f816878a2e3e19674ee2e60a73898aa0532f0ada7b644
4
+ data.tar.gz: 8677ea4796b8821bb1c1e8def36709d50d6bbdb49a61e2459e816f41a2055ae3
5
5
  SHA512:
6
- metadata.gz: ee253db8f45e7827d333beb10cc77fdef1dd11d6dfb74ddfe8446960b85da5c137f42cf201fef3bad292f3c5ce0b001a31752b25ea95259b0fd3861140b70883
7
- data.tar.gz: 7c16ca543cfb57e42a1a8da8a0afd0c3c568e6b3b6969b18c46186d651f5a94349bb40689cb1efdb7cacb271d9bbcff349e5e78e2be36693a9a7da94339c6c2c
6
+ metadata.gz: 7c5ca6ceb7a0327467e2a3ab062d4e93b495cf908a0216c59f564ad170801c71d8e9eed48841cbe219feaeb0720e5bc2d97c57f0e483611e3a4a0a581deae481
7
+ data.tar.gz: 76cfb02894397fdc38280f407be6848044f717b0fee2f4907b4edba881cac91693bc96eb75c8106182f6aff382c3bf042ff2fd8ed707c284cdca4f99d0763973
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.
@@ -1,7 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "securerandom"
3
+ require_relative "../scim/version"
4
+ require_relative "../scim/scim_metadata"
5
+ require_relative "../scim/scim_error"
6
+ require_relative "../scim/utils"
7
+ require_relative "../scim/client"
8
+ require_relative "../scim/user_schemas"
9
+ require_relative "../scim/scim_resources"
10
+ require_relative "../scim/mappings"
11
+ require_relative "../scim/scim_filters"
12
+ require_relative "../scim/patch_operations"
13
+ require_relative "../scim/scim_tokens"
14
+ require_relative "../scim/middlewares"
15
+ require_relative "../scim/routes"
5
16
 
6
17
  module BetterAuth
7
18
  module Plugins
@@ -14,9 +25,14 @@ module BetterAuth
14
25
  config = {store_scim_token: "plain"}.merge(normalize_hash(options))
15
26
  Plugin.new(
16
27
  id: "scim",
17
- schema: scim_schema,
28
+ version: BetterAuth::SCIM::VERSION,
29
+ client: scim_client,
30
+ schema: scim_schema(config),
18
31
  endpoints: {
19
32
  generate_scim_token: scim_generate_token_endpoint(config),
33
+ list_scim_provider_connections: scim_list_provider_connections_endpoint(config),
34
+ get_scim_provider_connection: scim_get_provider_connection_endpoint(config),
35
+ delete_scim_provider_connection: scim_delete_provider_connection_endpoint(config),
20
36
  create_scim_user: scim_create_user_endpoint(config),
21
37
  update_scim_user: scim_update_user_endpoint(config),
22
38
  patch_scim_user: scim_patch_user_endpoint(config),
@@ -33,413 +49,19 @@ module BetterAuth
33
49
  )
34
50
  end
35
51
 
36
- def scim_schema
52
+ def scim_schema(config = {})
53
+ scim_provider_fields = {
54
+ providerId: {type: "string", required: true, unique: true},
55
+ scimToken: {type: "string", required: true, unique: true},
56
+ organizationId: {type: "string", required: false}
57
+ }
58
+ scim_provider_fields[:userId] = {type: "string", required: false} if scim_provider_ownership_enabled?(config)
59
+
37
60
  {
38
61
  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
- }
62
+ fields: scim_provider_fields
50
63
  }
51
64
  }
52
65
  end
53
-
54
- def scim_generate_token_endpoint(config)
55
- Endpoint.new(path: "/scim/generate-token", method: "POST") do |ctx|
56
- session = Routes.current_session(ctx)
57
- body = normalize_hash(ctx.body)
58
- provider_id = body[:provider_id].to_s
59
- organization_id = body[:organization_id]
60
- raise APIError.new("BAD_REQUEST", message: "Provider id contains forbidden characters") if provider_id.include?(":")
61
- if organization_id && !scim_has_organization_plugin?(ctx)
62
- raise APIError.new("BAD_REQUEST", message: "Restricting a token to an organization requires the organization plugin")
63
- end
64
-
65
- if organization_id
66
- member = ctx.context.adapter.find_one(
67
- model: "member",
68
- where: [
69
- {field: "userId", value: session.fetch(:user).fetch("id")},
70
- {field: "organizationId", value: organization_id}
71
- ]
72
- )
73
- raise APIError.new("FORBIDDEN", message: "You are not a member of the organization") unless member
74
- end
75
-
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
- where = [{field: "providerId", value: provider_id}]
80
- where << {field: "organizationId", value: organization_id} if organization_id
81
- existing = ctx.context.adapter.find_one(model: "scimProvider", where: where)
82
- data = {providerId: provider_id, scimToken: stored, organizationId: organization_id}
83
- ctx.context.adapter.delete(model: "scimProvider", where: [{field: "id", value: existing.fetch("id")}]) if existing
84
- ctx.context.adapter.create(model: "scimProvider", data: data)
85
- ctx.json({scimToken: token}, status: 201)
86
- end
87
- end
88
-
89
- def scim_create_user_endpoint(config)
90
- Endpoint.new(path: "/scim/v2/Users", method: "POST", use: [scim_auth_middleware(config)]) do |ctx|
91
- body = normalize_hash(ctx.body)
92
- provider = ctx.context.scim_provider
93
- provider_id = provider.fetch("providerId")
94
- email = scim_primary_email(body).downcase
95
- account_id = scim_account_id(body)
96
- existing_account = ctx.context.adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
97
- raise APIError.new("CONFLICT", message: "User already exists") if existing_account
98
-
99
- user = ctx.context.internal_adapter.find_user_by_email(email)&.fetch(:user)
100
- user ||= ctx.context.internal_adapter.create_user(
101
- email: email,
102
- name: scim_display_name(body, email),
103
- emailVerified: true,
104
- active: body.key?(:active) ? body[:active] : true,
105
- externalId: body[:external_id]
106
- )
107
- account = ctx.context.internal_adapter.create_account(
108
- userId: user.fetch("id"),
109
- providerId: provider_id,
110
- accountId: account_id,
111
- accessToken: "",
112
- refreshToken: ""
113
- )
114
- scim_create_org_membership(ctx, user.fetch("id"), provider["organizationId"])
115
- ctx.json(scim_user_resource(user, account, ctx.context.base_url), status: 201)
116
- end
117
- end
118
-
119
- def scim_update_user_endpoint(config)
120
- Endpoint.new(path: "/scim/v2/Users/:userId", method: "PUT", use: [scim_auth_middleware(config)]) do |ctx|
121
- user, account = scim_find_user_with_account!(ctx)
122
- body = normalize_hash(ctx.body)
123
- updated = ctx.context.internal_adapter.update_user(user.fetch("id"), scim_user_update(body))
124
- updated_account = ctx.context.internal_adapter.update_account(account.fetch("id"), accountId: scim_account_id(body))
125
- ctx.json(scim_user_resource(updated, updated_account, ctx.context.base_url))
126
- end
127
- end
128
-
129
- def scim_patch_user_endpoint(config)
130
- Endpoint.new(path: "/scim/v2/Users/:userId", method: "PATCH", use: [scim_auth_middleware(config)]) do |ctx|
131
- user, account = scim_find_user_with_account!(ctx)
132
- update = {}
133
- account_update = {}
134
- Array(normalize_hash(ctx.body)[:operations] || ctx.body["Operations"]).each do |operation|
135
- op = normalize_hash(operation)
136
- operation_name = op[:op].to_s.downcase
137
- raise APIError.new("BAD_REQUEST", message: "Invalid SCIM patch operation") unless %w[replace add remove].include?(operation_name)
138
-
139
- if op[:path].to_s.empty? && op[:value].is_a?(Hash)
140
- scim_apply_patch_value!(user, update, account_update, normalize_hash(op[:value]), operation_name)
141
- next
142
- end
143
-
144
- scim_apply_patch_path!(user, update, account_update, op[:path], op[:value], operation_name)
145
- end
146
- raise APIError.new("BAD_REQUEST", message: "No valid fields to update") if update.empty? && account_update.empty?
147
-
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?
150
- ctx.json(nil, status: 204)
151
- end
152
- end
153
-
154
- def scim_delete_user_endpoint(config)
155
- Endpoint.new(path: "/scim/v2/Users/:userId", method: "DELETE", metadata: {allowed_media_types: ["application/json", ""]}, use: [scim_auth_middleware(config)]) do |ctx|
156
- user, = scim_find_user_with_account!(ctx)
157
- ctx.context.internal_adapter.delete_user(user.fetch("id"))
158
- ctx.json(nil, status: 204)
159
- end
160
- end
161
-
162
- def scim_list_users_endpoint(config)
163
- Endpoint.new(path: "/scim/v2/Users", method: "GET", use: [scim_auth_middleware(config)]) do |ctx|
164
- provider = ctx.context.scim_provider
165
- accounts = ctx.context.adapter.find_many(model: "account", where: [{field: "providerId", value: provider.fetch("providerId")}])
166
- users_by_id = ctx.context.internal_adapter.list_users.each_with_object({}) { |user, result| result[user.fetch("id")] = user }
167
- users = accounts.filter_map { |account| users_by_id[account.fetch("userId")] }
168
- if provider["organizationId"]
169
- member_ids = ctx.context.adapter.find_many(
170
- model: "member",
171
- where: [{field: "organizationId", value: provider.fetch("organizationId")}]
172
- ).map { |member| member.fetch("userId") }
173
- users = users.select { |user| member_ids.include?(user.fetch("id")) }
174
- end
175
- filter_field, filter_value = scim_parse_filter(ctx.query[:filter] || ctx.query["filter"]) if ctx.query[:filter] || ctx.query["filter"]
176
- resources = users.filter_map do |user|
177
- account = accounts.find { |entry| entry.fetch("userId") == user.fetch("id") }
178
- resource = scim_user_resource(user, account, ctx.context.base_url)
179
- next resource unless filter_field
180
-
181
- (resource[filter_field.to_sym].to_s.downcase == filter_value.to_s.downcase) ? resource : nil
182
- end
183
- ctx.json({schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
184
- end
185
- end
186
-
187
- def scim_get_user_endpoint(config)
188
- Endpoint.new(path: "/scim/v2/Users/:userId", method: "GET", use: [scim_auth_middleware(config)]) do |ctx|
189
- user, account = scim_find_user_with_account!(ctx)
190
- ctx.json(scim_user_resource(user, account, ctx.context.base_url))
191
- end
192
- end
193
-
194
- def scim_service_provider_config_endpoint
195
- Endpoint.new(path: "/scim/v2/ServiceProviderConfig", method: "GET") do |ctx|
196
- ctx.json({
197
- schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
198
- patch: {supported: true},
199
- filter: {supported: true, maxResults: 100},
200
- changePassword: {supported: false},
201
- sort: {supported: false},
202
- etag: {supported: false},
203
- authenticationSchemes: [{type: "oauthbearertoken", name: "OAuth Bearer Token"}]
204
- })
205
- end
206
- end
207
-
208
- def scim_schemas_endpoint
209
- Endpoint.new(path: "/scim/v2/Schemas", method: "GET") do |ctx|
210
- ctx.json({Resources: [scim_user_schema], totalResults: 1})
211
- end
212
- end
213
-
214
- def scim_schema_endpoint
215
- Endpoint.new(path: "/scim/v2/Schemas/:schemaId", method: "GET") do |ctx|
216
- raise APIError.new("NOT_FOUND", message: "Schema not found") unless scim_param(ctx, :schema_id).to_s.end_with?("User")
217
-
218
- ctx.json(scim_user_schema)
219
- end
220
- end
221
-
222
- def scim_resource_types_endpoint
223
- Endpoint.new(path: "/scim/v2/ResourceTypes", method: "GET") do |ctx|
224
- ctx.json({Resources: [scim_user_resource_type], totalResults: 1})
225
- end
226
- end
227
-
228
- def scim_resource_type_endpoint
229
- Endpoint.new(path: "/scim/v2/ResourceTypes/:resourceTypeId", method: "GET") do |ctx|
230
- raise APIError.new("NOT_FOUND", message: "Resource type not found") unless scim_param(ctx, :resource_type_id) == "User"
231
-
232
- ctx.json(scim_user_resource_type)
233
- end
234
- end
235
-
236
- def scim_auth_middleware(config)
237
- lambda do |ctx|
238
- encoded = ctx.headers["authorization"].to_s.sub(/\ABearer\s+/i, "")
239
- raise APIError.new("UNAUTHORIZED", message: "SCIM token is required") if encoded.empty?
240
-
241
- token, provider_id, organization_id = scim_decode_token(encoded)
242
- provider = scim_default_provider(config, token, provider_id, organization_id) ||
243
- ctx.context.adapter.find_one(
244
- model: "scimProvider",
245
- where: [{field: "providerId", value: provider_id}].tap { |where| where << {field: "organizationId", value: organization_id} if organization_id }
246
- )
247
- raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") unless provider
248
- raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") unless scim_token_matches?(ctx, config, token, provider.fetch("scimToken"))
249
-
250
- ctx.context.apply_plugin_context!(scim_provider: provider)
251
- nil
252
- end
253
- end
254
-
255
- def scim_store_token(ctx, config, token)
256
- storage = config[:store_scim_token]
257
- if storage == "hashed"
258
- Crypto.sha256(token)
259
- elsif storage == "encrypted"
260
- Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
261
- elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
262
- storage[:hash].call(token)
263
- elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
264
- storage[:encrypt].call(token)
265
- else
266
- token
267
- end
268
- end
269
-
270
- def scim_token_matches?(ctx, config, token, stored)
271
- storage = config[:store_scim_token]
272
- return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored) == token if storage == "encrypted"
273
- return storage[:decrypt].call(stored) == token if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
274
-
275
- !token.to_s.empty? && scim_store_token(ctx, config, token) == stored
276
- end
277
-
278
- def scim_decode_token(encoded)
279
- decoded = Base64.urlsafe_decode64(encoded.to_s)
280
- token, provider_id, *organization_parts = decoded.split(":")
281
- raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") if token.to_s.empty? || provider_id.to_s.empty?
282
-
283
- [token, provider_id, organization_parts.join(":").then { |value| value.empty? ? nil : value }]
284
- rescue ArgumentError
285
- raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token")
286
- end
287
-
288
- def scim_default_provider(config, token, provider_id, organization_id)
289
- Array(config[:default_scim]).find do |provider|
290
- candidate = normalize_hash(provider)
291
- candidate[:provider_id].to_s == provider_id.to_s &&
292
- candidate[:scim_token].to_s == token.to_s &&
293
- candidate[:organization_id].to_s == organization_id.to_s
294
- end&.then do |provider|
295
- data = normalize_hash(provider)
296
- {"providerId" => data[:provider_id], "scimToken" => data[:scim_token], "organizationId" => data[:organization_id]}
297
- end
298
- end
299
-
300
- def scim_find_user_with_account!(ctx)
301
- provider = ctx.context.scim_provider
302
- user_id = scim_param(ctx, :user_id)
303
- account = ctx.context.adapter.find_one(
304
- model: "account",
305
- where: [
306
- {field: "userId", value: user_id},
307
- {field: "providerId", value: provider.fetch("providerId")}
308
- ]
309
- )
310
- user = account && ctx.context.internal_adapter.find_user_by_id(user_id)
311
- if user && provider["organizationId"]
312
- member = ctx.context.adapter.find_one(
313
- model: "member",
314
- where: [{field: "organizationId", value: provider.fetch("organizationId")}, {field: "userId", value: user_id}]
315
- )
316
- user = nil unless member
317
- end
318
- raise APIError.new("NOT_FOUND", message: "User not found") unless user && account
319
-
320
- [user, account]
321
- end
322
-
323
- def scim_user_update(body)
324
- {
325
- email: scim_primary_email(body)&.downcase,
326
- name: scim_display_name(body, body[:user_name].to_s),
327
- active: body.key?(:active) ? body[:active] : nil,
328
- externalId: body[:external_id]
329
- }.compact
330
- end
331
-
332
- def scim_apply_patch_value!(user, update, account_update, value, operation_name, path = nil)
333
- value.each do |key, nested_value|
334
- nested_key = Schema.storage_key(key)
335
- nested_path = path ? "#{path}.#{nested_key}" : nested_key
336
- if nested_value.is_a?(Hash)
337
- scim_apply_patch_value!(user, update, account_update, normalize_hash(nested_value), operation_name, nested_path)
338
- else
339
- scim_apply_patch_path!(user, update, account_update, nested_path, nested_value, operation_name)
340
- end
341
- end
342
- end
343
-
344
- def scim_apply_patch_path!(user, update, account_update, path, value, operation_name)
345
- remove = operation_name == "remove"
346
- normalized = "/" + path.to_s.sub(%r{\A/+}, "").tr(".", "/")
347
- case normalized
348
- when "/active"
349
- update[:active] = remove ? nil : value
350
- when "/userName"
351
- update[:email] = remove ? nil : value.to_s.downcase
352
- when "/externalId"
353
- account_update[:accountId] = remove ? nil : value
354
- update[:externalId] = remove ? nil : value
355
- when "/name/formatted"
356
- update[:name] = value unless remove
357
- when "/name/givenName"
358
- update[:name] = scim_full_name(user.fetch("email"), given_name: value, family_name: scim_family_name(update[:name] || user["name"])) unless remove
359
- when "/name/familyName"
360
- update[:name] = scim_full_name(user.fetch("email"), given_name: scim_given_name(update[:name] || user["name"]), family_name: value) unless remove
361
- end
362
- end
363
-
364
- def scim_display_name(body, fallback = nil)
365
- name = normalize_hash(body[:name] || {})
366
- return name[:formatted].to_s.strip unless name[:formatted].to_s.strip.empty?
367
-
368
- scim_full_name(fallback, given_name: name[:given_name], family_name: name[:family_name])
369
- end
370
-
371
- def scim_user_resource(user, account = nil, base_url = nil)
372
- {
373
- schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
374
- id: user.fetch("id"),
375
- userName: user.fetch("email"),
376
- externalId: account&.fetch("accountId", nil) || user["externalId"],
377
- displayName: user["name"],
378
- active: user.key?("active") ? user["active"] : true,
379
- name: {formatted: user["name"]},
380
- emails: [{primary: true, value: user.fetch("email")}],
381
- meta: {resourceType: "User", location: base_url ? "#{base_url}/scim/v2/Users/#{user.fetch("id")}" : nil}.compact
382
- }.compact
383
- end
384
-
385
- def scim_parse_filter(filter)
386
- match = filter.to_s.match(/\A\s*([^\s]+)\s+(eq|ne|co|sw|ew|pr)\s*(?:"([^"]*)"|([^\s]+))?\s*\z/i)
387
- raise APIError.new("BAD_REQUEST", message: "Invalid SCIM filter") unless match
388
-
389
- field = match[1]
390
- operator = match[2].downcase
391
- raise APIError.new("BAD_REQUEST", message: "The operator \"#{operator}\" is not supported") unless operator == "eq"
392
- raise APIError.new("BAD_REQUEST", message: "Invalid SCIM filter") unless %w[userName externalId].include?(field)
393
-
394
- [field, match[3] || match[4]]
395
- end
396
-
397
- def scim_user_schema
398
- {id: "urn:ietf:params:scim:schemas:core:2.0:User", name: "User", attributes: [{name: "userName", type: "string"}, {name: "active", type: "boolean"}]}
399
- end
400
-
401
- def scim_user_resource_type
402
- {id: "User", name: "User", endpoint: "/Users", schema: "urn:ietf:params:scim:schemas:core:2.0:User"}
403
- end
404
-
405
- def scim_param(ctx, key)
406
- ctx.params[key] || ctx.params[key.to_s] || ctx.params[Schema.storage_key(key)] || ctx.params[Schema.storage_key(key).to_sym]
407
- end
408
-
409
- def scim_has_organization_plugin?(ctx)
410
- Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
411
- end
412
-
413
- def scim_create_org_membership(ctx, user_id, organization_id)
414
- return unless organization_id
415
- return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])
416
-
417
- ctx.context.adapter.create(model: "member", data: {userId: user_id, organizationId: organization_id, role: "member", createdAt: Time.now})
418
- end
419
-
420
- def scim_account_id(body)
421
- body[:external_id] || body[:user_name]
422
- end
423
-
424
- def scim_primary_email(body)
425
- primary = Array(body[:emails]).find { |email| normalize_hash(email)[:primary] }
426
- first = Array(body[:emails]).first
427
- normalize_hash(primary || first)[:value] || body[:user_name]
428
- end
429
-
430
- def scim_full_name(fallback, given_name:, family_name:)
431
- name = [given_name, family_name].compact.join(" ").strip
432
- name.empty? ? fallback.to_s : name
433
- end
434
-
435
- def scim_given_name(name)
436
- parts = name.to_s.split
437
- (parts.length > 1) ? parts[0...-1].join(" ") : name.to_s
438
- end
439
-
440
- def scim_family_name(name)
441
- parts = name.to_s.split
442
- (parts.length > 1) ? parts[1..].join(" ") : ""
443
- end
444
66
  end
445
67
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def scim_client
8
+ {
9
+ "id" => "scim-client",
10
+ "version" => BetterAuth::SCIM::VERSION,
11
+ "serverPluginId" => "scim"
12
+ }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def scim_user_update(body)
8
+ {
9
+ email: scim_primary_email(body)&.downcase,
10
+ name: scim_display_name(body, body[:user_name].to_s),
11
+ updatedAt: Time.now
12
+ }.compact
13
+ end
14
+
15
+ def scim_display_name(body, fallback = nil)
16
+ name = normalize_hash(body[:name] || {})
17
+ return name[:formatted].to_s.strip unless name[:formatted].to_s.strip.empty?
18
+
19
+ scim_full_name(fallback, given_name: name[:given_name], family_name: name[:family_name])
20
+ end
21
+
22
+ def scim_account_id(body)
23
+ body[:external_id] || body[:user_name].to_s.downcase
24
+ end
25
+
26
+ def scim_primary_email(body)
27
+ primary = Array(body[:emails]).find { |email| normalize_hash(email)[:primary] }
28
+ first = Array(body[:emails]).first
29
+ normalize_hash(primary || first)[:value] || body[:user_name]
30
+ end
31
+
32
+ def scim_full_name(fallback, given_name:, family_name:)
33
+ name = [given_name, family_name].compact.join(" ").strip
34
+ name.empty? ? fallback.to_s : name
35
+ end
36
+
37
+ def scim_given_name(name)
38
+ parts = name.to_s.split
39
+ (parts.length > 1) ? parts[0...-1].join(" ") : name.to_s
40
+ end
41
+
42
+ def scim_family_name(name)
43
+ parts = name.to_s.split
44
+ (parts.length > 1) ? parts[1..].join(" ") : ""
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def scim_auth_middleware(config)
8
+ lambda do |ctx|
9
+ encoded = ctx.headers["authorization"].to_s.sub(/\ABearer\s+/i, "")
10
+ raise scim_error("UNAUTHORIZED", "SCIM token is required") if encoded.empty?
11
+
12
+ token, provider_id, organization_id = scim_decode_token(encoded)
13
+ provider = scim_default_provider(config, provider_id, organization_id)
14
+ if provider
15
+ raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider.fetch("scimToken") == token
16
+ else
17
+ provider = ctx.context.adapter.find_one(
18
+ model: "scimProvider",
19
+ where: [{field: "providerId", value: provider_id}].tap { |where| where << {field: "organizationId", value: organization_id} if organization_id }
20
+ )
21
+ raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless provider
22
+ raise scim_error("UNAUTHORIZED", "Invalid SCIM token") unless scim_token_matches?(ctx, config, token, provider.fetch("scimToken"))
23
+ end
24
+
25
+ ctx.context.apply_plugin_context!(scim_provider: provider)
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def scim_apply_patch_value!(user, update, account_update, value, operation_name, path = nil)
8
+ value.each do |key, nested_value|
9
+ nested_key = Schema.storage_key(key)
10
+ nested_path = path ? "#{path}.#{nested_key}" : nested_key
11
+ if nested_value.is_a?(Hash)
12
+ scim_apply_patch_value!(user, update, account_update, normalize_hash(nested_value), operation_name, nested_path)
13
+ else
14
+ scim_apply_patch_path!(user, update, account_update, nested_path, nested_value, operation_name)
15
+ end
16
+ end
17
+ end
18
+
19
+ def scim_apply_patch_path!(user, update, account_update, path, value, operation_name)
20
+ remove = operation_name == "remove"
21
+ normalized = "/" + path.to_s.sub(%r{\A/+}, "").tr(".", "/")
22
+ case normalized
23
+ when "/userName"
24
+ new_value = value.to_s.downcase
25
+ update[:email] = new_value if scim_patch_should_apply?(user["email"], new_value, operation_name)
26
+ when "/externalId"
27
+ account_update[:accountId] = value unless remove
28
+ when "/name/formatted"
29
+ update[:name] = value if scim_patch_should_apply?(user["name"], value, operation_name)
30
+ when "/name/givenName"
31
+ new_value = scim_full_name(user.fetch("email"), given_name: value, family_name: scim_family_name(update[:name] || user["name"]))
32
+ update[:name] = new_value if scim_patch_should_apply?(user["name"], new_value, operation_name)
33
+ when "/name/familyName"
34
+ new_value = scim_full_name(user.fetch("email"), given_name: scim_given_name(update[:name] || user["name"]), family_name: value)
35
+ update[:name] = new_value if scim_patch_should_apply?(user["name"], new_value, operation_name)
36
+ end
37
+ end
38
+
39
+ def scim_patch_should_apply?(current_value, new_value, operation_name)
40
+ return false if operation_name == "remove"
41
+ return false if operation_name == "add" && current_value == new_value
42
+
43
+ true
44
+ end
45
+ end
46
+ end