Module: BetterAuth::Plugins

Defined in:
lib/better_auth/plugins/scim.rb

Class Method Summary collapse

Class Method Details

.scim(options = {}) ⇒ Object



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/better_auth/plugins/scim.rb', line 13

def scim(options = {})
  config = {store_scim_token: "plain"}.merge(normalize_hash(options))
  Plugin.new(
    id: "scim",
    schema: scim_schema,
    endpoints: {
      generate_scim_token: scim_generate_token_endpoint(config),
      create_scim_user: scim_create_user_endpoint(config),
      update_scim_user: scim_update_user_endpoint(config),
      patch_scim_user: scim_patch_user_endpoint(config),
      delete_scim_user: scim_delete_user_endpoint(config),
      list_scim_users: scim_list_users_endpoint(config),
      get_scim_user: scim_get_user_endpoint(config),
      get_scim_service_provider_config: scim_service_provider_config_endpoint,
      get_scim_schemas: scim_schemas_endpoint,
      get_scim_schema: scim_schema_endpoint,
      get_scim_resource_types: scim_resource_types_endpoint,
      get_scim_resource_type: scim_resource_type_endpoint
    },
    options: config
  )
end

.scim_account_id(body) ⇒ Object



420
421
422
# File 'lib/better_auth/plugins/scim.rb', line 420

def (body)
  body[:external_id] || body[:user_name]
end

.scim_apply_patch_path!(user, update, account_update, path, value, operation_name) ⇒ Object



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/better_auth/plugins/scim.rb', line 344

def scim_apply_patch_path!(user, update, , path, value, operation_name)
  remove = operation_name == "remove"
  normalized = "/" + path.to_s.sub(%r{\A/+}, "").tr(".", "/")
  case normalized
  when "/active"
    update[:active] = remove ? nil : value
  when "/userName"
    update[:email] = remove ? nil : value.to_s.downcase
  when "/externalId"
    [:accountId] = remove ? nil : value
    update[:externalId] = remove ? nil : value
  when "/name/formatted"
    update[:name] = value unless remove
  when "/name/givenName"
    update[:name] = scim_full_name(user.fetch("email"), given_name: value, family_name: scim_family_name(update[:name] || user["name"])) unless remove
  when "/name/familyName"
    update[:name] = scim_full_name(user.fetch("email"), given_name: scim_given_name(update[:name] || user["name"]), family_name: value) unless remove
  end
end

.scim_apply_patch_value!(user, update, account_update, value, operation_name, path = nil) ⇒ Object



332
333
334
335
336
337
338
339
340
341
342
# File 'lib/better_auth/plugins/scim.rb', line 332

def scim_apply_patch_value!(user, update, , value, operation_name, path = nil)
  value.each do |key, nested_value|
    nested_key = Schema.storage_key(key)
    nested_path = path ? "#{path}.#{nested_key}" : nested_key
    if nested_value.is_a?(Hash)
      scim_apply_patch_value!(user, update, , normalize_hash(nested_value), operation_name, nested_path)
    else
      scim_apply_patch_path!(user, update, , nested_path, nested_value, operation_name)
    end
  end
end

.scim_auth_middleware(config) ⇒ Object



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/better_auth/plugins/scim.rb', line 236

def scim_auth_middleware(config)
  lambda do |ctx|
    encoded = ctx.headers["authorization"].to_s.sub(/\ABearer\s+/i, "")
    raise APIError.new("UNAUTHORIZED", message: "SCIM token is required") if encoded.empty?

    token, provider_id, organization_id = scim_decode_token(encoded)
    provider = scim_default_provider(config, token, provider_id, organization_id) ||
      ctx.context.adapter.find_one(
        model: "scimProvider",
        where: [{field: "providerId", value: provider_id}].tap { |where| where << {field: "organizationId", value: organization_id} if organization_id }
      )
    raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") unless provider
    raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") unless scim_token_matches?(ctx, config, token, provider.fetch("scimToken"))

    ctx.context.apply_plugin_context!(scim_provider: provider)
    nil
  end
end

.scim_create_org_membership(ctx, user_id, organization_id) ⇒ Object



413
414
415
416
417
418
# File 'lib/better_auth/plugins/scim.rb', line 413

def scim_create_org_membership(ctx, user_id, organization_id)
  return unless organization_id
  return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])

  ctx.context.adapter.create(model: "member", data: {userId: user_id, organizationId: organization_id, role: "member", createdAt: Time.now})
end

.scim_create_user_endpoint(config) ⇒ Object



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/better_auth/plugins/scim.rb', line 89

