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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +254 -84
- data/lib/omniauth_syncer/auth_path.rb +38 -0
- data/lib/omniauth_syncer/configuration.rb +101 -6
- data/lib/omniauth_syncer/controller_helpers.rb +13 -11
- data/lib/omniauth_syncer/mappings.rb +19 -0
- data/lib/omniauth_syncer/railtie.rb +15 -0
- data/lib/omniauth_syncer/sync_service.rb +133 -32
- data/lib/omniauth_syncer/version.rb +3 -1
- data/lib/omniauth_syncer.rb +24 -5
- metadata +22 -77
- data/.rspec_status +0 -8
- data/.rubocop.yml +0 -48
- data/Gemfile +0 -13
- data/Gemfile.lock +0 -100
- data/app/controllers/users/omniauth_callbacks_controller.rb +0 -13
- data/config/initializers/omniauth_syncer.rb +0 -16
- data/lib/omniauth_syncer/engine.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48f9fcd40256a9a1a6b7ba4d94f2417bec8aced846f8510447eff1273acb1cae
|
|
4
|
+
data.tar.gz: 46379816e4394ed025c39c3b5fe263bae20789892c67124f4bde62ff612c9b07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
2
|
-
===================
|
|
1
|
+
# omniauth_syncer
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
Keeps a local ActiveRecord user in step with the OmniAuth auth hash after an SSO login.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
On each login, `OmniauthSyncer::SyncService.call(auth_hash)` does four things:
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
12
|
+
`ControllerHelpers#sync_sso_user` wraps the service for an OmniAuth callback controller.
|
|
12
13
|
|
|
13
|
-
|
|
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
|
-
|
|
27
|
+
## The auth hash
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
With `omniauth-ssoprovider` 0.2.0, a hub login produces:
|
|
22
30
|
|
|
23
|
-
```
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
Treat `uid` as the only stable identity. Users can change their email on the hub.
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Ruby
|
|
55
|
+
## Configuration
|
|
35
56
|
|
|
36
57
|
```ruby
|
|
37
58
|
# config/initializers/omniauth_syncer.rb
|
|
38
59
|
OmniauthSyncer.configure do |config|
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
config.
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
config.
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
104
|
+
### Nil values and `clear_blank`
|
|
67
105
|
|
|
68
|
-
|
|
106
|
+
`false`, `''` and `[]` are values and are always written, so a revoked admin or an unverified email is saved as such.
|
|
69
107
|
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
182
|
+
## Callback controller
|
|
83
183
|
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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'
|
|
11
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
10
|
-
|
|
38
|
+
@config.validate!
|
|
39
|
+
@user_class = @config.model_class
|
|
11
40
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
111
|
+
def conflicting_user(user, attributes)
|
|
112
|
+
email = attributes[@config.email_field.to_sym]
|
|
113
|
+
return if email.blank?
|
|
38
114
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
data/lib/omniauth_syncer.rb
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniele Frisanco
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
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.
|
|
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.
|
|
26
|
+
version: '6.1'
|
|
41
27
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
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: :
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
-
|
|
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/
|
|
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: '
|
|
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.
|
|
83
|
+
rubygems_version: 3.3.26
|
|
139
84
|
signing_key:
|
|
140
85
|
specification_version: 4
|
|
141
|
-
summary:
|
|
142
|
-
|
|
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
|