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