add_auth 0.3.0 → 0.5.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +61 -9
  4. data/ROADMAP.md +43 -31
  5. data/SECURITY.md +7 -1
  6. data/app/controllers/add_auth/accounts_controller.rb +10 -2
  7. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  8. data/app/controllers/add_auth/sessions_controller.rb +1 -1
  9. data/app/views/add_auth/accounts/_register.html.erb +1 -1
  10. data/app/views/add_auth/accounts/_request.html.erb +3 -0
  11. data/lib/add_auth/configuration.rb +2 -2
  12. data/lib/add_auth/core/account_lifecycle.rb +51 -7
  13. data/lib/add_auth/core/account_policy.rb +21 -3
  14. data/lib/add_auth/core/session_cursor.rb +38 -0
  15. data/lib/add_auth/core/session_key.rb +32 -0
  16. data/lib/add_auth/core/sessions.rb +19 -8
  17. data/lib/add_auth/rails/authentication_pages.rb +1 -1
  18. data/lib/add_auth/rails/doctor.rb +10 -3
  19. data/lib/add_auth/rails/migration/key_type.rb +3 -3
  20. data/lib/add_auth/rails/runtime.rb +13 -4
  21. data/lib/add_auth/rails/stores/account_tokens.rb +10 -2
  22. data/lib/add_auth/rails/stores/sessions.rb +10 -2
  23. data/lib/add_auth/version.rb +1 -1
  24. data/lib/generators/add_auth/accounts/accounts_generator.rb +7 -1
  25. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +2 -2
  26. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  27. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  28. data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +2 -0
  29. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +1 -1
  30. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +2 -0
  31. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +6 -12
  32. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  33. data/lib/generators/add_auth/install/templates/initializer.rb +154 -50
  34. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +2 -0
  35. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +4 -4
  36. data/lib/generators/add_auth/notifications/notifications_generator.rb +5 -4
  37. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +1 -1
  38. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +5 -21
  39. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +2 -2
  40. data/lib/generators/add_auth/primary_key.rb +19 -0
  41. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +6 -1
  42. data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_session_cursor_index.rb.tt +5 -0
  43. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  44. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5856e424011a6e4b49dabd271324aadd29862aad6e0dc5a8b5e06c6e1ea27186
4
- data.tar.gz: 5169c854a4428c4daee558abb68208eae29a1f5ae98b91478c9e803ccb3383d4
3
+ metadata.gz: b83eabd84666412ae2a83ffc20ac54c9765ef0133e0e401cffebce9e3f73f444
4
+ data.tar.gz: b187c3b70d0c43139f870a50231e5e024f71885218ed68770c98218c257b4be3
5
5
  SHA512:
