devise_scim 0.1.11
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 +7 -0
- data/AGENTS.md +124 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +348 -0
- data/Rakefile +21 -0
- data/app/controllers/devise_scim/application_controller.rb +69 -0
- data/app/controllers/devise_scim/groups_controller.rb +67 -0
- data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
- data/app/controllers/devise_scim/schemas_controller.rb +55 -0
- data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
- data/app/controllers/devise_scim/users_controller.rb +281 -0
- data/docs/contributing.md +163 -0
- data/docs/custom_adapter.md +456 -0
- data/docs/idp_setup.md +335 -0
- data/docs/multi_tenant.md +328 -0
- data/docs/testing.md +444 -0
- data/lib/devise_scim/auth/base_strategy.rb +16 -0
- data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
- data/lib/devise_scim/auth/token_strategy.rb +25 -0
- data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
- data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
- data/lib/devise_scim/configuration.rb +92 -0
- data/lib/devise_scim/engine.rb +15 -0
- data/lib/devise_scim/filter/arel_visitor.rb +77 -0
- data/lib/devise_scim/filter/parser.rb +190 -0
- data/lib/devise_scim/middleware/authenticator.rb +51 -0
- data/lib/devise_scim/minitest.rb +57 -0
- data/lib/devise_scim/models/scim_tenant.rb +14 -0
- data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
- data/lib/devise_scim/routing.rb +43 -0
- data/lib/devise_scim/rspec/factories.rb +17 -0
- data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
- data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
- data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
- data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
- data/lib/devise_scim/rspec.rb +7 -0
- data/lib/devise_scim/scim/error.rb +59 -0
- data/lib/devise_scim/scim/group.rb +66 -0
- data/lib/devise_scim/scim/list_response.rb +32 -0
- data/lib/devise_scim/scim/patch_operation.rb +55 -0
- data/lib/devise_scim/scim/user.rb +161 -0
- data/lib/devise_scim/scim_adapter.rb +84 -0
- data/lib/devise_scim/version.rb +5 -0
- data/lib/devise_scim.rb +48 -0
- data/lib/generators/devise_scim/adapter_generator.rb +17 -0
- data/lib/generators/devise_scim/install_generator.rb +117 -0
- data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
- data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
- data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
- data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
- data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
- data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
- data/sig/devise_scim.rbs +4 -0
- metadata +146 -0
data/docs/testing.md
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# Testing Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`devise_scim` ships two test harnesses:
|
|
6
|
+
|
|
7
|
+
- **RSpec** — shared examples that exercise every endpoint in a real request spec, plus a `ScimHelpers` module for building payloads and headers
|
|
8
|
+
- **Minitest** — a `ScimAssertions` module with targeted assertion helpers for `ActionDispatch::IntegrationTest`
|
|
9
|
+
|
|
10
|
+
Both harnesses configure `DeviseScim` internally and reset after each example/test, so they do not interfere with your application's configuration.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## RSpec setup
|
|
15
|
+
|
|
16
|
+
Add one require to your `spec/rails_helper.rb` (or `spec/spec_helper.rb`):
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "devise_scim/rspec"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This loads:
|
|
23
|
+
- `DeviseScim::RSpec::ScimHelpers`
|
|
24
|
+
- FactoryBot factories (`:scim_tenant`, `:scim_tenant_user`) — guarded by `defined?(FactoryBot)`, so they are a no-op if you are not using FactoryBot
|
|
25
|
+
- All three shared example groups
|
|
26
|
+
|
|
27
|
+
Make sure FactoryBot is required before `devise_scim/rspec` if you want the factories:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "factory_bot_rails"
|
|
31
|
+
require "devise_scim/rspec"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Running the shared examples
|
|
37
|
+
|
|
38
|
+
Drop `it_behaves_like` inside any `RSpec.describe` block that has access to your Rails routes. A request spec is the natural home.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# spec/requests/scim_users_spec.rb
|
|
42
|
+
require "rails_helper"
|
|
43
|
+
|
|
44
|
+
RSpec.describe "SCIM Users", type: :request do
|
|
45
|
+
it_behaves_like "a SCIM Users endpoint", devise_model: User
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# spec/requests/scim_groups_spec.rb
|
|
51
|
+
require "rails_helper"
|
|
52
|
+
|
|
53
|
+
RSpec.describe "SCIM Groups", type: :request do
|
|
54
|
+
it_behaves_like "a SCIM Groups endpoint"
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# spec/requests/scim_discovery_spec.rb
|
|
60
|
+
require "rails_helper"
|
|
61
|
+
|
|
62
|
+
RSpec.describe "SCIM discovery", type: :request do
|
|
63
|
+
it_behaves_like "SCIM discovery endpoints"
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Each shared example wraps itself in `before`/`after` blocks that:
|
|
68
|
+
1. Call `DeviseScim.configure` with `:single` tenancy, `:token` auth, and a freshly generated token
|
|
69
|
+
2. Call `DeviseScim.reset_configuration!` after the example finishes
|
|
70
|
+
|
|
71
|
+
This means the shared examples are safe to combine in the same suite with your own configuration.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Available shared examples
|
|
76
|
+
|
|
77
|
+
| Name | What it covers | Required options |
|
|
78
|
+
|------|----------------|-----------------|
|
|
79
|
+
| `"a SCIM Users endpoint"` | GET/POST/PUT/PATCH/DELETE /Users, filter, auth, re-provisioning, multi-tenant context | `devise_model:` (the AR class) |
|
|
80
|
+
| `"a SCIM Groups endpoint"` | GET/POST/PATCH/DELETE /Groups, adapter delegation, 500 when `group_to_scim` not implemented | none |
|
|
81
|
+
| `"SCIM discovery endpoints"` | GET /ServiceProviderConfig, /Schemas, /ResourceTypes; groups conditionally included | none |
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## `ScimHelpers` module
|
|
86
|
+
|
|
87
|
+
Include it in any `describe` block or RSpec configuration:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
RSpec.describe "my SCIM spec", type: :request do
|
|
91
|
+
include DeviseScim::RSpec::ScimHelpers
|
|
92
|
+
# ...
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or globally in `rails_helper.rb`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
RSpec.configure do |config|
|
|
100
|
+
config.include DeviseScim::RSpec::ScimHelpers, type: :request
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Methods:**
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# Parse a JSON response body into a Hash.
|
|
108
|
+
scim_json(response.body)
|
|
109
|
+
# => { "schemas" => [...], "id" => "1", ... }
|
|
110
|
+
|
|
111
|
+
# The configured route prefix (default: "/scim/v2").
|
|
112
|
+
scim_prefix
|
|
113
|
+
# => "/scim/v2"
|
|
114
|
+
|
|
115
|
+
# Authorization + Content-Type headers for a given token.
|
|
116
|
+
scim_auth_headers("my-token")
|
|
117
|
+
# => { "Authorization" => "Bearer my-token", "Content-Type" => "application/json" }
|
|
118
|
+
|
|
119
|
+
# Build a minimal valid POST /Users payload.
|
|
120
|
+
scim_user_payload(user_name: "alice@example.com")
|
|
121
|
+
# => { "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName" => "alice@example.com" }
|
|
122
|
+
|
|
123
|
+
# Extra keys are merged in:
|
|
124
|
+
scim_user_payload(user_name: "alice@example.com", "name" => { "givenName" => "Alice" })
|
|
125
|
+
|
|
126
|
+
# Wrap one or more operation hashes in a PatchOp envelope.
|
|
127
|
+
scim_patch_payload(
|
|
128
|
+
scim_replace_op("active", false),
|
|
129
|
+
scim_replace_op("userName", "new@example.com")
|
|
130
|
+
)
|
|
131
|
+
# => { "schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations" => [...] }
|
|
132
|
+
|
|
133
|
+
# Individual operation builders:
|
|
134
|
+
scim_replace_op("active", false)
|
|
135
|
+
# => { "op" => "replace", "path" => "active", "value" => false }
|
|
136
|
+
|
|
137
|
+
scim_add_op("emails", [{ "value" => "work@example.com", "type" => "work" }])
|
|
138
|
+
# => { "op" => "add", "path" => "emails", "value" => [...] }
|
|
139
|
+
|
|
140
|
+
scim_remove_op("phoneNumbers")
|
|
141
|
+
# => { "op" => "remove", "path" => "phoneNumbers" }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Testing multi-tenant scenarios
|
|
147
|
+
|
|
148
|
+
The multi-tenant cases are included automatically as a nested `context "multi-tenant"` inside `"a SCIM Users endpoint"`. You do not need to enable or opt in to them — they run whenever you call `it_behaves_like "a SCIM Users endpoint"`.
|
|
149
|
+
|
|
150
|
+
The context creates a `DeviseScim::ScimTenant` record, calls `rotate_token!` to get a raw token, then reconfigures `DeviseScim` to `:multi` tenancy for those examples.
|
|
151
|
+
|
|
152
|
+
The multi-tenant context covers:
|
|
153
|
+
|
|
154
|
+
- Claiming an existing manual user on first SCIM provision (sets `scim_claimed_at` on the join record)
|
|
155
|
+
- 404 for a user not assigned to the requesting tenant
|
|
156
|
+
- `user_exclusivity: :one_to_one` with `exclusivity_conflict: :error` → 409
|
|
157
|
+
- `user_exclusivity: :one_to_one` with `exclusivity_conflict: :reassign` → reassigns the join record
|
|
158
|
+
|
|
159
|
+
If your spec database does not have the `scim_tenants` and `scim_tenant_users` tables (i.e., you have not run the multi-tenant migrations), the multi-tenant context will fail. Run `rails g devise_scim:install --multi-tenant` and `rails db:migrate` in your test environment, or skip the shared example and write targeted specs instead.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Stubbing IdP payloads
|
|
164
|
+
|
|
165
|
+
Use `scim_user_payload` for standard fields, and merge extra keys for IdP-specific extensions.
|
|
166
|
+
|
|
167
|
+
**Okta-style POST /Users with name and phone:**
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
payload = scim_user_payload(
|
|
171
|
+
user_name: "alice@example.com",
|
|
172
|
+
"name" => { "givenName" => "Alice", "familyName" => "Smith", "formatted" => "Alice Smith" },
|
|
173
|
+
"emails" => [{ "value" => "alice@example.com", "type" => "work", "primary" => true }],
|
|
174
|
+
"phoneNumbers" => [{ "value" => "+14155550100", "type" => "work" }],
|
|
175
|
+
"userType" => "Employee"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
post "#{scim_prefix}/Users", params: payload.to_json, headers: scim_auth_headers(token)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Complex PATCH with multiple operations:**
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
payload = scim_patch_payload(
|
|
185
|
+
scim_replace_op("active", false),
|
|
186
|
+
scim_replace_op("name.givenName", "Alicia"),
|
|
187
|
+
scim_add_op("phoneNumbers", [{ "value" => "+14155550101", "type" => "mobile" }])
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
patch "#{scim_prefix}/Users/#{user.id}", params: payload.to_json, headers: scim_auth_headers(token)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Testing a filter-expression PATCH path (e.g., Okta email update):**
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
payload = scim_patch_payload(
|
|
197
|
+
scim_replace_op('emails[type eq "work"].value', "updated@example.com")
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
patch "#{scim_prefix}/Users/#{user.id}", params: payload.to_json, headers: scim_auth_headers(token)
|
|
201
|
+
expect(response).to have_http_status(:ok)
|
|
202
|
+
expect(user.reload.email).to eq("updated@example.com")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Testing re-provisioning
|
|
208
|
+
|
|
209
|
+
The full create → DELETE → re-provision cycle:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
include DeviseScim::RSpec::ScimHelpers
|
|
213
|
+
|
|
214
|
+
let(:token) { "test-token" }
|
|
215
|
+
let(:headers) { scim_auth_headers(token) }
|
|
216
|
+
|
|
217
|
+
before do
|
|
218
|
+
DeviseScim.configure do |c|
|
|
219
|
+
c.tenancy = :single
|
|
220
|
+
c.auth_method = :token
|
|
221
|
+
c.token = token
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
after { DeviseScim.reset_configuration! }
|
|
226
|
+
|
|
227
|
+
it "re-provisions a deleted SCIM user" do
|
|
228
|
+
# Create.
|
|
229
|
+
post "#{scim_prefix}/Users",
|
|
230
|
+
params: scim_user_payload(user_name: "alice@example.com").to_json,
|
|
231
|
+
headers: headers
|
|
232
|
+
expect(response).to have_http_status(:created)
|
|
233
|
+
user = User.find_by!(email: "alice@example.com")
|
|
234
|
+
|
|
235
|
+
# Deprovision.
|
|
236
|
+
delete "#{scim_prefix}/Users/#{user.id}", headers: headers
|
|
237
|
+
expect(response).to have_http_status(:no_content)
|
|
238
|
+
expect(user.reload.scim_active).to be(false)
|
|
239
|
+
|
|
240
|
+
# Re-provision — same email, POST again.
|
|
241
|
+
post "#{scim_prefix}/Users",
|
|
242
|
+
params: scim_user_payload(user_name: "alice@example.com").to_json,
|
|
243
|
+
headers: headers
|
|
244
|
+
expect(response).to have_http_status(:created)
|
|
245
|
+
expect(user.reload.scim_active).to be(true)
|
|
246
|
+
# Record count should not increase — same row was re-activated.
|
|
247
|
+
expect(User.where(email: "alice@example.com").count).to eq(1)
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Testing custom adapter behavior
|
|
254
|
+
|
|
255
|
+
**Verify `after_provision` is called:**
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
it "calls after_provision on the adapter after create" do
|
|
259
|
+
adapter = instance_double(ApplicationScimAdapter,
|
|
260
|
+
attributes_for_create: { email: "alice@example.com" },
|
|
261
|
+
after_provision: nil,
|
|
262
|
+
to_scim: DeviseScim::Scim::User.new.tap { |u| u.id = "1"; u.user_name = "alice@example.com" }
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
allow(ApplicationScimAdapter).to receive(:new).and_return(adapter)
|
|
266
|
+
|
|
267
|
+
post "#{scim_prefix}/Users",
|
|
268
|
+
params: scim_user_payload(user_name: "alice@example.com").to_json,
|
|
269
|
+
headers: headers
|
|
270
|
+
|
|
271
|
+
expect(adapter).to have_received(:after_provision)
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Verify group delegation with a spy:**
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
it "delegates POST /Groups to handle_group_create" do
|
|
279
|
+
group_scim = DeviseScim::Scim::Group.new.tap { |g| g.id = "grp-1"; g.display_name = "Admins" }
|
|
280
|
+
adapter_spy = instance_double(ApplicationScimAdapter,
|
|
281
|
+
handle_group_create: nil,
|
|
282
|
+
group_to_scim: group_scim
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
allow(ApplicationScimAdapter).to receive(:new).and_return(adapter_spy)
|
|
286
|
+
|
|
287
|
+
post "#{scim_prefix}/Groups",
|
|
288
|
+
params: { "schemas" => [DeviseScim::Scim::GROUP_SCHEMA], "displayName" => "Admins" }.to_json,
|
|
289
|
+
headers: headers
|
|
290
|
+
|
|
291
|
+
expect(adapter_spy).to have_received(:handle_group_create)
|
|
292
|
+
expect(response).to have_http_status(:created)
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Factories
|
|
299
|
+
|
|
300
|
+
`devise_scim/rspec` registers two FactoryBot factories when FactoryBot is available.
|
|
301
|
+
|
|
302
|
+
**`:scim_tenant`** — creates a `DeviseScim::ScimTenant` with a sequential name and token auth:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
tenant = create(:scim_tenant)
|
|
306
|
+
# tenant.name => "Test Tenant 1"
|
|
307
|
+
# tenant.auth_method => "token"
|
|
308
|
+
# tenant.active => true
|
|
309
|
+
# tenant.token_digest => nil <-- no token yet
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The factory does **not** call `rotate_token!`. Call it yourself to get the raw token:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
tenant = create(:scim_tenant)
|
|
316
|
+
raw_token = tenant.rotate_token!
|
|
317
|
+
headers = scim_auth_headers(raw_token)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**`:scim_tenant_user`** — creates a `DeviseScim::ScimTenantUser` join record:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
user = create(:user)
|
|
324
|
+
tenant = create(:scim_tenant)
|
|
325
|
+
|
|
326
|
+
join = create(:scim_tenant_user, scim_tenant: tenant, user: user)
|
|
327
|
+
# join.active => true
|
|
328
|
+
# join.provisioned_at => Time.current
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
You must pass `user:` explicitly — the factory has no default user association (it does not know your `User` class name).
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Minitest setup
|
|
336
|
+
|
|
337
|
+
Require the module and include it in your test class:
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
# test/test_helper.rb (or inside an individual test file)
|
|
341
|
+
require "devise_scim/minitest"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
class ScimTest < ActionDispatch::IntegrationTest
|
|
346
|
+
include DeviseScim::Minitest::ScimAssertions
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Or include it globally in `ActiveSupport::TestCase` / `ActionDispatch::IntegrationTest` via a concern in `test/test_helper.rb`.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Minitest example
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
require "test_helper"
|
|
358
|
+
|
|
359
|
+
class ScimUsersTest < ActionDispatch::IntegrationTest
|
|
360
|
+
include DeviseScim::Minitest::ScimAssertions
|
|
361
|
+
|
|
362
|
+
TOKEN = "minitest-scim-token"
|
|
363
|
+
|
|
364
|
+
setup do
|
|
365
|
+
DeviseScim.configure do |c|
|
|
366
|
+
c.tenancy = :single
|
|
367
|
+
c.auth_method = :token
|
|
368
|
+
c.token = TOKEN
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
teardown { DeviseScim.reset_configuration! }
|
|
373
|
+
|
|
374
|
+
test "GET /scim/v2/Users returns a ListResponse" do
|
|
375
|
+
User.create!(email: "alice@example.com")
|
|
376
|
+
|
|
377
|
+
get "/scim/v2/Users", headers: scim_auth_headers(TOKEN)
|
|
378
|
+
|
|
379
|
+
assert_scim_status(response, 200)
|
|
380
|
+
assert_scim_content_type(response)
|
|
381
|
+
assert_scim_list_response(response)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
test "POST /scim/v2/Users creates a user" do
|
|
385
|
+
payload = scim_user_payload(user_name: "bob@example.com")
|
|
386
|
+
|
|
387
|
+
post "/scim/v2/Users",
|
|
388
|
+
params: payload.to_json,
|
|
389
|
+
headers: scim_auth_headers(TOKEN)
|
|
390
|
+
|
|
391
|
+
assert_scim_status(response, 201)
|
|
392
|
+
assert_scim_schema(response, DeviseScim::Scim::USER_SCHEMA)
|
|
393
|
+
assert_equal "bob@example.com", scim_json(response)["userName"]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
test "GET /scim/v2/Users returns 401 without auth" do
|
|
397
|
+
get "/scim/v2/Users"
|
|
398
|
+
assert_scim_status(response, 401)
|
|
399
|
+
assert_scim_error(response, expected_status: 401)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
test "DELETE deprovisioning sets scim_active to false" do
|
|
403
|
+
user = User.create!(email: "carol@example.com", scim_source: "scim")
|
|
404
|
+
|
|
405
|
+
delete "/scim/v2/Users/#{user.id}", headers: scim_auth_headers(TOKEN)
|
|
406
|
+
|
|
407
|
+
assert_scim_status(response, 204)
|
|
408
|
+
assert_equal false, user.reload.scim_active
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Assertion reference:**
|
|
414
|
+
|
|
415
|
+
| Method | What it checks |
|
|
416
|
+
|--------|----------------|
|
|
417
|
+
| `assert_scim_status(response, status)` | `response.status == status` (coerces to string) |
|
|
418
|
+
| `assert_scim_content_type(response)` | `Content-Type` includes `application/scim+json` |
|
|
419
|
+
| `assert_scim_schema(response, schema)` | `body["schemas"]` includes the given URN |
|
|
420
|
+
| `assert_scim_list_response(response)` | Combines schema check with `totalResults` and `Resources` key presence |
|
|
421
|
+
| `assert_scim_error(response, expected_status: nil)` | `body["schemas"]` includes the SCIM error URN; optionally checks `body["status"]` |
|
|
422
|
+
| `scim_json(response)` | `JSON.parse(response.body)` |
|
|
423
|
+
| `scim_auth_headers(token)` | `{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }` |
|
|
424
|
+
| `scim_user_payload(user_name:, **attrs)` | Minimal User payload hash; extra keys are merged |
|
|
425
|
+
| `scim_patch_payload(*operations)` | PatchOp envelope hash |
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## Testing against a real IdP
|
|
430
|
+
|
|
431
|
+
To test your SCIM endpoint with an actual IdP (Okta, Entra ID, JumpCloud) during local development, expose your Rails server over a public HTTPS tunnel:
|
|
432
|
+
|
|
433
|
+
```sh
|
|
434
|
+
# Cloudflare Tunnel (no account required for one-off tunnels):
|
|
435
|
+
cloudflared tunnel --url http://localhost:3000
|
|
436
|
+
|
|
437
|
+
# ngrok:
|
|
438
|
+
ngrok http 3000
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Configure your IdP's SCIM connector to point at `https://<tunnel-host>/scim/v2`. Set a bearer token that matches `config.token` in your initializer (or create a `ScimTenant` record with a rotated token for multi-tenant mode).
|
|
442
|
+
|
|
443
|
+
> [!NOTE]
|
|
444
|
+
> Real IdPs send PATCH operations with filter-expression paths like `emails[type eq "work"].value`. Verify your adapter handles these before testing with an IdP — the gem's filter parser covers them, but your `attributes_for_update` or custom `to_scim` may need to account for the mapped attribute.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Auth
|
|
5
|
+
class BaseStrategy
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def extract_token(env)
|
|
9
|
+
auth = env["HTTP_AUTHORIZATION"]
|
|
10
|
+
return nil unless auth&.start_with?("Bearer ")
|
|
11
|
+
|
|
12
|
+
auth.delete_prefix("Bearer ").strip
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Auth
|
|
5
|
+
class OauthStrategy < BaseStrategy
|
|
6
|
+
def authenticate(env)
|
|
7
|
+
unless defined?(Doorkeeper)
|
|
8
|
+
raise ConfigurationError,
|
|
9
|
+
"auth_method :oauth requires the doorkeeper gem. Add `gem 'doorkeeper'` to your Gemfile."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
raw = extract_token(env)
|
|
13
|
+
return nil unless raw
|
|
14
|
+
|
|
15
|
+
access_token = Doorkeeper::AccessToken.by_token(raw)
|
|
16
|
+
return nil unless access_token&.accessible?
|
|
17
|
+
|
|
18
|
+
config = DeviseScim.configuration
|
|
19
|
+
|
|
20
|
+
if config.tenancy == :multi
|
|
21
|
+
config.tenant_model.constantize.find_by(doorkeeper_application_id: access_token.application_id)
|
|
22
|
+
else
|
|
23
|
+
:ok
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/security_utils"
|
|
4
|
+
|
|
5
|
+
module DeviseScim
|
|
6
|
+
module Auth
|
|
7
|
+
class TokenStrategy < BaseStrategy
|
|
8
|
+
def authenticate(env)
|
|
9
|
+
raw = extract_token(env)
|
|
10
|
+
return nil unless raw
|
|
11
|
+
|
|
12
|
+
config = DeviseScim.configuration
|
|
13
|
+
|
|
14
|
+
if config.tenancy == :multi
|
|
15
|
+
config.tenant_model.constantize.authenticate_token(raw)
|
|
16
|
+
else
|
|
17
|
+
return nil if config.token.nil?
|
|
18
|
+
return nil unless ActiveSupport::SecurityUtils.secure_compare(raw, config.token)
|
|
19
|
+
|
|
20
|
+
:ok
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Concerns
|
|
5
|
+
module ScimGroupIdentifiable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def find_by_scim_uid(uid, tenant: nil)
|
|
10
|
+
scope = where(scim_group_uid: uid)
|
|
11
|
+
scope = scope.where(tenant_id: tenant.id) if tenant && column_names.include?("tenant_id")
|
|
12
|
+
scope.first
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def authenticate_scim_group(scim_group, tenant: nil)
|
|
16
|
+
find_by_scim_uid(scim_group.external_id || scim_group.id, tenant: tenant)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module DeviseScim
|
|
7
|
+
module Concerns
|
|
8
|
+
module ScimTenant
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
validates :auth_method, inclusion: { in: %w[token oauth] }
|
|
13
|
+
validates scim_tenant_label_column, presence: true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def authenticate_token(raw_token)
|
|
18
|
+
where(auth_method: "token", active: true).find do |record|
|
|
19
|
+
BCrypt::Password.new(record.token_digest).is_password?(raw_token)
|
|
20
|
+
rescue BCrypt::Errors::InvalidHash
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def scim_tenant_label_column
|
|
26
|
+
:name
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rotate_token!
|
|
31
|
+
raw = SecureRandom.hex(32)
|
|
32
|
+
update!(token_digest: BCrypt::Password.create(raw))
|
|
33
|
+
raw
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scim_active?
|
|
37
|
+
active
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class Configuration
|
|
5
|
+
VALID_TENANCY = %i[single multi].freeze
|
|
6
|
+
VALID_AUTH_METHODS = %i[token oauth].freeze
|
|
7
|
+
VALID_DEPROVISION = [false, true, :error].freeze
|
|
8
|
+
VALID_USER_EXCLUSIVITY = %i[multiple one_to_one].freeze
|
|
9
|
+
VALID_EXCLUSIVITY_CONFLICT = %i[error reassign].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor \
|
|
12
|
+
:route_prefix,
|
|
13
|
+
:tenancy,
|
|
14
|
+
:auth_method,
|
|
15
|
+
:token,
|
|
16
|
+
:oauth_client_id,
|
|
17
|
+
:oauth_client_secret,
|
|
18
|
+
:devise_model,
|
|
19
|
+
:tenant_model,
|
|
20
|
+
:enable_groups,
|
|
21
|
+
:soft_delete,
|
|
22
|
+
:deprovision_manual_users,
|
|
23
|
+
:user_exclusivity,
|
|
24
|
+
:exclusivity_conflict,
|
|
25
|
+
:adapter
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@route_prefix = "/scim/v2"
|
|
29
|
+
@tenancy = :single
|
|
30
|
+
@auth_method = :token
|
|
31
|
+
@token = nil
|
|
32
|
+
@oauth_client_id = nil
|
|
33
|
+
@oauth_client_secret = nil
|
|
34
|
+
@devise_model = "User"
|
|
35
|
+
@tenant_model = "DeviseScim::ScimTenant"
|
|
36
|
+
@enable_groups = false
|
|
37
|
+
@soft_delete = true
|
|
38
|
+
@deprovision_manual_users = false
|
|
39
|
+
@user_exclusivity = :multiple
|
|
40
|
+
@exclusivity_conflict = :error
|
|
41
|
+
@adapter = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate!
|
|
45
|
+
validate_enum!(:tenancy, tenancy, VALID_TENANCY)
|
|
46
|
+
validate_enum!(:auth_method, auth_method, VALID_AUTH_METHODS)
|
|
47
|
+
validate_enum!(:user_exclusivity, user_exclusivity, VALID_USER_EXCLUSIVITY)
|
|
48
|
+
validate_enum!(:exclusivity_conflict, exclusivity_conflict, VALID_EXCLUSIVITY_CONFLICT)
|
|
49
|
+
validate_deprovision!
|
|
50
|
+
validate_oauth!
|
|
51
|
+
validate_single_tenant_options!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_enum!(name, value, valid)
|
|
57
|
+
return if valid.include?(value)
|
|
58
|
+
|
|
59
|
+
raise ConfigurationError,
|
|
60
|
+
"#{name} must be one of #{valid.inspect}; got #{value.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_deprovision!
|
|
64
|
+
return if VALID_DEPROVISION.include?(deprovision_manual_users)
|
|
65
|
+
|
|
66
|
+
raise ConfigurationError,
|
|
67
|
+
"deprovision_manual_users must be true, false, or :error; got #{deprovision_manual_users.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_oauth!
|
|
71
|
+
return unless auth_method == :oauth && !doorkeeper_available?
|
|
72
|
+
|
|
73
|
+
raise ConfigurationError,
|
|
74
|
+
"auth_method :oauth requires the doorkeeper gem. Add `gem 'doorkeeper'` to your Gemfile."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_single_tenant_options!
|
|
78
|
+
if tenancy == :single && tenant_model != "DeviseScim::ScimTenant"
|
|
79
|
+
raise ConfigurationError, "tenant_model is only applicable in multi-tenant mode."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return unless tenancy == :single && user_exclusivity == :one_to_one
|
|
83
|
+
|
|
84
|
+
raise ConfigurationError,
|
|
85
|
+
"user_exclusivity :one_to_one is only meaningful in multi-tenant mode."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def doorkeeper_available?
|
|
89
|
+
defined?(Doorkeeper)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace DeviseScim
|
|
6
|
+
|
|
7
|
+
initializer "devise_scim.middleware" do |app|
|
|
8
|
+
app.middleware.use DeviseScim::Middleware::Authenticator
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
config.after_initialize do
|
|
12
|
+
DeviseScim.configuration.validate!
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|