def scim_create_user_endpoint(config)
  Endpoint.new(path: "/scim/v2/Users", method: "POST", use: [scim_auth_middleware(config)]) do |ctx|
    body = normalize_hash(ctx.body)
    provider = ctx.context.scim_provider
    provider_id = provider.fetch("providerId")
    email = scim_primary_email(body).downcase
     = (body)
     = ctx.context.adapter.find_one(model: "account", where: [{field: "accountId", value: }, {field: "providerId", value: provider_id}])
    raise APIError.new("CONFLICT", message: "User already exists") if 

    user = ctx.context.internal_adapter.find_user_by_email(email)&.fetch(:user)
    user ||= ctx.context.internal_adapter.create_user(
      email: email,
      name: scim_display_name(body, email),
      emailVerified: true,
      active: body.key?(:active) ? body[:active] : true,
      externalId: body[:external_id]
    )
     = ctx.context.internal_adapter.(
      userId: user.fetch("id"),
      providerId: provider_id,
      accountId: ,
      accessToken: "",
      refreshToken: ""
    )
    scim_create_org_membership(ctx, user.fetch("id"), provider["organizationId"])
    ctx.json(scim_user_resource(user, , ctx.context.base_url), status: 201)
  end
end

.scim_decode_token(encoded) ⇒ Object



278
279
280
281
282
283
284
285
286
# File 'lib/better_auth/plugins/scim.rb', line 278

def scim_decode_token(encoded)
  decoded = Base64.urlsafe_decode64(encoded.to_s)
  token, provider_id, *organization_parts = decoded.split(":")
  raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token") if token.to_s.empty? || provider_id.to_s.empty?

  [token, provider_id, organization_parts.join(":").then { |value| value.empty? ? nil : value }]
rescue ArgumentError
  raise APIError.new("UNAUTHORIZED", message: "Invalid SCIM token")
end

.scim_default_provider(config, token, provider_id, organization_id) ⇒ Object



288
289
290
291
292
293
294
295
296
297
298
# File 'lib/better_auth/plugins/scim.rb', line 288

def scim_default_provider(config, token, provider_id, organization_id)
  Array(config[:default_scim]).find do |provider|
    candidate = normalize_hash(provider)
    candidate[:provider_id].to_s == provider_id.to_s &&
      candidate[:scim_token].to_s == token.to_s &&
      candidate[:organization_id].to_s == organization_id.to_s
  end&.then do |provider|
    data = normalize_hash(provider)
    {"providerId" => data[:provider_id], "scimToken" => data[:scim_token], "organizationId" => data[:organization_id]}
  end
end

.scim_delete_user_endpoint(config) ⇒ Object



154
155
156
157
158
159
160
# File 'lib/better_auth/plugins/scim.rb', line 154

def scim_delete_user_endpoint(config)
  Endpoint.new(path: "/scim/v2/Users/:userId", method: "DELETE", metadata: {allowed_media_types: ["application/json", ""]}, use: [scim_auth_middleware(config)]) do |ctx|
    user, = scim_find_user_with_account!(ctx)
    ctx.context.internal_adapter.delete_user(user.fetch("id"))
    ctx.json(nil, status: 204)
  end
end

.scim_display_name(body, fallback = nil) ⇒ Object



364
365
366
367
368
369
# File 'lib/better_auth/plugins/scim.rb', line 364

def scim_display_name(body, fallback = nil)
  name = normalize_hash(body[:name] || {})
  return name[:formatted].to_s.strip unless name[:formatted].to_s.strip.empty?

  scim_full_name(fallback, given_name: name[:given_name], family_name: name[:family_name])
end

.scim_family_name(name) ⇒ Object



440
441
442
443
# File 'lib/better_auth/plugins/scim.rb', line 440

def scim_family_name(name)
  parts = name.to_s.split
  (parts.length > 1) ? parts[1..].join(" ") : ""
end

.scim_find_user_with_account!(ctx) ⇒ Object

Raises:

  • (APIError)


300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/better_auth/plugins/scim.rb', line 300

def scim_find_user_with_account!(ctx)
  provider = ctx.context.scim_provider
  user_id = scim_param(ctx, :user_id)
   = ctx.context.adapter.find_one(
    model: "account",
    where: [
      {field: "userId", value: user_id},
      {field: "providerId", value: provider.fetch("providerId")}
    ]
  )
  user =  && ctx.context.internal_adapter.find_user_by_id(user_id)
  if user && provider["organizationId"]
    member = ctx.context.adapter.find_one(
      model: "member",
      where: [{field: "organizationId", value: provider.fetch("organizationId")}, {field: "userId", value: user_id}]
    )
    user = nil unless member
  end
  raise APIError.new("NOT_FOUND", message: "User not found") unless user && 

  [user, ]