6
- metadata.gz: ee5a6a0df85d2719372eba860f0afb1030c3c1ccaf5644fd0ece45395e0f648bd689e0cb8b8b48a04ffa3fb5d066aab5aa8d4925ba502d0db26e8922c48302a9
7
- data.tar.gz: 5b9bcc71dd3f72271dd140eb9b335cc93feb6222ec4a95e012afc2eaaa6f96945cd3ff7193e2fbfd816dc4e66bcaea293f9075ae9948931892952a4ba9b25b74
6
+ metadata.gz: 65eb201de7ebebca70a786c9664a46df8c27404e74dcd2704b25ef33673ab0f42f7a9c5a865c707cb33a943a4a933c8903fc5dafd1479c32bf0ad0266263f7d6
7
+ data.tar.gz: a48427dfe1145c18be90ddb4ec027b70a7179f7e742d3806ee99ee2ddbf7e9f865abbb0486d96cce600e7e5284ee7487ea7a4b17c71639b94c1801dc95b1b8ee
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-09-18
4
+
5
+ - Honor Rails' `primary_key_type` generator setting for all AddAuth-owned tables.
6
+ Support PostgreSQL UUID sessions and mixed integer/UUID account/session keys,
7
+ retaining bigint defaults and deriving references from the existing schema.
8
+ - Use creation time and a typed ID in opaque session-page cursors; validate
9
+ revocation and explicitly enabled legacy session adoption for either key type.
10
+ For apps already using hardened sessions, re-run `add_auth:session_upgrade`
11
+ for the new pagination index. Existing tables retain their IDs; refresh ejected
12
+ controllers through the normal review process.
13
+ Follow the [upgrade guide](https://addauthgem.com/upgrading/#uuid-support) for
14
+ the release gate, migration review, opaque client cursors and rollback boundary.
15
+
16
+ ## 0.4.0 — 2026-09-13
17
+
18
+ - Constrain JSON below 3.0 for the supported Rails decoding API; independently
19
+ installed hosts must be able to decrypt account and email proof deliveries.
20
+
21
+ - Add explicit optional password-signup confirmation with atomic provisioning and
22
+ initial session issuance. Unverified email stays unverified; required
23
+ confirmation remains the default.
24
+ - Add an independent opt-in for single-use password reset proofs to unconfirmed
25
+ password accounts, retaining account restrictions and authority revocation.
26
+ - Allow password-only lifecycle/step-up generation without enabling email sign-in.
27
+ - Expand the generated Rails initializer with executable defaults and commented
28
+ alternatives. Feature generators update their own setting in that file.
29
+
3
30
  ## 0.3.0 — 2026-09-09
4
31
 
5
32
  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.5.0 and are available after its
33
+ [RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.5.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.5.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.5 release line:
34
48
 
35
49
  ```ruby
36
50
  # Gemfile
37
- gem "add_auth", "~> 0.3.0"
51
+ gem "add_auth", "~> 0.5.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,13 +561,42 @@ 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
545
568
  account changes. Hosts retain account eligibility, resource authorization and
546
569
  provider registrations.
547
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
+
548
600
  ## Optional integrations in 0.3.0
549
601
 
550
602
  Enable bounded [Devise migration](https://addauthgem.com/migrating-from-devise/),
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,19 @@ 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] 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
+
34
+ - [x] Optional password-signup confirmation, immediate transactional provisioning
35
+ and session issuance, explicit unconfirmed-reset policy, password-only lifecycle
36
+ and the complete commented Rails initializer. Local supported-matrix, stock/
37
+ adopted-host, UUID PostgreSQL and three-mode bundled/ejected acceptance passes.
38
+ Added in the 0.4.0 source; see [release availability and provenance](https://github.com/taimoorq/add_auth/releases).
26
39
 
27
40
  - [x] **R1 · Resolve the outstanding dependency update.**
28
41
  [PR #9](https://github.com/taimoorq/add_auth/pull/9) updates the immutable
@@ -52,7 +65,7 @@ passwordless-fixture isolation failure is fixed in
52
65
  [PR #8](https://github.com/taimoorq/add_auth/pull/8); it is not an open runtime
53
66
  defect or a reason by itself to republish 0.2.1.
54
67
 
55
- ## Next — prove upgrades, plan migrations and define the 1.0 contract
68
+ ## Roadmap — migration delivery and the future 1.0 contract
56
69
 
57
70
  - [x] **R3 · Rehearse an upgrade from published 0.2.1.** Start with a populated
58
71
  host, active sessions, passkeys, strict accounts, pending mail and customized
@@ -114,26 +127,25 @@ defect or a reason by itself to republish 0.2.1.
114
127
  supported database/runtime, ordinary HTML with JavaScript and Turbo absent,
115
128
  Turbo enhancement and permitted no-JS journeys. Draft step-by-step guides
116
129
  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.
130
+ - [x] **R8 · Publish migration and native guides with the feature release.**
131
+ The 0.3.0 package, installation commands, HTTPS and deep links are verified;
132
+ the six guides now describe the published release.
120
133
 
121
- Local development has verified preflight, optional account lifecycle and
134
+ Version 0.3.0 verifies preflight, optional account lifecycle and
122
135
  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
136
+ ordinary HTML, Turbo and permitted no-JS journeys. These additions ship in 0.3.0. Migration/checkpoint/compatible-rollback tests and native client
125
137
  acceptance are complete locally. The full adopter suite passes 1,330 examples
126
138
  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.
139
+ published guides pass build, browser, keyboard and no-JS checks. The proposed
140
+ 1.0 commitment remains a separate gate.
129
141
 
130
- ## Locally verified — Android and iOS login
142
+ ## Shipped in 0.3.0 — Android and iOS login
131
143
 
132
- Mobile support is now part of the active migration implementation. It is not available in the published gem. These capabilities extend the
144
+ Mobile support ships as an optional integration in 0.3.0. These capabilities extend the
133
145
  same account and session policies. The verified finite profile uses explicit
134
146
  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.
147
+ refresh-token families remain a distinct optional capability, not implemented in this release.
148
+ The published 0.3.0 package and installed native-consumer contracts are verified.
137
149
 
138
150
  - [x] **R8 · Password and provider login for native clients.** Return credentials
139
151
  over authenticated HTTPS responses; preserve supported client contracts.
@@ -152,15 +164,15 @@ The owner selected 0.3.0; publication and package verification remain pending.
152
164
  Each change owns its tests and keeps intermediate releases usable. R3/R4 findings
153
165
  feed R5. A patch may address compatible corrections; another 0.x minor is possible
154
166
  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
167
+ R8 ships in 0.3.0 after the published 0.2.2 correction; its supported source profiles
156
168
  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.
169
+ remain subject to review before R5 freezes. The bounded Devise migration guide is available for 0.3.0.
158
170
 
159
171
  ## Delivery — 0.3.0 feature release, then the proposed 1.0 gate
160
172
 
161
173
  - [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.
174
+ - [x] Pass required PR and exact default-branch CI/CodeQL for 0.3.0.
175
+ - [x] Publish 0.3.0 through protected OIDC and verify the downloaded package and manual.
164
176
 
165
177
  - [x] **R6 · Deliver the 0.2.2 correction.**
166
178
  [PR #10](https://github.com/taimoorq/add_auth/pull/10) passed all required
@@ -172,7 +184,7 @@ remain subject to review before R5 freezes. Devise migration support is not avai
172
184
  - [ ] **R6 · Finish the adoption and compatibility review.** R1–R5 findings are
173
185
  completed or explicitly dispositioned, the supported API is accepted, and
174
186
  the published-package upgrade and documented operations profile pass.
175
- - [ ] **R6 · Verify the exact candidate.** Preserve the complete local
187
+ - [ ] **R6 · Verify the exact 1.0 candidate.** Preserve the complete local
176
188
  Ruby/Rails, SQLite/PostgreSQL, generated/ejected browser and operations
177
189
  acceptance; resolve release-blocking security and upgrade findings.
178
190
  Recheck dependency currency and pass required GitHub CI/CodeQL controls.
@@ -213,13 +225,13 @@ compatible extension could ship in 1.x after an explicit scope decision.
213
225
  are promoted to R8 above; broader module parity remains outside this scope.
214
226
  - [ ] **F4 · Multiple realms.** Require a concrete identity/session-isolation need
215
227
  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.
228
+ - [ ] **F4 · API/token authentication.** Android/iOS login shipped under R8
229
+ above; multiple realms and general API-key management remain separate.
218
230
  - [ ] **F5 · Standalone test helpers or challenge adapters.** Extract only if
219
231
  independent demand and maintenance capacity justify another package;
220
232
  retain one implementation per concern.
221
233
 
222
- Optional provider-library integration is locally verified under R8. SMS/TOTP and enterprise
234
+ Optional provider-library integration ships under R8 in 0.3.0. SMS/TOTP and enterprise
223
235
  or cross-origin WebAuthn remain outside the current roadmap. Account roles,
224
236
  invitations, authorization and email branding
225
237
  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.
@@ -52,6 +54,10 @@ the currency policy).
52
54
 
53
55
  ## Supported versions
54
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
+
55
61
  Until a 1.0 is released, only the latest published version receives security
56
62
  fixes. This table will be expanded once there are stable release lines to
57
63
  support.
@@ -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
@@ -25,7 +25,7 @@ module AddAuth
25
25
 
26
26
  def destroy
27
27
  target_id = params[:id].to_s
28
- if target_id.match?(/\A[1-9]\d*\z/) && target_id.to_i == Current.session.id
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
@@ -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") &&
@@ -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