add_auth 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +125 -0
  3. data/CODE_OF_CONDUCT.md +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +545 -0
  6. data/ROADMAP.md +244 -0
  7. data/SECURITY.md +75 -0
  8. data/app/controllers/add_auth/assets_controller.rb +54 -0
  9. data/app/controllers/add_auth/passkeys_controller.rb +144 -0
  10. data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
  11. data/app/controllers/add_auth/recoveries_controller.rb +50 -0
  12. data/app/controllers/add_auth/sessions_controller.rb +62 -0
  13. data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
  14. data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
  15. data/app/javascript/controllers/.keep +0 -0
  16. data/app/jobs/add_auth/delivery_job.rb +31 -0
  17. data/app/jobs/add_auth/email_delivery_job.rb +15 -0
  18. data/app/jobs/add_auth/email_request_job.rb +21 -0
  19. data/app/jobs/add_auth/security_notification_job.rb +13 -0
  20. data/app/mailers/add_auth/security_mailer.rb +16 -0
  21. data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
  22. data/app/models/concerns/.keep +0 -0
  23. data/app/views/.keep +0 -0
  24. data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
  25. data/app/views/add_auth/passkeys/_list.html.erb +32 -0
  26. data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
  27. data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
  28. data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
  29. data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
  30. data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
  31. data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
  32. data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
  33. data/app/views/add_auth/recoveries/_form.html.erb +11 -0
  34. data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
  35. data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
  36. data/app/views/add_auth/sessions/_list.html.erb +25 -0
  37. data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
  38. data/app/views/add_auth/sessions/index.html.erb +12 -0
  39. data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
  40. data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
  41. data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
  42. data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
  43. data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
  44. data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
  45. data/app/views/add_auth/sign_ins/show.html.erb +1 -0
  46. data/app/views/layouts/add_auth/authentication.html.erb +19 -0
  47. data/lib/add_auth/configuration.rb +61 -0
  48. data/lib/add_auth/core/access_policy.rb +55 -0
  49. data/lib/add_auth/core/browser_binding.rb +28 -0
  50. data/lib/add_auth/core/challenge/base.rb +100 -0
  51. data/lib/add_auth/core/challenge/http.rb +130 -0
  52. data/lib/add_auth/core/challenge/null.rb +21 -0
  53. data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
  54. data/lib/add_auth/core/challenge/test.rb +32 -0
  55. data/lib/add_auth/core/challenge/turnstile.rb +46 -0
  56. data/lib/add_auth/core/delivery.rb +55 -0
  57. data/lib/add_auth/core/digest/base.rb +34 -0
  58. data/lib/add_auth/core/digest/hmac.rb +41 -0
  59. data/lib/add_auth/core/intake.rb +59 -0
  60. data/lib/add_auth/core/maintenance.rb +45 -0
  61. data/lib/add_auth/core/rate_limit.rb +30 -0
  62. data/lib/add_auth/core/security_events.rb +48 -0
  63. data/lib/add_auth/core/sessions.rb +287 -0
  64. data/lib/add_auth/core/step_up.rb +129 -0
  65. data/lib/add_auth/core/strategies/email_link.rb +201 -0
  66. data/lib/add_auth/core/strategies/passkey.rb +286 -0
  67. data/lib/add_auth/rails/authentication.rb +67 -0
  68. data/lib/add_auth/rails/authentication_pages.rb +66 -0
  69. data/lib/add_auth/rails/delivery_cipher.rb +33 -0
  70. data/lib/add_auth/rails/doctor.rb +154 -0
  71. data/lib/add_auth/rails/ejection.rb +138 -0
  72. data/lib/add_auth/rails/elevation.rb +35 -0
  73. data/lib/add_auth/rails/engine.rb +34 -0
  74. data/lib/add_auth/rails/password_entry.rb +34 -0
  75. data/lib/add_auth/rails/runtime.rb +216 -0
  76. data/lib/add_auth/rails/stores/account_lock.rb +40 -0
  77. data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
  78. data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
  79. data/lib/add_auth/rails/stores/maintenance.rb +46 -0
  80. data/lib/add_auth/rails/stores/passkeys.rb +63 -0
  81. data/lib/add_auth/rails/stores/security_events.rb +40 -0
  82. data/lib/add_auth/rails/stores/sessions.rb +62 -0
  83. data/lib/add_auth/rails/user_lifecycle.rb +35 -0
  84. data/lib/add_auth/result.rb +55 -0
  85. data/lib/add_auth/testing.rb +30 -0
  86. data/lib/add_auth/version.rb +5 -0
  87. data/lib/add_auth.rb +52 -0
  88. data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
  89. data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
  90. data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
  91. data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
  92. data/lib/generators/add_auth/ejection.rb +18 -0
  93. data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
  94. data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
  95. data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
  96. data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
  97. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
  98. data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
  99. data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
  100. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
  101. data/lib/generators/add_auth/install/install_generator.rb +27 -0
  102. data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
  103. data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
  104. data/lib/generators/add_auth/javascript/templates/application.js +2 -0
  105. data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
  106. data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
  107. data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
  108. data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
  109. data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
  110. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
  111. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
  112. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
  113. data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
  114. data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
  115. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
  116. data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
  117. data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
  118. data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
  119. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
  120. data/lib/generators/add_auth/views/views_generator.rb +16 -0
  121. data/lib/tasks/add_auth.rake +38 -0
  122. metadata +361 -0
data/ROADMAP.md ADDED
@@ -0,0 +1,244 @@
1
+ # Roadmap
2
+
3
+ AddAuth extends Rails 8's `bin/rails generate authentication` with email-link
4
+ sign-in, passkeys, purpose-bound reauthentication, session hardening and pluggable
5
+ captcha. It reuses the host's accounts and Session model.
6
+
7
+ The canonical design lives in the companion **private** planning workspace:
8
+ [scope](https://github.com/taimoorq/add_auth-workspace/blob/main/docs/authentication-gem-plan.md#14-scope-decision-and-roadmap)
9
+ and [user journeys, contracts and test plan](https://github.com/taimoorq/add_auth-workspace/blob/main/docs/authentication-gem-plan.md#16-integrated-user-journeys-and-implementation-plan).
10
+ Those links require workspace access. This public checklist stands on its own as
11
+ progress tracking; it does not duplicate the private design. Engineering guidance
12
+ stays only in that workspace's `AGENTS.md`.
13
+
14
+ Reviewed 2026-09-07. The 0.2.1 source implements the v1
15
+ strategy features: password/email/passkey sign-in, hardened sessions, reauthentication,
16
+ credential management, default recovery and strict policy, security notifications,
17
+ challenge adapters and fingerprinted ejection. Core policy, session finalization
18
+ and leased mail delivery are shared by engine and ejected flows.
19
+
20
+ The 0.2 release gate uses local real-database, generated-host, browser and
21
+ SMTP/queue/cache acceptance. The final supported matrix is recorded in the
22
+ [canonical evidence ledger](https://github.com/taimoorq/add_auth-workspace/blob/main/docs/authentication-gem-plan.md#20-release-020-execution--2026-09-07).
23
+ Checked feature items mean passing relevant local specs. GitHub's required
24
+ merge/release controls still apply. Hosts verify their own live providers,
25
+ physical authenticators and deployment operations; those checks do not block
26
+ the gem's 0.2 release. Email themes and branding are optional host presentation.
27
+
28
+ Generated pages use shared HTML/Turbo partials. Password/email paths support
29
+ ordinary no-JS navigation when permitted by policy; passkeys require browser
30
+ JavaScript, and a configured captcha may also require it. Strict policy must never
31
+ be weakened to simulate no-JS parity.
32
+
33
+ ## 0. Project foundations
34
+
35
+ - [x] Gemspec with a capability-derived Rails floor (`>= 8.0`, the release
36
+ that shipped the authentication generator) and a security-patch-derived
37
+ Ruby floor (`>= 3.3.0`), not just whatever the newest Rails tolerates.
38
+ - [x] MIT license, Code of Conduct, Security policy (`SECURITY.md`).
39
+ - [x] RSpec test setup (`.rspec`, `spec/spec_helper.rb`).
40
+ - [x] `standard` for formatting/linting.
41
+ - [x] CI (GitHub Actions): RSpec + Standard across the Ruby support matrix,
42
+ plus a `bundler-audit` job.
43
+ - [x] Dependabot, grouped by ecosystem, with the Rails family grouped
44
+ together — the concrete mechanism behind the "stay current" mandate in
45
+ the workspace's `AGENTS.md`.
46
+ - [x] RubyGems release workflow configured for [Trusted
47
+ Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC from
48
+ GitHub Actions) instead of a long-lived API key, gated behind
49
+ `rubygems_mfa_required` and `allowed_push_host`.
50
+ - [x] `bin/setup` / `bin/console` dev scripts.
51
+ - [ ] First successful tagged release published via the Trusted Publishing
52
+ workflow, to lock in the gem name on RubyGems.
53
+
54
+ ## 1. Core primitives
55
+
56
+ - [x] `AddAuth::Result` — closed success/failure type for auth outcomes.
57
+ - [x] `AddAuth::Configuration` / `AddAuth.configure`.
58
+ - [x] Challenge adapter contract (`AddAuth::Core::Challenge::Base`) with the
59
+ three-state result (success / rejected / unavailable).
60
+ - [x] `Challenge::Null` (default, always succeeds) and `Challenge::Test`
61
+ (configurable, for specs) adapters.
62
+ - [x] Purpose-separated HMAC digests with explicit strong key material,
63
+ real Rails key derivation, override tests and a framework-free Core check.
64
+
65
+ ## 2. Shared contracts and a real host harness — slices A1/A2
66
+
67
+ - [x] One Core policy for eligibility, purpose, proof strength and freshness;
68
+ one session finalizer and public result presenter across all methods.
69
+ - [x] Ordinary email-token store and encrypted delivery-intent contracts,
70
+ shared examples against fake and real SQLite/PostgreSQL adapters, with clock/digest
71
+ injection, replay/race/rollback/address-binding coverage.
72
+ - [x] Passkey/recovery proof and host lifecycle contracts, with actual WebAuthn
73
+ cryptography, rollback and concurrency on SQLite and PostgreSQL.
74
+ - [x] Boot `spec/dummy` through RSpec; replace generated test stubs with
75
+ password sign-in/reset/sign-out requests and real database coverage.
76
+ - [x] Generate and boot Rails 8.0/8.1 hosts; exercise the persistence
77
+ generator and preserve host customizations. CI covers both Rails lines
78
+ on Ruby 3.3, 3.4 and 4.0. Commands are in CONTRIBUTING.md.
79
+ - [x] Browser/virtual-authenticator harness with the passkey slice.
80
+
81
+ ## 3. Adopt the host's sessions and password flow — slice B, U1/U8
82
+
83
+ - [x] Inert `add_auth:install` configuration plus additive `session_upgrade`
84
+ migration and shared lifecycle hooks; repeat generation preserves edits.
85
+ - [x] Bounded signed-ID cookie transition to random digested bearers, with
86
+ real signature/tamper, race, cutoff and revocation tests.
87
+ - [x] Password/reset normalization and routes preserved; password/address
88
+ invalidation and account deletion integrated. Hosts supply eligibility.
89
+ - [x] Absolute/idle expiry, protected cookies, fresh session IDs and safe local
90
+ return destinations for implemented password/email flows.
91
+ - [x] Current-session sign-out revokes its bearer and clears browser state.
92
+ - [x] Session list and revoke-one, including next-request rejection in another
93
+ browser and cache/back-safe authenticated-page handling.
94
+ - [x] Sign-out-everywhere, guarded by fresh allowed proof and covering every
95
+ active browser, with old bearers rejected on their next request.
96
+ - [x] Account-scoped cursor pages and bounded maintenance, with optional history
97
+ retention, active-lease protection and completed-pass counts.
98
+ - [x] Explicit passwordless mode, guarded stock entry routes and authenticated
99
+ account-management pages in hosts that also serve public pages.
100
+
101
+ ## 4. Complete email-link sign-in — slice C, U2
102
+
103
+ - [x] Internal `EmailLink#issue`/`#consume` lifecycle: atomic replacement and
104
+ session persistence, one-use proof, account/address eligibility rechecks,
105
+ expiring encrypted delivery handoff and tested concurrent use.
106
+ - [x] Wire the lifecycle to the hardened session finalizer and uniform
107
+ asynchronous/rate-limited public intake.
108
+ - [x] Additive token model/store generator and protected pending-delivery
109
+ payload, cleared on consumption/revocation; no Session schema changes.
110
+ - [x] Encrypted request jobs, idempotent issuance, leased mail delivery,
111
+ retry/cleanup sweep and delivered-link-to-browser integration.
112
+ - [x] Durable security notifications share the delivery lease/retry/cancellation
113
+ contract and recover interrupted queue handoffs.
114
+ - [x] Local SMTP, durable queue restart/retry and cross-process shared-cache
115
+ acceptance. Live transport and monitoring validation belongs to each host.
116
+ - [x] Shared IP + keyed identifier rate policy, normalization and generic
117
+ request/resend responses for unknown, disabled and throttled accounts.
118
+ - [x] Request → check-email → inert GET confirmation → explicit POST consume
119
+ → session; masked account confirmation and deliberate account switching.
120
+ - [x] Resend limits, newest-link guidance, expired/used-link recovery and
121
+ cross-device sign-in by default.
122
+ - [x] Optional same-browser binding, including delivered links, wrong-browser denial,
123
+ Turbo/no-JS browsers and generated/ejected hosts.
124
+ - [x] Real DB concurrency and delivered-mail-to-session specs, plus HTML,
125
+ Turbo and no-JS request/system coverage for the complete flow.
126
+
127
+ - [x] Basic scoped CSS, semantic class overrides for host Bootstrap/Tailwind
128
+ builds, stylesheet opt-out and view ejection with custom-file preservation.
129
+
130
+ ## 5. Reauthentication and recovery policy — slice D, U6/U7
131
+
132
+ - [x] Core purpose/freshness evaluator with account/session binding, generic
133
+ elevation failures and passkey UV requirements.
134
+ - [x] Additive session elevation metadata and bearer-rotation finalizer for a
135
+ previously authorized grant, now wired to public reauthentication routes.
136
+ - [x] Host reauthentication adapters persist purpose-bound grants and rotate the
137
+ existing session's bearer after password/email/passkey verification.
138
+ - [x] Password/email reauthentication adapters share policy and presentation;
139
+ email step-up is bound to the initiating browser/session/purpose.
140
+ - [x] Sensitive-action return goes to a safe confirmation page; final mutation
141
+ rechecks authorization/grant/target and never automatically replays a POST.
142
+ - [x] Default email recovery with explicit recovery purpose, replacement grant,
143
+ security notifications and post-recovery session/proof invalidation.
144
+ - [x] Stricter opt-in policy enforced across sign-in, fallback, credential
145
+ management, password reset and policy changes; no hidden weaker route.
146
+ - [x] Tests for fresh-but-insufficient proof, wrong account/session/purpose,
147
+ expiry, lost response, cancellation and attempted policy bypass.
148
+
149
+ ## 6. Complete passkeys and credential management — slice E, U3–U7
150
+
151
+ - [x] Registration with discoverable credentials, server-enforced user
152
+ verification and transaction binding to an existing account.
153
+ - [x] Discoverable sign-in with credential/userHandle ownership checks;
154
+ explicit and conditional-autofill UI share the same verification path.
155
+ - [x] Native browser support for another device/security key, neutral cancel,
156
+ understandable retry/fallback and strict-policy unavailable states.
157
+ - [x] First-passkey bootstrap and additional-passkey enrollment require
158
+ appropriate fresh proof. The optional post-login invitation is a host product
159
+ choice; v1 supplies the authenticated `/passkeys` entry point.
160
+ - [x] Credential list, rename, remove, notifications and atomic last-usable-method
161
+ checks; default/strict recovery works with enrollment and lost-device flows.
162
+ - [x] Correct sign-counter anomaly/backup-flag handling, with atomic counter
163
+ updates and tests for zero, equal, increasing and decreasing counters.
164
+ - [x] Exact origin/RP policy, single-use server transactions, one shared codec,
165
+ payload bounds and cleanup of pending browser ceremonies.
166
+ - [x] Virtual-authenticator and real-store coverage of success, UV/signature/
167
+ origin/ownership failures, replay, races, management and recovery.
168
+
169
+ ## 7. Challenge adapters and accessible failure paths — slice F, U9
170
+
171
+ - [x] Turnstile adapter with hostname/action checks, provider-owned lifetime,
172
+ bounded HTTPS timeouts and safe no-retry handling for single-use tokens.
173
+ - [x] reCAPTCHA v2/v3 adapters respecting their different verification fields
174
+ and configured score/action requirements where applicable.
175
+ - [x] Shared success/rejected/unavailable behavior, fail-closed default and
176
+ observable explicit fail-open policy; no implicit bypass without JS.
177
+ - [x] Retry/outage messages, preserved input, provider protocol fixtures and no
178
+ live-provider dependency in routine specs.
179
+ - [x] Local keyboard/focus/status, virtual-authenticator and provider-contract
180
+ browser coverage in bundled and ejected UI. Broader browser/device and
181
+ assistive-technology verification remains a host deployment responsibility.
182
+
183
+ ## 8. Generators, ejection and integrated acceptance — slice G
184
+
185
+ - [x] `add_auth:install` writes inert configuration; session/email feature
186
+ generators add reviewable wiring and preserve edits on repeat runs.
187
+ - [x] `add_auth:views`, `add_auth:controllers`, `add_auth:javascript` and
188
+ `add_auth:mailer_views` reuse the same Core policy, presenter and templates.
189
+ - [x] `add_auth:challenge` writes environment-keyed Turnstile/reCAPTCHA config,
190
+ adds the fixed challenge route and preserves an existing initializer.
191
+ - [x] `add_auth:doctor` checks deployment origins, cookies/session metadata,
192
+ delivery, migrations and challenge policy/routes.
193
+ - [x] `add_auth:doctor` checks recovery policy and generated-file drift.
194
+ - [x] Every generated flow exercised before and after ejection, with Turbo
195
+ Drive/Frames/Streams, ordinary HTML, no-JS alternatives and strict denial.
196
+ - [x] Auth-page cache/referrer protections, redacted app/job telemetry,
197
+ CSRF, safe redirects and correct success/failure HTTP contracts.
198
+ - [x] RSpec strategy/store shared examples and host-facing test helpers,
199
+ including virtual authenticator lifecycle and delivered-link extraction.
200
+ - [x] Full RSpec, Standard and dependency audit pass on supported local matrices;
201
+ README/roadmap distinguish working development APIs from release gates.
202
+ - [ ] Exact-commit remote CI/CodeQL and required repository security checks;
203
+ refresh deployed public documentation when the gem is released.
204
+
205
+ ## 9. Release readiness
206
+
207
+ - [x] README rewritten from skeleton status to verified usage and migration
208
+ instructions, linking to maintained public API docs as they ship.
209
+ - [x] Security policy updated for shipped strategies, recovery limits and
210
+ supported versions; redacted events and incident/rollback guidance documented.
211
+ - [ ] CHANGELOG entries and successful Trusted Publishing release; a tag or
212
+ configured workflow alone does not prove the gem was published.
213
+ - [x] Local enabled-journey, delivery-retry/restart, expiry and transaction rollback
214
+ evidence. Record 0.2 operational defaults and host deployment responsibilities.
215
+ - [x] Audit adopter-requested changes against stock Rails behavior; keep app roles,
216
+ invitations, authorization and email themes in the host.
217
+ - [ ] `v1.0.0` only after the integrated acceptance gates pass.
218
+
219
+ ## v2 — reassess after v1 usage
220
+
221
+ - [ ] AddAuth-owned password registration/reset, confirmation, lockout and
222
+ password policy. Existing host password integration is part of v1.
223
+ - [ ] Password-hashing adapter seam, following the canonical cryptography
224
+ policy and Rails support available at implementation time.
225
+ - [ ] Recovery codes; they are not an implied fallback for v1 strict policy.
226
+ - [ ] Multiple realms/routing scopes.
227
+
228
+ ## Decisions before they become dependencies
229
+
230
+ Owner and deadline details remain in the canonical plan's section 16.
231
+
232
+ - [x] Review the 0.2 configuration and public integration contract. The runtime
233
+ uses Rails-generator User/Session conventions; authentication models must
234
+ share one database connection pool and cross-pool writes are rejected.
235
+ - [x] Review lifetime/resend/legacy-bridge defaults and local failure evidence;
236
+ retention is opt-in, and key changes require a host deployment plan.
237
+ - [x] Framework-neutral mail and virtual-authenticator helpers; no
238
+ Minitest-specific integration DSL. AddAuth's own suite stays RSpec.
239
+ - [ ] Revisit API/token authentication after v1; outside current scope.
240
+
241
+ ## Potential standalone libraries
242
+
243
+ - [ ] Virtual-authenticator test helpers.
244
+ - [ ] Challenge adapter contract and its three-state result.
data/SECURITY.md ADDED
@@ -0,0 +1,75 @@
1
+ # Security policy
2
+
3
+ AddAuth handles authentication credentials -- passwords (via the host app),
4
+ passkeys, session and sign-in tokens, and cryptographic digests. Please report
5
+ suspected vulnerabilities privately rather than opening a public issue.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Use GitHub's private vulnerability reporting for this repository:
10
+ https://github.com/taimoorq/add_auth/security/advisories/new
11
+
12
+ If that isn't available to you, email taimoorq@gmail.com with:
13
+
14
+ - A description of the vulnerability and its potential impact.
15
+ - Steps to reproduce, or a proof-of-concept if you have one.
16
+ - The AddAuth version, Rails version, and Ruby version involved.
17
+
18
+ Please do not disclose the issue publicly (including in a GitHub issue,
19
+ mailing list, or social media) until a fix has been released.
20
+
21
+ ## What's in scope
22
+
23
+ - The `AddAuth::Core` cryptographic and authentication logic (token issuance
24
+ and consumption, session adoption/resume/revocation, session/sign-in
25
+ digesting, rate limiting and account eligibility).
26
+ - The encrypted email intake/outbox, worker retry/lease semantics and default
27
+ mail logging, plus the durable security-notification outbox.
28
+ - WebAuthn registration/assertion, UV, origin/RP and ownership verification,
29
+ single-use ceremonies, counters, last-credential concurrency and management.
30
+ - Purpose-bound password/email/passkey reauthentication, bearer rotation,
31
+ trusted-address recovery, strict policy and host mutation-guard contracts.
32
+ - The Turnstile and reCAPTCHA challenge adapters, their server-side verification
33
+ contract and scoped browser lifecycle, including failure, outage and Turbo
34
+ replacement handling.
35
+ - Bounded session listing and authentication-history cleanup, including account
36
+ scoping, eligibility rechecks and active-delivery-lease protection.
37
+ - The generated controllers and views this gem ships,
38
+ including their CSRF, enumeration-safety, and Turbo-Stream behavior.
39
+ - The GitHub Actions release pipeline (`.github/workflows/push_gem.yml`) and
40
+ its Trusted Publishing configuration.
41
+
42
+ Vulnerabilities in Rails itself, in `webauthn-ruby`, or in `bcrypt`/`argon2`
43
+ should be reported to those projects directly; AddAuth will pick up fixed
44
+ releases via Dependabot (see `AGENTS.md` in the companion workspace repo for
45
+ the currency policy).
46
+
47
+ ## Supported versions
48
+
49
+ Until a 1.0 is released, only the latest published version receives security
50
+ fixes. This table will be expanded once there are stable release lines to
51
+ support.
52
+
53
+ | Version | Supported |
54
+ | ------- | --------- |
55
+ | latest 0.x | :white_check_mark: |
56
+ | older 0.x | :x: |
57
+
58
+ ## Response expectations
59
+
60
+ This is currently a single-maintainer project. Please allow a reasonable
61
+ window for an initial response before following up, and understand that a fix
62
+ timeline depends on severity and complexity.
63
+
64
+ ## Recovery and deployment boundaries
65
+
66
+ The 0.2 line includes default email replacement only when
67
+ an explicit host callback returns a verified recovery address. Strict accounts
68
+ cannot use password or email recovery; they require a remaining passkey or the
69
+ host's documented support process. Password reset and feature disablement must
70
+ not relax strict policy. Report any contrary behavior as a policy bypass.
71
+
72
+ See README's operations and rollback instructions for log filtering, encrypted
73
+ outbox handling, key rotation and migration safety. Hosts own account eligibility,
74
+ address confirmation, resource authorization, SMTP/queue/cache/proxy configuration
75
+ and support recovery. A local test suite does not certify those deployments.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/ejection"
4
+
5
+ module AddAuth
6
+ # Fixed gem assets only. This works even in hosts without an asset pipeline.
7
+ class AssetsController < ActionController::API
8
+ # Stateless public files: no cookies, sessions or authenticated response data.
9
+ before_action do
10
+ response.set_header("Cross-Origin-Resource-Policy", "same-origin")
11
+ unless request.get? || request.head?
12
+ response.set_header("Allow", "GET, HEAD")
13
+ head :method_not_allowed
14
+ end
15
+ end
16
+ def stylesheet
17
+ expires_in 1.hour, public: true
18
+ send_file Rails::Engine.root.join("lib/generators/add_auth/email_link/templates/add_auth.css"),
19
+ type: "text/css", disposition: "inline"
20
+ end
21
+
22
+ def boot
23
+ source = 'if (!window.Turbo) await import("/add_auth/turbo.js"); await import("/add_auth/challenge.js");'
24
+ source += ' await import("/add_auth/passkey.js");' if Rails::Runtime.config.passkeys.enabled
25
+ render body: source, content_type: "text/javascript"
26
+ end
27
+
28
+ def turbo
29
+ expires_in 1.hour, public: true
30
+ source = File.read(Gem.loaded_specs.fetch("turbo-rails").full_gem_path + "/app/assets/javascripts/turbo.min.js")
31
+ render body: source, content_type: "text/javascript"
32
+ end
33
+
34
+ def stimulus
35
+ expires_in 1.hour, public: true
36
+ source = File.read(Gem.loaded_specs.fetch("stimulus-rails").full_gem_path + "/app/assets/javascripts/stimulus.min.js")
37
+ render body: source, content_type: "text/javascript"
38
+ end
39
+
40
+ %w[application codec passkey].each do |asset|
41
+ define_method(asset) do
42
+ expires_in 1.hour, public: true
43
+ send_file Rails::Ejection.new(host_root: ::Rails.root).asset(asset),
44
+ type: "text/javascript", disposition: "inline"
45
+ end
46
+ end
47
+
48
+ def challenge
49
+ expires_in 1.hour, public: true
50
+ send_file Rails::Ejection.new(host_root: ::Rails.root).asset("challenge"),
51
+ type: "text/javascript", disposition: "inline"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class PasskeysController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ helper AddAuth::SignInsHelper
8
+ skip_before_action :require_authentication, raise: false
9
+ before_action :private_response
10
+ before_action :enabled_feature
11
+ before_action :require_add_auth_authentication, only: %i[index registration_options register rename remove change_policy reauthentication_options reauthenticate]
12
+ protect_from_forgery with: :exception
13
+ rescue_from AddAuth::Error, with: :service_unavailable
14
+
15
+ def index
16
+ @credentials = service.list(user: Current.user, session: Current.session)
17
+ @strict = Rails::Runtime.access_policy.strict?(Current.user)
18
+ page("list")
19
+ end
20
+
21
+ def registration_options
22
+ return unless admitted(:passkey_enrollment)
23
+ options(service.registration_options(user: Current.user, session: Current.session, browser_secret: browser_secret))
24
+ end
25
+
26
+ def register
27
+ return unless admitted(:passkey_finish)
28
+ result = service.register(transaction: params[:transaction], credential_response: credential_payload,
29
+ user: Current.user, session: Current.session, browser_secret: browser_secret, nickname: params[:nickname])
30
+ finish(result, destination: "/passkeys", grant: result.grant)
31
+ end
32
+
33
+ def authentication_options
34
+ return unless admitted(:sign_in)
35
+ options(service.authentication_options(browser_secret: browser_secret))
36
+ end
37
+
38
+ def authenticate
39
+ return unless admitted(:passkey_finish)
40
+ result = service.authenticate(transaction: params[:transaction], credential_response: credential_payload,
41
+ browser_secret: browser_secret, replacing: add_auth_replacement_session, **add_auth_session_hints)
42
+ finish(result, destination: result.success? ? after_authentication_url : "/", grant: result.success? ? result.credential : nil)
43
+ end
44
+
45
+ def reauthentication_options
46
+ return unless admitted(:reauthenticate)
47
+ options(service.authentication_options(browser_secret: browser_secret, user: Current.user,
48
+ session: Current.session, purpose: params[:purpose]))
49
+ end
50
+
51
+ def reauthenticate
52
+ return unless admitted(:passkey_finish)
53
+ result = service.authenticate(transaction: params[:transaction], credential_response: credential_payload,
54
+ browser_secret: browser_secret, session: Current.session)
55
+ destination = result.success? ? Rails::Runtime.step_up_policy.return_to(result.session.elevation_purpose) : "/"
56
+ finish(result, destination: destination, grant: result.success? ? result.credential : nil)
57
+ end
58
+
59
+ def rename
60
+ management(service.rename(user: Current.user, session: Current.session, id: params[:id], nickname: params[:nickname]))
61
+ end
62
+
63
+ def remove
64
+ management(service.remove(user: Current.user, session: Current.session, id: params[:id]))
65
+ end
66
+
67
+ def change_policy
68
+ result = service.change_policy(user: Current.user, session: Current.session,
69
+ strict: {"strict" => true, "default" => false}[params[:policy]], acknowledged: params[:acknowledged] == "1")
70
+ add_auth_accept(result.credential) if result.success?
71
+ management(result, purpose: :manage_policy)
72
+ end
73
+
74
+ def cancel
75
+ service.cancel(transaction: params[:transaction], browser_secret: session[:add_auth_browser])
76
+ head :no_content
77
+ end
78
+
79
+ private
80
+
81
+ def service = Rails::Runtime.passkeys
82
+ def browser_secret = session[:add_auth_browser] ||= Rails::Runtime.browser_binding.generate
83
+
84
+ def enabled_feature
85
+ head :not_found unless Rails::Runtime.config.passkeys.enabled
86
+ end
87
+
88
+ def authentication_partial(partial) = "add_auth/passkeys/#{partial}"
89
+
90
+ def credential_payload
91
+ value = params[:credential]
92
+ value.to_unsafe_h if value.is_a?(ActionController::Parameters)
93
+ end
94
+
95
+ def admitted(action)
96
+ result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token)
97
+ return true if result == true
98
+ render json: {error: "Verification could not start. Try again shortly."}, status: if result == :rate_limited
99
+ 429
100
+ else
101
+ (result == :challenge_unavailable) ? 503 : 422
102
+ end
103
+ false
104
+ end
105
+
106
+ def options(result)
107
+ return render(json: result.credential) if result.success?
108
+ return render(json: {error: "Verification could not start. Try again shortly."}, status: :too_many_requests) if result.reason == :rate_limited
109
+ error(result)
110
+ end
111
+
112
+ def finish(result, destination:, grant:)
113
+ return error(result) unless result.success?
114
+ add_auth_accept(grant) if grant
115
+ render json: {redirect: Core::Sessions.safe_return(destination) || "/"}
116
+ end
117
+
118
+ def error(result)
119
+ payload = {error: "Verification did not complete. Try another passkey or an allowed recovery method."}
120
+ if result.reason == :elevation_required && %w[registration_options register].include?(action_name)
121
+ payload[:redirect] = "/reauthenticate?purpose=manage_passkeys"
122
+ end
123
+ render json: payload, status: :unprocessable_entity
124
+ end
125
+
126
+ def management(result, purpose: :manage_passkeys)
127
+ if result.success?
128
+ redirect_to "/passkeys", status: :see_other
129
+ elsif result.reason == :elevation_required
130
+ redirect_to "/reauthenticate?#{URI.encode_www_form(purpose: purpose)}", status: :see_other
131
+ else
132
+ @error = "That change could not be made. Keep a usable sign-in method and check your selection."
133
+ @credentials = service.list(user: Current.user, session: Current.session)
134
+ @strict = Rails::Runtime.access_policy.strict?(Current.user)
135
+ page("list", status: :unprocessable_entity)
136
+ end
137
+ end
138
+
139
+ def service_unavailable
140
+ response.set_header("Retry-After", "60")
141
+ render json: {error: "Passkeys are temporarily unavailable. Try again shortly."}, status: :service_unavailable
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class ReauthenticationsController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ helper AddAuth::SignInsHelper
8
+ skip_before_action :require_authentication, raise: false
9
+ before_action :private_response
10
+ before_action :enabled_feature
11
+ before_action :require_add_auth_authentication, except: %i[link confirm]
12
+ before_action :load_purpose, only: %i[new password request_link check_email]
13
+ protect_from_forgery with: :exception
14
+ rescue_from AddAuth::Error, "ActiveJob::EnqueueError", with: :service_unavailable
15
+
16
+ def new = page("form")
17
+
18
+ def password
19
+ result = Rails::Runtime.elevate_password(user: Current.user, session: Current.session,
20
+ purpose: @purpose, password: params[:password], ip: request.remote_ip, challenge_token: challenge_token)
21
+ finish(result)
22
+ end
23
+
24
+ def request_link
25
+ admitted = Rails::Runtime.intake.call(identifier: Current.user.email_address, ip: request.remote_ip,
26
+ action: :reauthenticate, challenge_token: challenge_token)
27
+ return intake_failure(admitted) if %i[challenge_rejected challenge_unavailable].include?(admitted)
28
+ unless admitted.is_a?(Symbol)
29
+ session[:add_auth_browser] ||= Rails::Runtime.browser_binding.generate
30
+ Rails::Runtime.enqueue_reauthentication(user: Current.user, session: Current.session,
31
+ purpose: @purpose, browser_secret: session[:add_auth_browser])
32
+ end
33
+ redirect_to "/reauthenticate/check-email?#{URI.encode_www_form(purpose: @purpose)}", status: :see_other
34
+ end
35
+
36
+ def check_email = page("check_email")
37
+
38
+ def link
39
+ @preview = service.preview(token: params[:token], browser_secret: session[:add_auth_browser])
40
+ page(service.confirmation_page(@preview))
41
+ end
42
+
43
+ def confirm
44
+ result = service.reauthenticate(token: params[:token], session: add_auth_replacement_session,
45
+ browser_secret: session[:add_auth_browser])
46
+ finish(result, failure_page: "invalid_link")
47
+ end
48
+
49
+ private
50
+
51
+ def service = Rails::Runtime.email(purpose: :reauthentication)
52
+
53
+ def enabled_feature
54
+ head :not_found unless Rails::Runtime.config.step_up.enabled
55
+ end
56
+
57
+ def load_purpose
58
+ @rule = Rails::Runtime.step_up_policy.reauthentication_rule_for(params[:purpose])
59
+ return head :not_found unless @rule
60
+ @purpose = params[:purpose].to_s
61
+ @methods = Rails::Runtime.step_up_policy.methods_for(user: Current.user, purpose: @purpose)
62
+ end
63
+
64
+ def finish(result, failure_page: "form")
65
+ if result.success?
66
+ destination = Rails::Runtime.step_up_policy.return_to(result.session.elevation_purpose)
67
+ add_auth_accept(result.credential)
68
+ redirect_to destination, status: :see_other
69
+ else
70
+ @error = "We could not verify this request. Try again from the browser where you started."
71
+ if %i[rate_limited challenge_rejected challenge_unavailable].include?(result.reason)
72
+ intake_failure(result.reason)
73
+ else
74
+ page(failure_page, status: :unprocessable_entity)
75
+ end
76
+ end
77
+ end
78
+
79
+ def authentication_partial(partial) = "add_auth/reauthentications/#{partial}"
80
+ end
81
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class RecoveriesController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ helper AddAuth::SignInsHelper
8
+ skip_before_action :require_authentication, raise: false
9
+ before_action :private_response
10
+ before_action :enabled_feature
11
+ protect_from_forgery with: :exception
12
+ rescue_from AddAuth::Error, "ActiveJob::EnqueueError", with: :service_unavailable
13
+
14
+ def new = page("form")
15
+ def check_email = page("check_email")
16
+
17
+ def request_link
18
+ value = Rails::Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
19
+ action: :email_link, challenge_token: challenge_token)
20
+ return intake_failure(value) if %i[challenge_rejected challenge_unavailable].include?(value)
21
+ Rails::Runtime.enqueue_recovery(value) unless value.is_a?(Symbol)
22
+ redirect_to "/recover/check-email", status: :see_other
23
+ end
24
+
25
+ def link
26
+ @preview = service.preview(token: params[:token])
27
+ page(service.confirmation_page(@preview))
28
+ end
29
+
30
+ def confirm
31
+ result = service.consume(token: params[:token], current_session: add_auth_replacement_session,
32
+ switch_account: params[:switch_account] == "1")
33
+ if result.success?
34
+ add_auth_accept(result.grant)
35
+ redirect_to "/passkeys", status: :see_other
36
+ else
37
+ page("invalid_link", status: :unprocessable_entity)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def service = Rails::Runtime.email(purpose: :recovery)
44
+ def authentication_partial(partial) = "add_auth/recoveries/#{partial}"
45
+
46
+ def enabled_feature
47
+ head :not_found unless Rails::Runtime.config.passkeys.enabled
48
+ end
49
+ end
50
+ end