end

.scim_full_name(fallback, given_name:, family_name:) ⇒ Object



430
431
432
433
# File 'lib/better_auth/plugins/scim.rb', line 430

def scim_full_name(fallback, given_name:, family_name:)
  name = [given_name, family_name].compact.join(" ").strip
  name.empty? ? fallback.to_s : name
end

.scim_generate_token_endpoint(config) ⇒ Object



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/better_auth/plugins/scim.rb', line 54

def scim_generate_token_endpoint(config)
  Endpoint.new(path: "/scim/generate-token", method: "POST") do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    provider_id = body[:provider_id].to_s
    organization_id = body[:organization_id]
    raise APIError.new("BAD_REQUEST", message: "Provider id contains forbidden characters") if provider_id.include?(":")
    if organization_id && !scim_has_organization_plugin?(ctx)
      raise APIError.new("BAD_REQUEST", message: "Restricting a token to an organization requires the organization plugin")
    end

    if organization_id
      member = ctx.context.adapter.find_one(
        model: "member",
        where: [
          {field: "userId", value: session.fetch(:user).fetch("id")},
          {field: "organizationId", value: organization_id}
        ]
      )
      raise APIError.new("FORBIDDEN", message: "You are not a member of the organization") unless member
    end

    base_token = Crypto.random_string(24)
    token = Base64.urlsafe_encode64([base_token, provider_id, organization_id].compact.join(":"), padding: false)
    stored = scim_store_token(ctx, config, base_token)
    where = [{field: "providerId", value: provider_id}]
    where << {field: "organizationId", value: organization_id} if organization_id
    existing = ctx.context.adapter.find_one(model: "scimProvider", where: where)
    data = {providerId: provider_id, scimToken: stored, organizationId: organization_id}
    ctx.context.adapter.delete(model: "scimProvider", where: [{field: "id", value: existing.fetch("id")}]) if existing
    ctx.context.adapter.create(model: "scimProvider", data: data)
    ctx.json({scimToken: token}, status: 201)
  end
end

.scim_get_user_endpoint(config) ⇒ Object



187
188
189
190
191
192
# File 'lib/better_auth/plugins/scim.rb', line 187

def scim_get_user_endpoint(config)
  Endpoint.new(path: "/scim/v2/Users/:userId", method: "GET", use: [scim_auth_middleware(config)]) do |ctx|
    user,  = scim_find_user_with_account!(ctx)
    ctx.json(scim_user_resource(user, , ctx.context.base_url))
  end
end

.scim_given_name(name) ⇒ Object



435
436
437
438
# File 'lib/better_auth/plugins/scim.rb', line 435

def scim_given_name(name)
  parts = name.to_s.split
  (parts.length > 1) ? parts[0...-1].join(" ") : name.to_s
end

.scim_has_organization_plugin?(ctx) ⇒ Boolean

Returns:

  • (Boolean)


409
410
411
# File 'lib/better_auth/plugins/scim.rb', line 409

def scim_has_organization_plugin?(ctx)
  Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
end

.scim_list_users_endpoint(config) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/better_auth/plugins/scim.rb', line 162

