add_auth 0.4.0 → 0.5.1
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 +33 -0
- data/README.md +34 -5
- data/ROADMAP.md +6 -0
- data/SECURITY.md +4 -0
- data/app/controllers/add_auth/accounts_controller.rb +5 -1
- data/app/controllers/add_auth/passkeys_controller.rb +6 -4
- data/app/controllers/add_auth/sessions_controller.rb +1 -1
- data/app/jobs/add_auth/account_delivery_job.rb +2 -1
- data/app/mailers/add_auth/account_mailer.rb +2 -1
- data/app/views/add_auth/account_mailer/link.text.erb +4 -0
- data/app/views/add_auth/accounts/_proof.html.erb +8 -1
- data/lib/add_auth/core/account_lifecycle.rb +28 -4
- data/lib/add_auth/core/account_policy.rb +15 -1
- data/lib/add_auth/core/external_identities.rb +15 -0
- data/lib/add_auth/core/intake.rb +11 -6
- data/lib/add_auth/core/passwords/lifecycle.rb +15 -6
- data/lib/add_auth/core/rate_limit.rb +11 -0
- data/lib/add_auth/core/session_cursor.rb +38 -0
- data/lib/add_auth/core/session_key.rb +32 -0
- data/lib/add_auth/core/sessions.rb +28 -10
- data/lib/add_auth/core/strategies/passkey.rb +5 -2
- data/lib/add_auth/rails/migration/key_type.rb +3 -3
- data/lib/add_auth/rails/runtime.rb +24 -4
- data/lib/add_auth/rails/stores/sessions.rb +10 -2
- data/lib/add_auth/version.rb +1 -1
- data/lib/generators/add_auth/accounts/accounts_generator.rb +2 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +2 -2
- data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +2 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +1 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +2 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +6 -12
- data/lib/generators/add_auth/install/templates/initializer.rb +1 -1
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +2 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +4 -4
- data/lib/generators/add_auth/notifications/notifications_generator.rb +2 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +1 -1
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +2 -0
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +2 -2
- data/lib/generators/add_auth/primary_key.rb +19 -0
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -0
- data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_session_cursor_index.rb.tt +5 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f425748354f43e9f37579150ae2fe1cae7797f5da8c37af7e9f330b783a2d96
|
|
4
|
+
data.tar.gz: 8b7187ffe5b74c138d68cf4ffac7edf7c65e3d258941edec49c72f55a743c621
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51c64dbd5366aac908be7df8fd4f82c9501ce081400fb77976ece4b92fa9066a7aca0fc799a7ba4e99ce3c19609190db702373e6e45fddc055795df8b23898c7
|
|
7
|
+
data.tar.gz: 9a37bfc7978081e75ea517ae9ba414fa4b2771ec7d9bb8ea6a56acf6da1ee3fe2b0882989e9177198a701ce409e2b05af225214389bb5361b0845bb5d6df5f01
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-10-21
|
|
4
|
+
|
|
5
|
+
Security hardening; upgrading is recommended. Refresh ejected account and
|
|
6
|
+
passkey controllers/views through the normal review process.
|
|
7
|
+
|
|
8
|
+
- The first email confirmation of an account with a linked sign-in provider
|
|
9
|
+
names that provider in the email and on the page. Only an explicit "Confirm
|
|
10
|
+
and keep" preserves the link; any other confirmation removes it.
|
|
11
|
+
- Automatic password lockout gates only password sign-in. It no longer revokes
|
|
12
|
+
existing sessions or blocks passkey, email-link or provider sign-in, never
|
|
13
|
+
counts failures for accounts that cannot use a password, and is cleared by a
|
|
14
|
+
successful password reset. Manual locks are unchanged.
|
|
15
|
+
- Sign out everywhere also revokes outstanding email proofs, account proofs
|
|
16
|
+
(such as a pending address change), passkey/provider ceremonies and mobile
|
|
17
|
+
handoffs.
|
|
18
|
+
- Rate limits check the IP budget first and charge the identifier budget only
|
|
19
|
+
after a configured challenge passes. Passkey sign-in option requests use their
|
|
20
|
+
own IP budget, and `passkeys.anonymous_limit` now applies per IPv4 address or
|
|
21
|
+
IPv6 /64 rather than globally.
|
|
22
|
+
|
|
23
|
+
## 0.5.0 — 2026-09-18
|
|
24
|
+
|
|
25
|
+
- Honor Rails' `primary_key_type` generator setting for all AddAuth-owned tables.
|
|
26
|
+
Support PostgreSQL UUID sessions and mixed integer/UUID account/session keys,
|
|
27
|
+
retaining bigint defaults and deriving references from the existing schema.
|
|
28
|
+
- Use creation time and a typed ID in opaque session-page cursors; validate
|
|
29
|
+
revocation and explicitly enabled legacy session adoption for either key type.
|
|
30
|
+
For apps already using hardened sessions, re-run `add_auth:session_upgrade`
|
|
31
|
+
for the new pagination index. Existing tables retain their IDs; refresh ejected
|
|
32
|
+
controllers through the normal review process.
|
|
33
|
+
Follow the [upgrade guide](https://addauthgem.com/upgrading/#uuid-support) for
|
|
34
|
+
the release gate, migration review, opaque client cursors and rollback boundary.
|
|
35
|
+
|
|
3
36
|
## 0.4.0 — 2026-09-13
|
|
4
37
|
|
|
5
38
|
- Constrain JSON below 3.0 for the supported Rails decoding API; independently
|
data/README.md
CHANGED
|
@@ -29,26 +29,26 @@ for verification in a disposable host.
|
|
|
29
29
|
|
|
30
30
|
## Quickstart
|
|
31
31
|
|
|
32
|
-
These commands target 0.
|
|
33
|
-
[RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.
|
|
32
|
+
These commands target 0.5.1 and are available after its
|
|
33
|
+
[RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.5.1).
|
|
34
34
|
Check package availability before changing your app’s bundle.
|
|
35
35
|
|
|
36
36
|
Install the `add_auth` gem from RubyGems through your Rails app’s Gemfile,
|
|
37
37
|
then enable password and email-link sign-in. Use Ruby 3.3+ and Rails 8.0+
|
|
38
38
|
with Active Record, and run the commands below from your Rails app’s root.
|
|
39
39
|
|
|
40
|
-
**Release availability:** these commands require the published `0.
|
|
40
|
+
**Release availability:** these commands require the published `0.5.1` package.
|
|
41
41
|
If it is not yet listed on [RubyGems](https://rubygems.org/gems/add_auth/versions),
|
|
42
42
|
wait for publication; see [release status](https://addauthgem.com/release-status/).
|
|
43
43
|
Start in your app’s development environment; use the deployment settings
|
|
44
44
|
below before enabling sign-in for users.
|
|
45
45
|
|
|
46
46
|
1. Keep `source "https://rubygems.org"` in your app’s Gemfile and add the
|
|
47
|
-
0.
|
|
47
|
+
0.5 release line:
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
50
|
# Gemfile
|
|
51
|
-
gem "add_auth", "~> 0.
|
|
51
|
+
gem "add_auth", "~> 0.5.1"
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Bundler downloads the package from RubyGems and records the resolved
|
|
@@ -568,6 +568,35 @@ adds registration, confirmation/reconfirmation, password reset, lock/unlock and
|
|
|
568
568
|
account changes. Hosts retain account eligibility, resource authorization and
|
|
569
569
|
provider registrations.
|
|
570
570
|
|
|
571
|
+
### UUID and integer primary keys in 0.5.0
|
|
572
|
+
|
|
573
|
+
Version 0.5.0 adds PostgreSQL UUID sessions and configurable primary keys for all
|
|
574
|
+
AddAuth authentication tables. Integer apps retain the normal Rails defaults.
|
|
575
|
+
These instructions become available after the 0.5.0 package is published.
|
|
576
|
+
|
|
577
|
+
For a new PostgreSQL app, set Rails' generator preference in `config/application.rb`
|
|
578
|
+
before generating authentication or AddAuth features:
|
|
579
|
+
|
|
580
|
+
```ruby
|
|
581
|
+
config.generators do |g|
|
|
582
|
+
g.orm :active_record, primary_key_type: :uuid
|
|
583
|
+
end
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
AddAuth uses this setting for every table it creates. Leave it unset for Rails'
|
|
587
|
+
bigint default, or set `:bigint` or `:integer` explicitly. References follow the
|
|
588
|
+
actual target table, so UUID users can have bigint sessions and vice versa.
|
|
589
|
+
The generated migration records its chosen type; later configuration changes do
|
|
590
|
+
not change that migration or convert existing rows. Other key types are unsupported.
|
|
591
|
+
|
|
592
|
+
For an existing installation, follow the
|
|
593
|
+
[version-gated package upgrade](https://addauthgem.com/upgrading/#uuid-support).
|
|
594
|
+
It covers the additive pagination index, ejected controller review, opaque
|
|
595
|
+
pagination cursors, verification and rollback from older gems. Integer apps need
|
|
596
|
+
no ID conversion. The [primary-key guide](https://addauthgem.com/uuid-primary-keys/)
|
|
597
|
+
explains mixed schemas and the separate, app-owned migration needed to convert
|
|
598
|
+
existing IDs. The published 0.4.0 package cannot manage UUID sessions.
|
|
599
|
+
|
|
571
600
|
## Optional integrations in 0.3.0
|
|
572
601
|
|
|
573
602
|
Enable bounded [Devise migration](https://addauthgem.com/migrating-from-devise/),
|
data/ROADMAP.md
CHANGED
|
@@ -25,6 +25,12 @@ this public checklist is derived from its section 14 and stands on its own.
|
|
|
25
25
|
|
|
26
26
|
## Now — maintain AddAuth and learn from adoption
|
|
27
27
|
|
|
28
|
+
- [x] Honor the Rails UUID/bigint generator setting across all authentication
|
|
29
|
+
tables, including UUID sessions, mixed references and three-mode bundled/ejected
|
|
30
|
+
session management. Local PostgreSQL and browser acceptance passes on Rails
|
|
31
|
+
8.0/8.1. Added in 0.5.0; see
|
|
32
|
+
[release availability](https://github.com/taimoorq/add_auth/releases).
|
|
33
|
+
|
|
28
34
|
- [x] Optional password-signup confirmation, immediate transactional provisioning
|
|
29
35
|
and session issuance, explicit unconfirmed-reset policy, password-only lifecycle
|
|
30
36
|
and the complete commented Rails initializer. Local supported-matrix, stock/
|
data/SECURITY.md
CHANGED
|
@@ -54,6 +54,10 @@ the currency policy).
|
|
|
54
54
|
|
|
55
55
|
## Supported versions
|
|
56
56
|
|
|
57
|
+
UUID/integer session lookup, account-scoped pagination and revocation are also
|
|
58
|
+
in scope. Version 0.5.0 adds UUID sessions and UUID authentication-table generation;
|
|
59
|
+
primary keys must never substitute for browser/mobile bearer secrets.
|
|
60
|
+
|
|
57
61
|
Until a 1.0 is released, only the latest published version receives security
|
|
58
62
|
fixes. This table will be expanded once there are stable release lines to
|
|
59
63
|
support.
|
|
@@ -52,6 +52,7 @@ module AddAuth
|
|
|
52
52
|
def proof
|
|
53
53
|
@purpose = purpose
|
|
54
54
|
result = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
|
|
55
|
+
@providers = Rails::Runtime.provider_labels(result.credential) if result.success?
|
|
55
56
|
page(result.success? ? "proof" : "invalid_proof")
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -59,11 +60,14 @@ module AddAuth
|
|
|
59
60
|
@purpose = purpose
|
|
60
61
|
admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: :account_consume)
|
|
61
62
|
return intake_failure(admitted) if admitted.is_a?(Symbol)
|
|
62
|
-
result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password]
|
|
63
|
+
result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password],
|
|
64
|
+
keep_providers: params[:providers] == "keep")
|
|
63
65
|
if result.success?
|
|
64
66
|
redirect_to "/sign-in", status: :see_other, notice: "Your account has been updated. Sign in to start a new session."
|
|
65
67
|
else
|
|
66
68
|
@error = "This request could not be completed. Check the password requirements or request a new link."
|
|
69
|
+
preview = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
|
|
70
|
+
@providers = Rails::Runtime.provider_labels(preview.credential) if preview.success?
|
|
67
71
|
page("proof", status: :unprocessable_entity)
|
|
68
72
|
end
|
|
69
73
|
end
|
|
@@ -30,9 +30,11 @@ module AddAuth
|
|
|
30
30
|
finish(result, destination: "/passkeys", grant: result.grant)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Conditional UI requests options on page load. Keep that off the
|
|
34
|
+
# password sign_in budget while still applying the sign_in challenge.
|
|
33
35
|
def authentication_options
|
|
34
|
-
return unless admitted(:sign_in)
|
|
35
|
-
options(service.authentication_options(browser_secret: browser_secret))
|
|
36
|
+
return unless admitted(:sign_in, budget: :passkey_options)
|
|
37
|
+
options(service.authentication_options(browser_secret: browser_secret, ip: request.remote_ip))
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def authenticate
|
|
@@ -92,8 +94,8 @@ module AddAuth
|
|
|
92
94
|
value.to_unsafe_h if value.is_a?(ActionController::Parameters)
|
|
93
95
|
end
|
|
94
96
|
|
|
95
|
-
def admitted(action)
|
|
96
|
-
result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token)
|
|
97
|
+
def admitted(action, budget: action)
|
|
98
|
+
result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token, budget: budget)
|
|
97
99
|
return true if result == true
|
|
98
100
|
render json: {error: "Verification could not start. Try again shortly."}, status: if result == :rate_limited
|
|
99
101
|
429
|
|
@@ -25,7 +25,7 @@ module AddAuth
|
|
|
25
25
|
|
|
26
26
|
def destroy
|
|
27
27
|
target_id = params[:id].to_s
|
|
28
|
-
if
|
|
28
|
+
if Rails::Runtime.sessions.current_session?(session: Current.session, session_id: target_id)
|
|
29
29
|
terminate_session
|
|
30
30
|
return redirect_to Rails::Runtime.sign_in_path, status: :see_other
|
|
31
31
|
end
|
|
@@ -7,7 +7,8 @@ module AddAuth
|
|
|
7
7
|
return unless record
|
|
8
8
|
deliver(service: Rails::Runtime.accounts, record: record, mailer: AccountMailer) do |delivery|
|
|
9
9
|
AccountMailer.link(recipient: delivery.fetch(:recipient), purpose: delivery.fetch(:purpose),
|
|
10
|
-
url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose))
|
|
10
|
+
url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose)),
|
|
11
|
+
providers: Rails::Runtime.provider_labels(delivery.fetch(:providers, [])))
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module AddAuth
|
|
4
4
|
class AccountMailer < SignInMailer
|
|
5
|
-
def link(recipient:, url:, purpose:)
|
|
5
|
+
def link(recipient:, url:, purpose:, providers: [])
|
|
6
|
+
@providers = providers
|
|
6
7
|
@instruction = {"confirm" => "Confirm your email address", "reset_password" => "Choose a new password", "unlock" => "Unlock your account"}.fetch(purpose)
|
|
7
8
|
@url = url
|
|
8
9
|
mail(to: recipient, from: Rails::Runtime.config.mail_from, subject: @instruction,
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Open this link, then use the button on the page to confirm your request:
|
|
4
4
|
<%= @url %>
|
|
5
|
+
<% if @providers.present? %>
|
|
6
|
+
|
|
7
|
+
This account was started by signing in with <%= @providers.to_sentence %>. Keep <%= @providers.to_sentence %> only if you used it to create this account. If you did not, choose "Confirm without <%= @providers.to_sentence %>".
|
|
8
|
+
<% end %>
|
|
5
9
|
|
|
6
10
|
This link expires in <%= AddAuth.configuration.lifecycle.proof_lifetime.to_i / 60 %> minutes and can be used once.
|
|
7
11
|
If you did not request this message, you can ignore it.
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
<%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
|
|
12
12
|
</div>
|
|
13
13
|
<% end %>
|
|
14
|
-
|
|
14
|
+
<% if @purpose == "confirm" && @providers.present? %>
|
|
15
|
+
<% providers = @providers.to_sentence %>
|
|
16
|
+
<p>This account was started by signing in with <%= providers %>. Keep <%= providers %> only if you used it to create this account.</p>
|
|
17
|
+
<%= form.button "Confirm and keep #{providers}", name: "providers", value: "keep", class: add_auth_class(:button) %>
|
|
18
|
+
<%= form.button "Confirm without #{providers}", name: "providers", value: "remove", class: add_auth_class(:button) %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<%= form.submit title, class: add_auth_class(:button) %>
|
|
21
|
+
<% end %>
|
|
15
22
|
<% end %>
|
|
16
23
|
<p><%= link_to "Back to sign in", "/sign-in" %></p>
|
|
@@ -35,7 +35,8 @@ module AddAuth
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
|
|
38
|
-
lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil
|
|
38
|
+
lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil,
|
|
39
|
+
external_bindings: nil)
|
|
39
40
|
raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
|
|
40
41
|
@store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
|
|
41
42
|
@clock, @random, @lifetime, @notify = clock, random, lifetime, notify
|
|
@@ -43,6 +44,7 @@ module AddAuth
|
|
|
43
44
|
@profile_attributes = profile_attributes
|
|
44
45
|
@deletion_allowed = deletion_allowed
|
|
45
46
|
@external_identities = external_identities
|
|
47
|
+
@external_bindings = external_bindings
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def register(identifier:, password:, profile: {}, replacing: nil, user_agent: nil, ip_address: nil, remember: false)
|
|
@@ -86,14 +88,21 @@ module AddAuth
|
|
|
86
88
|
accepted
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# A confirm preview's credential lists the provider configurations whose
|
|
92
|
+
# bindings the address owner must explicitly keep (see #consume).
|
|
89
93
|
def preview(token:, purpose:)
|
|
90
94
|
return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
|
|
91
95
|
user, record = @store.inspect_token(digest: @digest.digest(token))
|
|
92
96
|
reason = rejection(user, record, purpose: purpose.to_s)
|
|
93
|
-
|
|
97
|
+
return Result.failure(reason: reason) if reason
|
|
98
|
+
Result.success(user: user, strategy: purpose.to_sym, credential: (unadopted_providers(user) if purpose.to_s == "confirm"))
|
|
94
99
|
end
|
|
95
100
|
|
|
96
|
-
|
|
101
|
+
# A first confirmation is the address owner's adoption decision for any
|
|
102
|
+
# provider binding made before the address was proved. Only an explicit
|
|
103
|
+
# keep_providers: true preserves those bindings; any other confirmation
|
|
104
|
+
# revokes them in the same transaction (canonical plan section 18, A1).
|
|
105
|
+
def consume(token:, purpose:, password: nil, keep_providers: false)
|
|
97
106
|
return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
|
|
98
107
|
@store.with_token(digest: @digest.digest(token)) do |user, record|
|
|
99
108
|
reason = rejection(user, record, purpose: purpose.to_s)
|
|
@@ -110,12 +119,14 @@ module AddAuth
|
|
|
110
119
|
@notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
|
|
111
120
|
@notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
|
|
112
121
|
else
|
|
122
|
+
@external_bindings.revoke_all_in_transaction(user_id: user.id, at: now) if keep_providers != true && unadopted_providers(user).any?
|
|
113
123
|
@store.update_account(user: user, confirmed_at: now)
|
|
114
124
|
provision_account(user)
|
|
115
125
|
end
|
|
116
126
|
when "reset_password"
|
|
117
127
|
next failure unless valid_new_password?(password)
|
|
118
128
|
@store.replace_password(user: user, password: password)
|
|
129
|
+
clear_password_lock(user)
|
|
119
130
|
@store.revoke_authority(user: user, at: now)
|
|
120
131
|
@notify.call(user: user, kind: :password_changed, at: now)
|
|
121
132
|
when "unlock"
|
|
@@ -214,6 +225,18 @@ module AddAuth
|
|
|
214
225
|
failure
|
|
215
226
|
end
|
|
216
227
|
|
|
228
|
+
# A proved reset ends brute-force lockout, never a manual lock.
|
|
229
|
+
def clear_password_lock(user)
|
|
230
|
+
attempts = user.respond_to?(:failed_attempts) ? user.failed_attempts.to_i : 0
|
|
231
|
+
return if user.add_auth_manual_lock || (!user.locked_at && attempts.zero?)
|
|
232
|
+
@store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def unadopted_providers(user)
|
|
236
|
+
return [].freeze unless @external_bindings && user && !user.confirmed_at
|
|
237
|
+
@external_bindings.provider_ids(user_id: user.id)
|
|
238
|
+
end
|
|
239
|
+
|
|
217
240
|
def provision_account(user)
|
|
218
241
|
tracked = user.respond_to?(:add_auth_provisioned_at)
|
|
219
242
|
return if tracked && user.add_auth_provisioned_at
|
|
@@ -286,7 +309,8 @@ module AddAuth
|
|
|
286
309
|
payload = JSON.parse(raw)
|
|
287
310
|
return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
|
|
288
311
|
payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
|
|
289
|
-
{recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose
|
|
312
|
+
{recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose,
|
|
313
|
+
providers: (record.purpose == "confirm") ? unadopted_providers(user) : []}
|
|
290
314
|
rescue JSON::ParserError, KeyError
|
|
291
315
|
nil
|
|
292
316
|
end
|
|
@@ -41,13 +41,27 @@ module AddAuth
|
|
|
41
41
|
return :disabled unless available?(user)
|
|
42
42
|
return unless @enabled
|
|
43
43
|
return :unconfirmed if !value(user, :confirmed_at) && (confirmation_required? || !password_account?(user))
|
|
44
|
-
:locked if
|
|
44
|
+
:locked if account_locked?(user)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Password results may also disclose an automatic lock to the holder of
|
|
48
|
+
# the correct password; other methods never consult this.
|
|
49
|
+
def password_denial(user) = denial(user) || ((@enabled && locked?(user)) ? :locked : nil)
|
|
50
|
+
|
|
51
|
+
# Any active lock, including the automatic password lock.
|
|
47
52
|
def locked?(user)
|
|
48
53
|
value(user, :locked_at) && (value(user, :add_auth_manual_lock) || !value(user, :add_auth_locked_until) || value(user, :add_auth_locked_until) > @clock.now)
|
|
49
54
|
end
|
|
50
55
|
|
|
56
|
+
# Manual and imported (untimed) locks deny the whole account. An automatic
|
|
57
|
+
# brute-force lock gates only the password method (plan section 18, A2).
|
|
58
|
+
def account_locked?(user) = locked?(user) && !self.class.automatic_lock?(user, now: @clock.now)
|
|
59
|
+
|
|
60
|
+
def self.automatic_lock?(user, now:)
|
|
61
|
+
read = ->(name) { user.public_send(name) if user.respond_to?(name) }
|
|
62
|
+
!!(read.call(:locked_at) && !read.call(:add_auth_manual_lock) && read.call(:add_auth_locked_until) && read.call(:add_auth_locked_until) > now)
|
|
63
|
+
end
|
|
64
|
+
|
|
51
65
|
private
|
|
52
66
|
|
|
53
67
|
def password_account?(user)
|
|
@@ -185,6 +185,21 @@ module AddAuth
|
|
|
185
185
|
self.class.invalidate_credentials_in_transaction(store: @store, user_id: user_id, at: at)
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
+
# Bindings persist while provider sign-in is disabled, so G3 reaches them
|
|
189
|
+
# through this narrow port rather than the enabled service. G3 owns the
|
|
190
|
+
# adoption decision; this only reports and revokes live bindings.
|
|
191
|
+
class Bindings
|
|
192
|
+
def initialize(store:)
|
|
193
|
+
@store = store
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def provider_ids(user_id:) = @store.bindings(user_id: user_id).map(&:provider_id).uniq.sort.freeze
|
|
197
|
+
|
|
198
|
+
def revoke_all_in_transaction(user_id:, at:)
|
|
199
|
+
@store.bindings(user_id: user_id).each { |identity| @store.revoke(identity: identity, at: at) }.size
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
188
203
|
def credential_current?(user:, id:, version:)
|
|
189
204
|
return false unless @enabled
|
|
190
205
|
identity = @store.binding_for_user(user_id: user.id, id: id)
|
data/lib/add_auth/core/intake.rb
CHANGED
|
@@ -14,19 +14,24 @@ module AddAuth
|
|
|
14
14
|
@on_challenge_unavailable = on_challenge_unavailable
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# An exhausted IP budget stops before the identifier budget, and the
|
|
18
|
+
# identifier is charged only after any configured challenge passes, so
|
|
19
|
+
# other clients cannot spend a target's budget (plan section 18, A4).
|
|
17
20
|
def call(identifier:, ip:, action:, challenge_token: nil)
|
|
18
21
|
value = (identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)) ? @normalizer.call(identifier) : ""
|
|
19
22
|
value = "" unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
|
|
20
|
-
|
|
21
|
-
identifier_ok = @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
|
|
22
|
-
return :rate_limited unless ip_ok && identifier_ok
|
|
23
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
|
|
23
24
|
return :invalid_credentials if value.to_s.empty?
|
|
24
25
|
rejection = verify_challenge(ip: ip, action: action, challenge_token: challenge_token)
|
|
25
|
-
rejection
|
|
26
|
+
return rejection if rejection
|
|
27
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
|
|
28
|
+
value
|
|
26
29
|
end
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
# budget separates an IP counter from the challenge action, so a
|
|
32
|
+
# background request cannot spend the budget of an interactive form.
|
|
33
|
+
def anonymous(ip:, action:, challenge_token: nil, budget: action)
|
|
34
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{budget}"), limit: 30)
|
|
30
35
|
verify_challenge(ip: ip, action: action, challenge_token: challenge_token) || true
|
|
31
36
|
end
|
|
32
37
|
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "add_auth/core/account_policy"
|
|
4
|
+
|
|
3
5
|
module AddAuth
|
|
4
6
|
module Core
|
|
5
7
|
module Passwords
|
|
6
8
|
# Called only inside the owning account transaction. The adapter supplies
|
|
7
9
|
# Rails hashing and persistence; these decisions are shared by entry paths.
|
|
8
10
|
class Lifecycle
|
|
9
|
-
|
|
11
|
+
# password_allowed reports whether this account may use password sign-in
|
|
12
|
+
# at all (feature enabled, stored password, not strict). Accounts that
|
|
13
|
+
# cannot use a password never accumulate failures or locks.
|
|
14
|
+
def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {},
|
|
15
|
+
password_allowed: ->(_user) { true })
|
|
10
16
|
unless maximum_attempts.is_a?(Integer) && maximum_attempts.between?(1, 100) &&
|
|
11
17
|
unlock_in.is_a?(Numeric) && unlock_in.finite? && unlock_in.between?(60, 86_400)
|
|
12
18
|
raise ArgumentError, "configure bounded password attempts and unlock duration"
|
|
13
19
|
end
|
|
14
20
|
@store, @policy, @enabled, @maximum, @unlock_in, @clock, @issue_unlock = store, policy, enabled, maximum_attempts, unlock_in, clock, issue_unlock
|
|
21
|
+
@password_allowed = password_allowed
|
|
15
22
|
end
|
|
16
23
|
|
|
24
|
+
# The automatic lock gates the password method only; it never denies
|
|
25
|
+
# other methods or revokes existing authority.
|
|
26
|
+
def locked?(user) = @enabled && !!user && AccountPolicy.automatic_lock?(user, now: @clock.now)
|
|
27
|
+
|
|
17
28
|
def prepare(user:)
|
|
18
29
|
return unless @enabled && user && !user.add_auth_manual_lock && user.locked_at &&
|
|
19
30
|
user.add_auth_locked_until && user.add_auth_locked_until <= @clock.now
|
|
@@ -21,7 +32,8 @@ module AddAuth
|
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
def verified(user:, password:, valid:, rehash: true)
|
|
24
|
-
return false unless user && @policy.call(user) == true
|
|
35
|
+
return false unless user && @policy.call(user) == true && @password_allowed.call(user) == true
|
|
36
|
+
return false if locked?(user)
|
|
25
37
|
if valid
|
|
26
38
|
@store.update_account(user: user, failed_attempts: 0) if @enabled && user.failed_attempts != 0
|
|
27
39
|
if rehash && user.respond_to?(:add_auth_password_scheme) && user.add_auth_password_scheme == "devise_bcrypt" && assignable?(password)
|
|
@@ -49,10 +61,7 @@ module AddAuth
|
|
|
49
61
|
changes[:add_auth_locked_until] = @clock.now + @unlock_in
|
|
50
62
|
end
|
|
51
63
|
@store.update_account(user: user, **changes)
|
|
52
|
-
if attempts >= @maximum
|
|
53
|
-
@store.revoke_authority(user: user, at: @clock.now)
|
|
54
|
-
@issue_unlock.call(user)
|
|
55
|
-
end
|
|
64
|
+
@issue_unlock.call(user) if attempts >= @maximum
|
|
56
65
|
end
|
|
57
66
|
end
|
|
58
67
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
3
5
|
module AddAuth
|
|
4
6
|
module Core
|
|
5
7
|
class RateLimit
|
|
@@ -25,6 +27,15 @@ module AddAuth
|
|
|
25
27
|
end
|
|
26
28
|
counts.all? { |count| count <= limit }
|
|
27
29
|
end
|
|
30
|
+
|
|
31
|
+
# One budget per IPv4 address or IPv6 /64, which a single client
|
|
32
|
+
# commonly controls in full.
|
|
33
|
+
def self.network(ip)
|
|
34
|
+
address = IPAddr.new(ip.to_s).native
|
|
35
|
+
address.ipv6? ? "#{address.mask(64)}/64" : address.to_s
|
|
36
|
+
rescue IPAddr::Error
|
|
37
|
+
"unknown"
|
|
38
|
+
end
|
|
28
39
|
end
|
|
29
40
|
end
|
|
30
41
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module AddAuth
|
|
8
|
+
module Core
|
|
9
|
+
class SessionCursor
|
|
10
|
+
def initialize(key:)
|
|
11
|
+
@key = key
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def encode(row)
|
|
15
|
+
"sc1:" + Base64.urlsafe_encode64(JSON.generate([row.created_at.utc.iso8601(6), row.id]), padding: false)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def decode(value)
|
|
19
|
+
# Retain links issued by releases with integer-only pagination.
|
|
20
|
+
legacy = @key.parse(value)
|
|
21
|
+
return {legacy_id: legacy} if legacy.is_a?(Integer)
|
|
22
|
+
return unless value.is_a?(String) && value.ascii_only? && value.bytesize <= 256 && value.match?(/\Asc1:[A-Za-z0-9_-]+\z/)
|
|
23
|
+
|
|
24
|
+
fields = JSON.parse(Base64.urlsafe_decode64(value.delete_prefix("sc1:")))
|
|
25
|
+
return unless fields.is_a?(Array) && fields.length == 2
|
|
26
|
+
timestamp, id = fields
|
|
27
|
+
return unless timestamp.is_a?(String) && timestamp.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\z/)
|
|
28
|
+
id = @key.parse(id)
|
|
29
|
+
return unless id
|
|
30
|
+
time = Time.iso8601(timestamp)
|
|
31
|
+
return unless time.year.positive? && time.utc.iso8601(6) == timestamp
|
|
32
|
+
{created_at: time, id: id}
|
|
33
|
+
rescue ArgumentError, JSON::ParserError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
class SessionKey
|
|
6
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
7
|
+
MAX_INTEGER = (2**63) - 1
|
|
8
|
+
|
|
9
|
+
def initialize(type:)
|
|
10
|
+
raise ArgumentError, "unsupported session key type" unless %i[integer bigint uuid].include?(type)
|
|
11
|
+
@uuid = type == :uuid
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(value)
|
|
15
|
+
if @uuid
|
|
16
|
+
value.downcase if value.is_a?(String) && value.ascii_only? && UUID.match?(value)
|
|
17
|
+
elsif value.is_a?(Integer)
|
|
18
|
+
value if value.between?(1, MAX_INTEGER)
|
|
19
|
+
elsif value.is_a?(String) && value.ascii_only? && value.match?(/\A[1-9]\d{0,18}\z/)
|
|
20
|
+
integer = value.to_i
|
|
21
|
+
integer if integer <= MAX_INTEGER
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Stock Rails signed cookies serialize integer IDs as integers, UUIDs as
|
|
26
|
+
# strings. Numeric strings must not broaden the legacy integer bridge.
|
|
27
|
+
def legacy(value)
|
|
28
|
+
parse(value) if @uuid || value.is_a?(Integer)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "add_auth/core/mobile_profile"
|
|
5
|
+
require "add_auth/core/session_key"
|
|
6
|
+
require "add_auth/core/session_cursor"
|
|
5
7
|
|
|
6
8
|
module AddAuth
|
|
7
9
|
module Core
|
|
@@ -29,7 +31,8 @@ module AddAuth
|
|
|
29
31
|
Page = Struct.new(:entries, :next_cursor)
|
|
30
32
|
|
|
31
33
|
def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
|
|
32
|
-
legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil
|
|
34
|
+
legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil,
|
|
35
|
+
revoke_authority: nil)
|
|
33
36
|
unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
|
|
34
37
|
raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
|
|
35
38
|
end
|
|
@@ -37,10 +40,13 @@ module AddAuth
|
|
|
37
40
|
raise ArgumentError, "legacy_bridge_until must be an absolute Time or nil"
|
|
38
41
|
end
|
|
39
42
|
@store, @digest, @eligible, @clock = store, digest, eligible, clock
|
|
43
|
+
@key = SessionKey.new(type: store.respond_to?(:id_type) ? store.id_type : :integer)
|
|
44
|
+
@cursor = SessionCursor.new(key: @key)
|
|
40
45
|
@access_policy = access_policy
|
|
41
46
|
@password_lifecycle = password_lifecycle
|
|
42
47
|
@on_sign_in = on_sign_in
|
|
43
48
|
@verified_denial = verified_denial
|
|
49
|
+
@revoke_authority = revoke_authority
|
|
44
50
|
raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
|
|
45
51
|
@mobile = mobile_profile
|
|
46
52
|
@lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
|
|
@@ -94,6 +100,7 @@ module AddAuth
|
|
|
94
100
|
return if transport == :mobile && replacing
|
|
95
101
|
raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
|
|
96
102
|
return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
|
|
103
|
+
return if method.to_s == "password" && @password_lifecycle&.locked?(user)
|
|
97
104
|
if method.to_s == "passkey"
|
|
98
105
|
return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
|
|
99
106
|
proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
|
|
@@ -191,13 +198,14 @@ module AddAuth
|
|
|
191
198
|
def list_page(user:, current_session_id: nil, before: nil)
|
|
192
199
|
empty = Page.new(entries: [])
|
|
193
200
|
return empty unless user && @eligible.call(user) == true
|
|
194
|
-
|
|
201
|
+
boundary = @cursor.decode(before) unless before.nil?
|
|
202
|
+
return empty unless before.nil? || boundary
|
|
195
203
|
|
|
196
204
|
now = @clock.now
|
|
197
|
-
candidates = @store.list_for_user(user_id: user.id, before:
|
|
205
|
+
candidates = @store.list_for_user(user_id: user.id, before: boundary,
|
|
198
206
|
excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
|
|
199
207
|
active_after: now - [@idle, @remembered&.fetch(:idle_timeout) || @idle, @mobile&.idle_timeout || @idle].max, legacy: !!(@deadline && now < @deadline))
|
|
200
|
-
cursor = candidates[PAGE_SIZE - 1]
|
|
208
|
+
cursor = @cursor.encode(candidates[PAGE_SIZE - 1]) if candidates.length > PAGE_SIZE
|
|
201
209
|
rows = candidates.first(PAGE_SIZE)
|
|
202
210
|
if before.nil? && current_session_id
|
|
203
211
|
current = @store.find_for_user(user_id: user.id, session_id: current_session_id)
|
|
@@ -217,16 +225,22 @@ module AddAuth
|
|
|
217
225
|
|
|
218
226
|
# Both the initiating bearer and target ownership are checked under lock.
|
|
219
227
|
def revoke_one(user:, session:, session_id:)
|
|
220
|
-
|
|
228
|
+
target_id = @key.parse(session_id)
|
|
229
|
+
return false unless target_id
|
|
221
230
|
|
|
222
231
|
with_live_session(user: user, session: session) do |account, _current, now|
|
|
223
|
-
target = @store.find_for_user_in_transaction(user_id: account.id, session_id:
|
|
232
|
+
target = @store.find_for_user_in_transaction(user_id: account.id, session_id: target_id)
|
|
224
233
|
next false unless target
|
|
225
234
|
@store.update(target, revoked_at: now) unless target.revoked_at
|
|
226
235
|
true
|
|
227
236
|
end || false
|
|
228
237
|
end
|
|
229
238
|
|
|
239
|
+
def current_session?(session:, session_id:)
|
|
240
|
+
id = @key.parse(session_id)
|
|
241
|
+
!!(id && session && id == session.id)
|
|
242
|
+
end
|
|
243
|
+
|
|
230
244
|
# Password proof is obtained from the locked, current account. Intake
|
|
231
245
|
# limits must be applied by the caller before reaching this expensive port.
|
|
232
246
|
def reauthenticate(user:, session:, purpose:, policy:)
|
|
@@ -244,9 +258,13 @@ module AddAuth
|
|
|
244
258
|
end || Result.failure(reason: :elevation_required)
|
|
245
259
|
end
|
|
246
260
|
|
|
261
|
+
# Sign-out-everywhere retires every outstanding proof, not only sessions:
|
|
262
|
+
# the shared authority port also revokes tokens, ceremonies and handoffs.
|
|
247
263
|
def revoke_all(user:, session:, grant:)
|
|
248
264
|
with_authority(user: user, session: session, grant: grant, purpose: :sign_out_everywhere) do |account, _row, now|
|
|
249
|
-
@store.revoke_all_in_transaction(user_id: account.id, at: now)
|
|
265
|
+
count = @store.revoke_all_in_transaction(user_id: account.id, at: now)
|
|
266
|
+
@revoke_authority&.call(user_id: account.id, at: now)
|
|
267
|
+
count
|
|
250
268
|
end || false
|
|
251
269
|
end
|
|
252
270
|
|
|
@@ -315,10 +333,10 @@ module AddAuth
|
|
|
315
333
|
private
|
|
316
334
|
|
|
317
335
|
def cookie_lookup(value)
|
|
318
|
-
if value.is_a?(String) && PATTERN.match?(value)
|
|
336
|
+
if value.is_a?(String) && value.ascii_only? && PATTERN.match?(value)
|
|
319
337
|
{digest: @digest.digest(value)}
|
|
320
|
-
elsif
|
|
321
|
-
{id:
|
|
338
|
+
elsif @deadline && @clock.now < @deadline && (id = @key.legacy(value))
|
|
339
|
+
{id: id}
|
|
322
340
|
end
|
|
323
341
|
end
|
|
324
342
|
|
|
@@ -44,7 +44,9 @@ module AddAuth
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
# ip selects the anonymous ceremony budget; one network cannot exhaust
|
|
48
|
+
# passkey sign-in for every other client.
|
|
49
|
+
def authentication_options(browser_secret:, user: nil, session: nil, purpose: nil, ip: nil)
|
|
48
50
|
return failure unless @binding.valid?(browser_secret)
|
|
49
51
|
if user || session || purpose
|
|
50
52
|
@store.with_user(id: user&.id) do |account|
|
|
@@ -54,7 +56,8 @@ module AddAuth
|
|
|
54
56
|
create_options(options, kind: "assertion", user: account, session: session, purpose: purpose, browser_secret: browser_secret)
|
|
55
57
|
end
|
|
56
58
|
else
|
|
57
|
-
|
|
59
|
+
budget = "passkey:anonymous-ceremonies:#{RateLimit.network(ip)}"
|
|
60
|
+
return failure(:rate_limited) unless @limiter.call(key: @digest.digest(budget), limit: @anonymous_limit)
|
|
58
61
|
options = @rp.options_for_authentication(user_verification: "required")
|
|
59
62
|
create_options(options, kind: "assertion", browser_secret: browser_secret)
|
|
60
63
|
end
|
|
@@ -7,15 +7,15 @@ module AddAuth
|
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
9
|
# Resolve when the migration runs, after the host's account table exists.
|
|
10
|
-
#
|
|
10
|
+
# Keep the session keyword for migrations generated by earlier releases.
|
|
11
11
|
def for(connection, table, session: false)
|
|
12
12
|
key = connection.primary_key(table)
|
|
13
13
|
column = connection.columns(table).find { |entry| entry.name == key }
|
|
14
14
|
raise ArgumentError, "AddAuth requires a single integer or UUID primary key on #{table}" unless column
|
|
15
15
|
return :bigint if column.type == :integer && column.limit == 8
|
|
16
16
|
return :integer if column.type == :integer
|
|
17
|
-
return :uuid if column.type == :uuid
|
|
18
|
-
raise ArgumentError,
|
|
17
|
+
return :uuid if column.type == :uuid
|
|
18
|
+
raise ArgumentError, "AddAuth requires an integer or UUID primary key on #{table}"
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -90,7 +90,26 @@ module AddAuth
|
|
|
90
90
|
policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: method(:trusted_recovery_address),
|
|
91
91
|
lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
|
|
92
92
|
sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
|
|
93
|
-
external_identities: -> { external_identities })
|
|
93
|
+
external_identities: -> { external_identities }, external_bindings: external_bindings)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Present whenever the binding table exists, even with providers disabled:
|
|
97
|
+
# a first confirmation must still decide what happens to old bindings.
|
|
98
|
+
def external_bindings
|
|
99
|
+
return unless defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity.table_exists?
|
|
100
|
+
Core::ExternalIdentities::Bindings.new(store: external_identity_store)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Display-only labels for provider configuration IDs held by Core. A
|
|
104
|
+
# native configuration borrows the label of a browser provider with the
|
|
105
|
+
# same issuer.
|
|
106
|
+
def provider_labels(ids)
|
|
107
|
+
options = config.external_identities
|
|
108
|
+
Array(ids).map { |id|
|
|
109
|
+
native = options.native_provider(id)
|
|
110
|
+
provider = options.provider(id) || (native && options.providers.find { |item| item.configuration.issuer == native.issuer })
|
|
111
|
+
provider&.label || "your sign-in provider"
|
|
112
|
+
}.uniq
|
|
94
113
|
end
|
|
95
114
|
|
|
96
115
|
def enqueue_account_request(identifier:, purpose:)
|
|
@@ -112,9 +131,9 @@ module AddAuth
|
|
|
112
131
|
idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy,
|
|
113
132
|
password_lifecycle: password_lifecycle,
|
|
114
133
|
mobile_profile: mobile_profile,
|
|
115
|
-
verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.
|
|
134
|
+
verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.password_denial(user) : :disabled },
|
|
116
135
|
remembered_profile: config.lifecycle.enabled ? {lifetime: config.lifecycle.remember_lifetime, idle_timeout: config.lifecycle.remember_idle_timeout} : nil,
|
|
117
|
-
on_sign_in: method(:record_sign_in))
|
|
136
|
+
on_sign_in: method(:record_sign_in), revoke_authority: authority.method(:revoke))
|
|
118
137
|
end
|
|
119
138
|
|
|
120
139
|
def mobile_profile
|
|
@@ -163,7 +182,8 @@ module AddAuth
|
|
|
163
182
|
Core::Passwords::Lifecycle.new(store: Stores::AccountCredentials.new(user_model: ::User, authority: authority),
|
|
164
183
|
policy: method(:eligible), enabled: config.lifecycle.enabled,
|
|
165
184
|
maximum_attempts: config.lifecycle.maximum_attempts, unlock_in: config.lifecycle.unlock_in,
|
|
166
|
-
issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) }
|
|
185
|
+
issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) },
|
|
186
|
+
password_allowed: ->(user) { access_policy.sign_in_allowed?(user, :password) })
|
|
167
187
|
end
|
|
168
188
|
|
|
169
189
|
def security_events
|
|
@@ -6,8 +6,11 @@ module AddAuth
|
|
|
6
6
|
module Rails
|
|
7
7
|
module Stores
|
|
8
8
|
class Sessions
|
|
9
|
+
attr_reader :id_type
|
|
10
|
+
|
|
9
11
|
def initialize(user_model:, session_model:)
|
|
10
12
|
@users, @sessions = user_model, session_model
|
|
13
|
+
@id_type = @sessions.columns_hash.fetch(@sessions.primary_key).type
|
|
11
14
|
raise ArgumentError, "authentication models must share one connection pool" unless @users.connection_pool.equal?(@sessions.connection_pool)
|
|
12
15
|
@lock = AccountLock.new(@users)
|
|
13
16
|
end
|
|
@@ -38,11 +41,16 @@ module AddAuth
|
|
|
38
41
|
# Query cutoffs come from Core; Core still authorizes every returned row.
|
|
39
42
|
def list_for_user(user_id:, before:, excluding:, limit:, now:, active_after:, legacy:)
|
|
40
43
|
scope = @sessions.where(user_id: user_id, revoked_at: nil)
|
|
41
|
-
|
|
44
|
+
if before&.key?(:legacy_id)
|
|
45
|
+
scope = scope.where("id < ?", before.fetch(:legacy_id))
|
|
46
|
+
elsif before
|
|
47
|
+
scope = scope.where("created_at < ? OR (created_at = ? AND id < ?)",
|
|
48
|
+
before.fetch(:created_at), before.fetch(:created_at), before.fetch(:id))
|
|
49
|
+
end
|
|
42
50
|
scope = scope.where.not(id: excluding) if excluding
|
|
43
51
|
scope = scope.where("(expires_at IS NULL OR expires_at > ?) AND (last_seen_at IS NULL OR last_seen_at > ?)", now, active_after)
|
|
44
52
|
scope = scope.where.not(expires_at: nil).where.not(last_seen_at: nil) unless legacy
|
|
45
|
-
scope.order(id: :desc).limit(limit).to_a
|
|
53
|
+
scope.order(created_at: :desc, id: :desc).limit(limit).to_a
|
|
46
54
|
end
|
|
47
55
|
|
|
48
56
|
def find_for_user(user_id:, session_id:) = @sessions.find_by(user_id: user_id, id: session_id)
|
data/lib/add_auth/version.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/primary_key"
|
|
4
5
|
require "rails/generators/active_record"
|
|
5
6
|
|
|
6
7
|
module AddAuth
|
|
7
8
|
module Generators
|
|
8
9
|
class AccountsGenerator < ::Rails::Generators::Base
|
|
9
10
|
include ::Rails::Generators::Migration
|
|
11
|
+
include PrimaryKey
|
|
10
12
|
|
|
11
13
|
source_root File.expand_path("templates", __dir__)
|
|
12
14
|
class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in as a step-up dependency"
|
|
@@ -14,7 +14,7 @@ class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
|
|
|
14
14
|
add_column :users, :add_auth_authority, :string, default: "add_auth", null: false unless column_exists?(:users, :add_auth_authority)
|
|
15
15
|
add_column :sessions, :remembered, :boolean, default: false_default, null: false unless column_exists?(:sessions, :remembered)
|
|
16
16
|
add_column :sessions, :idle_timeout, :integer unless column_exists?(:sessions, :idle_timeout)
|
|
17
|
-
create_table :add_auth_address_claims do |t|
|
|
17
|
+
create_table :add_auth_address_claims, id: <%= authentication_primary_key_type %> do |t|
|
|
18
18
|
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
19
19
|
t.string :digest, null: false
|
|
20
20
|
t.string :state, null: false
|
|
@@ -22,7 +22,7 @@ class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
|
|
|
22
22
|
end
|
|
23
23
|
add_index :add_auth_address_claims, :digest, unique: true
|
|
24
24
|
add_index :add_auth_address_claims, [:user_id, :state], unique: true
|
|
25
|
-
create_table :add_auth_account_tokens do |t|
|
|
25
|
+
create_table :add_auth_account_tokens, id: <%= authentication_primary_key_type %> do |t|
|
|
26
26
|
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
27
27
|
t.string :digest, null: false
|
|
28
28
|
t.string :purpose, null: false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/primary_key"
|
|
4
5
|
require "rails/generators/active_record"
|
|
5
6
|
|
|
6
7
|
module AddAuth
|
|
@@ -8,6 +9,7 @@ module AddAuth
|
|
|
8
9
|
# Internal persistence foundation only. Does not install sign-in routes.
|
|
9
10
|
class EmailTokensGenerator < ::Rails::Generators::Base
|
|
10
11
|
include ::Rails::Generators::Migration
|
|
12
|
+
include PrimaryKey
|
|
11
13
|
|
|
12
14
|
source_root File.expand_path("templates", __dir__)
|
|
13
15
|
|
|
@@ -2,7 +2,7 @@ require "add_auth/rails/migration/key_type"
|
|
|
2
2
|
|
|
3
3
|
class CreateAddAuthSignInTokens < ActiveRecord::Migration[8.0]
|
|
4
4
|
def change
|
|
5
|
-
create_table :add_auth_sign_in_tokens do |t|
|
|
5
|
+
create_table :add_auth_sign_in_tokens, id: <%= authentication_primary_key_type %> do |t|
|
|
6
6
|
t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), null: false, foreign_key: true
|
|
7
7
|
t.string :digest, limit: 64, null: false
|
|
8
8
|
t.string :identifier_digest, limit: 64, null: false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/primary_key"
|
|
4
5
|
require "rails/generators/active_record"
|
|
5
6
|
|
|
6
7
|
module AddAuth
|
|
@@ -8,6 +9,7 @@ module AddAuth
|
|
|
8
9
|
# Optional persistence and routes; provider strategies remain disabled until configured.
|
|
9
10
|
class ExternalIdentitiesGenerator < ::Rails::Generators::Base
|
|
10
11
|
include ::Rails::Generators::Migration
|
|
12
|
+
include PrimaryKey
|
|
11
13
|
|
|
12
14
|
source_root File.expand_path("templates", __dir__)
|
|
13
15
|
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
require "add_auth/rails/migration/key_type"
|
|
2
|
+
|
|
1
3
|
class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
|
|
2
4
|
def change
|
|
3
|
-
user_type =
|
|
4
|
-
session_type =
|
|
5
|
-
create_table :add_auth_external_identities do |t|
|
|
5
|
+
user_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
|
|
6
|
+
session_type = AddAuth::Rails::Migration::KeyType.for(connection, :sessions, session: true)
|
|
7
|
+
create_table :add_auth_external_identities, id: <%= authentication_primary_key_type %> do |t|
|
|
6
8
|
t.references :user, type: user_type, null: false, foreign_key: {on_delete: :cascade}
|
|
7
9
|
# SHA256 of the exact JSON [configuration, issuer, audience, subject].
|
|
8
10
|
# Indexing this key preserves case-sensitive subjects on either store.
|
|
@@ -18,7 +20,7 @@ class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
|
|
|
18
20
|
t.timestamps
|
|
19
21
|
end
|
|
20
22
|
add_index :add_auth_external_identities, :namespace, unique: true
|
|
21
|
-
create_table :add_auth_external_transactions do |t|
|
|
23
|
+
create_table :add_auth_external_transactions, id: <%= authentication_primary_key_type %> do |t|
|
|
22
24
|
t.string :digest, null: false
|
|
23
25
|
t.string :browser_digest, null: false
|
|
24
26
|
t.string :provider_id, limit: 2048, null: false
|
|
@@ -39,12 +41,4 @@ class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
|
|
|
39
41
|
add_column :sessions, :authentication_external_id, :string
|
|
40
42
|
add_column :sessions, :authentication_external_version, :string
|
|
41
43
|
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def reference_type(table)
|
|
46
|
-
column = connection.columns(table).find { |item| item.name == connection.primary_key(table) }
|
|
47
|
-
raise "Unsupported authentication primary key" unless column && [:integer, :uuid].include?(column.type)
|
|
48
|
-
(column.type == :integer && column.limit == 8) ? :bigint : column.type
|
|
49
|
-
end
|
|
50
44
|
end
|
|
@@ -79,7 +79,7 @@ AddAuth.configure do |config|
|
|
|
79
79
|
config.passkeys.origins = [] # Required: exact trusted origins, never request Host.
|
|
80
80
|
# config.passkeys.origins = ["https://app.example.com"]
|
|
81
81
|
config.passkeys.name = "Your account"
|
|
82
|
-
config.passkeys.anonymous_limit = 1000 #
|
|
82
|
+
config.passkeys.anonymous_limit = 1000 # Ceremony budget per IPv4 address or IPv6 /64 per five minutes.
|
|
83
83
|
config.trusted_recovery_address = ->(_user) {} # No trusted recovery by default.
|
|
84
84
|
# config.trusted_recovery_address = ->(user) { user.email_address if user.confirmed_at }
|
|
85
85
|
# Lifecycle additionally rejects unconfirmed addresses as trusted recovery.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/primary_key"
|
|
4
5
|
require "rails/generators/active_record"
|
|
5
6
|
|
|
6
7
|
module AddAuth
|
|
7
8
|
module Generators
|
|
8
9
|
class MobileSessionsGenerator < ::Rails::Generators::Base
|
|
9
10
|
include ::Rails::Generators::Migration
|
|
11
|
+
include PrimaryKey
|
|
10
12
|
|
|
11
13
|
source_root File.expand_path("templates", __dir__)
|
|
12
14
|
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
require "add_auth/rails/migration/key_type"
|
|
2
|
+
|
|
1
3
|
class CreateAddAuthMobileHandoffs < ActiveRecord::Migration[8.0]
|
|
2
4
|
def change
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
user_type = (user_key.type == :integer && user_key.limit == 8) ? :bigint : user_key.type
|
|
6
|
-
create_table :add_auth_mobile_handoffs do |t|
|
|
5
|
+
user_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
|
|
6
|
+
create_table :add_auth_mobile_handoffs, id: <%= authentication_primary_key_type %> do |t|
|
|
7
7
|
t.references :user, type: user_type, foreign_key: {on_delete: :cascade}
|
|
8
8
|
t.string :external_digest, null: false
|
|
9
9
|
t.string :digest
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/primary_key"
|
|
4
5
|
require "generators/add_auth/feature_configuration"
|
|
5
6
|
require "rails/generators/active_record"
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ module AddAuth
|
|
|
8
9
|
module Generators
|
|
9
10
|
class NotificationsGenerator < ::Rails::Generators::Base
|
|
10
11
|
include ::Rails::Generators::Migration
|
|
12
|
+
include PrimaryKey
|
|
11
13
|
include FeatureConfiguration
|
|
12
14
|
|
|
13
15
|
source_root File.expand_path("templates", __dir__)
|
|
@@ -2,7 +2,7 @@ require "add_auth/rails/migration/key_type"
|
|
|
2
2
|
|
|
3
3
|
class CreateAddAuthSecurityEvents < ActiveRecord::Migration[8.0]
|
|
4
4
|
def change
|
|
5
|
-
create_table :add_auth_security_events do |t|
|
|
5
|
+
create_table :add_auth_security_events, id: <%= authentication_primary_key_type %> do |t|
|
|
6
6
|
t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), foreign_key: {on_delete: :nullify}
|
|
7
7
|
t.string :kind, null: false
|
|
8
8
|
t.string :digest, null: false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/primary_key"
|
|
4
5
|
require "generators/add_auth/feature_configuration"
|
|
5
6
|
require "rails/generators/active_record"
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ module AddAuth
|
|
|
8
9
|
module Generators
|
|
9
10
|
class PasskeysGenerator < ::Rails::Generators::Base
|
|
10
11
|
include ::Rails::Generators::Migration
|
|
12
|
+
include PrimaryKey
|
|
11
13
|
include FeatureConfiguration
|
|
12
14
|
|
|
13
15
|
source_root File.expand_path("templates", __dir__)
|
|
@@ -14,7 +14,7 @@ class AddAddAuthPasskeys < ActiveRecord::Migration[8.0]
|
|
|
14
14
|
add_column :sessions, :authentication_policy_version, :integer, default: 0, null: false
|
|
15
15
|
add_column :sessions, :authentication_credential_id, :string
|
|
16
16
|
add_column :sessions, :authentication_uv, :boolean, default: false_default, null: false
|
|
17
|
-
create_table :add_auth_credentials do |t|
|
|
17
|
+
create_table :add_auth_credentials, id: <%= authentication_primary_key_type %> do |t|
|
|
18
18
|
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
19
19
|
t.string :external_id, null: false
|
|
20
20
|
t.text :public_key, null: false
|
|
@@ -29,7 +29,7 @@ class AddAddAuthPasskeys < ActiveRecord::Migration[8.0]
|
|
|
29
29
|
t.timestamps
|
|
30
30
|
end
|
|
31
31
|
add_index :add_auth_credentials, :external_id, unique: true
|
|
32
|
-
create_table :add_auth_ceremonies do |t|
|
|
32
|
+
create_table :add_auth_ceremonies, id: <%= authentication_primary_key_type %> do |t|
|
|
33
33
|
t.string :digest, null: false
|
|
34
34
|
t.string :challenge, null: false
|
|
35
35
|
t.string :kind, null: false
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Generators
|
|
5
|
+
module PrimaryKey
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Freeze Rails' generator preference into the migration source. Foreign
|
|
9
|
+
# keys independently follow the actual referenced table when it runs.
|
|
10
|
+
def authentication_primary_key_type
|
|
11
|
+
type = (::Rails.application.config.generators.options.dig(:active_record, :primary_key_type) || :bigint).to_s
|
|
12
|
+
unless %w[bigint integer uuid].include?(type)
|
|
13
|
+
raise Thor::Error, "AddAuth supports Rails primary_key_type :bigint, :integer or :uuid"
|
|
14
|
+
end
|
|
15
|
+
":#{type}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -25,6 +25,9 @@ module AddAuth
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def migration
|
|
28
|
+
unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_session_cursor_index.rb")].any?
|
|
29
|
+
migration_template "add_add_auth_session_cursor_index.rb.tt", "db/migrate/add_add_auth_session_cursor_index.rb"
|
|
30
|
+
end
|
|
28
31
|
unless Dir[File.join(destination_root, "db/migrate/*_extend_sessions_for_add_auth.rb")].any?
|
|
29
32
|
migration_template "extend_sessions_for_add_auth.rb.tt", "db/migrate/extend_sessions_for_add_auth.rb"
|
|
30
33
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: add_auth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Taimoor Qureshi
|
|
@@ -329,6 +329,8 @@ files:
|
|
|
329
329
|
- lib/add_auth/core/rate_limit.rb
|
|
330
330
|
- lib/add_auth/core/remaining_factors.rb
|
|
331
331
|
- lib/add_auth/core/security_events.rb
|
|
332
|
+
- lib/add_auth/core/session_cursor.rb
|
|
333
|
+
- lib/add_auth/core/session_key.rb
|
|
332
334
|
- lib/add_auth/core/sessions.rb
|
|
333
335
|
- lib/add_auth/core/step_up.rb
|
|
334
336
|
- lib/add_auth/core/strategies/email_link.rb
|
|
@@ -423,8 +425,10 @@ files:
|
|
|
423
425
|
- lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt
|
|
424
426
|
- lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb
|
|
425
427
|
- lib/generators/add_auth/passkeys/templates/add_auth_credential.rb
|
|
428
|
+
- lib/generators/add_auth/primary_key.rb
|
|
426
429
|
- lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb
|
|
427
430
|
- lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt
|
|
431
|
+
- lib/generators/add_auth/session_upgrade/templates/add_add_auth_session_cursor_index.rb.tt
|
|
428
432
|
- lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt
|
|
429
433
|
- lib/generators/add_auth/step_up/step_up_generator.rb
|
|
430
434
|
- lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt
|