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
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class EmailLinkGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+
14
+ def dependencies
15
+ invoke "add_auth:session_upgrade"
16
+ invoke "add_auth:email_tokens"
17
+ end
18
+
19
+ def account_cleanup
20
+ path = "app/models/user.rb"
21
+ unless File.read(File.join(destination_root, path)).include?("has_many :add_auth_sign_in_tokens")
22
+ inject_into_file path, " has_many :add_auth_sign_in_tokens, dependent: :delete_all\n", after: /class User < [^\n]+\n/
23
+ end
24
+ end
25
+
26
+ def outbox
27
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_email_delivery.rb")].any?
28
+ migration_template "add_add_auth_email_delivery.rb.tt", "db/migrate/add_add_auth_email_delivery.rb"
29
+ end
30
+ end
31
+
32
+ def browser_binding
33
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_email_binding.rb")].any?
34
+ migration_template "add_add_auth_email_binding.rb.tt", "db/migrate/add_add_auth_email_binding.rb"
35
+ end
36
+ end
37
+
38
+ def routes_and_styles
39
+ unless File.read(File.join(destination_root, "config/routes.rb")).include?("# AddAuth sign-in")
40
+ route <<~ROUTES
41
+ # AddAuth sign-in
42
+ post "sign-in/email", to: "add_auth/sign_ins#request_link"
43
+ get "sign-in/check-email", to: "add_auth/sign_ins#check_email"
44
+ get "sign-in/link", to: "add_auth/sign_ins#link"
45
+ post "sign-in/link", to: "add_auth/sign_ins#confirm"
46
+ ROUTES
47
+ end
48
+ unless File.read(File.join(destination_root, "config/routes.rb")).include?("add_auth/challenge.js")
49
+ route 'get "add_auth/challenge.js", to: "add_auth/assets#challenge"'
50
+ end
51
+ gsub_file "config/initializers/add_auth.rb", "# config.email_link.enabled = true", "config.email_link.enabled = true"
52
+ 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
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ class AddAddAuthEmailBinding < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :add_auth_sign_in_tokens, :browser_digest, :string
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ class AddAddAuthEmailDelivery < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :add_auth_sign_in_tokens, :request_id, :string
4
+ add_index :add_auth_sign_in_tokens, :request_id, unique: true
5
+ add_column :add_auth_sign_in_tokens, :delivery_lease_key, :string
6
+ add_column :add_auth_sign_in_tokens, :delivery_lease_until, :datetime
7
+ add_column :add_auth_sign_in_tokens, :delivered_at, :datetime
8
+ add_index :add_auth_sign_in_tokens, [:delivered_at, :expires_at], name: "index_add_auth_pending_delivery"
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ :where(.add_auth-body) {
2
+ --add_auth-canvas: #f4f5f3;
3
+ --add_auth-surface: #ffffff;
4
+ --add_auth-text: #202923;
5
+ --add_auth-muted: #505b53;
6
+ --add_auth-accent: #225e42;
7
+ --add_auth-border: #737d75;
8
+ --add_auth-focus: #1559a0;
9
+ margin: 0; padding: 2rem 1rem; background: var(--add_auth-canvas);
10
+ color: var(--add_auth-text); font: 1rem/1.6 system-ui, sans-serif;
11
+ }
12
+ :where(.add_auth-panel) { box-sizing: border-box; max-width: 28rem; margin: 4vh auto; padding: clamp(1.25rem, 5vw, 2.5rem); background: var(--add_auth-surface); border-top: 4px solid var(--add_auth-accent); }
13
+ :where(.add_auth-panel h1) { font-size: 1.8rem; line-height: 1.2; margin-top: 0; }
14
+ :where(.add_auth-field) { margin: 1.25rem 0; }
15
+ :where(.add_auth-field label) { display: block; margin-bottom: .35rem; font-weight: 600; }
16
+ :where(.add_auth-input) { box-sizing: border-box; width: 100%; min-height: 2.75rem; border: 1px solid var(--add_auth-border); border-radius: .25rem; padding: .6rem .75rem; background: var(--add_auth-surface); color: var(--add_auth-text); font: inherit; }
17
+ :where(.add_auth-button) { box-sizing: border-box; white-space: normal; width: 100%; min-height: 2.75rem; border: 2px solid transparent; border-radius: .25rem; padding: .65rem .8rem; font: inherit; font-weight: 600; color: #ffffff; background: var(--add_auth-accent); cursor: pointer; }
18
+ :where(.add_auth-button:hover) { text-decoration: underline; }
19
+ :where(.add_auth-input:focus-visible, .add_auth-button:focus-visible, .add_auth-link:focus-visible) { outline: 3px solid var(--add_auth-focus); outline-offset: 3px; }
20
+ :where(.add_auth-muted) { color: var(--add_auth-muted); }
21
+ :where(.add_auth-link) { color: var(--add_auth-accent); text-underline-offset: .2em; }
22
+ :where(.add_auth-notice) { padding: .75rem; border-left: 3px solid currentColor; }
23
+ :where(.add_auth-divider) { border: 0; border-top: 1px solid var(--add_auth-border); margin: 2rem 0; }
24
+ :where(.add_auth-session-list) { list-style: none; margin: 1.5rem 0 0; padding: 0; }
25
+ :where(.add_auth-session) { display: flex; align-items: start; justify-content: space-between; gap: 1rem; padding: 1rem 0; border-top: 1px solid var(--add_auth-border); }
26
+ :where(.add_auth-session p) { margin: .25rem 0 0; overflow-wrap: anywhere; }
27
+ :where(.add_auth-session .add_auth-button) { width: auto; min-width: 7rem; }
28
+ @media (forced-colors: active) { .add_auth-button { border-color: ButtonText; } }
@@ -0,0 +1,129 @@
1
+ import { Controller } from "/add_auth/stimulus.js"
2
+ import { application } from "/add_auth/application.js"
3
+
4
+ const scripts = new Map()
5
+ function loadScript(url) {
6
+ if (!url) return Promise.resolve()
7
+ if (!scripts.has(url)) {
8
+ scripts.set(url, new Promise((resolve, reject) => {
9
+ const script = document.createElement("script")
10
+ const timer = setTimeout(() => { script.remove(); reject(new Error("unavailable")) }, 15000)
11
+ script.src = url
12
+ script.async = true
13
+ script.onload = () => { clearTimeout(timer); resolve() }
14
+ script.onerror = () => { clearTimeout(timer); script.remove(); reject(new Error("unavailable")) }
15
+ document.head.append(script)
16
+ }).catch(error => { scripts.delete(url); throw error }))
17
+ }
18
+ return scripts.get(url)
19
+ }
20
+
21
+ class ChallengeController extends Controller {
22
+ static targets = ["token", "widget", "status", "submit"]
23
+ static values = { provider: String, siteKey: String, action: String, scriptUrl: String }
24
+
25
+ connect() {
26
+ this.generation = (this.generation || 0) + 1
27
+ this.active = true
28
+ this.pending = false
29
+ this.allowSubmit = false
30
+ this.tokenTarget.value = ""
31
+ this.prepare()
32
+ }
33
+
34
+ async prepare() {
35
+ const generation = this.generation
36
+ this.setDisabled(true)
37
+ this.message("Verification is loading.")
38
+ try {
39
+ await loadScript(this.scriptUrlValue)
40
+ if (!this.current(generation)) return
41
+ this.provider = this.providerValue === "turnstile" ? window.turnstile : window.grecaptcha
42
+ if (!this.provider) throw new Error("unavailable")
43
+ if (this.providerValue === "recaptcha-v3") {
44
+ await new Promise((resolve, reject) => {
45
+ const timer = setTimeout(() => reject(new Error("unavailable")), 15000)
46
+ this.provider.ready(() => { clearTimeout(timer); resolve() })
47
+ })
48
+ } else {
49
+ this.widgetTarget.hidden = false
50
+ const options = {
51
+ sitekey: this.siteKeyValue,
52
+ callback: token => {
53
+ if (this.current(generation)) { this.tokenTarget.value = token; this.setDisabled(false); this.message("") }
54
+ },
55
+ "expired-callback": () => { if (this.current(generation)) { this.tokenTarget.value = ""; this.setDisabled(true) } },
56
+ "error-callback": () => { if (this.current(generation)) this.unavailable() }
57
+ }
58
+ if (this.providerValue === "turnstile") Object.assign(options, { action: this.actionValue, "response-field": false })
59
+ this.widgetId = this.provider.render(this.widgetTarget, options)
60
+ }
61
+ if (this.current(generation)) {
62
+ this.ready = true
63
+ if (this.providerValue === "recaptcha-v3") { this.setDisabled(false); this.message("") }
64
+ }
65
+ } catch (_) { if (this.current(generation)) this.unavailable() }
66
+ }
67
+
68
+ async submit(event) {
69
+ if (this.allowSubmit) { this.allowSubmit = false; return }
70
+ if (this.pending) { event.preventDefault(); event.stopImmediatePropagation(); return }
71
+ // Server-side verification remains authoritative when loading fails.
72
+ if (!this.ready || this.providerValue !== "recaptcha-v3") return
73
+ event.preventDefault()
74
+ event.stopImmediatePropagation()
75
+ const generation = this.generation
76
+ const submitter = event.submitter
77
+ this.pending = true
78
+ this.setDisabled(true)
79
+ try {
80
+ const token = await Promise.race([
81
+ this.provider.execute(this.siteKeyValue, { action: this.actionValue }),
82
+ new Promise((_, reject) => { this.executionTimer = setTimeout(() => reject(new Error("unavailable")), 15000) })
83
+ ])
84
+ if (!this.current(generation)) return
85
+ this.tokenTarget.value = typeof token === "string" ? token : ""
86
+ // Even an already-resolved provider promise must leave the original
87
+ // submit event before requestSubmit; browsers reject recursive submits.
88
+ await new Promise(resolve => setTimeout(resolve, 0))
89
+ if (!this.current(generation)) return
90
+ this.allowSubmit = true
91
+ this.setDisabled(false)
92
+ this.element.requestSubmit(submitter)
93
+ } catch (_) { if (this.current(generation)) this.unavailable() }
94
+ finally { clearTimeout(this.executionTimer); if (this.current(generation)) this.pending = false }
95
+ }
96
+
97
+ reset() {
98
+ this.tokenTarget.value = ""
99
+ this.allowSubmit = false
100
+ if (this.widgetId !== undefined && this.provider) {
101
+ this.setDisabled(true)
102
+ this.provider.reset(this.widgetId)
103
+ }
104
+ }
105
+
106
+ beforeCache() { this.teardown() }
107
+ disconnect() { this.teardown() }
108
+ teardown() {
109
+ this.active = false
110
+ this.generation = (this.generation || 0) + 1
111
+ clearTimeout(this.executionTimer)
112
+ this.tokenTarget.value = ""
113
+ this.setDisabled(false)
114
+ if (this.widgetId !== undefined && this.provider) {
115
+ if (this.providerValue === "turnstile") this.provider.remove(this.widgetId)
116
+ else this.provider.reset(this.widgetId)
117
+ this.widgetId = undefined
118
+ this.widgetTarget.replaceChildren()
119
+ }
120
+ this.ready = false
121
+ }
122
+
123
+ current(generation) { return this.active && this.element.isConnected && this.generation === generation }
124
+ setDisabled(value) { this.submitTargets.forEach(button => { button.disabled = value }) }
125
+ message(text) { this.statusTarget.textContent = text; this.statusTarget.hidden = !text }
126
+ unavailable() { this.ready = false; this.tokenTarget.value = ""; this.setDisabled(false); this.message("Verification is unavailable. Submit to check again or try shortly.") }
127
+ }
128
+
129
+ application.register("add-auth-challenge", ChallengeController)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ # Internal persistence foundation only. Does not install sign-in routes.
9
+ class EmailTokensGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ def self.next_migration_number(dirname)
15
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
16
+ end
17
+
18
+ def create_token_model
19
+ unless File.file?(File.join(destination_root, "app/models/user.rb"))
20
+ raise Thor::Error, "Run the Rails authentication generator first."
21
+ end
22
+ return if File.exist?(File.join(destination_root, "app/models/add_auth_sign_in_token.rb"))
23
+
24
+ copy_file "add_auth_sign_in_token.rb", "app/models/add_auth_sign_in_token.rb"
25
+ end
26
+
27
+ def create_token_migration
28
+ return if Dir[File.join(destination_root, "db/migrate/*_create_add_auth_sign_in_tokens.rb")].any?
29
+
30
+ migration_template "create_add_auth_sign_in_tokens.rb.tt",
31
+ "db/migrate/create_add_auth_sign_in_tokens.rb"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ class AddAuthSignInToken < ApplicationRecord
2
+ belongs_to :user
3
+ self.filter_attributes += [:delivery_payload, :digest]
4
+ end
@@ -0,0 +1,18 @@
1
+ class CreateAddAuthSignInTokens < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :add_auth_sign_in_tokens do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :digest, limit: 64, null: false
6
+ t.string :identifier_digest, limit: 64, null: false
7
+ t.string :purpose, null: false, default: "sign_in"
8
+ t.datetime :expires_at, null: false
9
+ t.datetime :consumed_at
10
+ t.datetime :revoked_at
11
+ t.text :delivery_payload
12
+ t.string :requested_ip_address
13
+ t.timestamps
14
+ end
15
+ add_index :add_auth_sign_in_tokens, :digest, unique: true
16
+ add_index :add_auth_sign_in_tokens, [:user_id, :purpose], name: "index_add_auth_email_tokens_on_user_and_purpose"
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module AddAuth
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def verify_host
11
+ %w[app/models/user.rb app/models/session.rb app/controllers/concerns/authentication.rb].each do |path|
12
+ raise Thor::Error, "Run the Rails authentication generator first (missing #{path})." unless File.file?(File.join(destination_root, path))
13
+ end
14
+ end
15
+
16
+ def configuration
17
+ copy_file "initializer.rb", "config/initializers/add_auth.rb" unless File.exist?(File.join(destination_root, "config/initializers/add_auth.rb"))
18
+ end
19
+
20
+ def report
21
+ say "AddAuth: host authentication files found. Install enables no features."
22
+ rails_command "add_auth:doctor", abort_on_failure: false
23
+ say "Run add_auth:session_upgrade or add_auth:email_link, review migrations, then run bin/rails add_auth:doctor."
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # AddAuth configuration. Feature generators enable only their own block.
2
+ 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
+ # BEGIN add_auth session
8
+ # config.session.enabled = true
9
+ # END add_auth session
10
+ # config.session.lifetime = 12.hours
11
+ # config.session.idle_timeout = 30.minutes
12
+ # Legacy cookies are rejected by default. Opt into a finite bridge explicitly:
13
+ # config.session.legacy_bridge_until = Time.iso8601("2026-09-13T00:00:00Z")
14
+
15
+ # BEGIN add_auth email_link
16
+ # config.email_link.enabled = true
17
+ # END add_auth email_link
18
+ # config.email_link.token_lifetime = 20.minutes
19
+ # config.email_link.same_browser = false # true requires the requesting browser
20
+ # REQUIRED for email delivery: fixed, trusted origin; never derive it from Host.
21
+ # config.base_url = "https://your-app.example"
22
+ # config.mail_from = "Your app <sign-in@your-app.example>"
23
+ # Configure a durable Active Job adapter and schedule add_auth:deliver_pending.
24
+ # config.rate_limit_store = Rails.cache # shared, atomic increment in production
25
+ # Each maintenance pass handles at most this many rows per operation/model:
26
+ # config.maintenance.batch_size = 100 # 1..1000
27
+ # History is retained until the host chooses a retention period (seconds):
28
+ # config.maintenance.session_retention = 7.days
29
+ # config.maintenance.email_retention = 7.days
30
+ # config.maintenance.notification_retention = 30.days
31
+
32
+ # Apply the host's current confirmed/locked/disabled policy on every resume:
33
+ # config.eligible = ->(user) { user.confirmed? && !user.locked? && !user.disabled? }
34
+
35
+ # Styling: nil disables CSS; a local path uses your own compiled stylesheet.
36
+ # config.stylesheet = "/add_auth.css"
37
+ # config.css_classes = { input: "form-control", button: "btn btn-primary" }
38
+ # Eject editable templates with bin/rails generate add_auth:views.
39
+
40
+ # Enable optional features with add_auth:passkeys, add_auth:step_up,
41
+ # add_auth:notifications or add_auth:challenge. Install alone enables none.
42
+ # Passkey recovery needs a host-verified recovery address; strict accounts
43
+ # use another passkey or the host's support process, never email recovery.
44
+ # config.challenge = AddAuth::Core::Challenge::Null.new
45
+ # config.challenge_on = []
46
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "generators/add_auth/ejection"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class JavascriptGenerator < ::Rails::Generators::Base
9
+ include Ejection
10
+
11
+ def generate = eject(:javascript)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,2 @@
1
+ import { Application } from "/add_auth/stimulus.js"
2
+ export const application = Application.start()
@@ -0,0 +1,28 @@
1
+ export function decode(value) {
2
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]*$/.test(value)) throw new TypeError("Invalid encoding")
3
+ const text = atob(value.replace(/-/g, "+").replace(/_/g, "/"))
4
+ return Uint8Array.from(text, character => character.charCodeAt(0))
5
+ }
6
+ export function encode(value) {
7
+ return btoa(Array.from(new Uint8Array(value), byte => String.fromCharCode(byte)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
8
+ }
9
+ export function options(json, create) {
10
+ const native = create ? PublicKeyCredential.parseCreationOptionsFromJSON : PublicKeyCredential.parseRequestOptionsFromJSON
11
+ if (native) return native.call(PublicKeyCredential, json)
12
+ const value = { ...json, challenge: decode(json.challenge) }
13
+ if (create) value.user = { ...json.user, id: decode(json.user.id) }
14
+ for (const key of ["allowCredentials", "excludeCredentials"]) {
15
+ if (json[key]) value[key] = json[key].map(item => ({ ...item, id: decode(item.id) }))
16
+ }
17
+ return value
18
+ }
19
+ export function credential(value) {
20
+ if (value.toJSON) return value.toJSON()
21
+ const response = { clientDataJSON: encode(value.response.clientDataJSON) }
22
+ for (const key of ["attestationObject", "authenticatorData", "signature", "userHandle"]) {
23
+ if (value.response[key]) response[key] = encode(value.response[key])
24
+ }
25
+ if (value.response.getTransports) response.transports = value.response.getTransports()
26
+ return { id: value.id, rawId: encode(value.rawId), type: value.type, response,
27
+ clientExtensionResults: value.getClientExtensionResults(), authenticatorAttachment: value.authenticatorAttachment }
28
+ }
@@ -0,0 +1,81 @@
1
+ import { Controller } from "/add_auth/stimulus.js"
2
+ import { application } from "/add_auth/application.js"
3
+ import { options, credential } from "/add_auth/codec.js"
4
+
5
+ export class PasskeyController extends Controller {
6
+ static targets = ["form", "controls", "unavailable", "status", "button"]
7
+ static values = { optionsUrl: String, finishUrl: String, mode: String, purpose: String, csrf: String, conditional: Boolean }
8
+ connect() {
9
+ this.generation = 0
10
+ this.active = true
11
+ if (!window.PublicKeyCredential || !navigator.credentials || !window.isSecureContext) return
12
+ this.controlsTarget.hidden = false
13
+ this.unavailableTarget.hidden = true
14
+ if (this.conditionalValue && PublicKeyCredential.isConditionalMediationAvailable) {
15
+ PublicKeyCredential.isConditionalMediationAvailable().then(available => {
16
+ if (available && this.active && !this.abort) this.run(true)
17
+ }).catch(() => {})
18
+ }
19
+ }
20
+ submit(event) {
21
+ if (event.defaultPrevented) return
22
+ event.preventDefault()
23
+ this.run(false)
24
+ }
25
+ otherSubmission(event) { if (event.target !== this.formTarget) this.stop() }
26
+ disconnect() { this.active = false; this.stop() }
27
+ beforeCache() { this.active = false; this.stop(); this.controlsTarget.hidden = true; this.unavailableTarget.hidden = false; this.message("") }
28
+ async run(conditional) {
29
+ this.stop()
30
+ const generation = this.generation
31
+ this.abort = new AbortController()
32
+ if (!conditional) { this.buttonTarget.disabled = true; this.message("Follow your browser’s passkey prompt.") }
33
+ try {
34
+ const challenge = new FormData(this.formTarget).get("challenge_token")
35
+ const start = await this.post(this.optionsUrlValue, { purpose: this.purposeValue || null, challenge_token: challenge }, this.abort.signal)
36
+ this.formTarget.dispatchEvent(new CustomEvent("add_auth:proof-used"))
37
+ if (!this.current(generation)) return
38
+ if (start.redirect) { this.navigate(start.redirect); return }
39
+ this.transaction = start.transaction
40
+ const create = this.modeValue === "create"
41
+ const request = { publicKey: options(start.publicKey, create), signal: this.abort.signal }
42
+ if (conditional) request.mediation = "conditional"
43
+ const proof = await navigator.credentials[create ? "create" : "get"](request)
44
+ if (!this.current(generation)) return
45
+ const result = await this.post(this.finishUrlValue, { transaction: this.transaction, credential: credential(proof) }, this.abort.signal)
46
+ if (!this.current(generation)) return
47
+ this.transaction = null
48
+ this.navigate(result.redirect)
49
+ } catch (error) {
50
+ if (this.current(generation) && error.name !== "AbortError") {
51
+ this.message(error.name === "NotAllowedError" ? "No change was made. Try again, use another passkey, or choose an allowed recovery method." : error.message)
52
+ if (!conditional) this.statusTarget.focus()
53
+ }
54
+ } finally {
55
+ if (this.current(generation)) { this.buttonTarget.disabled = false; this.stop() }
56
+ }
57
+ }
58
+ async post(path, body, signal) {
59
+ const response = await fetch(path, { method: "POST", credentials: "same-origin", signal,
60
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": this.csrfValue }, body: JSON.stringify(body) })
61
+ const value = await response.json()
62
+ if (!response.ok && !value.redirect) throw new Error(value.error || "Verification is unavailable. Try again shortly.")
63
+ return value
64
+ }
65
+ stop() {
66
+ this.generation += 1
67
+ this.abort?.abort()
68
+ this.abort = null
69
+ if (this.transaction) {
70
+ fetch("/passkeys/cancel", { method: "POST", credentials: "same-origin", keepalive: true,
71
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
72
+ body: JSON.stringify({ transaction: this.transaction }) }).catch(() => {})
73
+ this.transaction = null
74
+ }
75
+ if (this.hasButtonTarget) this.buttonTarget.disabled = false
76
+ }
77
+ current(generation) { return this.active && this.element.isConnected && this.generation === generation }
78
+ message(text) { this.statusTarget.textContent = text; this.statusTarget.hidden = !text }
79
+ navigate(path) { if (path) window.Turbo ? window.Turbo.visit(path) : window.location.assign(path) }
80
+ }
81
+ application.register("add-auth-passkey", PasskeyController)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "generators/add_auth/ejection"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class MailerViewsGenerator < ::Rails::Generators::Base
9
+ include Ejection
10
+
11
+ def generate = eject(:mailer_views)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class NotificationsGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+
14
+ def dependencies = invoke("add_auth:session_upgrade")
15
+
16
+ def persistence
17
+ unless Dir[File.join(destination_root, "db/migrate/*_create_add_auth_security_events.rb")].any?
18
+ migration_template "create_add_auth_security_events.rb.tt", "db/migrate/create_add_auth_security_events.rb"
19
+ end
20
+ copy_file "add_auth_security_event.rb", "app/models/add_auth_security_event.rb", skip: true
21
+ end
22
+
23
+ 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
28
+ say "Migrate, configure mail_from and a durable queue, and schedule add_auth:deliver_pending every minute."
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,4 @@
1
+ class AddAuthSecurityEvent < ApplicationRecord
2
+ belongs_to :user, optional: true
3
+ self.filter_attributes += [:delivery_payload, :digest, :delivery_lease_key]
4
+ end
@@ -0,0 +1,18 @@
1
+ class CreateAddAuthSecurityEvents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :add_auth_security_events do |t|
4
+ t.references :user, foreign_key: {on_delete: :nullify}
5
+ t.string :kind, null: false
6
+ t.string :digest, null: false
7
+ t.text :delivery_payload
8
+ t.string :delivery_lease_key
9
+ t.datetime :delivery_lease_until
10
+ t.datetime :delivered_at
11
+ t.datetime :revoked_at
12
+ t.datetime :expires_at, null: false
13
+ t.timestamps
14
+ end
15
+ add_index :add_auth_security_events, :digest, unique: true
16
+ add_index :add_auth_security_events, :expires_at
17
+ end
18
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class PasskeysGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+
14
+ def dependencies
15
+ invoke "add_auth:step_up"
16
+ invoke "add_auth:notifications"
17
+ end
18
+
19
+ def persistence
20
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_passkeys.rb")].any?
21
+ migration_template "add_add_auth_passkeys.rb.tt", "db/migrate/add_add_auth_passkeys.rb"
22
+ end
23
+ %w[credential ceremony].each { |name| copy_file "add_auth_#{name}.rb", "app/models/add_auth_#{name}.rb", skip: true }
24
+ end
25
+
26
+ def wiring
27
+ unless File.read(File.join(destination_root, "config/routes.rb")).include?("# AddAuth passkeys")
28
+ route <<~ROUTES
29
+ # AddAuth passkeys
30
+ get "add_auth/passkey.js", to: "add_auth/assets#passkey"
31
+ get "add_auth/codec.js", to: "add_auth/assets#codec"
32
+ get "passkeys", to: "add_auth/passkeys#index"
33
+ post "passkeys/options", to: "add_auth/passkeys#registration_options"
34
+ post "passkeys", to: "add_auth/passkeys#register"
35
+ post "passkeys/sign-in/options", to: "add_auth/passkeys#authentication_options"
36
+ post "passkeys/sign-in", to: "add_auth/passkeys#authenticate"
37
+ post "passkeys/cancel", to: "add_auth/passkeys#cancel"
38
+ post "passkeys/policy", to: "add_auth/passkeys#change_policy"
39
+ patch "passkeys/:id", to: "add_auth/passkeys#rename"
40
+ delete "passkeys/:id", to: "add_auth/passkeys#remove"
41
+ post "reauthenticate/passkey/options", to: "add_auth/passkeys#reauthentication_options"
42
+ post "reauthenticate/passkey", to: "add_auth/passkeys#reauthenticate"
43
+ get "recover", to: "add_auth/recoveries#new"
44
+ post "recover/email", to: "add_auth/recoveries#request_link"
45
+ get "recover/check-email", to: "add_auth/recoveries#check_email"
46
+ get "recover/link", to: "add_auth/recoveries#link"
47
+ post "recover/link", to: "add_auth/recoveries#confirm"
48
+ ROUTES
49
+ 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
71
+ say "Review and migrate before enabling traffic. Configure RP/origins, trusted recovery and notifications; run add_auth:doctor."
72
+ end
73
+ end
74
+ end
75
+ end