def scim_list_users_endpoint(config)
  Endpoint.new(path: "/scim/v2/Users", method: "GET", use: [scim_auth_middleware(config)]) do |ctx|
    provider = ctx.context.scim_provider
    accounts = ctx.context.adapter.find_many(model: "account", where: [{field: "providerId", value: provider.fetch("providerId")}])
    users_by_id = ctx.context.internal_adapter.list_users.each_with_object({}) { |user, result| result[user.fetch("id")] = user }
    users = accounts.filter_map { || users_by_id[.fetch("userId")] }
    if provider["organizationId"]
      member_ids = ctx.context.adapter.find_many(
        model: "member",
        where: [{field: "organizationId", value: provider.fetch("organizationId")}]
      ).map { |member| member.fetch("userId") }
      users = users.select { |user| member_ids.include?(user.fetch("id")) }
    end
    filter_field, filter_value = scim_parse_filter(ctx.query[:filter] || ctx.query["filter"]) if ctx.query[:filter] || ctx.query["filter"]
    resources = users.filter_map do |user|
       = accounts.find { |entry| entry.fetch("userId") == user.fetch("id") }
      resource = scim_user_resource(user, , ctx.context.base_url)
      next resource unless filter_field

      (resource[filter_field.to_sym].to_s.downcase == filter_value.to_s.downcase) ? resource : nil
    end
    ctx.json({schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
  end
end

.scim_param(ctx, key) ⇒ Object



405
406
407
# File 'lib/better_auth/plugins/scim.rb', line 405

def scim_param(ctx, key)
  ctx.params[key] || ctx.params[key.to_s] || ctx.params[Schema.storage_key(key)] || ctx.params[Schema.storage_key(key).to_sym]
end

.scim_parse_filter(filter) ⇒ Object

Raises:

  • (APIError)


385
386
387
388
389
390
391
392
393
394
395
# File 'lib/better_auth/plugins/scim.rb', line 385

def scim_parse_filter(filter)
  match = filter.to_s.match(/\A\s*([^\s]+)\s+(eq|ne|co|sw|ew|pr)\s*(?:"([^"]*)"|([^\s]+))?\s*\z/i)
  raise APIError.new("BAD_REQUEST", message: "Invalid SCIM filter") unless match

  field = match[1]
  operator = match[2].downcase
  raise APIError.new("BAD_REQUEST", message: "The operator \"#{operator}\" is not supported") unless operator == "eq"
  raise APIError.new("BAD_REQUEST", message: "Invalid SCIM filter") unless %w[userName externalId].include?(field)

  [field, match[3] || match[4]]
end

.scim_patch_user_endpoint(config) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/better_auth/plugins/scim.rb', line 129

def scim_patch_user_endpoint(config)
  Endpoint.new(path: "/scim/v2/Users/:userId", method: "PATCH", use: [scim_auth_middleware(config)]) do |ctx|
    user,  = scim_find_user_with_account!(ctx)
    update = {}
     = {}
    Array(normalize_hash(ctx.body)[:operations] || ctx.body["Operations"]).each do |operation|
      op = normalize_hash(operation)
      operation_name = op[:op].to_s.downcase
      raise APIError.new("BAD_REQUEST", message: "Invalid SCIM patch operation") unless %w[replace add remove].include?(operation_name)

      if op[:path].to_s.empty? && op[:value].is_a?(Hash)
        scim_apply_patch_value!(user, update, , normalize_hash(op[:value]), operation_name)
        next
      end

      scim_apply_patch_path!(user, update, , op[:path], op[:value], operation_name)
    end
    raise APIError.new("BAD_REQUEST", message: "No valid fields to update") if update.empty? && .empty?

    ctx.context.internal_adapter.update_user(user.fetch("id"), update) unless update.empty?
    ctx.context.internal_adapter.(.fetch("id"), ) unless .empty?
    ctx.json(nil, status: 204)
  end
end

.scim_primary_email(body) ⇒ Object



424
425
426
427
428
# File 'lib/better_auth/plugins/scim.rb', line 424

def scim_primary_email(body)
  primary = Array(body[:emails]).find { |email| normalize_hash(email)[:primary] }
  first = Array(body[:emails]).first
  normalize_hash(primary || first)[:value] || body[:user_name]
end

.scim_resource_type_endpointObject



228
229
230
231
232
233
234
# File 'lib/better_auth/plugins/scim.rb', line 228

def scim_resource_type_endpoint
  Endpoint.new(path: "/scim/v2/ResourceTypes/:resourceTypeId", method: "GET") do |ctx|
    raise APIError.new("NOT_FOUND", message: "Resource type not found") unless scim_param(ctx, :resource_type_id) == "User"

    ctx.json(scim_user_resource_type)
  end
end

.scim_resource_types_endpointObject



222
223
224
225
226
# File 'lib/better_auth/plugins/scim.rb', line 222

def scim_resource_types_endpoint
  Endpoint.new(path: "/scim/v2/ResourceTypes", method: "GET") do |ctx|
    ctx.json({Resources: [scim_user_resource_type], totalResults: 1})
  end
end

.scim_schemaObject



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/better_auth/plugins/scim.rb', line 36

def scim_schema
  {
    scimProvider: {
      fields: {
        providerId: {type: "string", required: true, unique: true},
        scimToken: {type: "string", required: true, unique: true},
        organizationId: {type: "string", required: false}
      }
    },
    user: {
      fields: {
        active: {type: "boolean", required: false, default_value: true},
        externalId: {type: "string", required: false}
      }
    }
  }
end

.scim_schema_endpointObject



214
215
216
217
218
219
220
# File 'lib/better_auth/plugins/scim.rb', line 214

def scim_schema_endpoint
  Endpoint.new(path: "/scim/v2/Schemas/:schemaId", method: "GET") do |ctx|
    raise APIError.new("NOT_FOUND", message: "Schema not found") unless scim_param(ctx, :schema_id).to_s.end_with?("User")

    ctx.json(scim_user_schema)
  end
end

.scim_schemas_endpointObject



208
209
210
211
212
# File 'lib/better_auth/plugins/scim.rb', line 208

def scim_schemas_endpoint
  Endpoint.new(path: "/scim/v2/Schemas", method: "GET") do |ctx|
    ctx.json({Resources: [scim_user_schema], totalResults: 1})
  end
end

.scim_service_provider_config_endpointObject



194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/better_auth/plugins/scim.rb', line 194

def scim_service_provider_config_endpoint
  Endpoint.new(path: "/scim/v2/ServiceProviderConfig", method: "GET") do |ctx|
    ctx.json({
      schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
      patch: {supported: true},
      filter: {supported: true, maxResults: 100},
      changePassword: {supported: false},
      sort: {supported: false},
      etag: {supported: false},
      authenticationSchemes: [{type: "oauthbearertoken", name: "OAuth Bearer Token"}]
    })
  end
end

.scim_store_token(ctx, config, token) ⇒ Object



255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/better_auth/plugins/scim.rb', line 255

def scim_store_token(ctx, config, token)
  storage = config[:store_scim_token]
  if storage == "hashed"
    Crypto.sha256(token)
  elsif storage == "encrypted"
    Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
  elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
    storage[:hash].call(token)
  elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
    storage[:encrypt].call(token)
  else
    token
  end
end

.scim_token_matches?(ctx, config, token, stored) ⇒ Boolean

Returns:

  • (Boolean)


270
271
272
273
274
275
276
# File 'lib/better_auth/plugins/scim.rb', line 270

def scim_token_matches?(ctx, config, token, stored)
  storage = config[:store_scim_token]
  return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored) == token if storage == "encrypted"
  return storage[:decrypt].call(stored) == token if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)

  !token.to_s.empty? && scim_store_token(ctx, config, token) == stored
