omniauth_syncer 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a819b9cbe1e4eac56eeb9504aaa0b61efd924995f9a98036a346de8a4032fe7
4
- data.tar.gz: df5d6f52e868a53c096de1f0dae3e57f777b41ccf6fdd25a7e9967802feccafa
3
+ metadata.gz: 48f9fcd40256a9a1a6b7ba4d94f2417bec8aced846f8510447eff1273acb1cae
4
+ data.tar.gz: 46379816e4394ed025c39c3b5fe263bae20789892c67124f4bde62ff612c9b07
5
5
  SHA512:
6
- metadata.gz: 885231fb38457b3b9284f80081c60b87ccfb9a1672164df8e5efd657a7165087b4bfef266efdf4a1206fa9ed83e0406a5cc2d1099e0a952588eddc85e01333f2
7
- data.tar.gz: f7aecc03a67a0f66aa77d854708e57a34955feabc32f7225dfe8496c046c31e67e13b9ff36bcc806d4453a04ec5adab2d66add266403325cf9464df725825d03
6
+ metadata.gz: 25e9c166e961720e081eed2c4b87d2e78da3e821c8907c8e6438a0c5f47dc24e352069ea3d2b0acb71ad08c5965472d75f89d37cd3bd0bb97a9f952564c6af02
7
+ data.tar.gz: d083b9589e57d984cf83b49ee519364ed256d3a75a48a8316959f8a51d4461450eba17e435b28456d55f5da5781660c9ec08a62ac037ad61f89e72d66a779441
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-09-24
4
+
5
+ ### Breaking
6
+
7
+ - The default `mappings` are now `{ email: 'info.email', name: 'info.name' }` (was `{}`).
8
+ - `nil` is the only value skipped when syncing. `false`, `''` and `[]` are now written. In 0.1.0 anything not
9
+ `present?` was skipped, so a revoked flag could never be saved as `false`.
10
+ - A blank uid now raises `OmniauthSyncer::MissingIdentityError`. In 0.1.0, `find_or_initialize_by(uid: nil)` could
11
+ return an unrelated user.
12
+ - Another row holding the incoming email now raises `OmniauthSyncer::ConflictError` by default. Before, it failed on
13
+ the unique index or created a duplicate. Emails are compared case-insensitively. See `on_conflict`.
14
+ - `ControllerHelpers#sync_sso_user` is now private. It logs and re-raises any error, and returns `nil` when the sync
15
+ is refused.
16
+ - The `Rails::Engine` is replaced by a Railtie. The example controller and initializer moved from the gem root to
17
+ `examples/rails/`. Under an engine, Rails would have loaded them into every host app.
18
+ - Requires Ruby ≥ 3.1 and ActiveSupport ≥ 6.1.
19
+
20
+ ### Added
21
+
22
+ - `provider_field`: look users up by `(provider, uid)` and store `auth['provider']`.
23
+ - `on_conflict: :raise | :link | :ignore` for "same email, different uid". `:link` attaches the uid only when the
24
+ existing row has no uid and `email_verified` is true. Each conflict publishes `conflict.omniauth_syncer`.
25
+ - Mappings can be callables. `OmniauthSyncer::Mappings::ADMIN` and `EMAIL_VERIFIED` are ready-made hub mappings.
26
+ - `clear_blank`: write `nil` when the provider sent the attribute empty. It checks the granted scopes (`extra.scope`,
27
+ or key presence in `extra.raw_info`) via `path_scopes`.
28
+ - `Configuration#validate!` and `OmniauthSyncer.validate!` raise `OmniauthSyncer::ConfigurationError` with a precise
29
+ message. `SyncService` runs the same checks before every sync.
30
+ - `include_controller_helpers`: have the Railtie include `ControllerHelpers` into `ActionController::Base`.
31
+ - `ControllerHelpers` is required from `omniauth_syncer`.
32
+ - Two simultaneous first logins no longer fail with `ActiveRecord::RecordNotUnique`: the losing request retries the
33
+ lookup once. The save runs in a savepoint, so the retry also works inside a host transaction.
34
+
35
+ ### Fixed
36
+
37
+ - Path lookup no longer turns `false` into `nil`.
38
+
39
+ ### Internal
40
+
41
+ - New spec suite against an in-memory SQLite database, with auth hash fixtures for strategy 0.2.0 and 0.1.2.
42
+ - Added RuboCop config for Ruby 3.1, GitHub Actions CI (Ruby 3.1–3.4), a `Rakefile` and a `.gitignore`.
43
+ - README rewritten around the `omniauth-ssoprovider` auth hash.
44
+
45
+ ## 0.1.0
46
+
47
+ - Initial release.
data/README.md CHANGED
@@ -1,127 +1,297 @@
1
- omniauth\_syncer 🚀
2
- ===================
1
+ # omniauth_syncer
3
2
 
4
- A plug-and-play Ruby gem designed for applications using **OmniAuth** (often with SSO providers) to reliably and safely synchronize user profile data into a local $\\text{ActiveRecord}$ model.
3
+ Keeps a local ActiveRecord user in step with the OmniAuth auth hash after an SSO login.
5
4
 
6
- This gem prevents data drift by ensuring the local user record is always up-to-date with the latest information fetched from the SSO provider during a successful login.
5
+ On each login, `OmniauthSyncer::SyncService.call(auth_hash)` does four things:
7
6
 
8
- Installation
9
- ------------
7
+ 1. It looks up the local user by `(provider, uid)`, or by `uid` alone.
8
+ 2. It copies the mapped attributes out of the auth hash.
9
+ 3. It applies an explicit policy when another local user already holds the incoming email.
10
+ 4. It saves the user with `save!`.
10
11
 
11
- Add this line to your application's Gemfile:
12
+ `ControllerHelpers#sync_sso_user` wraps the service for an OmniAuth callback controller.
12
13
 
