add_auth 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5856e424011a6e4b49dabd271324aadd29862aad6e0dc5a8b5e06c6e1ea27186
4
- data.tar.gz: 5169c854a4428c4daee558abb68208eae29a1f5ae98b91478c9e803ccb3383d4
3
+ metadata.gz: 1ed4ef07ed6308f6bb4b82de3e67552af3604472f38e7a379f1d9c863f76954e
4
+ data.tar.gz: 8189c0ddf517a9d06d291738e0d0d20bb383ffc9a7fdd085c9df33ad914312d4
5
5
  SHA512:
6
- metadata.gz: ee5a6a0df85d2719372eba860f0afb1030c3c1ccaf5644fd0ece45395e0f648bd689e0cb8b8b48a04ffa3fb5d066aab5aa8d4925ba502d0db26e8922c48302a9
7
- data.tar.gz: 5b9bcc71dd3f72271dd140eb9b335cc93feb6222ec4a95e012afc2eaaa6f96945cd3ff7193e2fbfd816dc4e66bcaea293f9075ae9948931892952a4ba9b25b74
6
+ metadata.gz: 643216bb646e0ca6032f0b53f1928eb6da0b4efeffccadd8477fbb7776f6aa411c6ecf36bb4c3f785d94fa435320c04745a2565c70cef47e387f14fc534b805b
7
+ data.tar.gz: 48375790d81d186fa5cfd6f4f3ecf4672c46b2ec8602a3597c5a40b69562661212de41f5df8676a7080ef96ab36915af6dec798400aa97610b5b3c22d76be2d4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-13
4
+
5
+ - Constrain JSON below 3.0 for the supported Rails decoding API; independently
6
+ installed hosts must be able to decrypt account and email proof deliveries.
7
+
8
+ - Add explicit optional password-signup confirmation with atomic provisioning and
9
+ initial session issuance. Unverified email stays unverified; required
10
+ confirmation remains the default.
11
+ - Add an independent opt-in for single-use password reset proofs to unconfirmed
12
+ password accounts, retaining account restrictions and authority revocation.
13
+ - Allow password-only lifecycle/step-up generation without enabling email sign-in.
14
+ - Expand the generated Rails initializer with executable defaults and commented
15
+ alternatives. Feature generators update their own setting in that file.
16
+
3
17
  ## 0.3.0 — 2026-09-09
4
18
 
5
19
  Optional account lifecycle, bounded Devise migration and native authentication.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AddAuth
2
2
 
3
- **0.3.0:** password, email-link and passkey sign-in extend
3
+ Password, email-link and passkey sign-in extend
4
4
  Rails' generated authentication. AddAuth adds hardened sessions, verification
5
5
  for sensitive actions, passkey management and email recovery with an optional
6
6
  strict policy. Turnstile and reCAPTCHA integrations are available. See
@@ -9,7 +9,7 @@ strict policy. Turnstile and reCAPTCHA integrations are available. See
9
9
  AddAuth builds on top of Rails 8's built-in login system instead of
10
10
  replacing it -- it keeps using your existing `User` and `Session` models.
11
11
 
12
- Version 0.3.0 adds optional account lifecycle, bounded Devise migration,
12
+ Introduced in 0.3.0: optional account lifecycle, bounded Devise migration,
13
13
  provider-library integration and finite Android/iOS sessions. See the