end

.scim_update_user_endpoint(config) ⇒ Object



119
120
121
122
123
124
125
126
127
# File 'lib/better_auth/plugins/scim.rb', line 119

def scim_update_user_endpoint(config)
  Endpoint.new(path: "/scim/v2/Users/:userId", method: "PUT", use: [scim_auth_middleware(config)]) do |ctx|
    user,  = scim_find_user_with_account!(ctx)
    body = normalize_hash(ctx.body)
    updated = ctx.context.internal_adapter.update_user(user.fetch("id"), scim_user_update(body))
     = ctx.context.internal_adapter.(.fetch("id"), accountId: (body))
    ctx.json(scim_user_resource(updated, , ctx.context.base_url))
  end
end

.scim_user_resource(user, account = nil, base_url = nil) ⇒ Object



371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/better_auth/plugins/scim.rb', line 371

def scim_user_resource(user,  = nil, base_url = nil)
  {
    schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
    id: user.fetch("id"),
    userName: user.fetch("email"),
    externalId: &.fetch("accountId", nil) || user["externalId"],
    displayName: user["name"],
    active: user.key?("active") ? user["active"] : true,
    name: {formatted: user["name"]},
    emails: [{primary: true, value: user.fetch("email")}],
    meta: {resourceType: "User", location: base_url ? "#{base_url}/scim/v2/Users/#{user.fetch("id")}" : nil}.compact
  }.compact
end

.scim_user_resource_typeObject



401
402
403
# File 'lib/better_auth/plugins/scim.rb', line 401

def scim_user_resource_type
  {id: "User", name: "User", endpoint: "/Users", schema: "urn:ietf:params:scim:schemas:core:2.0:User"}
end

.scim_user_schemaObject



397
398
399
# File 'lib/better_auth/plugins/scim.rb', line 397

def scim_user_schema
  {id: "urn:ietf:params:scim:schemas:core:2.0:User", name: "User", attributes: [{name: "userName", type: "string"}, {name: "active", type: "boolean"}]}
end

.scim_user_update(body) ⇒ Object



323
324
325
326
327
328
329
330
# File 'lib/better_auth/plugins/scim.rb', line 323

def scim_user_update(body)
  {
    email: scim_primary_email(body)&.downcase,
    name: scim_display_name(body, body[:user_name].to_s),
    active: body.key?(:active) ? body[:active] : nil,
    externalId: body[:external_id]
  }.compact
end