13
- Ruby
14
+ The gem is written for client apps that sign in through a SecureSSOHub server with
15
+ [`omniauth-ssoprovider`](https://github.com/danielefrisanco/omniauth-ssoprovider). It works with any OmniAuth
16
+ strategy that fills `uid` and `info`.
17
+
18
+ Requires Ruby ≥ 3.1 and ActiveSupport ≥ 6.1. The user model must be ActiveRecord.
19
+
20
+ ## Installation
14
21
 
15
22
  ```ruby
23
+ gem 'omniauth-ssoprovider'
16
24
  gem 'omniauth_syncer'
17
25
  ```
18
26
 
19
- Then execute:
27
+ ## The auth hash
20
28
 
21
- Bash
29
+ With `omniauth-ssoprovider` 0.2.0, a hub login produces:
22
30
 
23
- ```bash
24
- bundle install
31
+ ```ruby
32
+ {
33
+ "provider" => "ssoprovider", # or whatever name the strategy is mounted under
34
+ "uid" => "<sso_id>", # stable UUID, the hub's `sub`
35
+ "info" => { "name" => "…", "email" => "…" }, # each nil without its scope
36
+ "extra" => {
37
+ "raw_info" => { "id" => "<sso_id>", "sub" => "<sso_id>", "name" => "…",
38
+ "email" => "…", "email_verified" => false, "roles" => ["admin"] },
39
+ "access_token" => "<RS256 JWT, 10 min>",
40
+ "refresh_token" => "<opaque or nil>",
41
+ "expires_at" => 1758200000,
42
+ "id_token" => { "sub" => "…", "iss" => "…", "aud" => "…", "nonce" => "…" },
43
+ "roles" => ["admin"], # or []
44
+ "email_verified" => false,
45
+ "scope" => ["openid", "profile", "email"]
46
+ }
47
+ }
25
48
  ```
26
49
 
27
- Configuration
28
- -------------
50
+ `omniauth-ssoprovider` 0.1.2 gives the same `provider`, `uid` and `info`. Its `extra` holds only `raw_info` and
51
+ `access_token`. The default mappings work with both versions.
29
52
 
30
- The gem requires a basic setup to know which local model to use and how to map fields from the OmniAuth hash.
53
+ Treat `uid` as the only stable identity. Users can change their email on the hub.
31
54
 
32
- Create an initializer file at config/initializers/omniauth\_syncer.rb:
33
-
34
- Ruby
55
+ ## Configuration
35
56
 
36
57
  ```ruby
37
58
  # config/initializers/omniauth_syncer.rb
38
59
  OmniauthSyncer.configure do |config|
39
- # 1. Local User Model
40
- # The name of the local ActiveRecord class to sync data to (e.g., 'User')
41
- config.user_model = 'User'
42
- # 2. Unique Identifier Field
43
- # The column in your local User model used for lookup (must be unique).
44
- config.uid_field = :sso_uid
45
- # 3. Path to UID in Auth Hash
46
- # The string path in the OmniAuth Auth Hash that holds the unique identifier.
47
- # Example: 'uid' is used for the top-level hash['uid'].
48
- config.uid_field_in_auth = 'uid'
49
- # 4. Attribute Mappings
50
- # Map local attributes (keys) to the path in the OmniAuth Auth Hash (values).
51
- # Use dot notation for nested fields (e.g., 'info.email').
52
- config.mappings = {
53
- email: 'info.email',
54
- full_name: 'info.name',
55
- # Example for nested data like roles provided by the SSO server:
56
- roles: 'extra.raw_info.roles'
57
- }
58
- end
60
+ config.user_model = 'User' # default
61
+ config.uid_field = :sso_uid # column holding the uid; default :uid
62
+ config.uid_field_in_auth = 'uid' # dotted path in the auth hash; default
63
+ config.provider_field = :provider # optional: look users up by (provider, uid)
64
+
65
+ # Default mappings: { email: 'info.email', name: 'info.name' }
66
+ config.mappings[:admin] = OmniauthSyncer::Mappings::ADMIN
67
+ config.mappings[:email_verified] = OmniauthSyncer::Mappings::EMAIL_VERIFIED
68
+
69
+ config.on_conflict = :raise # :raise (default), :link or :ignore
70
+ config.clear_blank = false # default
71
+ end
59
72
  ```
60
73
 
61
- Usage
62
- -----
74
+ The host needs the matching columns and unique indexes:
75
+
76
+ ```ruby
77
+ add_column :users, :sso_uid, :string
78
+ add_column :users, :provider, :string
79
+ add_index :users, %i[provider sso_uid], unique: true
80
+ add_index :users, :email, unique: true
81
+ ```
82
+
83
+ ### Mappings
84
+
85
+ A mapping points a local attribute at a source. The source can be one of two things:
86
+
87
+ - **A dotted path** such as `'info.email'` or `'extra.raw_info.roles'`. Paths work with string keys, symbol keys and
88
+ `OmniAuth::AuthHash`.
89
+ - **A callable** that receives the auth hash, such as `->(auth) { auth['info']['name']&.titleize }`.
90
+
91
+ Two mappings are ready to use for the hub, and neither is enabled by default:
92
+
93
+ | Constant | Writes |
94
+ | - | - |
95
+ | `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. |
96
+ | `Mappings::EMAIL_VERIFIED` | `extra.email_verified`. For strategy 0.1.2 use `'extra.raw_info.email_verified'` instead. |
97
+
98
+ `roles` is the hub's assertion that a user is a hub administrator. It is not an entitlement system.
63
99
 
64
- The gem provides the core synchronization logic via a helper module, which should be included in your application's **OmniAuth Callback Controller**.
100
+ The hub sends roles only from `/api/v1/userinfo`, so `ADMIN` needs the strategy's `user_info_url:
101
+ '/api/v1/userinfo'`. If the strategy uses `/oauth/userinfo` or has userinfo turned off, admins arrive with `roles: []`,
102
+ and `ADMIN` revokes their flag.
65
103
 
66
- ### Step 1: Include the Helper
104
+ ### Nil values and `clear_blank`
67
105
 
68
- In your OmniAuth Callback Controller (e.g., app/controllers/users/omniauth\_callbacks\_controller.rb):
106
+ `false`, `''` and `[]` are values and are always written, so a revoked admin or an unverified email is saved as such.
69
107
 
70
- Ruby
108
+ A `nil` value is skipped by default, which keeps the local value.
109
+
110
+ With `clear_blank = true`, a `nil` is written only when the provider actually sent the attribute:
111
+
112
+ - **Scoped paths.** `path_scopes` lists paths that depend on a scope. By default `info.email` and
113
+ `extra.email_verified` need `email`, and `info.name` needs `profile`. Such a path is cleared only when its scope was
114
+ granted.
115
+ - When `extra.scope` is present (strategy 0.2.0), the gem reads the granted scopes from it.
116
+ - Otherwise, the gem checks whether `extra.raw_info` has the key. The hub leaves a key out entirely when its scope
117
+ wasn't granted.
118
+ - **Other paths.** A path not in `path_scopes` is cleared when its key is present in the auth hash with a `nil` value.
119
+ - **Callables.** A callable that returns `nil` never clears anything. Return `''` or `false` to clear.
120
+
121
+ Without this check, a client that didn't request the `email` scope would wipe every user's email on login.
122
+
123
+ ### Same email, different uid
124
+
125
+ This happens when no user has the incoming uid, but another row already holds the incoming email. Emails are compared
126
+ case-insensitively, so an older `Ada@Example.com` row matches the hub's `ada@example.com`. The same check runs when a
127
+ known user changes to an email that another row holds. Typical causes:
128
+
129
+ - a local account created before SSO,
130
+ - a hub account that was deleted and recreated,
131
+ - a stale email, where user X changed their email on the hub and user Y took the old address.
132
+
133
+ | `on_conflict` | Result |
134
+ | - | - |
135
+ | `:raise` (default) | Raises `OmniauthSyncer::ConflictError` and writes nothing. |
136
+ | `:ignore` | Returns `nil` and writes nothing. Treat it as a failed login. |
137
+ | `: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`. |
138
+
139
+ `:link` never overwrites another uid, even when the email is verified. Doing so would hand user X's local account to
140
+ user Y.
141
+
142
+ The hub reports `email_verified: false` for everyone until it gets email confirmation (hub TODO T26). Until then,
143
+ `:link` always raises against the hub.
144
+
145
+ If the same user's first login arrives twice at once, both requests try to insert the row. The one that loses the race
146
+ gets `ActiveRecord::RecordNotUnique`, looks the user up once more and updates the row the other request created. This
147
+ relies on a unique index on the uid (or `(provider, uid)`) column.
148
+
149
+ Every conflict publishes a `conflict.omniauth_syncer` notification with the payload `{ policy:, uid:, provider:,
150
+ existing_id: }`:
71
151
 
72
152
  ```ruby
73
- class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
74
- # Include the helper module
75
- include OmniauthSyncer::ControllerHelpers
76
- # ...
77
- end
153
+ ActiveSupport::Notifications.subscribe('conflict.omniauth_syncer') do |event|
154
+ Rails.logger.warn("SSO email conflict: #{event.payload.inspect}")
155
+ end
78
156
  ```
79
157
 
80
- ### Step 2: Sync the User
158
+ ### Validation
159
+
160
+ `SyncService` validates the configuration before every sync. A mistake raises `OmniauthSyncer::ConfigurationError`
161
+ with a precise message (for example `User has no writer for mapped attribute :full_name`) instead of a
162
+ `NoMethodError`. The checks are:
163
+
164
+ - `user_model` constantizes,
165
+ - the uid, provider and email columns exist,
166
+ - every mapped attribute has a writer,
167
+ - every mapping is a path or a callable,
168
+ - `on_conflict` is a known policy.
169
+
170
+ To catch mistakes before the first login, call `OmniauthSyncer.validate!` from a spec, a boot check, or a deploy
171
+ task. It needs the database schema, so don't call it from `config/initializers`, which also run for `db:create` and
172
+ `assets:precompile`.
173
+
174
+ ### Tokens
175
+
176
+ The gem never stores tokens unless you map them yourself:
177
+
178
+ - **`extra.access_token`** lives for 10 minutes. Don't persist it. Use it during the callback, or keep it in memory.
179
+ - **`extra.refresh_token`**, if you do store it, must be encrypted, for example with `encrypts :sso_refresh_token`
180
+ (Rails 7+ ActiveRecord encryption).
81
181
 
82
- Call the **sync\_sso\_user** method inside your provider action (e.g., sso\_provider). This method will handle the following automatically:
182
+ ## Callback controller
83
183
 
84
- 1. Retrieving the current $\\text{OmniAuth Auth Hash}$.
85
-
86
- 2. Calling the $\\text{SyncService}$ to $\\text{find or create}$ the local user record based on the configured $\\text{uid}$.
87
-
88
- 3. Updating all configured attributes.
89
-
90
- 4. Safely **preserving** existing local data if a field is missing or nil in the incoming SSO data.
91
-
184
+ `sync_sso_user` has three outcomes:
92
185
 
93
- Ruby
186
+ - It returns the saved user.
187
+ - It returns `nil` when the `:ignore` policy refuses the sync.
188
+ - It logs the error and re-raises it: `OmniauthSyncer::Error` subclasses, or `ActiveRecord::RecordInvalid` from your
189
+ model.
190
+
191
+ It is a private method, so it never becomes a routable action.
192
+
193
+ ### With Devise
194
+
195
+ ```ruby
196
+ # config/initializers/devise.rb (inside Devise.setup)
197
+ config.omniauth :ssoprovider, ENV.fetch('SSO_CLIENT_ID'), ENV.fetch('SSO_CLIENT_SECRET'),
198
+ strategy_class: OmniAuth::Strategies::SSOProvider,
199
+ client_options: { site: ENV.fetch('SSO_HUB_URL') },
200
+ user_info_url: '/api/v1/userinfo',
201
+ scope: 'openid profile email'
202
+
203
+ # config/routes.rb
204
+ devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
205
+
206
+ # app/models/user.rb
207
+ devise :omniauthable, omniauth_providers: %i[ssoprovider]
208
+ ```
94
209
 
95
210
  ```ruby
96
211
  # app/controllers/users/omniauth_callbacks_controller.rb
97
- class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
98
- include OmniauthSyncer::ControllerHelpers
99
- def sso_provider
100
- begin
101
- # 1. Sync the user and get the local user object
102
- @user = sync_sso_user
103
- # 2. Sign in the user (e.g., using Devise or a custom method)
104
- if @user.persisted?
105
- sign_in_and_redirect @user, event: :authentication
106
- else
107
- # This occurs if the user record fails local validations (e.g., missing mandatory field)
108
- redirect_to root_path, alert: "Could not sync user profile due to data issues."
109
- end
110
- rescue => e
111
- # Log any synchronization or validation errors
112
- logger.error "SSO Sync Failure: #{e.message}"
113
- redirect_to root_path, alert: "Authentication failed due to internal error."
212
+ module Users
213
+ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
214
+ include OmniauthSyncer::ControllerHelpers
215
+
216
+ # Named after the provider the strategy is mounted as.
217
+ def ssoprovider
218
+ user = sync_sso_user
219
+ return refuse('This email already belongs to another account.') unless user
220
+
221
+ sign_in_and_redirect user, event: :authentication
222
+ rescue OmniauthSyncer::Error, ActiveRecord::RecordInvalid
223
+ refuse('Sign-in failed.')
224
+ end
225
+
226
+ def failure
227
+ refuse('Sign-in failed.')
228
+ end
229
+
230
+ private
231
+
232
+ def refuse(message)
233
+ redirect_to new_user_session_path, alert: message
114
234
  end
115
235
  end
116
- end
236
+ end
117
237
  ```
118
238
 
119
- Contributing
120
- ------------
239
+ ### Without Devise
240
+
241
+ ```ruby
242
+ # config/initializers/omniauth.rb
243
+ Rails.application.config.middleware.use OmniAuth::Builder do
244
+ provider OmniAuth::Strategies::SSOProvider, ENV.fetch('SSO_CLIENT_ID'), ENV.fetch('SSO_CLIENT_SECRET'),
245
+ client_options: { site: ENV.fetch('SSO_HUB_URL') },
246
+ user_info_url: '/api/v1/userinfo',
247
+ scope: 'openid profile email'
248
+ end
249
+
250
+ # config/routes.rb
251
+ get '/auth/:provider/callback', to: 'sessions#create'
252
+ get '/auth/failure', to: 'sessions#failure'
253
+ ```
254
+
255
+ ```ruby
256
+ class SessionsController < ApplicationController
257
+ include OmniauthSyncer::ControllerHelpers
258
+
259
+ def create
260
+ user = sync_sso_user
261
+ return redirect_to(root_path, alert: 'This email already belongs to another account.') unless user
262
+
263
+ reset_session
264
+ session[:user_id] = user.id
265
+ redirect_to root_path
266
+ rescue OmniauthSyncer::Error, ActiveRecord::RecordInvalid
267
+ redirect_to root_path, alert: 'Sign-in failed.'
268
+ end
269
+
270
+ def failure
271
+ redirect_to root_path, alert: 'Sign-in failed.'
272
+ end
273
+ end
274
+ ```
275
+
276
+ OmniAuth 2 starts the login with a POST. Use `button_to '/auth/ssoprovider'` with
277
+ `omniauth-rails_csrf_protection`.
278
+
279
+ To include the helpers in every controller instead of each callback controller, set
280
+ `config.include_controller_helpers = true`. The gem's Railtie then includes them into `ActionController::Base`.
281
+
282
+ The same setup is in [`examples/rails/`](examples/rails/).
283
+
284
+ ## Development
285
+
286
+ ```bash
287
+ bundle install
288
+ bundle exec rake # specs + rubocop
289
+ bundle exec rake build
290
+ ```
121
291
 
122
- Bug reports and pull requests are welcome on GitHub at https://github.com/danielefrisanco/omniauth_syncer.
292
+ The specs use an in-memory SQLite database, so no Rails app is needed. Fixture auth hashes for strategy 0.2.0 and
293
+ 0.1.2 are in `spec/fixtures/`.
123
294
 
124
- License
125
- -------
295
+ ## License
126
296
 
127
- The gem is available as open source under the terms of the **MIT License**.
297
+ MIT
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniauthSyncer
4
+ # Dotted-path lookup into an auth hash ('info.email', 'extra.raw_info.roles').
5
+ # Works on plain Hashes with string or symbol keys and on OmniAuth::AuthHash.
6
+ # Unlike `a || b` lookups it keeps `false` values, and it can tell a key that
7
+ # is present with a nil value from a key that is missing.
8
+ module AuthPath
9
+ MISSING = Object.new.freeze
10
+ private_constant :MISSING
11
+
12
+ module_function
13
+
14
+ def dig(auth, path)
15
+ value = lookup(auth, path)
16
+ value.equal?(MISSING) ? nil : value
17
+ end
18
+
19
+ def key?(auth, path)
20
+ !lookup(auth, path).equal?(MISSING)
21
+ end
22
+
23
+ def lookup(auth, path)
24
+ path.to_s.split('.').reduce(auth) do |node, segment|
25
+ break MISSING unless node.respond_to?(:key?)
26
+
27
+ if node.key?(segment)
28
+ node[segment]
29
+ elsif node.key?(segment.to_sym)
30
+ node[segment.to_sym]
31
+ else
32
+ break MISSING
33
+ end
34
+ end
35
+ end
36
+ private_class_method :lookup
37
+ end
38
+ end
@@ -1,18 +1,105 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OmniauthSyncer
2
4
  class Configuration
3
- # Define settings with default values or required accessors
4
- attr_accessor :user_model, :uid_field, :uid_field_in_auth, :mappings
5
+ ON_CONFLICT_POLICIES = %i[raise link ignore].freeze
6
+
7
+ # user_model: ActiveRecord class name the users live in.
8
+ # uid_field: column holding the provider's uid.
9
+ # uid_field_in_auth: dotted path to the uid in the auth hash.
10
+ # provider_field: column holding auth['provider']; when set, users are
11
+ # looked up by (provider, uid) instead of uid alone.
12
+ # email_field: local attribute checked for "same email, different uid".
13
+ # mappings: local attribute => dotted path or callable(auth).
14
+ # on_conflict: :raise, :link or :ignore (see SyncService).
15
+ # clear_blank: write nil when the provider sent the attribute empty.
16
+ # path_scopes: dotted path => OAuth scope that must be granted before
17
+ # clear_blank may clear it.
18
+ # email_verified_path: dotted path to the provider's email_verified flag.
19
+ # include_controller_helpers: include ControllerHelpers into
20
+ # ActionController::Base (Rails only).
21
+ attr_accessor :user_model, :uid_field, :uid_field_in_auth, :provider_field, :email_field,
22
+ :mappings, :on_conflict, :clear_blank, :path_scopes, :email_verified_path,
23
+ :include_controller_helpers
5
24
 
6
25
  def initialize
7
- # Sensible defaults
8
26
  @user_model = 'User'
9
27
  @uid_field = :uid
10
- @uid_field_in_auth = 'uid' # Default path in AuthHash
11
- @mappings = {}
28
+ @uid_field_in_auth = 'uid'
29
+ @provider_field = nil
30
+ @email_field = :email
31
+ @mappings = { email: 'info.email', name: 'info.name' }
32
+ @on_conflict = :raise
33
+ @clear_blank = false
34
+ @path_scopes = { 'info.email' => 'email', 'info.name' => 'profile', 'extra.email_verified' => 'email' }
35
+ @email_verified_path = 'extra.email_verified'
36
+ @include_controller_helpers = false
37
+ end
38
+
39
+ def model_class
40
+ user_model.to_s.safe_constantize ||
41
+ raise(ConfigurationError, "user_model #{user_model.inspect} is not a defined constant")
42
+ end
43
+
44
+ # Raises ConfigurationError with the first problem found; returns true otherwise.
45
+ # Needs the database schema, so call it where a connection is available.
46
+ def validate!
47
+ klass = model_class
48
+ validate_on_conflict!
49
+ validate_mappings!(klass)
50
+ validate_link!
51
+ validate_lookup_columns!(klass)
52
+ true
53
+ end
54
+
55
+ private
56
+
57
+ def validate_on_conflict!
58
+ return if ON_CONFLICT_POLICIES.include?(on_conflict)
59
+
60
+ raise ConfigurationError,
61
+ "on_conflict must be one of #{ON_CONFLICT_POLICIES.map(&:inspect).join(', ')}, got #{on_conflict.inspect}"
62
+ end
63
+
64
+ def validate_mappings!(klass)
65
+ raise ConfigurationError, "mappings must be a Hash, got #{mappings.class}" unless mappings.is_a?(Hash)
66
+
67
+ klass.define_attribute_methods if klass.respond_to?(:define_attribute_methods)
68
+ mappings.each { |attribute, source| validate_mapping!(klass, attribute, source) }
69
+ end
70
+
71
+ def validate_link!
72
+ return if on_conflict != :link || mapped?(email_field)
73
+
74
+ raise ConfigurationError, "on_conflict :link needs a mapping for email_field #{email_field.inspect}"
75
+ end
76
+
77
+ def validate_mapping!(klass, attribute, source)
78
+ unless klass.method_defined?("#{attribute}=")
79
+ raise ConfigurationError, "#{klass.name} has no writer for mapped attribute #{attribute.inspect}"
80
+ end
81
+ return if source.respond_to?(:call) || (source.is_a?(String) && !source.empty?)
82
+
83
+ raise ConfigurationError,
84
+ "mapping #{attribute.inspect} must be a dotted path String or a callable, got #{source.inspect}"
85
+ end
86
+
87
+ def validate_lookup_columns!(klass)
88
+ fields = { uid_field: uid_field }
89
+ fields[:provider_field] = provider_field if provider_field
90
+ fields[:email_field] = email_field if mapped?(email_field)
91
+ fields.each do |setting, column|
92
+ next if klass.column_names.include?(column.to_s)
93
+
94
+ raise ConfigurationError, "#{klass.name} has no column #{column.inspect} (configured as #{setting})"
95
+ end
96
+ end
97
+
98
+ def mapped?(attribute)
99
+ mappings.keys.map(&:to_s).include?(attribute.to_s)
12
100
  end
13
101
  end
14
102
 
15
- # Class method to expose the configuration object and the configuration block
16
103
  def self.configuration
17
104
  @configuration ||= Configuration.new
18
105
  end
@@ -20,4 +107,12 @@ module OmniauthSyncer
20
107
  def self.configure
21
108
  yield(configuration)
22
109
  end
110
+
111
+ def self.validate!
112
+ configuration.validate!
113
+ end
114
+
115
+ def self.reset_configuration!
116
+ @configuration = Configuration.new
117
+ end
23
118
  end
@@ -1,17 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OmniauthSyncer
2
4
  module ControllerHelpers
3
- # Call this method inside the OmniAuth callback action
4
- def sync_sso_user
5
- # Retrieve the auth hash from the Rack environment
6
- auth_hash = request.env['omniauth.auth']
5
+ private
7
6
 
8
- # Execute the core sync logic
9
- OmniauthSyncer::SyncService.call(auth_hash)
10
- rescue ActiveRecord::RecordInvalid => e
11
- # Provide a clear, common way to handle sync validation errors
12
- logger.error "OmniauthSyncer Validation Error: #{e.message}"
13
- # Optionally, you might raise a custom error here
14
- raise e
7
+ # Call this inside the OmniAuth callback action. Returns the synced user, or
8
+ # nil when the sync was refused (on_conflict: :ignore); treat nil as a
9
+ # failed login. Errors are logged and re-raised.
10
+ def sync_sso_user
11
+ user = OmniauthSyncer::SyncService.call(request.env['omniauth.auth'])
12
+ logger&.warn('OmniauthSyncer: sync refused, email belongs to another local user') if user.nil?
13
+ user
14
+ rescue StandardError => e
15
+ logger&.error("OmniauthSyncer: #{e.class}: #{e.message}")
16
+ raise
15
17
  end
16
18
  end
17
19
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniauthSyncer
4
+ # Opt-in mappings for the auth hash omniauth-ssoprovider builds from a hub login.
5
+ #
6
+ # config.mappings[:admin] = OmniauthSyncer::Mappings::ADMIN
7
+ # config.mappings[:email_verified] = OmniauthSyncer::Mappings::EMAIL_VERIFIED
8
+ module Mappings
9
+ # true/false from the hub's role assertion, nil (left untouched) when the
10
+ # auth hash has no extra.roles. The hub sends roles only from
11
+ # /api/v1/userinfo, so the strategy must use that user_info_url; against
12
+ # /oauth/userinfo every user, admins included, arrives with roles [].
13
+ ADMIN = lambda do |auth|
14
+ Array(AuthPath.dig(auth, 'extra.roles')).include?('admin') if AuthPath.key?(auth, 'extra.roles')
15
+ end
16
+
17
+ EMAIL_VERIFIED = 'extra.email_verified'
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniauthSyncer
4
+ # A Railtie rather than an Engine: an Engine would add this gem's app/ and
5
+ # config/ directories to the host application.
6
+ class Railtie < ::Rails::Railtie
7
+ # after_initialize runs after the host's config/initializers, so the flag
8
+ # set there is visible.
9
+ config.after_initialize do
10
+ next unless OmniauthSyncer.configuration.include_controller_helpers
11
+
12
+ ActiveSupport.on_load(:action_controller_base) { include OmniauthSyncer::ControllerHelpers }
13
+ end
14
+ end
15
+ end
@@ -1,57 +1,158 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OmniauthSyncer
4
+ # Finds or initialises the local user for an auth hash, copies the mapped
5
+ # attributes onto it and saves it.
6
+ #
7
+ # Returns the saved user, or nil when on_conflict: :ignore refused the sync.
8
+ # Raises MissingIdentityError, ConflictError, ConfigurationError, or the
9
+ # model's own ActiveRecord::RecordInvalid.
10
+ #
11
+ # "Same email, different uid" (another row already holds the incoming email,
12
+ # compared case-insensitively):
13
+ # :raise -> ConflictError, nothing written.
14
+ # :ignore -> nil, nothing written.
15
+ # :link -> attach the uid to that row, but only when this identity has no
16
+ # row yet, the row has no uid, and the provider says the email is
17
+ # verified; ConflictError otherwise.
18
+ # Every conflict is published as the 'conflict.omniauth_syncer' notification.
19
+ #
20
+ # If a concurrent first login inserts the same user between lookup and save,
21
+ # the resulting ActiveRecord::RecordNotUnique triggers one fresh lookup.
2
22
  class SyncService
3
- # Use the .call convention for single-purpose service objects
23
+ # The strategy's list of granted scopes, and the raw userinfo response used
24
+ # to infer them (key presence) when the list is absent.
25
+ GRANTED_SCOPES_PATH = 'extra.scope'
26
+ RAW_INFO_PATH = 'extra.raw_info'
27
+
4
28
  def self.call(auth_hash)
5
29
  new(auth_hash).sync_user
6
30
  end
7
31
 
32
+ def initialize(auth_hash)
33
+ @auth_hash = auth_hash
34
+ @config = OmniauthSyncer.configuration
35
+ end
36
+
8
37
  def sync_user
9
- # Step 1: Find or Create the User
10
- user = find_or_initialize_user
38
+ @config.validate!
39
+ @user_class = @config.model_class
11
40
 
12
- # Step 2: Update Mapped Attributes
13
- @config.mappings.each do |local_attr, auth_path|
14
- # Use a helper to safely retrieve nested data (e.g., 'info.email')
15
- auth_value = get_auth_value(auth_path)
41
+ identity = identity_attributes
42
+ attributes = mapped_attributes
43
+ retried = false
44
+ begin
45
+ find_and_save(identity, attributes)
46
+ rescue ActiveRecord::RecordNotUnique
47
+ # A concurrent first login inserted the row after our lookup: look it up once more.
48
+ raise if retried
16
49
 
17
- # Only set if the value is present and the local attribute is writable
18
- user.send("#{local_attr}=", auth_value) if auth_value.present?
50
+ retried = true
51
+ retry
19
52
  end
53
+ end
54
+
55
+ private
56
+
57
+ def find_and_save(identity, attributes)
58
+ user = @user_class.find_by(identity) || @user_class.new
59
+ conflicting = conflicting_user(user, attributes)
60
+ user = resolve_conflict(conflicting, user) if conflicting
61
+ return if user.nil?
20
62
 
21
- # Step 3: Save and Return
22
- user.save! # Use save! to ensure we raise an error on validation failure
63
+ user.assign_attributes(identity.merge(attributes))
64
+ # A savepoint, so a unique-index failure leaves a surrounding transaction usable for the retry.
65
+ @user_class.transaction(requires_new: true) { user.save! }
23
66
  user
24
67
  end
25
68
 
26
- private
69
+ def identity_attributes
70
+ uid = AuthPath.dig(@auth_hash, @config.uid_field_in_auth)
71
+ raise MissingIdentityError, "auth hash has no uid at #{@config.uid_field_in_auth.inspect}" if uid.blank?
27
72
 
28
- def initialize(auth_hash)
29
- @auth_hash = auth_hash
30
- @config = OmniauthSyncer.configuration
31
- # Dynamically get the User model class
32
- @user_class = @config.user_model.constantize
73
+ identity = { @config.uid_field => uid.to_s }
74
+ return identity unless @config.provider_field
75
+
76
+ provider = AuthPath.dig(@auth_hash, 'provider')
77
+ raise MissingIdentityError, 'auth hash has no provider' if provider.blank?
78
+
79
+ identity.merge(@config.provider_field => provider.to_s)
80
+ end
81
+
82
+ # nil means "nothing to write" and is skipped, unless clear_blank is on and
83
+ # the provider actually sent the attribute. false, '' and [] are values.
84
+ def mapped_attributes
85
+ @config.mappings.each_with_object({}) do |(attribute, source), attributes|
86
+ value = source.respond_to?(:call) ? source.call(@auth_hash) : AuthPath.dig(@auth_hash, source)
87
+ next if value.nil? && !clearable?(source)
88
+
89
+ attributes[attribute.to_sym] = value
90
+ end
91
+ end
92
+
93
+ # A callable signals "clear" by returning a non-nil blank value itself.
94
+ def clearable?(source)
95
+ return false if !@config.clear_blank || source.respond_to?(:call)
96
+
97
+ scope = @config.path_scopes[source.to_s]
98
+ scope ? scope_granted?(scope, source) : AuthPath.key?(@auth_hash, source)
99
+ end
100
+
101
+ def scope_granted?(scope, path)
102
+ granted = AuthPath.dig(@auth_hash, GRANTED_SCOPES_PATH)
103
+ if granted
104
+ scopes = granted.is_a?(String) ? granted.split : Array(granted)
105
+ scopes.map(&:to_s).include?(scope.to_s)
106
+ else
107
+ AuthPath.key?(AuthPath.dig(@auth_hash, RAW_INFO_PATH), path.to_s.split('.').last)
108
+ end
33
109
  end
34
110
 
35
- def find_or_initialize_user
36
- # Get the unique identifier value from the auth hash (e.g., the UID)
37
- uid_value = get_auth_value(@config.uid_field_in_auth) # Assume config holds the path to UID
111
+ def conflicting_user(user, attributes)
112
+ email = attributes[@config.email_field.to_sym]
113
+ return if email.blank?
38
114
 
39
- # Use the configured uid_field for the database lookup
40
- @user_class.find_or_initialize_by(@config.uid_field => uid_value)
115
+ scope = users_with_email(email)
116
+ scope = scope.where.not(@user_class.primary_key => user.id) if user.persisted?
117
+ scope.first
41
118
  end
42
119
 
43
- # Helper method to safely traverse the nested AuthHash
44
- # (Simplified for demonstration; a real implementation would be more robust)
45
- def get_auth_value(path)
46
- parts = path.to_s.split('.')
47
- current = @auth_hash.dup
120
+ # Case-insensitive, with LOWER() on both sides so SQL and Ruby case rules can't disagree.
121
+ def users_with_email(email)
122
+ column = @user_class.arel_table[@config.email_field]
123
+ incoming = Arel::Nodes::NamedFunction.new('LOWER', [Arel::Nodes.build_quoted(email, column)])
124
+ @user_class.where(column.lower.eq(incoming))
125
+ end
48
126
 
49
- parts.each do |part|
50
- # Use dig for safety, converting to symbol or string keys
51
- current = current.respond_to?(:dig) ? current.dig(part.to_sym) || current.dig(part.to_s) : current[part]
52
- return nil unless current
127
+ def resolve_conflict(conflicting, user)
128
+ ActiveSupport::Notifications.instrument(
129
+ 'conflict.omniauth_syncer',
130
+ policy: @config.on_conflict, uid: AuthPath.dig(@auth_hash, @config.uid_field_in_auth),
131
+ provider: AuthPath.dig(@auth_hash, 'provider'), existing_id: conflicting.id
132
+ )
133
+ case @config.on_conflict
134
+ when :ignore then nil
135
+ when :link then link(conflicting, user)
136
+ else raise conflict_error(conflicting, 'on_conflict is :raise')
53
137
  end
54
- current
138
+ end
139
+
140
+ def link(conflicting, user)
141
+ raise conflict_error(conflicting, 'cannot link, this uid already has its own user') if user.persisted?
142
+
143
+ existing_uid = conflicting[@config.uid_field]
144
+ raise conflict_error(conflicting, 'cannot link, that user already has a uid') if existing_uid.present?
145
+ raise conflict_error(conflicting, 'cannot link, the email is not verified') unless email_verified?
146
+
147
+ conflicting
148
+ end
149
+
150
+ def email_verified?
151
+ AuthPath.dig(@auth_hash, @config.email_verified_path) == true
152
+ end
153
+
154
+ def conflict_error(conflicting, reason)
155
+ ConflictError.new("#{@user_class.name} ##{conflicting.id} already holds this email (#{reason})")
55
156
  end
56
157
  end
57
158
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OmniauthSyncer
2
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
3
5
  end
@@ -1,9 +1,28 @@
1
- require 'active_support/core_ext/string/inflections' # For constantize
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/object/blank'
5
+ require 'active_support/core_ext/string/inflections'
6
+ require 'active_support/notifications'
7
+
2
8
  require 'omniauth_syncer/version'
3
- require 'omniauth_syncer/configuration'
4
- require 'omniauth_syncer/sync_service'
5
9
 
6
10
  module OmniauthSyncer
7
- # No code here, just module definition.
8
- # All logic is in SyncService and Configuration.
11
+ class Error < StandardError; end
12
+
13
+ # The configuration cannot work against the configured model.
14
+ class ConfigurationError < Error; end
15
+
16
+ # The auth hash carries no usable uid (or provider, when provider_field is set).
17
+ class MissingIdentityError < Error; end
18
+
19
+ # Another local user already holds the incoming email.
20
+ class ConflictError < Error; end
9
21
  end
22
+
23
+ require 'omniauth_syncer/auth_path'
24
+ require 'omniauth_syncer/configuration'
25
+ require 'omniauth_syncer/mappings'
26
+ require 'omniauth_syncer/sync_service'
27
+ require 'omniauth_syncer/controller_helpers'
28
+ require 'omniauth_syncer/railtie' if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,125 +1,70 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth_syncer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniele Frisanco
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-23 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: omniauth
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '2.0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '2.0'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: activesupport
29
15
  requirement: !ruby/object:Gem::Requirement
30
16
  requirements:
31
17
  - - ">="
32
18
  - !ruby/object:Gem::Version
33
- version: '6.0'
19
+ version: '6.1'
34
20
  type: :runtime
35
21
  prerelease: false
36
22
  version_requirements: !ruby/object:Gem::Requirement
37
23
  requirements:
38
24
  - - ">="
39
25
  - !ruby/object:Gem::Version
40
- version: '6.0'
26
+ version: '6.1'
41
27
  - !ruby/object:Gem::Dependency
42
- name: bundler
28
+ name: omniauth
43
29
  requirement: !ruby/object:Gem::Requirement
44
30
  requirements:
45
31
  - - "~>"
46
32
  - !ruby/object:Gem::Version
47
33
  version: '2.0'
48
- type: :development
34
+ type: :runtime
49
35
  prerelease: false
50
36
  version_requirements: !ruby/object:Gem::Requirement
51
37
  requirements:
52
38
  - - "~>"
53
39
  - !ruby/object:Gem::Version
54
40
  version: '2.0'
55
- - !ruby/object:Gem::Dependency
56
- name: rake
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - "~>"
60
- - !ruby/object:Gem::Version
61
- version: '13.0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - "~>"
67
- - !ruby/object:Gem::Version
68
- version: '13.0'
69
- - !ruby/object:Gem::Dependency
70
- name: rspec
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - "~>"
74
- - !ruby/object:Gem::Version
75
- version: '3.0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - "~>"
81
- - !ruby/object:Gem::Version
82
- version: '3.0'
83
- - !ruby/object:Gem::Dependency
84
- name: pry
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '0'
97
- description: Ensures local user records are created or updated with the latest data
98
- from the SSO provider after a successful OmniAuth login.
41
+ description: Finds or creates the local user by (provider, uid), copies mapped attributes
42
+ out of the OmniAuth auth hash and saves it, with explicit policies for email conflicts
43
+ and cleared values.
99
44
  email:
100
45
  - daniele.frisanco@gmail.com
101
46
  executables: []
102
47
  extensions: []
103
48
  extra_rdoc_files: []
104
49
  files:
105
- - ".rspec_status"
106
- - ".rubocop.yml"
107
- - Gemfile
108
- - Gemfile.lock
50
+ - CHANGELOG.md
109
51
  - LICENSE.txt
110
52
  - README.md
111
- - app/controllers/users/omniauth_callbacks_controller.rb
112
- - config/initializers/omniauth_syncer.rb
113
53
  - lib/omniauth_syncer.rb
54
+ - lib/omniauth_syncer/auth_path.rb
114
55
  - lib/omniauth_syncer/configuration.rb
115
56
  - lib/omniauth_syncer/controller_helpers.rb
116
- - lib/omniauth_syncer/engine.rb
57
+ - lib/omniauth_syncer/mappings.rb
58
+ - lib/omniauth_syncer/railtie.rb
117
59
  - lib/omniauth_syncer/sync_service.rb
118
60
  - lib/omniauth_syncer/version.rb
119
61
  homepage: https://github.com/danielefrisanco/omniauth_syncer
120
62
  licenses:
121
63
  - MIT
122
- metadata: {}
64
+ metadata:
65
+ source_code_uri: https://github.com/danielefrisanco/omniauth_syncer
66
+ changelog_uri: https://github.com/danielefrisanco/omniauth_syncer/blob/main/CHANGELOG.md
67
+ rubygems_mfa_required: 'true'
123
68
  post_install_message:
124
69
  rdoc_options: []
125
70
  require_paths:
@@ -128,16 +73,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
128
73
  requirements:
129
74
  - - ">="
130
75
  - !ruby/object:Gem::Version
131
- version: '0'
76
+ version: '3.1'
132
77
  required_rubygems_version: !ruby/object:Gem::Requirement
133
78
  requirements:
134
79
  - - ">="
135
80
  - !ruby/object:Gem::Version
136
81
  version: '0'
137
82
  requirements: []
138
- rubygems_version: 3.2.3
83
+ rubygems_version: 3.3.26
139
84
  signing_key:
140
85
  specification_version: 4
141
- summary: A Ruby gem for synchronizing user profile data from OmniAuth to local ActiveRecord
142
- models.
86
+ summary: Keeps a local ActiveRecord user in step with the OmniAuth auth hash after
87
+ an SSO login.
143
88
  test_files: []
data/.rspec_status DELETED
@@ -1,8 +0,0 @@
1
- example_id | status | run_time |
2
- ---------------------------------------------------- | ------ | --------------- |
3
- ./spec/omniauth_syncer/sync_service_spec.rb[1:1:1:1] | passed | 0.00427 seconds |
4
- ./spec/omniauth_syncer/sync_service_spec.rb[1:1:2:1] | passed | 0.00022 seconds |
5
- ./spec/omniauth_syncer/sync_service_spec.rb[1:1:3:1] | passed | 0.00079 seconds |
6
- ./spec/omniauth_syncer/sync_service_spec.rb[1:1:3:2] | passed | 0.00019 seconds |
7
- ./spec/omniauth_syncer/sync_service_spec.rb[1:1:4:1] | passed | 0.00072 seconds |
8
- ./spec/omniauth_syncer/sync_service_spec.rb[1:1:5:1] | passed | 0.00029 seconds |
data/.rubocop.yml DELETED
@@ -1,48 +0,0 @@
1
- require:
2
- - rubocop-performance
3
- - rubocop-rails # Even if this is a non-Rails gem, many conventions apply
4
-
5
- AllCops:
6
- # Exclude common files that aren't source code
7
- Exclude:
8
- - 'bin/*'
9
- - 'vendor/**/*'
10
- - 'spec/fixtures/**/*'
11
- - 'Gemfile'
12
- - 'Rakefile'
13
- - '**/*.gemspec'
14
- # Target the Ruby version you are developing against (e.g., 3.2 is modern)
15
- TargetRubyVersion: 3.2
16
- NewCops: enable
17
-
18
- # === Layout Cops (Formatting) ===
19
- Layout/LineLength:
20
- # Relax line length for documentation/complex setup in specs
21
- Max: 120
22
- IgnoredPatterns: ['\.gemspec']
23
-
24
- # === Style Cops (Idiomatic Ruby) ===
25
- Style/Documentation:
26
- # Gems often skip documenting simple classes/modules
27
- Enabled: false
28
- Style/ClassAndModuleChildren:
29
- # Use the standard, compact (nested) style:
30
- # e.g., 'module A; class B; end; end' instead of 'class A::B'
31
- EnforcedStyle: nested
32
- Style/FrozenStringLiteralComment:
33
- # Enable the magic comment for performance in modern Ruby
34
- Enabled: true
35
- Style/ClassMethods:
36
- # Use self.method_name for class methods
37
- EnforcedStyle: self_name
38
-
39
- # === Performance Cops ===
40
- # Performance cops are helpful for writing efficient code
41
- Performance/FlatMap:
42
- Enabled: true
43
-
44
- # === Naming Cops ===
45
- Naming/FileName:
46
- # Allow the standard gem entry point file to have a hyphen
47
- Exclude:
48
- - 'exe/omniauth-syncer'
data/Gemfile DELETED
@@ -1,13 +0,0 @@
1
- source "https://rubygems.org"
2
- gemspec
3
- # Add development and testing tools
4
- group :development, :test do
5
- # ... (other tools like rspec, pry)
6
-
7
- # Core RuboCop gem
8
- gem 'rubocop', '~> 1.0'
9
-
10
- # Extensions for common Ruby idioms and performance checks
11
- gem 'rubocop-performance'
12
- gem 'rubocop-rails' # Good to include even for a general gem
13
- end
data/Gemfile.lock DELETED
@@ -1,100 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- omniauth_syncer (0.1.0)
5
- activesupport (>= 6.0)
6
- omniauth (~> 2.0)
7
-
8
- GEM
9
- remote: https://rubygems.org/
10
- specs:
11
- activesupport (6.1.7.10)
12
- concurrent-ruby (~> 1.0, >= 1.0.2)
13
- i18n (>= 1.6, < 2)
14
- minitest (>= 5.1)
15
- tzinfo (~> 2.0)
16
- zeitwerk (~> 2.3)
17
- ast (2.4.3)
18
- coderay (1.1.3)
19
- concurrent-ruby (1.3.5)
20
- diff-lcs (1.6.2)
21
- hashie (5.0.0)
22
- i18n (1.14.7)
23
- concurrent-ruby (~> 1.0)
24
- json (2.7.6)
25
- logger (1.7.0)
26
- method_source (1.1.0)
27
- minitest (5.25.4)
28
- omniauth (2.1.4)
29
- hashie (>= 3.4.6)
30
- logger
31
- rack (>= 2.2.3)
32
- rack-protection
33
- parallel (1.24.0)
34
- parser (3.3.9.0)
35
- ast (~> 2.4.1)
36
- racc
37
- pry (0.15.2)
38
- coderay (~> 1.1)
39
- method_source (~> 1.0)
40
- racc (1.8.1)
41
- rack (3.2.3)
42
- rack-protection (3.0.6)
43
- rack
44
- rainbow (3.1.1)
45
- rake (13.3.0)
46
- regexp_parser (2.11.3)
47
- rexml (3.4.4)
48
- rspec (3.13.2)
49
- rspec-core (~> 3.13.0)
50
- rspec-expectations (~> 3.13.0)
51
- rspec-mocks (~> 3.13.0)
52
- rspec-core (3.13.6)
53
- rspec-support (~> 3.13.0)
54
- rspec-expectations (3.13.5)
55
- diff-lcs (>= 1.2.0, < 2.0)
56
- rspec-support (~> 3.13.0)
57
- rspec-mocks (3.13.6)
58
- diff-lcs (>= 1.2.0, < 2.0)
59
- rspec-support (~> 3.13.0)
60
- rspec-support (3.13.6)
61
- rubocop (1.50.2)
62
- json (~> 2.3)
63
- parallel (~> 1.10)
64
- parser (>= 3.2.0.0)
65
- rainbow (>= 2.2.2, < 4.0)
66
- regexp_parser (>= 1.8, < 3.0)
67
- rexml (>= 3.2.5, < 4.0)
68
- rubocop-ast (>= 1.28.0, < 2.0)
69
- ruby-progressbar (~> 1.7)
70
- unicode-display_width (>= 2.4.0, < 3.0)
71
- rubocop-ast (1.30.0)
72
- parser (>= 3.2.1.0)
73
- rubocop-performance (1.17.1)
74
- rubocop (>= 1.7.0, < 2.0)
75
- rubocop-ast (>= 0.4.0)
76
- rubocop-rails (2.19.1)
77
- activesupport (>= 4.2.0)
78
- rack (>= 1.1)
79
- rubocop (>= 1.33.0, < 2.0)
80
- ruby-progressbar (1.13.0)
81
- tzinfo (2.0.6)
82
- concurrent-ruby (~> 1.0)
83
- unicode-display_width (2.6.0)
84
- zeitwerk (2.6.18)
85
-
86
- PLATFORMS
87
- x86_64-linux
88
-
89
- DEPENDENCIES
90
- bundler (~> 2.0)
91
- omniauth_syncer!
92
- pry
93
- rake (~> 13.0)
94
- rspec (~> 3.0)
95
- rubocop (~> 1.0)
96
- rubocop-performance
97
- rubocop-rails
98
-
99
- BUNDLED WITH
100
- 2.4.22
@@ -1,13 +0,0 @@
1
- # The final controller setup in the user's application
2
- class Users::OmniauthCallbacksController < ApplicationController
3
- include OmniauthSyncer::ControllerHelpers # Includes the sync_sso_user method
4
-
5
- def sso_provider
6
- # Use the helper to sync the user and retrieve the record
7
- @user = sync_sso_user
8
-
9
- sign_in_and_redirect @user, event: :authentication
10
- rescue StandardError
11
- redirect_to root_path, alert: 'Authentication failed.'
12
- end
13
- end
@@ -1,16 +0,0 @@
1
- OmniauthSyncer.configure do |config|
2
- # The ActiveRecord model class name
3
- config.user_model = 'User'
4
-
5
- # The column used for the unique identifier
6
- config.uid_field = :sso_id
7
-
8
- # Mapping: local_attribute => path_in_auth_hash
9
- # 'info.email' looks into auth_hash.info['email']
10
- config.mappings = {
11
- email: 'info.email',
12
- full_name: 'info.name',
13
- # Example for nested/extra data: auth_hash.extra['raw_info']['roles']
14
- roles: 'extra.raw_info.roles'
15
- }
16
- end
@@ -1,25 +0,0 @@
1
- require 'rails/engine'
2
-
3
- module OmniauthSyncer
4
- class Engine < ::Rails::Engine
5
- # Allows the host application to use OmniauthSyncer configurations
6
- isolate_namespace OmniauthSyncer
7
-
8
- # This initializer runs before all other application initializers.
9
- # It ensures that our configuration block is loaded early.
10
- initializer 'omniauth_syncer.configure_defaults' do
11
- # Load your configuration settings here if needed,
12
- # but typically, we rely on the host app's initializer.
13
- end
14
-
15
- # Optionally, you can include logic to automatically integrate
16
- # the SyncService into the host app's controller.
17
- initializer 'omniauth_syncer.controller_mixin' do
18
- ActiveSupport.on_load(:action_controller_base) do
19
- # This is where you would define a helper method
20
- # that the user can call in their OmniAuth controller:
21
- # include OmniauthSyncer::ControllerHelpers
22
- end
23
- end
24
- end
25
- end