14
14
  [migration guide](https://addauthgem.com/migrating-from-devise/) before changing
15
15
  an existing Devise app; do not run the Rails generator over its custom files.
@@ -17,24 +17,38 @@ an existing Devise app; do not run the Rails generator over its custom files.
17
17
  Read the [documentation](https://addauthgem.com) for setup guides, configuration
18
18
  reference and troubleshooting.
19
19
 
20
+ ## Configuration and optional signup confirmation
21
+
22
+ Version 0.4.0 adds optional password-signup confirmation and a
23
+ complete, commented [Rails initializer](lib/generators/add_auth/install/templates/initializer.rb).
24
+ Its default assignments are active; opt-in alternatives are commented out.
25
+ Required confirmation remains the default. See the [configuration guide](https://addauthgem.com/configuration/)
26
+ and [account setup](https://addauthgem.com/configuration-preview/), or the
27
+ [contributor rehearsal](CONTRIBUTING.md#optional-confirmation-integration-rehearsal)
28
+ for verification in a disposable host.
29
+
20
30
  ## Quickstart
21
31
 
32
+ These commands target 0.4.0 and are available after its
33
+ [RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.4.0).
34
+ Check package availability before changing your app’s bundle.
35
+
22
36
  Install the `add_auth` gem from RubyGems through your Rails app’s Gemfile,
23
37
  then enable password and email-link sign-in. Use Ruby 3.3+ and Rails 8.0+
24
38
  with Active Record, and run the commands below from your Rails app’s root.
25
39
 
26
- **Release availability:** these commands require the published `0.3.0` package.
40
+ **Release availability:** these commands require the published `0.4.0` package.
27
41
  If it is not yet listed on [RubyGems](https://rubygems.org/gems/add_auth/versions),
28
42
  wait for publication; see [release status](https://addauthgem.com/release-status/).
29
43
  Start in your app’s development environment; use the deployment settings
30
44
  below before enabling sign-in for users.
31
45
 
32
46
  1. Keep `source "https://rubygems.org"` in your app’s Gemfile and add the
33
- 0.3 release line:
47
+ 0.4 release line:
34
48
 
35
49
  ```ruby
36
50
  # Gemfile
37
- gem "add_auth", "~> 0.3.0"
51
+ gem "add_auth", "~> 0.4.0"
38
52
  ```
39
53
 
40
54
  Bundler downloads the package from RubyGems and records the resolved
@@ -51,10 +65,19 @@ below before enabling sign-in for users.
51
65
  bin/rails generate authentication
52
66
  ```
53
67
 
54
- 3. Add AddAuth:
68
+ 3. Create your configuration file:
55
69
 
56
70
  ```sh
57
71
  bin/rails generate add_auth:install
72
+ ```
73
+
74
+ Open `config/initializers/add_auth.rb`. Defaults are active and optional
75
+ alternatives are commented out. This command enables no features and preserves
76
+ an existing initializer. Each feature section names its generator.
77
+
78
+ 4. Enable password and email-link sign-in:
79
+
80
+ ```sh
58
81
  bin/rails generate add_auth:email_link
59
82
  bin/rails db:migrate
60
83
  ```
@@ -63,7 +86,7 @@ below before enabling sign-in for users.
63
86
  in, plus a page where a signed-in user can see their active sessions and
64
87
  sign out of one remotely.
65
88
 
66
- 4. Set these values in `config/initializers/add_auth.rb`:
89
+ 5. Set these values in `config/initializers/add_auth.rb`:
67
90
 
68
91
  ```ruby
69
92
  AddAuth.configure do |config|
@@ -83,7 +106,7 @@ below before enabling sign-in for users.
83
106
  config.action_mailer.raise_delivery_errors = true
84
107
  ```
85
108
 
86
- 5. Create a trial account in `bin/rails console` using your own test address and
109
+ 6. Create a trial account in `bin/rails console` using your own test address and
87
110
  password, then start `bin/rails server` and visit `/sign-in`. Request a link
88
111
  for that account and open the message written under `tmp/mail`. Treat these
89
112
  local messages as credentials and delete them after testing. Raw sign-in
@@ -538,7 +561,7 @@ or recovery procedures work in production.
538
561
 
539
562
  ## Scope
540
563
 
541
- AddAuth 0.3 implements password/email/passkey sign-in, session adoption and
564
+ AddAuth implements password/email/passkey sign-in, session adoption and
542
565
  management, purpose-bound verification, passkey recovery and strict account
543
566
  policy, durable security mail and challenge adapters. Optional account lifecycle
544
567
  adds registration, confirmation/reconfirmation, password reset, lock/unlock and
data/ROADMAP.md CHANGED
@@ -4,16 +4,17 @@ AddAuth extends Rails 8's authentication generator with email-link sign-in,
4
4
  passkeys, purpose-bound reauthentication, hardened sessions and pluggable captcha.
5
5
  It reuses the host's accounts and Session model.
6
6
 
7
- **Reviewed 2026-09-09: 0.2.2 is published, and the planned v1 strategy features
8
- have shipped.** The bounded Devise migration and native additions below are locally verified
9
- and selected for 0.3.0. The future 1.0 support commitment remains separate. Milestones below describe priorities, not promised dates or
10
- versions. See [release status](https://addauthgem.com/release-status/),
11
- [the changelog](CHANGELOG.md) and [the manual](https://addauthgem.com/).
7
+ **Reviewed 2026-09-13.** Check [published releases](https://github.com/taimoorq/add_auth/releases) for package availability and provenance. This checklist distinguishes verified implementation from publication.
8
+ It adds the bounded Devise migration, optional account lifecycle, provider-library
9
+ integration and finite native authentication below. Required PR and exact
10
+ release-commit CI/CodeQL, protected OIDC publication, downloaded-package comparison
11
+ and fresh-host installation passed. The matching migration and native guides are
12
+ published in [the manual](https://addauthgem.com/).
12
13
 
13
- **[0.2.2 is published and verified](https://github.com/taimoorq/add_auth/releases/tag/v0.2.2).**
14
- It contains the counter-store correction and upgrade/operations coverage below.
15
- The registry package passes fresh-host installation and the matching manual is
16
- live. The owner selected this patch before accepting the proposed 1.0 commitment.
14
+ The original strategy baseline shipped in 0.2.1; 0.2.2 supplied the counter-store
15
+ correction. The separate 1.0 support commitment remains under review. Milestones
16
+ describe priorities, not promised dates. See [release status](https://addauthgem.com/release-status/)
17
+ and [the changelog](CHANGELOG.md).
17
18
 
18
19
  An unchecked item is remaining work; deferred candidates need a scope decision
19
20
  before implementation. Checked features have passing relevant acceptance specs.
@@ -22,7 +23,13 @@ can describe tested local work; release/publication remains a separate gate. Det
22
23
  design and decision records remain in the companion private planning workspace;
23
24
  this public checklist is derived from its section 14 and stands on its own.
24
25
 
25
- ## Now — maintain 0.2 and learn from adoption
26
+ ## Now — maintain AddAuth and learn from adoption
27
+
28
+ - [x] Optional password-signup confirmation, immediate transactional provisioning
29
+ and session issuance, explicit unconfirmed-reset policy, password-only lifecycle
30
+ and the complete commented Rails initializer. Local supported-matrix, stock/
31
+ adopted-host, UUID PostgreSQL and three-mode bundled/ejected acceptance passes.
32
+ Added in the 0.4.0 source; see [release availability and provenance](https://github.com/taimoorq/add_auth/releases).
26
33
 
27
34
  - [x] **R1 · Resolve the outstanding dependency update.**
28
35
  [PR #9](https://github.com/taimoorq/add_auth/pull/9) updates the immutable
@@ -52,7 +59,7 @@ passwordless-fixture isolation failure is fixed in
52
59
  [PR #8](https://github.com/taimoorq/add_auth/pull/8); it is not an open runtime
53
60
  defect or a reason by itself to republish 0.2.1.
54
61
 
55
- ## Nextprove upgrades, plan migrations and define the 1.0 contract
62
+ ## Roadmapmigration delivery and the future 1.0 contract
56
63
 
57
64
  - [x] **R3 · Rehearse an upgrade from published 0.2.1.** Start with a populated
58
65
  host, active sessions, passkeys, strict accounts, pending mail and customized
@@ -114,26 +121,25 @@ defect or a reason by itself to republish 0.2.1.
114
121
  supported database/runtime, ordinary HTML with JavaScript and Turbo absent,
115
122
  Turbo enhancement and permitted no-JS journeys. Draft step-by-step guides
116
123
  and prove the destination works with Devise removed.
117
- - [ ] **R8 · Publish migration and native guides with the feature release.**
118
- Verify the released package, installation commands, HTTPS and deep links;
119
- keep development guides explicitly unpublished until then.
124
+ - [x] **R8 · Publish migration and native guides with the feature release.**
125
+ The 0.3.0 package, installation commands, HTTPS and deep links are verified;
126
+ the six guides now describe the published release.
120
127
 
121
- Local development has verified preflight, optional account lifecycle and
128
+ Version 0.3.0 verifies preflight, optional account lifecycle and
122
129
  provider-library integration across the supported Ruby/Rails matrix, including
123
- ordinary HTML, Turbo and permitted no-JS journeys. These additions are
124
- unpublished. Migration/checkpoint/compatible-rollback tests and native client
130
+ ordinary HTML, Turbo and permitted no-JS journeys. These additions ship in 0.3.0. Migration/checkpoint/compatible-rollback tests and native client
125
131
  acceptance are complete locally. The full adopter suite passes 1,330 examples
126
132
  with zero failures and three existing empty scaffold examples pending. Six
127
- development guides pass build, browser, keyboard and no-JS checks. Publication
128
- and the proposed 1.0 commitment remain separate gates.
133
+ published guides pass build, browser, keyboard and no-JS checks. The proposed
134
+ 1.0 commitment remains a separate gate.
129
135
 
130
- ## Locally verified — Android and iOS login
136
+ ## Shipped in 0.3.0 — Android and iOS login
131
137
 
132
- Mobile support is now part of the active migration implementation. It is not available in the published gem. These capabilities extend the
138
+ Mobile support ships as an optional integration in 0.3.0. These capabilities extend the
133
139
  same account and session policies. The verified finite profile uses explicit
134
140
  30-day absolute and 14-day idle limits, with fresh sign-in after expiry. Renewable
135
- refresh-token families remain a distinct optional capability, currently disabled.
136
- The owner selected 0.3.0; publication and package verification remain pending.
141
+ refresh-token families remain a distinct optional capability, not implemented in this release.
142
+ The published 0.3.0 package and installed native-consumer contracts are verified.
137
143
 
138
144
  - [x] **R8 · Password and provider login for native clients.** Return credentials
139
145
  over authenticated HTTPS responses; preserve supported client contracts.
@@ -152,15 +158,15 @@ The owner selected 0.3.0; publication and package verification remain pending.
152
158
  Each change owns its tests and keeps intermediate releases usable. R3/R4 findings
153
159
  feed R5. A patch may address compatible corrections; another 0.x minor is possible
154
160
  if integration contracts change. A 0.3 release is not a prerequisite for 1.0.
155
- R8 follows the published 0.2.2 correction; its supported source profiles
161
+ R8 ships in 0.3.0 after the published 0.2.2 correction; its supported source profiles
156
162
  are bounded to the verified Devise 5.0.4 fixtures. The selected delivery version is 0.3.0. Its pre-1.0 integration contracts
157
- remain subject to review before R5 freezes. Devise migration support is not available yet.
163
+ remain subject to review before R5 freezes. The bounded Devise migration guide is available for 0.3.0.
158
164
 
159
165
  ## Delivery — 0.3.0 feature release, then the proposed 1.0 gate
160
166
 
161
167
  - [x] Select 0.3.0 for the locally verified migration, lifecycle, provider and native scope.
162
- - [ ] Pass required PR and exact default-branch CI/CodeQL for 0.3.0.
163
- - [ ] Publish 0.3.0 through protected OIDC and verify the downloaded package and manual.
168
+ - [x] Pass required PR and exact default-branch CI/CodeQL for 0.3.0.
169
+ - [x] Publish 0.3.0 through protected OIDC and verify the downloaded package and manual.
164
170
 
165
171
  - [x] **R6 · Deliver the 0.2.2 correction.**
166
172
  [PR #10](https://github.com/taimoorq/add_auth/pull/10) passed all required
@@ -172,7 +178,7 @@ remain subject to review before R5 freezes. Devise migration support is not avai
172
178
  - [ ] **R6 · Finish the adoption and compatibility review.** R1–R5 findings are
173
179
  completed or explicitly dispositioned, the supported API is accepted, and
174
180
  the published-package upgrade and documented operations profile pass.
175
- - [ ] **R6 · Verify the exact candidate.** Preserve the complete local
181
+ - [ ] **R6 · Verify the exact 1.0 candidate.** Preserve the complete local
176
182
  Ruby/Rails, SQLite/PostgreSQL, generated/ejected browser and operations
177
183
  acceptance; resolve release-blocking security and upgrade findings.
178
184
  Recheck dependency currency and pass required GitHub CI/CodeQL controls.
@@ -213,13 +219,13 @@ compatible extension could ship in 1.x after an explicit scope decision.
213
219
  are promoted to R8 above; broader module parity remains outside this scope.
214
220
  - [ ] **F4 · Multiple realms.** Require a concrete identity/session-isolation need
215
221
  and migration design before adding models, cookies or routing APIs.
216
- - [ ] **F4 · API/token authentication.** Android/iOS login is the active R8
217
- work above; multiple realms and general API-key management remain separate.
222
+ - [ ] **F4 · API/token authentication.** Android/iOS login shipped under R8
223
+ above; multiple realms and general API-key management remain separate.
218
224
  - [ ] **F5 · Standalone test helpers or challenge adapters.** Extract only if
219
225
  independent demand and maintenance capacity justify another package;
220
226
  retain one implementation per concern.
221
227
 
222
- Optional provider-library integration is locally verified under R8. SMS/TOTP and enterprise
228
+ Optional provider-library integration ships under R8 in 0.3.0. SMS/TOTP and enterprise
223
229
  or cross-origin WebAuthn remain outside the current roadmap. Account roles,
224
230
  invitations, authorization and email branding
225
231
  stay with the host.
data/SECURITY.md CHANGED
@@ -36,7 +36,9 @@ mailing list, or social media) until a fix has been released.
36
36
  scoping, eligibility rechecks and active-delivery-lease protection.
37
37
  - Optional account lifecycle and migration boundaries: credential
38
38
  profile selection, source-write fencing, address ownership, account proofs,
39
- policy and revocation. These capabilities are introduced in 0.3.0.
39
+ policy and revocation. These capabilities are introduced in 0.3.0; 0.4.0 adds
40
+ optional confirmation, atomic signup provisioning/session issuance and the
41
+ independent unconfirmed-password-reset policy.
40
42
  - Optional provider identity binding and mobile sessions/handoffs, including
41
43
  nonce/client/purpose binding, exact callbacks, single use and bearer/cookie
42
44
  separation. Provider protocol cryptography remains library-owned.
@@ -19,9 +19,17 @@ module AddAuth
19
19
  def create
20
20
  admitted = intake(:register)
21
21
  return intake_failure(admitted) if admitted.is_a?(Symbol)
22
- result = Rails::Runtime.accounts.register(identifier: admitted, password: params[:password], profile: registration_profile)
22
+ accounts = Rails::Runtime.accounts
23
+ result = accounts.register(identifier: admitted, password: params[:password], profile: registration_profile,
24
+ replacing: add_auth_replacement_session, **add_auth_session_hints)
23
25
  if result.success?
24
- redirect_to "/account/check-email", status: :see_other
26
+ if result.grant
27
+ destination = after_authentication_url
28
+ add_auth_accept(result.grant)
29
+ redirect_to destination, status: :see_other
30
+ else
31
+ redirect_to accounts.registration_next_path, status: :see_other
32
+ end
25
33
  else
26
34
  @error = "Check your email and choose a password that meets this application's requirements."
27
35
  page("register", status: :unprocessable_entity)
@@ -8,6 +8,7 @@ module AddAuth
8
8
  skip_before_action :require_authentication, raise: false
9
9
  before_action :private_response
10
10
  before_action :enabled_feature
11
+ before_action :enabled_email_feature, only: %i[request_link check_email link confirm]
11
12
  before_action :require_add_auth_authentication, except: %i[link confirm]
12
13
  before_action :load_purpose, only: %i[new password request_link check_email]
13
14
  protect_from_forgery with: :exception
@@ -54,6 +55,10 @@ module AddAuth
54
55
  head :not_found unless Rails::Runtime.config.step_up.enabled
55
56
  end
56
57
 
58
+ def enabled_email_feature
59
+ head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
60
+ end
61
+
57
62
  def load_purpose
58
63
  @rule = Rails::Runtime.step_up_policy.reauthentication_rule_for(params[:purpose])
59
64
  return head :not_found unless @rule
@@ -1,6 +1,6 @@
1
1
  <% content_for :title, "Create an account" %>
2
2
  <h1 id="add_auth-title">Create an account</h1>
3
- <p class="<%= add_auth_class(:muted) %>">We’ll email you a link to confirm your address before you sign in.</p>
3
+ <p class="<%= add_auth_class(:muted) %>"><%= AddAuth::Rails::Runtime.accounts.registration_instructions %></p>
4
4
  <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
5
  <%= form_with url: "/account/sign-up", data: add_auth_challenge_form_data(:register) do |form| %>
6
6
  <div class="<%= add_auth_class(:field) %>">
@@ -2,6 +2,9 @@
2
2
  <% content_for :title, title %>
3
3
  <h1 id="add_auth-title"><%= title %></h1>
4
4
  <p class="<%= add_auth_class(:muted) %>">Enter your account email address to request a secure link.</p>
5
+ <% if @purpose == "reset_password" %>
6
+ <p>You can also <%= link_to "request email confirmation", "/account/requests/confirm" %> if you haven’t confirmed your address.</p>
7
+ <% end %>
5
8
  <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
6
9
  <%= form_with url: "/account/requests/#{@purpose}", data: add_auth_challenge_form_data(:account_request) do |form| %>
7
10
  <div class="<%= add_auth_class(:field) %>">
@@ -15,7 +15,7 @@ module AddAuth
15
15
  PasskeyOptions = Struct.new(:enabled, :rp_id, :origins, :name, :anonymous_limit)
16
16
  NotificationOptions = Struct.new(:enabled)
17
17
  MaintenanceOptions = Struct.new(:batch_size, :session_retention, :email_retention, :notification_retention, :account_retention)
18
- LifecycleOptions = Struct.new(:enabled, :password_policy, :eligible, :provision, :profile_attributes, :maximum_attempts, :unlock_in, :proof_lifetime, :remember_lifetime, :remember_idle_timeout, :deletion_allowed, :delete_account)
18
+ LifecycleOptions = Struct.new(:enabled, :password_policy, :eligible, :provision, :profile_attributes, :maximum_attempts, :unlock_in, :proof_lifetime, :remember_lifetime, :remember_idle_timeout, :deletion_allowed, :delete_account, :confirmation_required, :reset_unconfirmed)
19
19
  class ExternalProvider
20
20
  attr_reader :id, :label, :middleware_name, :configuration, :apple_form_post, :reauthentication
21
21
 
@@ -88,7 +88,7 @@ module AddAuth
88
88
  @lifecycle = LifecycleOptions.new(enabled: false, password_policy: ->(password) { password.length >= 12 && password.bytesize <= 72 },
89
89
  eligible: ->(_user) { true }, provision: ->(_user) {}, profile_attributes: ->(_profile) { {} }, maximum_attempts: 20, unlock_in: 3600, proof_lifetime: 3600,
90
90
  remember_lifetime: 14 * 86_400, remember_idle_timeout: 7 * 86_400,
91
- deletion_allowed: ->(_user) { true }, delete_account: ->(user) { user.destroy! })
91
+ deletion_allowed: ->(_user) { true }, delete_account: ->(user) { user.destroy! }, confirmation_required: true, reset_unconfirmed: false)
92
92
  @session = SessionOptions.new(enabled: false, lifetime: 43_200, idle_timeout: 1800)
93
93
  @mobile = MobileOptions.new(enabled: false, clients: [], callbacks: {}, apple_providers: {})
94
94
  @email_link = EmailOptions.new(enabled: false, token_lifetime: 1200, same_browser: false)
@@ -16,6 +16,7 @@ module AddAuth
16
16
  class Conflict < StandardError; end
17
17
  class InvalidPassword < StandardError; end
18
18
  class DeletionRejected < StandardError; end
19
+ class RegistrationRejected < StandardError; end
19
20
 
20
21
  class NewAccount
21
22
  attr_reader :user, :user_id
@@ -44,9 +45,21 @@ module AddAuth
44
45
  @external_identities = external_identities
45
46
  end
46
47
 
47
- def register(identifier:, password:, profile: {})
48
+ def register(identifier:, password:, profile: {}, replacing: nil, user_agent: nil, ip_address: nil, remember: false)
48
49
  return failure unless valid_new_password?(password)
49
- register_account(identifier: identifier, password: password, profile: profile)
50
+ register_account(identifier: identifier, password: password, profile: profile,
51
+ immediate: !@policy.confirmation_required?, replacing: replacing,
52
+ session_hints: {user_agent: user_agent, ip_address: ip_address, remember: remember})
53
+ end
54
+
55
+ # Generic non-grant result mapping; never authenticate a duplicate result.
56
+ def registration_next_path
57
+ @policy.confirmation_required? ? "/account/check-email" : "/sign-in"
58
+ end
59
+
60
+ def registration_instructions
61
+ @policy.confirmation_required? ? "We’ll email you a link to confirm your address before you sign in." :
62
+ "Create your account and sign in right away. Your email address stays unverified until you confirm it."
50
63
  end
51
64
 
52
65
  def register_external(identifier:, evidence:, profile: {})
@@ -98,7 +111,7 @@ module AddAuth
98
111
  @notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
99
112
  else
100
113
  @store.update_account(user: user, confirmed_at: now)
101
- @store.provision(user: user)
114
+ provision_account(user)
102
115
  end
103
116
  when "reset_password"
104
117
  next failure unless valid_new_password?(password)
@@ -165,19 +178,47 @@ module AddAuth
165
178
 
166
179
  private
167
180
 
168
- def register_account(identifier:, password:, profile:)
181
+ def register_account(identifier:, password:, profile:, immediate: false, replacing: nil, session_hints: {})
169
182
  email = normalize(identifier)
170
183
  return failure unless email && profile.is_a?(Hash) && profile.length <= 20
171
184
  attributes = @profile_attributes.call(profile)
172
185
  profile_valid = attributes.is_a?(Hash) && attributes.keys.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && !SECURITY_ATTRIBUTES.include?(name.to_s) && !name.to_s.start_with?("add_auth_") }
173
186
  raise AddAuth::Error, "registration profile must contain host profile fields only" unless profile_valid
174
- outcome = @store.create_account(email: email, password: password, profile: attributes) do |user|
187
+ registered = nil
188
+ outcome = @store.create_account(email: email, password: password, profile: attributes, replacing: replacing) do |user|
175
189
  raise Conflict unless @policy.allowed?(user, purpose: :confirm)
176
190
  yield user if block_given?
177
191
  @store.claim_address(user: user, digest: address_digest(email), address: email, state: "current")
178
- issue_in_transaction(user: user, purpose: "confirm", recipient: email)
192
+ if immediate
193
+ raise AddAuth::Error, "optional confirmation requires sessions and the provisioning migration" unless @sessions && user.respond_to?(:add_auth_provisioned_at)
194
+ raise RegistrationRejected unless @policy.allowed?(user)
195
+ password_version = user.password_digest
196
+ provision_account(user)
197
+ unless user.email_address == email && user.password_digest == password_version && !user.confirmed_at && user.unconfirmed_email.to_s.empty?
198
+ raise RegistrationRejected
199
+ end
200
+ grant = nil
201
+ @store.finalize_session(user: user) do |writer|
202
+ grant = @sessions.create_in_transaction(user: user, method: :password, persist: writer, replacing: replacing, **session_hints)
203
+ raise RegistrationRejected unless grant
204
+ grant.session
205
+ end
206
+ registered = Result.success(user: user, strategy: :password, session: grant.session, grant: grant)
207
+ else
208
+ issue_in_transaction(user: user, purpose: "confirm", recipient: email)
209
+ end
179
210
  end
180
- (outcome == :invalid) ? failure : accepted
211
+ return failure if outcome == :invalid
212
+ (outcome == :created && registered) || accepted
213
+ rescue RegistrationRejected
214
+ failure
215
+ end
216
+
217
+ def provision_account(user)
218
+ tracked = user.respond_to?(:add_auth_provisioned_at)
219
+ return if tracked && user.add_auth_provisioned_at
220
+ @store.provision(user: user)
221
+ @store.update_account(user: user, add_auth_provisioned_at: @clock.now) if tracked
181
222
  end
182
223
 
183
224
  def with_account_change(user:, session:, purpose:)
@@ -203,7 +244,10 @@ module AddAuth
203
244
  def recipient_for(user, purpose)
204
245
  if purpose == "confirm"
205
246
  normalize(user.unconfirmed_email.to_s.empty? ? user.email_address : user.unconfirmed_email)
247
+ elsif purpose == "reset_password" && @policy.unconfirmed_reset?(user)
248
+ normalize(user.email_address)
206
249
  else
250
+ return unless @policy.trusted_address?(user)
207
251
  address = normalize(@trusted_address.call(user))
208
252
  address if address == normalize(user.email_address)
209
253
  end
@@ -3,10 +3,24 @@
3
3
  module AddAuth
4
4
  module Core
5
5
  class AccountPolicy
6
- def initialize(enabled:, eligible: ->(_user) { true }, clock: Time)
6
+ def initialize(enabled:, eligible: ->(_user) { true }, clock: Time, confirmation_required: true, reset_unconfirmed: false)
7
+ unless [confirmation_required, reset_unconfirmed].all? { |value| value == true || value == false }
8
+ raise ArgumentError, "confirmation_required and reset_unconfirmed must be true or false"
9
+ end
10
+ raise ArgumentError, "reset_unconfirmed requires optional confirmation" if confirmation_required && reset_unconfirmed
7
11
  @enabled, @eligible, @clock = enabled, eligible, clock
12
+ @confirmation_required, @reset_unconfirmed = confirmation_required, reset_unconfirmed
13
+ end
14
+
15
+ def confirmation_required? = @confirmation_required
16
+
17
+ def unconfirmed_reset?(user)
18
+ @enabled && @reset_unconfirmed && !value(user, :confirmed_at) &&
19
+ password_account?(user)
8
20
  end
9
21
 
22
+ def trusted_address?(user) = !@enabled || !!value(user, :confirmed_at)
23
+
10
24
  def allowed?(user, purpose: :sign_in)
11
25
  return denial(user).nil? if %i[sign_in manage].include?(purpose.to_sym)
12
26
  return false unless available?(user)
@@ -17,7 +31,7 @@ module AddAuth
17
31
  when :unlock
18
32
  locked?(user) && !value(user, :add_auth_manual_lock)
19
33
  when :reset_password
20
- !!value(user, :confirmed_at) && !value(user, :add_auth_strict)
34
+ (!!value(user, :confirmed_at) || unconfirmed_reset?(user)) && !value(user, :add_auth_strict)
21
35
  else false
22
36
  end
23
37
  end
@@ -26,7 +40,7 @@ module AddAuth
26
40
  def denial(user)
27
41
  return :disabled unless available?(user)
28
42
  return unless @enabled
29
- return :unconfirmed unless value(user, :confirmed_at)
43
+ return :unconfirmed if !value(user, :confirmed_at) && (confirmation_required? || !password_account?(user))
30
44
  :locked if locked?(user)
31
45
  end
32
46
 
@@ -36,6 +50,10 @@ module AddAuth
36
50
 
37
51
  private
38
52
 
53
+ def password_account?(user)
54
+ value(user, :password_digest).is_a?(String) && !user.password_digest.empty?
55
+ end
56
+
39
57
  def available?(user)
40
58
  user && @eligible.call(user) == true &&
41
59
  (!user.respond_to?(:add_auth_authority) || user.add_auth_authority == "add_auth") &&
@@ -16,7 +16,7 @@ module AddAuth
16
16
  add_auth_accept(grant)
17
17
  redirect_to destination, status: :see_other
18
18
  else
19
- @error = "Email or password is incorrect. Try again or request a sign-in link."
19
+ @error = "Email or password is incorrect. Try again."
20
20
  page("form", status: :unprocessable_entity)
21
21
  end
22
22
  end
@@ -120,6 +120,11 @@ module AddAuth
120
120
  check("Install provider account management routes") { ::Rails.application.routes.recognize_path("/account/external-identities", method: :get) }
121
121
  end
122
122
  if config.lifecycle.enabled
123
+ check("Set explicit confirmation and unconfirmed-reset policies") { Runtime.account_policy }
124
+ if config.lifecycle.confirmation_required == false
125
+ columns(::User, %w[add_auth_provisioned_at], "optional confirmation provisioning")
126
+ check("Enable password authentication for optional signup confirmation") { config.passwords_enabled }
127
+ end
123
128
  if defined?(::PasswordsController)
124
129
  check("Retire stock password reset entry points through the shared account flow") { ::PasswordsController < AccountPasswordEntry }
125
130
  end
@@ -179,10 +184,12 @@ module AddAuth
179
184
  check("Enable security notification delivery errors") { ::AddAuth::SecurityMailer.raise_delivery_errors }
180
185
  end
181
186
  if config.step_up.enabled
182
- check("Enable session and email prerequisites for step_up") { config.session.enabled && config.email_link.enabled }
187
+ check("Enable session prerequisites for step_up") { config.session.enabled }
183
188
  columns(::Session, %w[elevation_version elevation_expires_at], "reauthentication")
184
- columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
185
- %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
189
+ if config.email_link.enabled
190
+ columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
191
+ %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
192
+ end
186
193
  check("Declare valid step-up purposes and safe return destinations") { Runtime.step_up_purposes.any? && Runtime.step_up_policy }
187
194
  %w[/reauthenticate /reauthenticate/link].each do |path|
188
195
  check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
@@ -15,11 +15,17 @@ module AddAuth
15
15
  def config = AddAuth.configuration
16
16
 
17
17
  def account_policy
18
- Core::AccountPolicy.new(enabled: config.lifecycle.enabled, eligible: config.lifecycle.eligible)
18
+ Core::AccountPolicy.new(enabled: config.lifecycle.enabled,
19
+ eligible: ->(user) { config.eligible.call(user) == true && config.lifecycle.eligible.call(user) == true },
20
+ confirmation_required: config.lifecycle.confirmation_required, reset_unconfirmed: config.lifecycle.reset_unconfirmed)
19
21
  end
20
22
 
21
23
  def eligible(user)
22
- config.eligible.call(user) == true && account_policy.allowed?(user)
24
+ account_policy.allowed?(user)
25
+ end
26
+
27
+ def trusted_recovery_address(user)
28
+ config.trusted_recovery_address.call(user) if account_policy.trusted_address?(user)
23
29
  end
24
30
 
25
31
  def authority
@@ -74,11 +80,14 @@ module AddAuth
74
80
 
75
81
  def accounts
76
82
  raise AddAuth::Error, "enable account lifecycle first" unless config.lifecycle.enabled
83
+ if !account_policy.confirmation_required? && !::User.column_names.include?("add_auth_provisioned_at")
84
+ raise AddAuth::Error, "rerun add_auth:accounts and migrate before enabling optional confirmation"
85
+ end
77
86
  require "add_auth/rails/stores/account_tokens"
78
87
  Core::AccountLifecycle.new(store: Stores::AccountTokens.new(user_model: ::User, token_model: ::AddAuthAccountToken,
79
88
  session_model: ::Session, address_model: ::AddAuthAddressClaim, authority: authority, provision: config.lifecycle.provision, delete_account: config.lifecycle.delete_account),
80
89
  digest: config.sign_in_token_digest, delivery_cipher: DeliveryCipher.new(key: key("account-proofs")),
81
- policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: config.trusted_recovery_address,
90
+ policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: method(:trusted_recovery_address),
82
91
  lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
83
92
  sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
84
93
  external_identities: -> { external_identities })
@@ -168,7 +177,7 @@ module AddAuth
168
177
  Core::AccessPolicy.new(credentials: ->(id) {
169
178
  ::AddAuthCredential.find_by(external_id: id) if defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?
170
179
  }, passkeys_enabled: config.passkeys.enabled, email_enabled: Core::Intake.email_available?(config),
171
- trusted_recovery_address: config.trusted_recovery_address, password_enabled: config.passwords_enabled,
180
+ trusted_recovery_address: method(:trusted_recovery_address), password_enabled: config.passwords_enabled,
172
181
  external_enabled: config.external_identities.enabled,
173
182
  external_current: ->(**arguments) { external_identities.credential_current?(**arguments) })
174
183
  end
@@ -16,16 +16,19 @@ module AddAuth
16
16
  @account_credentials = AccountCredentials.new(user_model: user_model, authority: authority)
17
17
  end
18
18
 
19
- def create_account(email:, password:, profile: {})
19
+ def create_account(email:, password:, profile: {}, replacing: nil)
20
20
  raise AddAuth::Error, "registration must own its transaction" if @users.connection.transaction_open?
21
21
  completed = false
22
+ saved = false
22
23
  @users.transaction do |transaction|
23
24
  transaction.after_commit { completed = true }
25
+ AccountLock.new(@users).current_in_transaction(id: replacing.user_id) if replacing
24
26
  user = @users.new(profile)
25
27
  user.email_address = email
26
28
  user.password = password
27
29
  user.add_auth_authority = "add_auth"
28
30
  user.save!
31
+ saved = true
29
32
  yield user
30
33
  end
31
34
  raise AddAuth::Error, "registration transaction rolled back" unless completed
@@ -33,6 +36,7 @@ module AddAuth
33
36
  rescue ActiveRecord::RecordNotUnique, Core::AccountLifecycle::Conflict
34
37
  :duplicate
35
38
  rescue ActiveRecord::RecordInvalid => error
39
+ raise if saved
36
40
  (error.record.errors.attribute_names - [:email_address]).empty? ? :duplicate : :invalid
37
41
  end
38
42
 
@@ -81,7 +85,11 @@ module AddAuth
81
85
  end
82
86
 
83
87
  def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
84
- def provision(user:) = @provision.call(user)
88
+
89
+ def provision(user:)
90
+ @provision.call(user)
91
+ user.reload
92
+ end
85
93
 
86
94
  def delete_account(user:)
87
95
  raise AddAuth::Error, "account deletion requires its owning transaction" unless @users.connection.transaction_open?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AddAuth
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -9,19 +9,23 @@ module AddAuth
9
9
  include ::Rails::Generators::Migration
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
+ class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in as a step-up dependency"
12
13
  desc "Prepare optional account lifecycle schema and pages; review existing account confirmation and policy before enabling"
13
14
 
14
15
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
15
16
 
16
17
  def prerequisites
17
18
  invoke "add_auth:notifications"
18
- invoke "add_auth:step_up"
19
+ invoke "add_auth:step_up", [], email_link: options[:email_link]
19
20
  end
20
21
 
21
22
  def persistence
22
23
  unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_accounts.rb")].any?
23
24
  migration_template "add_add_auth_accounts.rb.tt", "db/migrate/add_add_auth_accounts.rb"
24
25
  end
26
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_provisioning.rb")].any?
27
+ migration_template "add_add_auth_provisioning.rb.tt", "db/migrate/add_add_auth_provisioning.rb"
28
+ end
25
29
  %w[add_auth_account_token add_auth_address_claim].each do |name|
26
30
  template "#{name}.rb", "app/models/#{name}.rb" unless File.exist?(File.join(destination_root, "app/models/#{name}.rb"))
27
31
  end
@@ -0,0 +1,9 @@
1
+ class AddAddAuthProvisioning < ActiveRecord::Migration[8.0]
2
+ def up
3
+ add_column :users, :add_auth_provisioned_at, :datetime unless column_exists?(:users, :add_auth_provisioned_at)
4
+ end
5
+
6
+ def down
7
+ raise ActiveRecord::IrreversibleMigration, "Retain provisioning receipts to prevent repeating account setup"
8
+ end
9
+ end
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class EmailLinkGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
14
+ class_option :enable, type: :boolean, default: true, desc: "Enable email sign-in after preparing its storage"
12
15
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
16
 
14
17
  def dependencies
@@ -48,7 +51,7 @@ module AddAuth
48
51
  unless File.read(File.join(destination_root, "config/routes.rb")).include?("add_auth/challenge.js")
49
52
  route 'get "add_auth/challenge.js", to: "add_auth/assets#challenge"'
50
53
  end
51
- gsub_file "config/initializers/add_auth.rb", "# config.email_link.enabled = true", "config.email_link.enabled = true"
54
+ enable_feature(:email_link) if options[:enable]
52
55
  say "Review and migrate, configure base_url/mail_from, a durable queue, shared rate-limit cache and a recurring add_auth:deliver_pending sweep."
53
56
  end
54
57
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Generators
5
+ module FeatureConfiguration
6
+ private
7
+
8
+ def enable_feature(name)
9
+ path = "config/initializers/add_auth.rb"
10
+ source = File.read(File.join(destination_root, path))
11
+ setting = "config.#{name}.enabled"
12
+ pattern = /^(\s*)(?:# #{Regexp.escape(setting)} = true|#{Regexp.escape(setting)} = false)$/
13
+ if source.match?(pattern)
14
+ gsub_file path, pattern, "\\1#{setting} = true"
15
+ elsif !source.match?(/^\s*#{Regexp.escape(setting)}\s*=/)
16
+ append_to_file path, "\nAddAuth.configure do |config|\n #{setting} = true\nend\n"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,52 +1,156 @@
1
- # AddAuth configuration. Feature generators enable only their own block.
1
+ # frozen_string_literal: true
2
+
3
+ # AddAuth settings. Defaults are active; alternatives are commented out.
4
+ # Run the relevant generator and migrations before enabling a feature.
5
+ # Install alone enables no features. Existing initializers are never replaced.
2
6
  AddAuth.configure do |config|
3
- # Set false for email/passkey-only sign-in. Existing Rails password entry
4
- # routes remain guarded; the host owns password reset and account provisioning.
5
- # config.passwords_enabled = true
6
-
7
- # Ordinary Rails HTML navigation, including hosts without Hotwire:
8
- # config.turbo_enabled = false
9
- # Passkeys/captcha still use scoped gem JavaScript; no host JS build is needed.
10
-
11
- # BEGIN add_auth session
12
- # config.session.enabled = true
13
- # END add_auth session
14
- # config.session.lifetime = 12.hours
15
- # config.session.idle_timeout = 30.minutes
16
- # Legacy cookies are rejected by default. Opt into a finite bridge explicitly:
17
- # config.session.legacy_bridge_until = Time.iso8601("2026-09-13T00:00:00Z")
18
-
19
- # BEGIN add_auth email_link
20
- # config.email_link.enabled = true
21
- # END add_auth email_link
22
- # config.email_link.token_lifetime = 20.minutes
23
- # config.email_link.same_browser = false # true requires the requesting browser
24
- # REQUIRED for email delivery: fixed, trusted origin; never derive it from Host.
25
- # config.base_url = "https://your-app.example"
26
- # config.mail_from = "Your app <sign-in@your-app.example>"
27
- # Configure a durable Active Job adapter and schedule add_auth:deliver_pending.
28
- # Rails.cache is suitable only with shared atomic increments and expiry.
29
- # Solid Cache is not suitable; configure a separate counter store:
30
- # https://addauthgem.com/production/#rate-limits
31
- # Each maintenance pass handles at most this many rows per operation/model:
32
- # config.maintenance.batch_size = 100 # 1..1000
33
- # History is retained until the host chooses a retention period (seconds):
34
- # config.maintenance.session_retention = 7.days
35
- # config.maintenance.email_retention = 7.days
36
- # config.maintenance.notification_retention = 30.days
37
-
38
- # Apply the host's current confirmed/locked/disabled policy on every resume:
39
- # config.eligible = ->(user) { user.confirmed? && !user.locked? && !user.disabled? }
40
-
41
- # Styling: nil disables CSS; a local path uses your own compiled stylesheet.
42
- # config.stylesheet = "/add_auth.css"
43
- # config.css_classes = { input: "form-control", button: "btn btn-primary" }
44
- # Eject editable templates with bin/rails generate add_auth:views.
45
-
46
- # Enable optional features with add_auth:passkeys, add_auth:step_up,
47
- # add_auth:notifications or add_auth:challenge. Install alone enables none.
48
- # Passkey recovery needs a host-verified recovery address; strict accounts
49
- # use another passkey or the host's support process, never email recovery.
50
- # config.challenge = AddAuth::Core::Challenge::Null.new
51
- # config.challenge_on = []
7
+ # Sign-in and rendering. HTML forms work in all browser modes.
8
+ config.passwords_enabled = true
9
+ # config.passwords_enabled = false # Use only other explicitly enabled methods.
10
+ config.turbo_enabled = true
11
+ # config.turbo_enabled = false # Ordinary JS navigation, with Turbo absent.
12
+
13
+ # Host business eligibility is checked alongside Core account policy.
14
+ config.eligible = ->(_user) { true }
15
+ # config.eligible = ->(user) { user.access_state == "active" }
16
+
17
+ # Browser sessions: bin/rails generate add_auth:session_upgrade
18
+ config.session.enabled = false
19
+ config.session.lifetime = 43_200 # 12 hours, absolute expiry.
20
+ config.session.idle_timeout = 1800 # 30 minutes; no greater than lifetime.
21
+ config.session.legacy_bridge_until = nil # Reject old Rails session-ID cookies.
22
+ # config.session.legacy_bridge_until = Time.iso8601("2030-01-01T00:00:00Z")
23
+ # Choose a short, fixed cutover deadline for a reviewed legacy-cookie bridge.
24
+
25
+ # Account lifecycle: bin/rails generate add_auth:accounts
26
+ # Password-only host: bin/rails generate add_auth:accounts --no-email-link
27
+ config.lifecycle.enabled = false
28
+ # config.lifecycle.enabled = true # After policy review and migrations.
29
+ config.lifecycle.confirmation_required = true
30
+ # config.lifecycle.confirmation_required = false
31
+ # false: password signup provisions and signs in immediately, without mail.
32
+ # confirmed_at stays nil. Requires the add_auth_provisioned_at migration.
33
+ config.lifecycle.reset_unconfirmed = false
34
+ # config.lifecycle.reset_unconfirmed = true # Requires optional confirmation.
35
+ # true: the stored mailbox holder may reset an unconfirmed password account
36
+ # after consuming an exact-address, single-use reset proof. No automatic login
37
+ # or confirmation. This does not trust the address for any other recovery.
38
+ # false: confirm the address before password reset, or use host support.
39
+ # Email changes always need fresh reauthentication and proof of the new address.
40
+ config.lifecycle.password_policy = ->(password) { password.length >= 12 && password.bytesize <= 72 }
41
+ config.lifecycle.eligible = ->(_user) { true } # Additional lifecycle eligibility.
42
+ config.lifecycle.profile_attributes = ->(_profile) { {} } # Accept no profile fields.
43
+ # config.lifecycle.profile_attributes = ->(profile) { profile.slice(:name, :consent) }
44
+ # Also allowlist those fields in the host registration controller. Never roles.
45
+ config.lifecycle.provision = ->(_user) {} # Local writes in the account transaction.
46
+ # config.lifecycle.provision = ->(user) { user.create_personal_workspace! }
47
+ # Raise on failure. Use the same DB pool; remote work needs a host outbox.
48
+ config.lifecycle.maximum_attempts = 20 # Failed-password threshold: 1..100.
49
+ config.lifecycle.unlock_in = 3600 # Timed lock: 60..86400 seconds.
50
+ config.lifecycle.proof_lifetime = 3600 # Account links: 60..86400 seconds.
51
+ config.lifecycle.remember_lifetime = 1_209_600 # 14 days, absolute expiry.
52
+ config.lifecycle.remember_idle_timeout = 604_800 # 7 days; <= remember_lifetime.
53
+ config.lifecycle.deletion_allowed = ->(_user) { true } # Host authorization/retention.
54
+ # config.lifecycle.deletion_allowed = ->(user) { user.invoices.none? }
55
+ config.lifecycle.delete_account = ->(user) { user.destroy! } # Same-DB local deletion.
56
+
57
+ # Email-link sign-in: bin/rails generate add_auth:email_link
58
+ # Account confirmation/reset mail does not require email-link sign-in.
59
+ config.email_link.enabled = false
60
+ config.email_link.token_lifetime = 1200 # 20 minutes.
61
+ config.email_link.same_browser = false
62
+ # config.email_link.same_browser = true # Require the requesting browser.
63
+
64
+ # Fresh proof for sensitive actions: bin/rails generate add_auth:step_up
65
+ # Use --no-email-link for password-only reauthentication.
66
+ config.step_up.enabled = false
67
+ config.step_up.fresh_for = 600 # Ordinary proof: 10 minutes.
68
+ config.step_up.strong_for = 300 # Strong proof: 5 minutes.
69
+ config.step_up.purposes = {} # Built-in feature purposes are supplied by Core.
70
+ # config.step_up.purposes = {
71
+ # export_data: {methods: [:password], return_to: "/exports/new", label: "export your data"}
72
+ # }
73
+ # Recheck elevation and host authorization inside the actual mutation.
74
+
75
+ # Passkeys: bin/rails generate add_auth:passkeys
76
+ config.passkeys.enabled = false
77
+ config.passkeys.rp_id = nil # Required: stable deployment RP ID.
78
+ # config.passkeys.rp_id = "example.com"
79
+ config.passkeys.origins = [] # Required: exact trusted origins, never request Host.
80
+ # config.passkeys.origins = ["https://app.example.com"]
81
+ config.passkeys.name = "Your account"
82
+ config.passkeys.anonymous_limit = 1000 # Shared ceremony budget per five minutes.
83
+ config.trusted_recovery_address = ->(_user) {} # No trusted recovery by default.
84
+ # config.trusted_recovery_address = ->(user) { user.email_address if user.confirmed_at }
85
+ # Lifecycle additionally rejects unconfirmed addresses as trusted recovery.
86
+ config.support_url = nil # Required before strict-policy activation.
87
+ # config.support_url = "/support" # Document your host-owned recovery process.
88
+
89
+ # Security notices: bin/rails generate add_auth:notifications
90
+ config.notifications.enabled = false
91
+ # Mail/queue settings also serve account proofs and optional email sign-in.
92
+ config.base_url = nil # Required for links: fixed trusted HTTPS origin.
93
+ # config.base_url = "https://app.example.com"
94
+ config.mail_from = nil # Required for mail; configure SMTP in Rails as usual.
95
+ # config.mail_from = "Your app <sign-in@example.com>"
96
+ # Configure a durable Active Job adapter and run add_auth:deliver_pending each minute.
97
+ config.rate_limit_store = nil # Defaults to Rails.cache; shared atomic counters required.
98
+ # config.rate_limit_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("AUTH_REDIS_URL"))
99
+ # Solid Cache can serve application caching, but cannot serve these counters.
100
+ config.maintenance.batch_size = 100 # Maximum rows per operation/model: 1..1000.
101
+ config.maintenance.session_retention = nil # Keep history until explicitly selected.
102
+ config.maintenance.email_retention = nil
103
+ config.maintenance.notification_retention = nil
104
+ config.maintenance.account_retention = nil
105
+ # config.maintenance.session_retention = 604_800 # 7 days, seconds.
106
+ # config.maintenance.email_retention = 604_800
107
+ # config.maintenance.notification_retention = 2_592_000 # 30 days.
108
+ # config.maintenance.account_retention = 604_800
109
+
110
+ # Captcha: bin/rails generate add_auth:challenge (provider-specific setup).
111
+ config.challenge = AddAuth::Core::Challenge::Null.new
112
+ config.challenge_on = [] # Available: sign_in, email_link, reauthenticate,
113
+ # passkey_enrollment, register, account_request, provider.
114
+ # config.challenge_on = [:sign_in, :register, :account_request]
115
+ config.challenge_when_unavailable = :closed
116
+ # config.challenge_when_unavailable = :open # Explicit outage bypass; emits an event.
117
+ # Replace challenge with a configured Turnstile or Recaptcha adapter. Keep
118
+ # secrets in Rails credentials/environment, and configure expected hostnames.
119
+
120
+ # Optional providers: bin/rails generate add_auth:external_identities
121
+ config.external_identities.enabled = false
122
+ # Register only reviewed Core provider configurations through
123
+ # config.external_identities.register(id:, label:, middleware_name:, configuration:,
124
+ # apple_form_post: false, reauthentication: false)
125
+ # or config.external_identities.register_native(configuration:).
126
+ # The generated add_auth_providers.rb explains provider-library middleware,
127
+ # verifier and credential setup. It remains commented out until reviewed.
128
+
129
+ # Native sessions: bin/rails generate add_auth:mobile_sessions
130
+ config.mobile.enabled = false
131
+ config.mobile.lifetime = nil # Required when enabled; choose finite seconds.
132
+ config.mobile.idle_timeout = nil # Required; <= lifetime.
133
+ config.mobile.clients = [] # Explicit first-party client identifiers.
134
+ config.mobile.callbacks = {} # Reviewed callbacks per client, when using handoffs.
135
+ config.mobile.apple_providers = {} # Native Apple provider IDs per client.
136
+ # config.mobile.lifetime = 2_592_000 # Example: 30 days.
137
+ # config.mobile.idle_timeout = 1_209_600 # Example: 14 days.
138
+ # config.mobile.clients = %w[android ios]
139
+
140
+ # Presentation: eject with bin/rails generate add_auth:views.
141
+ config.stylesheet = "/add_auth.css"
142
+ # config.stylesheet = nil # Supply your own styling.
143
+ config.css_classes = {}
144
+ # config.css_classes = {input: "form-control", button: "btn btn-primary"}
145
+
146
+ # Password migration and cryptography: retain Rails' verifier by default.
147
+ config.legacy_password_verifier = nil
148
+ config.current_password_support = AddAuth::Core::Passwords::BcryptSupport.new
149
+ # A custom verifier must also declare current_password_support.available?(digest:).
150
+ # Rails configures digest_secret from its key generator automatically. Preserve it.
151
+ # Outside Rails only:
152
+ # config.digest_secret = -> { ENV.fetch("ADD_AUTH_DIGEST_SECRET") }
153
+ # Optional adapter overrides must implement digest(token) and matches?(digest, token):
154
+ # config.session_token_digest = MySessionDigest.new
155
+ # config.sign_in_token_digest = MyProofDigest.new
52
156
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class NotificationsGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
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)
@@ -21,10 +23,7 @@ module AddAuth
21
23
  end
22
24
 
23
25
  def configuration
24
- path = "config/initializers/add_auth.rb"
25
- unless File.read(File.join(destination_root, path)).include?("config.notifications.enabled = true")
26
- append_to_file path, "\nAddAuth.configure do |config|\n config.notifications.enabled = true\nend\n"
27
- end
26
+ enable_feature(:notifications)
28
27
  say "Migrate, configure mail_from and a durable queue, and schedule add_auth:deliver_pending every minute."
29
28
  end
30
29
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class PasskeysGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
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)
@@ -47,27 +49,7 @@ module AddAuth
47
49
  post "recover/link", to: "add_auth/recoveries#confirm"
48
50
  ROUTES
49
51
  end
50
- path = "config/initializers/add_auth.rb"
51
- unless File.read(File.join(destination_root, path)).include?("config.passkeys.enabled = true")
52
- append_to_file path, <<~CONFIG
53
-
54
- AddAuth.configure do |config|
55
- config.passkeys.enabled = true
56
- # REQUIRED: stable RP ID and exact deployment origins, never request Host.
57
- # config.passkeys.rp_id = "example.com"
58
- # config.passkeys.origins = ["https://app.example.com"]
59
- # config.passkeys.name = "Your app"
60
- # Shared anonymous ceremony budget per five minutes; positive integer.
61
- # config.passkeys.anonymous_limit = 1000
62
- # Schedule add_auth:deliver_pending every minute. Production doctor
63
- # requires a completed cleanup within the last two minutes.
64
- # REQUIRED for email replacement: a host-verified recovery address.
65
- # config.trusted_recovery_address = ->(user) { user.email_address if user.confirmed? }
66
- # REQUIRED before strict activation: your documented support route.
67
- # config.support_url = "/support"
68
- end
69
- CONFIG
70
- end
52
+ enable_feature(:passkeys)
71
53
  say "Review and migrate before enabling traffic. Configure RP/origins, trusted recovery and notifications; run add_auth:doctor."
72
54
  end
73
55
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class SessionUpgradeGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
 
@@ -62,7 +64,7 @@ module AddAuth
62
64
  unless File.read(File.join(destination_root, path)).include?('require "add_auth/rails/authentication"')
63
65
  prepend_to_file path, %(require "add_auth/rails/authentication"\nrequire "add_auth/rails/user_lifecycle"\n)
64
66
  end
65
- gsub_file path, "# config.session.enabled = true", "config.session.enabled = true"
67
+ enable_feature(:session)
66
68
  routes = File.join(destination_root, "config/routes.rb")
67
69
  source = File.read(routes)
68
70
  unless source.include?("# AddAuth session management")
@@ -1,18 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class StepUpGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
14
+ class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in alongside reauthentication"
12
15
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
16
 
14
17
  def dependencies
15
- invoke "add_auth:email_link"
18
+ invoke "add_auth:email_link", [], enable: options[:email_link]
16
19
  end
17
20
 
18
21
  def persistence
@@ -33,10 +36,7 @@ module AddAuth
33
36
  post "reauthenticate/link", to: "add_auth/reauthentications#confirm"
34
37
  ROUTES
35
38
  end
36
- initializer = "config/initializers/add_auth.rb"
37
- unless File.read(File.join(destination_root, initializer)).include?("config.step_up.enabled = true")
38
- append_to_file initializer, "\nAddAuth.configure do |config|\n config.step_up.enabled = true\n # Declare purpose methods and a fixed safe GET return_to before use.\nend\n"
39
- end
39
+ enable_feature(:step_up)
40
40
  say "Migrate and declare config.step_up.purposes. Use with_elevated_session around sensitive database mutations."
41
41
  end
42
42
  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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taimoor Qureshi
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "<"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: webauthn
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -364,6 +378,7 @@ files:
364
378
  - lib/add_auth/version.rb
365
379
  - lib/generators/add_auth/accounts/accounts_generator.rb
366
380
  - lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt
381
+ - lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt
367
382
  - lib/generators/add_auth/accounts/templates/add_auth_account_token.rb
368
383
  - lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb
369
384
  - lib/generators/add_auth/challenge/challenge_generator.rb
@@ -388,6 +403,7 @@ files:
388
403
  - lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb
389
404
  - lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt
390
405
  - lib/generators/add_auth/external_identities/templates/providers.rb
406
+ - lib/generators/add_auth/feature_configuration.rb
391
407
  - lib/generators/add_auth/install/install_generator.rb
392
408
  - lib/generators/add_auth/install/templates/initializer.rb
393
409
  - lib/generators/add_auth/javascript/javascript_generator.rb