omniauth_syncer
Keeps a local ActiveRecord user in step with the OmniAuth auth hash after an SSO login.
On each login, OmniauthSyncer::SyncService.call(auth_hash) does four things:
- It looks up the local user by
(provider, uid), or byuidalone. - It copies the mapped attributes out of the auth hash.
- It applies an explicit policy when another local user already holds the incoming email.
- It saves the user with
save!.
ControllerHelpers#sync_sso_user wraps the service for an OmniAuth callback controller.
The gem is written for client apps that sign in through a SecureSSOHub server with
omniauth-ssoprovider. It works with any OmniAuth
strategy that fills uid and info.
Requires Ruby ≥ 3.1 and ActiveSupport ≥ 6.1. The user model must be ActiveRecord.
Installation
gem 'omniauth-ssoprovider'
gem 'omniauth_syncer'The auth hash
With omniauth-ssoprovider 0.2.0, a hub login produces:
{
"provider" => "ssoprovider", # or whatever name the strategy is mounted under
"uid" => "<sso_id>", # stable UUID, the hub's `sub`
"info" => { "name" => "…", "email" => "…" }, # each nil without its scope
"extra" => {
"raw_info" => { "id" => "<sso_id>", "sub" => "<sso_id>", "name" => "…",
"email" => "…", "email_verified" => false, "roles" => ["admin"] },
"access_token" => "<RS256 JWT, 10 min>",
"refresh_token" => "<opaque or nil>",
"expires_at" => 1758200000,
"id_token" => { "sub" => "…", "iss" => "…", "aud" => "…", "nonce" => "…" },
"roles" => ["admin"], # or []
"email_verified" => false,
"scope" => ["openid", "profile", "email"]
}
}omniauth-ssoprovider 0.1.2 gives the same provider, uid and info. Its extra holds only raw_info and
access_token. The default mappings work with both versions.
Treat uid as the only stable identity. Users can change their email on the hub.
Configuration
# config/initializers/omniauth_syncer.rb
OmniauthSyncer.configure do |config|
config.user_model = 'User' # default
config.uid_field = :sso_uid # column holding the uid; default :uid
config.uid_field_in_auth = 'uid' # dotted path in the auth hash; default
config.provider_field = :provider # optional: look users up by (provider, uid)
# Default mappings: { email: 'info.email', name: 'info.name' }
config.mappings[:admin] = OmniauthSyncer::Mappings::ADMIN
config.mappings[:email_verified] = OmniauthSyncer::Mappings::EMAIL_VERIFIED
config.on_conflict = :raise # :raise (default), :link or :ignore
config.clear_blank = false # default
endThe host needs the matching columns and unique indexes:
add_column :users, :sso_uid, :string
add_column :users, :provider, :string
add_index :users, %i[provider sso_uid], unique: true
add_index :users, :email, unique: trueMappings
A mapping points a local attribute at a source. The source can be one of two things:
-
A dotted path such as
'info.email'or'extra.raw_info.roles'. Paths work with string keys, symbol keys andOmniAuth::AuthHash. -
A callable that receives the auth hash, such as
->(auth) { auth['info']['name']&.titleize }.
Two mappings are ready to use for the hub, and neither is enabled by default:
| Constant | Writes |
|---|---|
Mappings::ADMIN |
true or false from extra.roles.include?("admin"). Leaves the attribute untouched when the auth hash has no extra.roles, which is the case with strategy 0.1.2. |
Mappings::EMAIL_VERIFIED |
extra.email_verified. For strategy 0.1.2 use 'extra.raw_info.email_verified' instead. |
roles is the hub's assertion that a user is a hub administrator. It is not an entitlement system.
The hub sends roles only from /api/v1/userinfo, so ADMIN needs the strategy's user_info_url: '/api/v1/userinfo'. If the strategy uses /oauth/userinfo or has userinfo turned off, admins arrive with roles: [],
and ADMIN revokes their flag.
Nil values and clear_blank
false, '' and [] are values and are always written, so a revoked admin or an unverified email is saved as such.
A nil value is skipped by default, which keeps the local value.
With clear_blank = true, a nil is written only when the provider actually sent the attribute:
-
Scoped paths.
path_scopeslists paths that depend on a scope. By defaultinfo.emailandextra.email_verifiedneedemail, andinfo.nameneedsprofile. Such a path is cleared only when its scope was granted.- When
extra.scopeis present (strategy 0.2.0), the gem reads the granted scopes from it. - Otherwise, the gem checks whether
extra.raw_infohas the key. The hub leaves a key out entirely when its scope wasn't granted.
- When
-
Other paths. A path not in
path_scopesis cleared when its key is present in the auth hash with anilvalue. -
Callables. A callable that returns
nilnever clears anything. Return''orfalseto clear.
Without this check, a client that didn't request the email scope would wipe every user's email on login.
Same email, different uid
This happens when no user has the incoming uid, but another row already holds the incoming email. Emails are compared
case-insensitively, so an older Ada@Example.com row matches the hub's ada@example.com. The same check runs when a
known user changes to an email that another row holds. Typical causes:
- a local account created before SSO,
- a hub account that was deleted and recreated,
- a stale email, where user X changed their email on the hub and user Y took the old address.
on_conflict |
Result |
|---|---|
:raise (default) |
Raises OmniauthSyncer::ConflictError and writes nothing. |
:ignore |
Returns nil and writes nothing. Treat it as a failed login. |
:link |
Attaches the uid to the existing row if three conditions hold: the uid has no row of its own yet, the existing row has no uid, and email_verified is true. Otherwise it raises ConflictError. |
:link never overwrites another uid, even when the email is verified. Doing so would hand user X's local account to
user Y.
The hub reports email_verified: false for everyone until it gets email confirmation (hub TODO T26). Until then,
:link always raises against the hub.
If the same user's first login arrives twice at once, both requests try to insert the row. The one that loses the race
gets ActiveRecord::RecordNotUnique, looks the user up once more and updates the row the other request created. This
relies on a unique index on the uid (or (provider, uid)) column.
Every conflict publishes a conflict.omniauth_syncer notification with the payload { policy:, uid:, provider:, existing_id: }:
ActiveSupport::Notifications.subscribe('conflict.omniauth_syncer') do |event|
Rails.logger.warn("SSO email conflict: #{event.payload.inspect}")
endValidation
SyncService validates the configuration before every sync. A mistake raises OmniauthSyncer::ConfigurationError
with a precise message (for example User has no writer for mapped attribute :full_name) instead of a
NoMethodError. The checks are:
-
user_modelconstantizes, - the uid, provider and email columns exist,
- every mapped attribute has a writer,
- every mapping is a path or a callable,
-
on_conflictis a known policy.
To catch mistakes before the first login, call OmniauthSyncer.validate! from a spec, a boot check, or a deploy
task. It needs the database schema, so don't call it from config/initializers, which also run for db:create and
assets:precompile.
Tokens
The gem never stores tokens unless you map them yourself:
-
extra.access_tokenlives for 10 minutes. Don't persist it. Use it during the callback, or keep it in memory. -
extra.refresh_token, if you do store it, must be encrypted, for example withencrypts :sso_refresh_token(Rails 7+ ActiveRecord encryption).
Callback controller
sync_sso_user has three outcomes:
- It returns the saved user.
- It returns
nilwhen the:ignorepolicy refuses the sync. - It logs the error and re-raises it:
OmniauthSyncer::Errorsubclasses, orActiveRecord::RecordInvalidfrom your model.
It is a private method, so it never becomes a routable action.
With Devise
# config/initializers/devise.rb (inside Devise.setup)
config.omniauth :ssoprovider, ENV.fetch('SSO_CLIENT_ID'), ENV.fetch('SSO_CLIENT_SECRET'),
strategy_class: OmniAuth::Strategies::SSOProvider,
client_options: { site: ENV.fetch('SSO_HUB_URL') },
user_info_url: '/api/v1/userinfo',
scope: 'openid profile email'
# config/routes.rb
devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
# app/models/user.rb
devise :omniauthable, omniauth_providers: %i[ssoprovider]# app/controllers/users/omniauth_callbacks_controller.rb
module Users
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include OmniauthSyncer::ControllerHelpers
# Named after the provider the strategy is mounted as.
def ssoprovider
user = sync_sso_user
return refuse('This email already belongs to another account.') unless user
sign_in_and_redirect user, event: :authentication
rescue OmniauthSyncer::Error, ActiveRecord::RecordInvalid
refuse('Sign-in failed.')
end
def failure
refuse('Sign-in failed.')
end
private
def refuse(message)
redirect_to new_user_session_path, alert: message
end
end
endWithout Devise
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider OmniAuth::Strategies::SSOProvider, ENV.fetch('SSO_CLIENT_ID'), ENV.fetch('SSO_CLIENT_SECRET'),
client_options: { site: ENV.fetch('SSO_HUB_URL') },
user_info_url: '/api/v1/userinfo',
scope: 'openid profile email'
end
# config/routes.rb
get '/auth/:provider/callback', to: 'sessions#create'
get '/auth/failure', to: 'sessions#failure'class SessionsController < ApplicationController
include OmniauthSyncer::ControllerHelpers
def create
user = sync_sso_user
return redirect_to(root_path, alert: 'This email already belongs to another account.') unless user
reset_session
session[:user_id] = user.id
redirect_to root_path
rescue OmniauthSyncer::Error, ActiveRecord::RecordInvalid
redirect_to root_path, alert: 'Sign-in failed.'
end
def failure
redirect_to root_path, alert: 'Sign-in failed.'
end
endOmniAuth 2 starts the login with a POST. Use button_to '/auth/ssoprovider' with
omniauth-rails_csrf_protection.
To include the helpers in every controller instead of each callback controller, set
config.include_controller_helpers = true. The gem's Railtie then includes them into ActionController::Base.
The same setup is in examples/rails/.
Development
bundle install
bundle exec rake # specs + rubocop
bundle exec rake buildThe specs use an in-memory SQLite database, so no Rails app is needed. Fixture auth hashes for strategy 0.2.0 and
0.1.2 are in spec/fixtures/.
License
MIT