devise_scim
SCIM 2.0 server for Rails + Devise
What is this?
devise_scim mounts a fully compliant SCIM 2.0 server inside any Rails + Devise application, handling user and group provisioning from identity providers like Okta, Azure AD, and OneLogin. Unlike most existing gems it supports both single- and multi-tenant architectures out of the box, is actively maintained, makes no external API calls (pure Ruby — no third-party SCIM SDK), and conforms strictly to RFC 7643 and RFC 7644.
Requirements
- Ruby 3.2+
- Rails 7.0+
- Devise 4.9+
- bcrypt
Doorkeeper
[!IMPORTANT]
doorkeeper >= 5.6is required only when using OAuth 2.0 authentication (auth_method: :oauth) or multi-tenant mode (tenancy: :multi). If the gem is missing,DeviseScim::ConfigurationErroris raised at boot. Addgem 'doorkeeper', '~> 5.6'to your Gemfile before running the generator with--oauthor--multi-tenant— the generator performs a preflight check and will abort if it is absent.
Installation
# Gemfile
gem 'devise_scim'
bundle install
Quick start: single-tenant, bearer token
1. Run the generator
rails g devise_scim:install User
This creates config/initializers/devise_scim.rb and the required migrations.
2. Configure the generated initializer
# config/initializers/devise_scim.rb
DeviseScim.configure do |config|
config.route_prefix = "/scim/v2"
config.tenancy = :single
config.auth_method = :token
config.token = ENV.fetch("SCIM_BEARER_TOKEN")
config.devise_model = "User"
end
3. Mount routes
# config/routes.rb
Rails.application.routes.draw do
scim_for :users
end
4. Run migrations
rails db:migrate
Quick start: single-tenant, OAuth (Doorkeeper)
Prerequisites: add Doorkeeper to your Gemfile first.
gem 'doorkeeper', '~> 5.6'
bundle install
rails g devise_scim:install User --oauth
rails db:migrate
The generator runs rails g doorkeeper:install for you if Doorkeeper's tables are not yet present (with your confirmation).
The token endpoint is mounted at {route_prefix}/oauth/token. Configure your IdP to POST client credentials there.
# config/initializers/devise_scim.rb
DeviseScim.configure do |config|
config.route_prefix = "/scim/v2"
config.tenancy = :single
config.auth_method = :oauth
config.oauth_client_id = ENV.fetch("SCIM_CLIENT_ID")
config.oauth_client_secret = ENV.fetch("SCIM_CLIENT_SECRET")
config.devise_model = "User"
end
Quick start: multi-tenant (built-in ScimTenant)
# Gemfile
gem 'doorkeeper', '~> 5.6'
bundle install
rails g devise_scim:install User --multi-tenant
rails db:migrate
Create tenants in the console (see Tenant management).
Quick start: multi-tenant (existing model)
Use this when you already have an Org (or similar) model that should act as the SCIM tenant.
rails g devise_scim:install User --multi-tenant --tenant-model=Org
rails db:migrate
Include the concern in your model:
class Org < ApplicationRecord
include DeviseScim::Concerns::ScimTenant
end
The generator adds only the columns that are missing — every add_column call in the migration is guarded with unless column_exists?.
Generator reference
rails g devise_scim:install <ModelName> [--oauth] [--multi-tenant] [--tenant-model=ModelName]
| Flag | Effect |
|---|---|
--oauth |
Configures OAuth 2.0 client-credentials auth; requires Doorkeeper |
--multi-tenant |
Generates tenant + join-table migrations; requires Doorkeeper |
--tenant-model=Org |
Uses existing Org model instead of the built-in DeviseScim::ScimTenant |
Preflight behavior: the generator checks whether doorkeeper appears in your Gemfile when --oauth or --multi-tenant is set. If Doorkeeper is in the Gemfile but its tables have not been generated yet, it prompts to run rails g doorkeeper:install before continuing.
Action order:
preflight_check— validates Doorkeeper presencecopy_user_migration—add_scim_to_<table>.rbcopy_tenant_migrations—create_scim_tenants.rb+create_scim_tenant_users.rb(oradd_scim_to_<tenant_table>.rbif--tenant-modelgiven)copy_initializer—config/initializers/devise_scim.rb
[!NOTE] The
devise_modelvalue in the generated initializer is automatically set from the generator argument — no manual editing needed.
rails g devise_scim:adapter
Generates a pre-filled ApplicationScimAdapter that overrides the base adapter's defaults:
rails g devise_scim:adapter
# → app/scim/application_scim_adapter.rb
Then wire it up:
config.adapter = "ApplicationScimAdapter"
Configuration reference
| Option | Type | Default | Description |
|---|---|---|---|
route_prefix |
String |
"/scim/v2" |
Mount path for all SCIM endpoints |
tenancy |
:single \ |
:multi |
:single |
auth_method |
:token \ |
:oauth |
:token |
token |
`String\ | nil` | nil |
oauth_client_id |
`String\ | nil` | nil |
oauth_client_secret |
`String\ | nil` | nil |
devise_model |
String |
"User" |
Devise model class name |
tenant_model |
String |
"DeviseScim::ScimTenant" |
Tenant model class name (multi-tenant only) |
enable_groups |
Boolean |
false |
Mount /Groups endpoints |
soft_delete |
Boolean |
true |
true = set scim_active=false on DELETE; false = destroy record |
deprovision_manual_users |
false \ |
true \ |
:error |
user_exclusivity |
:multiple \ |
:one_to_one |
:multiple |
exclusivity_conflict |
:error \ |
:reassign |
:error |
adapter |
`String\ | nil` | nil |
Routes reference
scim_for :users mounts the following routes under route_prefix (default /scim/v2):
GET /scim/v2/Users
POST /scim/v2/Users
GET /scim/v2/Users/:id
PUT /scim/v2/Users/:id
PATCH /scim/v2/Users/:id
DELETE /scim/v2/Users/:id
# when enable_groups: true
GET /scim/v2/Groups
POST /scim/v2/Groups
GET /scim/v2/Groups/:id
PUT /scim/v2/Groups/:id
PATCH /scim/v2/Groups/:id
DELETE /scim/v2/Groups/:id
# when auth_method: :oauth
POST /scim/v2/oauth/token
# always present
GET /scim/v2/ServiceProviderConfig
GET /scim/v2/Schemas
GET /scim/v2/ResourceTypes
Override the prefix with the at: option:
scim_for :users, at: "/api/scim/v2"
Tenant management
# Create a tenant
tenant = DeviseScim::ScimTenant.create!(name: "Acme Corp", auth_method: "token", active: true)
# Issue a bearer token — only returned once, store it securely
raw_token = tenant.rotate_token!
[!WARNING]
rotate_token!returns the raw token exactly once. Only the bcrypt digest is persisted. Store the raw token immediately (e.g. copy it to your IdP's configuration) — it cannot be retrieved again. Callrotate_token!again to issue a replacement, which invalidates the previous token.
# Link to a Doorkeeper OAuth application (OAuth auth method)
app = Doorkeeper::Application.find_by(name: "Acme IdP")
tenant.update!(doorkeeper_application: app, auth_method: "oauth")
# Check active status
tenant.scim_active? # => true / false
# Deactivate (blocks all further SCIM requests for this tenant)
tenant.update!(active: false)
User source tracking and deprovision
The scim_source column on the user record tracks how the user was created:
| Value | Meaning |
|---|---|
"scim" |
Created or claimed via SCIM provisioning |
nil |
Created manually (e.g. sign-up, seed data, console) |
The deprovision_manual_users config controls what happens when a DELETE request targets a user with scim_source: nil:
scim_source |
deprovision_manual_users |
Result |
|---|---|---|
"scim" |
any | Deprovision (soft-delete or destroy) — 204 No Content |
nil |
false (default) |
Skip silently — 200 OK |
nil |
true |
Deprovision — 204 No Content |
nil |
:error |
409 Conflict |
Re-provisioning
When a POST to /Users matches a user with scim_active: false (previously deprovisioned), the gem re-provisions them rather than returning a conflict:
scim_activeis set totruescim_deprovisioned_atis clearedafter_provisioncallback is invoked on the adapter
This allows IdPs to deprovision and later re-provision the same user without manual intervention.
User exclusivity (multi-tenant)
Available only in tenancy: :multi mode.
user_exclusivity |
Behaviour |
|---|---|
:multiple (default) |
A user may belong to any number of tenants simultaneously |
:one_to_one |
A user may belong to at most one tenant at a time |
When :one_to_one is set and a POST /Users matches a user already assigned to a different tenant, exclusivity_conflict controls the outcome:
exclusivity_conflict |
Outcome |
|---|---|
:error (default) |
409 Conflict |
:reassign |
Deactivates the old tenant join record, creates a new one for the requesting tenant |
Group provisioning
The gem handles the SCIM protocol layer for groups; business logic lives in your adapter. When enable_groups: true, implement the following methods in your ApplicationScimAdapter:
def handle_group_create # called on POST /Groups
def handle_group_update # called on PUT/PATCH /Groups/:id
def handle_group_destroy # called on DELETE /Groups/:id
def group_to_scim # returns a DeviseScim::Scim::Group instance
The base adapter raises NotImplementedError for group_to_scim and no-ops the other callbacks — groups succeed at the protocol level until you implement the methods. See docs/custom_adapter.md for a full walkthrough.
Test harness
The gem ships a test harness you can include in your host app's test suite.
RSpec:
# spec/rails_helper.rb
require "devise_scim/rspec"
RSpec.describe "SCIM Users" do
it_behaves_like "a SCIM Users endpoint", devise_model: User
end
Minitest:
require "devise_scim/minitest"
class ScimUsersTest < ActionDispatch::IntegrationTest
include DeviseScim::Minitest::ScimAssertions
# ...
end
The shared RSpec example covers index, show, create, replace, PATCH update, delete, re-provisioning, authentication enforcement, and multi-tenant scenarios. See docs/testing.md for available options and writing custom assertions.
License
MIT. Used at your own risk. No liability is held by the author.
Contributing
This gem was developed with significant assistance from Claude (Anthropic). Contributions and audits welcome, AI or otherwise.
- Please follow the contributing guidelines for submitting pull requests and reporting issues.
- Ensure your code adheres to the code of conduct and is tested with the provided test harness.