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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a25f1b935926c0b3cd74a888fc9c923ddbb9c7ade8912d2c935ab2f27b747f33
4
- data.tar.gz: 37210f202bee13853b720dbec539db4ba086eb3693a7234d2745ab41f45ceb63
3
+ metadata.gz: 8df92e031a036d624d2ecf54d316007b428e789bc991f6bff2a930d04b11b982
4
+ data.tar.gz: 0c1364c9ac343a17cf235b043ab19c0134a3b468716ee6ae2b7ed927352a7563
5
5
  SHA512:
6
- metadata.gz: ee253db8f45e7827d333beb10cc77fdef1dd11d6dfb74ddfe8446960b85da5c137f42cf201fef3bad292f3c5ce0b001a31752b25ea95259b0fd3861140b70883
7
- data.tar.gz: 7c16ca543cfb57e42a1a8da8a0afd0c3c568e6b3b6969b18c46186d651f5a94349bb40689cb1efdb7cacb271d9bbcff349e5e78e2be36693a9a7da94339c6c2c
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
- schema: scim_schema,
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 scim_schema
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.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
- )
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 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)
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
- 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))
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(normalize_hash(ctx.body)[:operations] || ctx.body["Operations"]).each do |operation|
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 APIError.new("BAD_REQUEST", message: "Invalid SCIM patch operation") unless %w[replace add remove].include?(operation_name)
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[:path].to_s.empty? && op[:value].is_a?(Hash)
140
- scim_apply_patch_value!(user, update, account_update, normalize_hash(op[:value]), operation_name)
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 APIError.new("BAD_REQUEST", message: "No valid fields to update") if update.empty? && account_update.empty?
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: {allowed_media_types: ["application/json", ""]}, use: [scim_auth_middleware(config)]) do |ctx|
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: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
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
- filter: {supported: true, maxResults: 100},
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: [{type: "oauthbearertoken", name: "OAuth Bearer Token"}]
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.json({Resources: [scim_user_schema], totalResults: 1})
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 APIError.new("NOT_FOUND", message: "Schema not found") unless scim_param(ctx, :schema_id).to_s.end_with?("User")
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.json({Resources: [scim_user_resource_type], totalResults: 1})
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 APIError.new("NOT_FOUND", message: "Resource type not found") unless scim_param(ctx, :resource_type_id) == "User"
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 APIError.new("UNAUTHORIZED", message: "SCIM token is required") if encoded.empty?
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, token, provider_id, organization_id) ||
243
- ctx.context.adapter.find_one(
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
- 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"))
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 APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") if token.to_s.empty? || provider_id.to_s.empty?
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 APIError.new("UNAUTHORIZED", message: "Invalid SCIM token")
398
+ raise scim_error("UNAUTHORIZED", "Invalid SCIM token")
286
399
  end
287
400
 
288
- def scim_default_provider(config, token, provider_id, organization_id)
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
- candidate[:scim_token].to_s == token.to_s &&
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 APIError.new("NOT_FOUND", message: "User not found") unless user && account
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
- active: body.key?(:active) ? body[:active] : nil,
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
- update[:email] = remove ? nil : value.to_s.downcase
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] = remove ? nil : value
354
- update[:externalId] = remove ? nil : value
487
+ account_update[:accountId] = value unless remove
355
488
  when "/name/formatted"
356
- update[:name] = value unless remove
489
+ update[:name] = value if scim_patch_should_apply?(user["name"], value, operation_name)
357
490
  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
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
- update[:name] = scim_full_name(user.fetch("email"), given_name: scim_given_name(update[:name] || user["name"]), family_name: value) unless remove
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: ["urn:ietf:params:scim:schemas:core:2.0:User"],
515
+ schemas: [SCIM_USER_SCHEMA_ID],
374
516
  id: user.fetch("id"),
375
517
  userName: user.fetch("email"),
376
- externalId: account&.fetch("accountId", nil) || user["externalId"],
518
+ externalId: account&.fetch("accountId", nil),
377
519
  displayName: user["name"],
378
- active: user.key?("active") ? user["active"] : true,
520
+ active: true,
379
521
  name: {formatted: user["name"]},
380
522
  emails: [{primary: true, value: user.fetch("email")}],
381
- meta: {resourceType: "User", location: base_url ? "#{base_url}/scim/v2/Users/#{user.fetch("id")}" : nil}.compact
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 APIError.new("BAD_REQUEST", message: "Invalid SCIM filter") unless match
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 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)
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
- {id: "urn:ietf:params:scim:schemas:core:2.0:User", name: "User", attributes: [{name: "userName", type: "string"}, {name: "active", type: "boolean"}]}
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
- {id: "User", name: "User", endpoint: "/Users", schema: "urn:ietf:params:scim:schemas:core:2.0:User"}
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module SCIM
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala