better_auth-scim 0.2.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/better_auth/plugins/scim.rb +14 -676
- data/lib/better_auth/scim/client.rb +15 -0
- data/lib/better_auth/scim/mappings.rb +47 -0
- data/lib/better_auth/scim/middlewares.rb +30 -0
- data/lib/better_auth/scim/patch_operations.rb +46 -0
- data/lib/better_auth/scim/routes.rb +421 -0
- data/lib/better_auth/scim/scim_error.rb +17 -0
- data/lib/better_auth/scim/scim_filters.rb +21 -0
- data/lib/better_auth/scim/scim_metadata.rb +42 -0
- data/lib/better_auth/scim/scim_resources.rb +26 -0
- data/lib/better_auth/scim/scim_tokens.rb +56 -0
- data/lib/better_auth/scim/user_schemas.rb +61 -0
- data/lib/better_auth/scim/utils.rb +17 -0
- data/lib/better_auth/scim/version.rb +1 -1
- data/lib/better_auth/scim.rb +12 -0
- metadata +13 -1
|
@@ -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
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Plugins
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def scim_generate_token_endpoint(config)
|
|
10
|
+
Endpoint.new(path: "/scim/generate-token", method: "POST", metadata: scim_openapi_metadata("Generates a new SCIM token for the given provider")) do |ctx|
|
|
11
|
+
session = Routes.current_session(ctx)
|
|
12
|
+
body = normalize_hash(ctx.body)
|
|
13
|
+
raw_provider_id = body[:provider_id]
|
|
14
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) unless raw_provider_id.is_a?(String)
|
|
15
|
+
|
|
16
|
+
provider_id = raw_provider_id
|
|
17
|
+
organization_id = body[:organization_id]
|
|
18
|
+
if body.key?(:organization_id) && !organization_id.nil? && !organization_id.is_a?(String)
|
|
19
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"])
|
|
20
|
+
end
|
|
21
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["MISSING_FIELD"]) if provider_id.empty?
|
|
22
|
+
raise APIError.new("BAD_REQUEST", message: "Provider id contains forbidden characters") if provider_id.include?(":")
|
|
23
|
+
required_roles = scim_required_roles(ctx, config)
|
|
24
|
+
if organization_id && !scim_has_organization_plugin?(ctx)
|
|
25
|
+
raise APIError.new("BAD_REQUEST", message: "Restricting a token to an organization requires the organization plugin")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
member = nil
|
|
29
|
+
if organization_id
|
|
30
|
+
member = scim_find_organization_member(ctx, session.fetch(:user).fetch("id"), organization_id)
|
|
31
|
+
raise APIError.new("FORBIDDEN", message: "You are not a member of the organization") unless member
|
|
32
|
+
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
where = [{field: "providerId", value: provider_id}]
|
|
36
|
+
where << {field: "organizationId", value: organization_id} if organization_id
|
|
37
|
+
existing = ctx.context.adapter.find_one(model: "scimProvider", where: where)
|
|
38
|
+
if existing
|
|
39
|
+
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), existing, required_roles)
|
|
40
|
+
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "id", value: existing.fetch("id")}])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
base_token = Crypto.random_string(24)
|
|
44
|
+
token = Base64.urlsafe_encode64([base_token, provider_id, organization_id].compact.join(":"), padding: false)
|
|
45
|
+
scim_call_token_hook(config[:before_scim_token_generated], user: session.fetch(:user), member: member, scim_token: token)
|
|
46
|
+
stored = scim_store_token(ctx, config, base_token)
|
|
47
|
+
data = {providerId: provider_id, scimToken: stored, organizationId: organization_id}
|
|
48
|
+
data[:userId] = session.fetch(:user).fetch("id") if scim_provider_ownership_enabled?(config)
|
|
49
|
+
provider = ctx.context.adapter.create(model: "scimProvider", data: data)
|
|
50
|
+
scim_call_token_hook(config[:after_scim_token_generated], user: session.fetch(:user), member: member, scim_token: token, scim_provider: provider)
|
|
51
|
+
ctx.json({scimToken: token}, status: 201)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scim_list_provider_connections_endpoint(config)
|
|
56
|
+
Endpoint.new(path: "/scim/list-provider-connections", method: "GET", metadata: scim_openapi_metadata("List SCIM provider connections.")) do |ctx|
|
|
57
|
+
session = Routes.current_session(ctx)
|
|
58
|
+
user_id = session.fetch(:user).fetch("id")
|
|
59
|
+
required_roles = scim_required_roles(ctx, config)
|
|
60
|
+
org_memberships = scim_has_organization_plugin?(ctx) ? scim_user_org_memberships(ctx, user_id) : {}
|
|
61
|
+
providers = ctx.context.adapter.find_many(model: "scimProvider").select do |provider|
|
|
62
|
+
organization_id = provider["organizationId"]
|
|
63
|
+
if organization_id
|
|
64
|
+
member = org_memberships[organization_id]
|
|
65
|
+
member && scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
66
|
+
else
|
|
67
|
+
!provider.key?("userId") || provider["userId"].nil? || provider["userId"] == user_id
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
ctx.json({providers: providers.map { |provider| scim_normalized_provider(provider) }})
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def scim_get_provider_connection_endpoint(config)
|
|
75
|
+
Endpoint.new(path: "/scim/get-provider-connection", method: "GET", metadata: scim_openapi_metadata("Get SCIM provider connection.")) do |ctx|
|
|
76
|
+
session = Routes.current_session(ctx)
|
|
77
|
+
provider = scim_provider_by_provider_id!(ctx, scim_provider_id_query(ctx))
|
|
78
|
+
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), provider, scim_required_roles(ctx, config))
|
|
79
|
+
ctx.json(scim_normalized_provider(provider))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def scim_delete_provider_connection_endpoint(config)
|
|
84
|
+
Endpoint.new(path: "/scim/delete-provider-connection", method: "POST", metadata: scim_openapi_metadata("Delete SCIM provider connection.")) do |ctx|
|
|
85
|
+
session = Routes.current_session(ctx)
|
|
86
|
+
body = normalize_hash(ctx.body)
|
|
87
|
+
provider = scim_provider_by_provider_id!(ctx, body[:provider_id])
|
|
88
|
+
scim_assert_provider_access!(ctx, session.fetch(:user).fetch("id"), provider, scim_required_roles(ctx, config))
|
|
89
|
+
ctx.context.adapter.delete(model: "scimProvider", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
90
|
+
ctx.json({success: true})
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def scim_create_user_endpoint(config)
|
|
95
|
+
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|
|
|
96
|
+
body = normalize_hash(ctx.body)
|
|
97
|
+
scim_validate_user_body!(body)
|
|
98
|
+
provider = ctx.context.scim_provider
|
|
99
|
+
provider_id = provider.fetch("providerId")
|
|
100
|
+
email = scim_primary_email(body).downcase
|
|
101
|
+
account_id = scim_account_id(body)
|
|
102
|
+
existing_account = ctx.context.adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
|
|
103
|
+
raise scim_error("CONFLICT", "User already exists", scim_type: "uniqueness") if existing_account
|
|
104
|
+
|
|
105
|
+
user, account = ctx.context.adapter.transaction do
|
|
106
|
+
user = ctx.context.internal_adapter.find_user_by_email(email)&.fetch(:user)
|
|
107
|
+
user ||= ctx.context.internal_adapter.create_user(
|
|
108
|
+
email: email,
|
|
109
|
+
name: scim_display_name(body, email),
|
|
110
|
+
emailVerified: true
|
|
111
|
+
)
|
|
112
|
+
account = ctx.context.internal_adapter.create_account(
|
|
113
|
+
userId: user.fetch("id"),
|
|
114
|
+
providerId: provider_id,
|
|
115
|
+
accountId: account_id,
|
|
116
|
+
accessToken: "",
|
|
117
|
+
refreshToken: ""
|
|
118
|
+
)
|
|
119
|
+
scim_create_org_membership(ctx, user.fetch("id"), provider["organizationId"])
|
|
120
|
+
[user, account]
|
|
121
|
+
end
|
|
122
|
+
resource = scim_user_resource(user, account, ctx.context.base_url)
|
|
123
|
+
ctx.json(resource, status: 201, headers: {location: resource.fetch(:meta).fetch(:location)})
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def scim_update_user_endpoint(config)
|
|
128
|
+
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|
|
|
129
|
+
user, account = scim_find_user_with_account!(ctx)
|
|
130
|
+
body = normalize_hash(ctx.body)
|
|
131
|
+
scim_validate_user_body!(body)
|
|
132
|
+
updated, updated_account = ctx.context.adapter.transaction do
|
|
133
|
+
[
|
|
134
|
+
ctx.context.internal_adapter.update_user(user.fetch("id"), scim_user_update(body)),
|
|
135
|
+
ctx.context.internal_adapter.update_account(account.fetch("id"), accountId: scim_account_id(body), updatedAt: Time.now)
|
|
136
|
+
]
|
|
137
|
+
end
|
|
138
|
+
ctx.json(scim_user_resource(updated, updated_account, ctx.context.base_url))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def scim_patch_user_endpoint(config)
|
|
143
|
+
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|
|
|
144
|
+
user, account = scim_find_user_with_account!(ctx)
|
|
145
|
+
body = normalize_hash(ctx.body)
|
|
146
|
+
scim_validate_patch_body!(body)
|
|
147
|
+
update = {}
|
|
148
|
+
account_update = {}
|
|
149
|
+
Array(body[:operations] || ctx.body["Operations"]).each do |operation|
|
|
150
|
+
op = normalize_hash(operation)
|
|
151
|
+
operation_name = op[:op].to_s.empty? ? "replace" : op[:op].to_s.downcase
|
|
152
|
+
raise scim_error("BAD_REQUEST", "Invalid SCIM patch operation") unless %w[replace add remove].include?(operation_name)
|
|
153
|
+
|
|
154
|
+
if op[:value].is_a?(Hash)
|
|
155
|
+
patch_path = op[:path].to_s.empty? ? nil : op[:path]
|
|
156
|
+
scim_apply_patch_value!(user, update, account_update, normalize_hash(op[:value]), operation_name, patch_path)
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
scim_apply_patch_path!(user, update, account_update, op[:path], op[:value], operation_name)
|
|
161
|
+
end
|
|
162
|
+
raise scim_error("BAD_REQUEST", "No valid fields to update") if update.empty? && account_update.empty?
|
|
163
|
+
|
|
164
|
+
ctx.context.internal_adapter.update_user(user.fetch("id"), update.merge(updatedAt: Time.now)) unless update.empty?
|
|
165
|
+
ctx.context.internal_adapter.update_account(account.fetch("id"), account_update.merge(updatedAt: Time.now)) unless account_update.empty?
|
|
166
|
+
ctx.json(nil, status: 204)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def scim_delete_user_endpoint(config)
|
|
171
|
+
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|
|
|
172
|
+
user, = scim_find_user_with_account!(ctx)
|
|
173
|
+
ctx.context.internal_adapter.delete_user(user.fetch("id"))
|
|
174
|
+
ctx.json(nil, status: 204)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def scim_list_users_endpoint(config)
|
|
179
|
+
Endpoint.new(path: "/scim/v2/Users", method: "GET", metadata: scim_hidden_metadata("List SCIM users.", SCIM_SUPPORTED_MEDIA_TYPES), use: [scim_auth_middleware(config)]) do |ctx|
|
|
180
|
+
provider = ctx.context.scim_provider
|
|
181
|
+
accounts = ctx.context.adapter.find_many(model: "account", where: [{field: "providerId", value: provider.fetch("providerId")}])
|
|
182
|
+
users_by_id = ctx.context.internal_adapter.list_users.each_with_object({}) { |user, result| result[user.fetch("id")] = user }
|
|
183
|
+
users = accounts.filter_map { |account| users_by_id[account.fetch("userId")] }
|
|
184
|
+
if provider["organizationId"]
|
|
185
|
+
member_ids = ctx.context.adapter.find_many(
|
|
186
|
+
model: "member",
|
|
187
|
+
where: [{field: "organizationId", value: provider.fetch("organizationId")}]
|
|
188
|
+
).map { |member| member.fetch("userId") }
|
|
189
|
+
users = users.select { |user| member_ids.include?(user.fetch("id")) }
|
|
190
|
+
end
|
|
191
|
+
filter_field, filter_value = scim_parse_filter(ctx.query[:filter] || ctx.query["filter"]) if ctx.query[:filter] || ctx.query["filter"]
|
|
192
|
+
resources = users.filter_map do |user|
|
|
193
|
+
account = accounts.find { |entry| entry.fetch("userId") == user.fetch("id") }
|
|
194
|
+
resource = scim_user_resource(user, account, ctx.context.base_url)
|
|
195
|
+
next resource unless filter_field
|
|
196
|
+
|
|
197
|
+
(resource[filter_field.to_sym].to_s.downcase == filter_value.to_s.downcase) ? resource : nil
|
|
198
|
+
end
|
|
199
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], totalResults: resources.length, itemsPerPage: resources.length, startIndex: 1, Resources: resources})
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def scim_get_user_endpoint(config)
|
|
204
|
+
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|
|
|
205
|
+
user, account = scim_find_user_with_account!(ctx)
|
|
206
|
+
ctx.json(scim_user_resource(user, account, ctx.context.base_url))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def scim_service_provider_config_endpoint
|
|
211
|
+
Endpoint.new(path: "/scim/v2/ServiceProviderConfig", method: "GET", metadata: scim_hidden_metadata("SCIM Service Provider Configuration", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
212
|
+
ctx.json({
|
|
213
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
214
|
+
patch: {supported: true},
|
|
215
|
+
bulk: {supported: false},
|
|
216
|
+
filter: {supported: true},
|
|
217
|
+
changePassword: {supported: false},
|
|
218
|
+
sort: {supported: false},
|
|
219
|
+
etag: {supported: false},
|
|
220
|
+
authenticationSchemes: [{
|
|
221
|
+
type: "oauthbearertoken",
|
|
222
|
+
name: "OAuth Bearer Token",
|
|
223
|
+
description: "Authentication scheme using the Authorization header with a bearer token tied to an organization.",
|
|
224
|
+
specUri: "http://www.rfc-editor.org/info/rfc6750",
|
|
225
|
+
primary: true
|
|
226
|
+
}],
|
|
227
|
+
meta: {resourceType: "ServiceProviderConfig"}
|
|
228
|
+
})
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def scim_schemas_endpoint
|
|
233
|
+
Endpoint.new(path: "/scim/v2/Schemas", method: "GET", metadata: scim_hidden_metadata("List SCIM schemas.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
234
|
+
resource = scim_user_schema(ctx.context.base_url)
|
|
235
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], Resources: [resource], totalResults: 1, itemsPerPage: 1, startIndex: 1})
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def scim_schema_endpoint
|
|
240
|
+
Endpoint.new(path: "/scim/v2/Schemas/:schemaId", method: "GET", metadata: scim_hidden_metadata("Get SCIM schema.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
241
|
+
raise scim_error("NOT_FOUND", "Schema not found") unless scim_param(ctx, :schema_id).to_s == SCIM_USER_SCHEMA_ID
|
|
242
|
+
|
|
243
|
+
ctx.json(scim_user_schema(ctx.context.base_url))
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def scim_resource_types_endpoint
|
|
248
|
+
Endpoint.new(path: "/scim/v2/ResourceTypes", method: "GET", metadata: scim_hidden_metadata("List SCIM resource types.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
249
|
+
resource = scim_user_resource_type(ctx.context.base_url)
|
|
250
|
+
ctx.json({schemas: [SCIM_LIST_RESPONSE_SCHEMA], Resources: [resource], totalResults: 1, itemsPerPage: 1, startIndex: 1})
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def scim_resource_type_endpoint
|
|
255
|
+
Endpoint.new(path: "/scim/v2/ResourceTypes/:resourceTypeId", method: "GET", metadata: scim_hidden_metadata("Get SCIM resource type.", SCIM_SUPPORTED_MEDIA_TYPES)) do |ctx|
|
|
256
|
+
raise scim_error("NOT_FOUND", "Resource type not found") unless scim_param(ctx, :resource_type_id) == "User"
|
|
257
|
+
|
|
258
|
+
ctx.json(scim_user_resource_type(ctx.context.base_url))
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def scim_find_user_with_account!(ctx)
|
|
263
|
+
provider = ctx.context.scim_provider
|
|
264
|
+
user_id = scim_param(ctx, :user_id)
|
|
265
|
+
account = ctx.context.adapter.find_one(
|
|
266
|
+
model: "account",
|
|
267
|
+
where: [
|
|
268
|
+
{field: "userId", value: user_id},
|
|
269
|
+
{field: "providerId", value: provider.fetch("providerId")}
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
user = account && ctx.context.internal_adapter.find_user_by_id(user_id)
|
|
273
|
+
if user && provider["organizationId"]
|
|
274
|
+
member = ctx.context.adapter.find_one(
|
|
275
|
+
model: "member",
|
|
276
|
+
where: [{field: "organizationId", value: provider.fetch("organizationId")}, {field: "userId", value: user_id}]
|
|
277
|
+
)
|
|
278
|
+
user = nil unless member
|
|
279
|
+
end
|
|
280
|
+
raise scim_error("NOT_FOUND", "User not found") unless user && account
|
|
281
|
+
|
|
282
|
+
[user, account]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def scim_validate_user_body!(body)
|
|
286
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless body[:user_name].is_a?(String)
|
|
287
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body[:user_name].empty?
|
|
288
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:external_id) && !body[:external_id].is_a?(String)
|
|
289
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:name) && !body[:name].is_a?(Hash)
|
|
290
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if body.key?(:emails) && !body[:emails].is_a?(Array)
|
|
291
|
+
normalize_hash(body[:name] || {}).each_value do |value|
|
|
292
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.is_a?(String)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
Array(body[:emails]).each do |email|
|
|
296
|
+
email = normalize_hash(email)
|
|
297
|
+
value = email[:value]
|
|
298
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) if email.key?(:primary) && ![true, false].include?(email[:primary])
|
|
299
|
+
raise scim_error("BAD_REQUEST", BASE_ERROR_CODES["VALIDATION_ERROR"]) unless value.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def scim_validate_patch_body!(body)
|
|
304
|
+
schemas = Array(body[:schemas])
|
|
305
|
+
raise scim_error("BAD_REQUEST", "Invalid schemas for PatchOp") unless schemas.include?("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
|
306
|
+
|
|
307
|
+
Array(body[:operations]).each_with_index do |operation, index|
|
|
308
|
+
op = normalize_hash(operation)[:op]
|
|
309
|
+
next if op.nil? || op.to_s.empty?
|
|
310
|
+
|
|
311
|
+
unless op.is_a?(String)
|
|
312
|
+
raise scim_patch_validation_error("[body.Operations.#{index}.op] Invalid input: expected string")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
next if %w[replace add remove].include?(op.downcase)
|
|
316
|
+
|
|
317
|
+
raise scim_patch_validation_error("[body.Operations.#{index}.op] Invalid option: expected one of \"replace\"|\"add\"|\"remove\"")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def scim_patch_validation_error(message)
|
|
322
|
+
APIError.new(
|
|
323
|
+
"BAD_REQUEST",
|
|
324
|
+
message: BASE_ERROR_CODES["VALIDATION_ERROR"],
|
|
325
|
+
body: {code: "VALIDATION_ERROR", message: message}
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def scim_has_organization_plugin?(ctx)
|
|
330
|
+
Array(ctx.context.options.plugins).any? { |plugin| plugin.id == "organization" }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def scim_organization_plugin(ctx)
|
|
334
|
+
Array(ctx.context.options.plugins).find { |plugin| plugin.id == "organization" }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def scim_required_roles(ctx, config)
|
|
338
|
+
configured = config[:required_role] || config[:required_roles]
|
|
339
|
+
return Array(configured).map(&:to_s) if configured
|
|
340
|
+
|
|
341
|
+
creator_role = scim_organization_plugin(ctx)&.options&.fetch(:creator_role, nil)
|
|
342
|
+
["admin", creator_role || "owner"].uniq
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def scim_provider_ownership_enabled?(config)
|
|
346
|
+
normalize_hash(config[:provider_ownership] || {})[:enabled] == true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def scim_find_organization_member(ctx, user_id, organization_id)
|
|
350
|
+
ctx.context.adapter.find_one(
|
|
351
|
+
model: "member",
|
|
352
|
+
where: [
|
|
353
|
+
{field: "userId", value: user_id},
|
|
354
|
+
{field: "organizationId", value: organization_id}
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def scim_parse_roles(role)
|
|
360
|
+
Array(role).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def scim_has_required_role?(role, required_roles)
|
|
364
|
+
required = Array(required_roles).map(&:to_s)
|
|
365
|
+
required.empty? || scim_parse_roles(role).any? { |candidate| required.include?(candidate) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def scim_user_org_memberships(ctx, user_id)
|
|
369
|
+
ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}]).each_with_object({}) do |member, result|
|
|
370
|
+
result[member.fetch("organizationId")] = member
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def scim_assert_provider_access!(ctx, user_id, provider, required_roles)
|
|
375
|
+
return unless provider
|
|
376
|
+
|
|
377
|
+
organization_id = provider["organizationId"]
|
|
378
|
+
if organization_id
|
|
379
|
+
raise APIError.new("FORBIDDEN", message: "Organization plugin is required to access this SCIM provider") unless scim_has_organization_plugin?(ctx)
|
|
380
|
+
|
|
381
|
+
member = scim_find_organization_member(ctx, user_id, organization_id)
|
|
382
|
+
raise APIError.new("FORBIDDEN", message: "You must be a member of the organization to access this provider") unless member
|
|
383
|
+
raise APIError.new("FORBIDDEN", message: "Insufficient role for this operation") unless scim_has_required_role?(member.fetch("role", ""), required_roles)
|
|
384
|
+
elsif provider.key?("userId") && provider["userId"] && provider["userId"] != user_id
|
|
385
|
+
raise APIError.new("FORBIDDEN", message: "You must be the owner to access this provider")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def scim_provider_by_provider_id!(ctx, provider_id)
|
|
390
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) unless provider_id.is_a?(String)
|
|
391
|
+
|
|
392
|
+
provider = ctx.context.adapter.find_one(model: "scimProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
393
|
+
raise APIError.new("NOT_FOUND", message: "SCIM provider not found") unless provider
|
|
394
|
+
|
|
395
|
+
provider
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def scim_provider_id_query(ctx)
|
|
399
|
+
ctx.query[:providerId] || ctx.query[:provider_id] || ctx.query["providerId"] || ctx.query["provider_id"]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def scim_normalized_provider(provider)
|
|
403
|
+
{
|
|
404
|
+
id: provider.fetch("id"),
|
|
405
|
+
providerId: provider.fetch("providerId"),
|
|
406
|
+
organizationId: provider["organizationId"]
|
|
407
|
+
}
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def scim_call_token_hook(callback, payload)
|
|
411
|
+
callback.call(payload) if callback.respond_to?(:call)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def scim_create_org_membership(ctx, user_id, organization_id)
|
|
415
|
+
return unless organization_id
|
|
416
|
+
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user_id}])
|
|
417
|
+
|
|
418
|
+
ctx.context.adapter.create(model: "member", data: {userId: user_id, organizationId: organization_id, role: "member", createdAt: Time.now})
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def scim_error(status, detail, scim_type: nil)
|
|
8
|
+
body = {
|
|
9
|
+
schemas: [SCIM_ERROR_SCHEMA],
|
|
10
|
+
status: APIError::STATUS_CODES.fetch(status.to_s.upcase, 500).to_s,
|
|
11
|
+
detail: detail
|
|
12
|
+
}
|
|
13
|
+
body[:scimType] = scim_type if scim_type
|
|
14
|
+
APIError.new(status, message: detail, body: body)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def scim_parse_filter(filter)
|
|
8
|
+
match = filter.to_s.match(/\A\s*([^\s]+)\s+(eq|ne|co|sw|ew|pr)\s*(?:"([^"]*)"|([^\s]+))?\s*\z/i)
|
|
9
|
+
raise scim_error("BAD_REQUEST", "Invalid SCIM filter", scim_type: "invalidFilter") unless match
|
|
10
|
+
|
|
11
|
+
field = match[1]
|
|
12
|
+
operator = match[2].downcase
|
|
13
|
+
value = match[3] || match[4]
|
|
14
|
+
raise scim_error("BAD_REQUEST", "Invalid filter expression", scim_type: "invalidFilter") if value.nil?
|
|
15
|
+
raise scim_error("BAD_REQUEST", "The operator \"#{operator}\" is not supported", scim_type: "invalidFilter") unless operator == "eq"
|
|
16
|
+
raise scim_error("BAD_REQUEST", "The attribute \"#{field}\" is not supported", scim_type: "invalidFilter") unless field == "userName"
|
|
17
|
+
|
|
18
|
+
[field, value]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
|
|
6
|
+
SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
7
|
+
SCIM_USER_SCHEMA_ID = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
8
|
+
SCIM_SUPPORTED_MEDIA_TYPES = ["application/json", "application/scim+json"].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def scim_hidden_metadata(summary, allowed_media_types)
|
|
13
|
+
{
|
|
14
|
+
hide: true,
|
|
15
|
+
allowed_media_types: allowed_media_types,
|
|
16
|
+
openapi: {
|
|
17
|
+
summary: summary,
|
|
18
|
+
responses: scim_openapi_responses
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scim_openapi_metadata(summary)
|
|
24
|
+
{
|
|
25
|
+
openapi: {
|
|
26
|
+
summary: summary,
|
|
27
|
+
responses: scim_openapi_responses
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def scim_openapi_responses
|
|
33
|
+
{
|
|
34
|
+
"200" => {description: "Success"},
|
|
35
|
+
"400" => {description: "Bad Request"},
|
|
36
|
+
"401" => {description: "Unauthorized"},
|
|
37
|
+
"403" => {description: "Forbidden"},
|
|
38
|
+
"404" => {description: "Not Found"}
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def scim_user_resource(user, account = nil, base_url = nil)
|
|
8
|
+
{
|
|
9
|
+
schemas: [SCIM_USER_SCHEMA_ID],
|
|
10
|
+
id: user.fetch("id"),
|
|
11
|
+
userName: user.fetch("email"),
|
|
12
|
+
externalId: account&.fetch("accountId", nil),
|
|
13
|
+
displayName: user["name"],
|
|
14
|
+
active: true,
|
|
15
|
+
name: {formatted: user["name"]},
|
|
16
|
+
emails: [{primary: true, value: user.fetch("email")}],
|
|
17
|
+
meta: {
|
|
18
|
+
resourceType: "User",
|
|
19
|
+
created: user["createdAt"],
|
|
20
|
+
lastModified: user["updatedAt"],
|
|
21
|
+
location: base_url ? "#{base_url}/scim/v2/Users/#{user.fetch("id")}" : nil
|
|
22
|
+
}.compact
|
|
23
|
+
}.compact
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|