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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +125 -0
- data/CODE_OF_CONDUCT.md +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +545 -0
- data/ROADMAP.md +244 -0
- data/SECURITY.md +75 -0
- data/app/controllers/add_auth/assets_controller.rb +54 -0
- data/app/controllers/add_auth/passkeys_controller.rb +144 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
- data/app/controllers/add_auth/recoveries_controller.rb +50 -0
- data/app/controllers/add_auth/sessions_controller.rb +62 -0
- data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
- data/app/javascript/controllers/.keep +0 -0
- data/app/jobs/add_auth/delivery_job.rb +31 -0
- data/app/jobs/add_auth/email_delivery_job.rb +15 -0
- data/app/jobs/add_auth/email_request_job.rb +21 -0
- data/app/jobs/add_auth/security_notification_job.rb +13 -0
- data/app/mailers/add_auth/security_mailer.rb +16 -0
- data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
- data/app/models/concerns/.keep +0 -0
- data/app/views/.keep +0 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
- data/app/views/add_auth/passkeys/_list.html.erb +32 -0
- data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
- data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
- data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
- data/app/views/add_auth/recoveries/_form.html.erb +11 -0
- data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
- data/app/views/add_auth/sessions/_list.html.erb +25 -0
- data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
- data/app/views/add_auth/sessions/index.html.erb +12 -0
- data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
- data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
- data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
- data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/sign_ins/show.html.erb +1 -0
- data/app/views/layouts/add_auth/authentication.html.erb +19 -0
- data/lib/add_auth/configuration.rb +61 -0
- data/lib/add_auth/core/access_policy.rb +55 -0
- data/lib/add_auth/core/browser_binding.rb +28 -0
- data/lib/add_auth/core/challenge/base.rb +100 -0
- data/lib/add_auth/core/challenge/http.rb +130 -0
- data/lib/add_auth/core/challenge/null.rb +21 -0
- data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
- data/lib/add_auth/core/challenge/test.rb +32 -0
- data/lib/add_auth/core/challenge/turnstile.rb +46 -0
- data/lib/add_auth/core/delivery.rb +55 -0
- data/lib/add_auth/core/digest/base.rb +34 -0
- data/lib/add_auth/core/digest/hmac.rb +41 -0
- data/lib/add_auth/core/intake.rb +59 -0
- data/lib/add_auth/core/maintenance.rb +45 -0
- data/lib/add_auth/core/rate_limit.rb +30 -0
- data/lib/add_auth/core/security_events.rb +48 -0
- data/lib/add_auth/core/sessions.rb +287 -0
- data/lib/add_auth/core/step_up.rb +129 -0
- data/lib/add_auth/core/strategies/email_link.rb +201 -0
- data/lib/add_auth/core/strategies/passkey.rb +286 -0
- data/lib/add_auth/rails/authentication.rb +67 -0
- data/lib/add_auth/rails/authentication_pages.rb +66 -0
- data/lib/add_auth/rails/delivery_cipher.rb +33 -0
- data/lib/add_auth/rails/doctor.rb +154 -0
- data/lib/add_auth/rails/ejection.rb +138 -0
- data/lib/add_auth/rails/elevation.rb +35 -0
- data/lib/add_auth/rails/engine.rb +34 -0
- data/lib/add_auth/rails/password_entry.rb +34 -0
- data/lib/add_auth/rails/runtime.rb +216 -0
- data/lib/add_auth/rails/stores/account_lock.rb +40 -0
- data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
- data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
- data/lib/add_auth/rails/stores/maintenance.rb +46 -0
- data/lib/add_auth/rails/stores/passkeys.rb +63 -0
- data/lib/add_auth/rails/stores/security_events.rb +40 -0
- data/lib/add_auth/rails/stores/sessions.rb +62 -0
- data/lib/add_auth/rails/user_lifecycle.rb +35 -0
- data/lib/add_auth/result.rb +55 -0
- data/lib/add_auth/testing.rb +30 -0
- data/lib/add_auth/version.rb +5 -0
- data/lib/add_auth.rb +52 -0
- data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
- data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
- data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
- data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
- data/lib/generators/add_auth/ejection.rb +18 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
- data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
- data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
- data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
- data/lib/generators/add_auth/install/install_generator.rb +27 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
- data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
- data/lib/generators/add_auth/javascript/templates/application.js +2 -0
- data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
- data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
- data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
- data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
- data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
- data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
- data/lib/generators/add_auth/views/views_generator.rb +16 -0
- data/lib/tasks/add_auth.rake +38 -0
- 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,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,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,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,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
|