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,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AddAuth
6
+ module Core
7
+ module Strategies
8
+ # Internal sign-in token lifecycle, NOT an HTTP endpoint. Run issuance
9
+ # behind a uniform asynchronous intake/rate-limit boundary. A nil return
10
+ # does not make account-dependent database work timing indistinguishable.
11
+ # Sign-in and reauthentication have distinct purposes. Store transactions
12
+ # enclose Core policy, proof consumption and the session finalizer.
13
+ class EmailLink
14
+ include Delivery
15
+
16
+ DEFAULT_TOKEN_LIFETIME = 20 * 60
17
+ TOKEN_BYTES = 32
18
+ TOKEN_PATTERN = /\A[A-Za-z0-9_-]{43}\z/
19
+
20
+ def initialize(store:, digest:, delivery_cipher:, eligible:, normalize_identifier:, identifier_for:,
21
+ clock: Time, random: SecureRandom, token_lifetime: DEFAULT_TOKEN_LIFETIME, same_browser: false, purpose: :sign_in, sessions: nil, policy: nil, proof_allowed: ->(_user) { true })
22
+ unless token_lifetime.is_a?(Numeric) && token_lifetime.finite? && token_lifetime.positive?
23
+ raise ArgumentError, "token_lifetime must be finite and positive"
24
+ end
25
+ @store, @digest, @cipher = store, digest, delivery_cipher
26
+ @eligible, @normalize, @identifier_for = eligible, normalize_identifier, identifier_for
27
+ @clock, @random, @token_lifetime = clock, random, token_lifetime
28
+ raise ArgumentError, "same_browser must be true or false" unless [true, false].include?(same_browser)
29
+ @purpose = purpose.to_s
30
+ raise ArgumentError, "unsupported email purpose" unless %w[sign_in reauthentication recovery].include?(@purpose)
31
+ if @purpose != "sign_in" && (!sessions || !policy)
32
+ raise ArgumentError, "reauthentication requires sessions and purpose policy"
33
+ end
34
+ @sessions, @policy, @proof_allowed = sessions, policy, proof_allowed
35
+ @same_browser = same_browser || @purpose == "reauthentication"
36
+ @binding = BrowserBinding.new(digest: digest)
37
+ end
38
+
39
+ def browser_digest(secret) = @same_browser ? @binding.digest(secret) : nil
40
+
41
+ def confirmation_page(preview)
42
+ return "invalid_link" unless preview
43
+ preview.fetch(:browser_matches) ? "confirmation" : "different_browser"
44
+ end
45
+
46
+ def issue(identifier:, requested_ip_address: nil, request_id: nil, browser_digest: nil, session_id: nil, session_digest: nil, authentication_purpose: nil)
47
+ return if @same_browser && (!browser_digest.is_a?(String) || browser_digest.empty?)
48
+ return nil unless identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)
49
+
50
+ normalized = @normalize.call(identifier)
51
+ return nil unless normalized.is_a?(String) && normalized.valid_encoding? && normalized.bytesize.between?(1, 254)
52
+ raw = @random.urlsafe_base64(TOKEN_BYTES)
53
+ raise AddAuth::Error, "random source returned an invalid token" unless valid_token?(raw)
54
+ digest = @digest.digest(raw)
55
+ at = @clock.now
56
+ expires_at = at + @token_lifetime
57
+ payload = @cipher.encrypt(token: raw, digest: digest, expires_at: expires_at)
58
+ @store.with_user(identifier: normalized) do |user|
59
+ next if request_id && @store.issued?(request_id: request_id)
60
+ if user && @eligible.call(user) == true && @proof_allowed.call(user) == true && @normalize.call(@identifier_for.call(user)) == normalized
61
+ context = {}
62
+ if @purpose == "reauthentication"
63
+ row = @store.session_for(user: user, id: session_id)
64
+ next unless row && row.token_digest == session_digest && @sessions.current_in_transaction(user: user, session: row) &&
65
+ @policy.reauthentication_rule_for(authentication_purpose) && @policy.methods_for(user: user, purpose: authentication_purpose).include?(:email_link)
66
+ context = {session_id: row.id, session_digest: row.token_digest, authentication_purpose: authentication_purpose.to_s}
67
+ end
68
+ @store.replace_pending(user: user, purpose: @purpose, **context, digest: digest, expires_at: expires_at,
69
+ created_at: at, delivery_payload: payload, identifier_digest: @digest.digest("identifier:#{normalized}"),
70
+ requested_ip_address: requested_ip_address, **(browser_digest ? {browser_digest: browser_digest} : {}), **(request_id ? {request_id: request_id} : {}))
71
+ end
72
+ end
73
+ nil
74
+ end
75
+
76
+ # The finalizer must persist the session in the same transaction; cookie
77
+ # writing/delivery happens only after this method successfully returns.
78
+ # Pass a trusted current_session snapshot here and as replacing: to the
79
+ # session finalizer so both accounts are locked during a browser switch.
80
+ def consume(token:, current_user_id: nil, current_session: nil, switch_account: false, browser_secret: nil)
81
+ raise ArgumentError, "use reauthenticate for this proof" if @purpose == "reauthentication"
82
+ raise ArgumentError, "a transactional session finalizer is required" unless block_given? || @purpose == "recovery"
83
+ return failure(:invalid_credentials) unless valid_token?(token)
84
+
85
+ current_user_id = current_session.user_id if current_session
86
+ @store.with_token(digest: @digest.digest(token), current_session: current_session) do |user, record|
87
+ reason = rejection(user, record)
88
+ reason ||= :invalid_credentials unless browser_matches?(record, browser_secret)
89
+ reason ||= :invalid_credentials if current_user_id && user && current_user_id != user.id && switch_account != true
90
+ if reason
91
+ failure(reason)
92
+ else
93
+ @store.consume(record: record, at: @clock.now)
94
+ grant = nil
95
+ session = @store.finalize_session(user: user) do |persist|
96
+ if @purpose == "recovery"
97
+ grant = recovery_session(user, record, persist, current_session)
98
+ grant.session
99
+ else
100
+ yield user, persist
101
+ end
102
+ end
103
+ AddAuth::Result.success(user: user, strategy: :email_link, session: session, grant: grant)
104
+ end
105
+ end
106
+ end
107
+
108
+ def reauthenticate(token:, session:, browser_secret:)
109
+ return failure(:invalid_credentials) unless @purpose == "reauthentication" && valid_token?(token)
110
+ @store.with_token(digest: @digest.digest(token)) do |user, record|
111
+ reason = rejection(user, record)
112
+ reason ||= :invalid_credentials unless browser_matches?(record, browser_secret)
113
+ reason ||= :elevation_required unless session && record && session.id == record.session_id &&
114
+ session.token_digest == record.session_digest && user && @sessions.current_in_transaction(user: user, session: session)
115
+ next failure(reason) if reason
116
+ evidence = StepUp::Evidence.new(user_id: user.id, session_id: session.id, method: :email_link,
117
+ verified_at: @clock.now, session_digest: session.token_digest, credential_version: record.identifier_digest)
118
+ result = @policy.authorize(user: user, session_id: session.id, purpose: record.authentication_purpose, evidence: evidence)
119
+ next result unless result.success?
120
+ rotated = @sessions.rotate_in_transaction(user: user, session: session, grant: result.credential)
121
+ next failure(:elevation_required) unless rotated
122
+ @store.consume(record: record, at: @clock.now)
123
+ Result.success(user: user, strategy: :step_up, session: rotated.session, credential: rotated)
124
+ end
125
+ end
126
+
127
+ # Delivery workers fetch by digest/issuance identity, never raw job args.
128
+ # Repeated calls return the SAME token. A future worker must recheck before
129
+ # sending and tolerate an email becoming stale while already in transit.
130
+ def delivery_token(digest:)
131
+ @store.with_token(digest: digest) do |user, record|
132
+ unless rejection(user, record) || record.delivery_payload.nil?
133
+ token = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
134
+ token if valid_token?(token) && @digest.matches?(record.digest, token)
135
+ end
136
+ end
137
+ end
138
+
139
+ def preview(token:, browser_secret: nil)
140
+ return unless valid_token?(token)
141
+ @store.with_token(digest: @digest.digest(token)) do |user, record|
142
+ unless rejection(user, record)
143
+ address = @identifier_for.call(user)
144
+ local, domain = address.split("@", 2)
145
+ {user_id: user.id, masked_address: "#{local[0]}•••@#{domain}", browser_matches: browser_matches?(record, browser_secret)}
146
+ end
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def recovery_session(user, record, persist, previous)
153
+ initial = @sessions.create_in_transaction(user: user, method: :email_link, persist: persist, replacing: previous)
154
+ raise AddAuth::Error, "recovery could not create a session" unless initial
155
+ evidence = StepUp::Evidence.new(user_id: user.id, session_id: initial.session.id, method: :email_link,
156
+ verified_at: @clock.now, session_digest: initial.session.token_digest, credential_version: record.identifier_digest)
157
+ proof = @policy.authorize(user: user, session_id: initial.session.id, purpose: :recover_passkeys, evidence: evidence)
158
+ raise AddAuth::Error, "recovery is not permitted" unless proof.success?
159
+ grant = @sessions.rotate_in_transaction(user: user, session: initial.session, grant: proof.credential)
160
+ raise AddAuth::Error, "recovery could not finalize" unless grant
161
+ # Preserve the transaction writer's exact newly created row identity.
162
+ grant.session = @store.refresh_session(initial.session)
163
+ grant
164
+ end
165
+
166
+ def delivery_details(user, record)
167
+ raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
168
+ {token: raw, recipient: @identifier_for.call(user)} if valid_token?(raw) && @digest.matches?(record.digest, raw)
169
+ end
170
+
171
+ def browser_matches?(record, secret)
172
+ stored = record.browser_digest if record&.respond_to?(:browser_digest)
173
+ return true if !@same_browser && stored.nil?
174
+ @binding.matches?(stored, secret)
175
+ end
176
+
177
+ def valid_token?(token)
178
+ token.is_a?(String) && token.bytesize == 43 && token.ascii_only? && TOKEN_PATTERN.match?(token)
179
+ end
180
+
181
+ def rejection(user, record)
182
+ return :invalid_credentials unless user && record && record.purpose == @purpose
183
+ return :disabled unless @eligible.call(user) == true && @proof_allowed.call(user) == true
184
+ identifier = @normalize.call(@identifier_for.call(user))
185
+ return :revoked_token unless @digest.matches?(record.identifier_digest, "identifier:#{identifier}")
186
+ if @purpose == "reauthentication"
187
+ row = @store.session_for(user: user, id: record.session_id)
188
+ return :elevation_required unless row && row.token_digest == record.session_digest &&
189
+ @sessions.current_in_transaction(user: user, session: row) &&
190
+ @policy.reauthentication_rule_for(record.authentication_purpose) && @policy.methods_for(user: user, purpose: record.authentication_purpose).include?(:email_link)
191
+ end
192
+ return :revoked_token if record.revoked_at
193
+ return :consumed_token if record.consumed_at
194
+ :expired_token if record.expires_at <= @clock.now
195
+ end
196
+
197
+ def failure(reason) = AddAuth::Result.failure(reason: reason)
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webauthn"
4
+ require "json"
5
+ require "base64"
6
+ require "uri"
7
+ require "public_suffix"
8
+
9
+ module AddAuth
10
+ module Core
11
+ module Strategies
12
+ class Passkey
13
+ PURPOSE = :manage_passkeys
14
+ PAYLOAD_LIMIT = 65_536
15
+ Entry = Data.define(:id, :nickname, :created_at, :last_used_at, :backup_eligible, :backup_state)
16
+
17
+ def initialize(store:, sessions:, policy:, access_policy:, digest:, eligible:, rp_id:, origins:, name:,
18
+ notify:, limiter:, anonymous_limit: 1000, clock: Time, allow_localhost: false, support_url: nil, on_failure: ->(_reason) {})
19
+ @store, @sessions, @policy, @access, @digest, @eligible = store, sessions, policy, access_policy, digest, eligible
20
+ @clock, @notify, @support_url, @on_failure = clock, notify, support_url, on_failure
21
+ @limiter, @anonymous_limit = limiter, anonymous_limit
22
+ unless anonymous_limit.is_a?(Integer) && anonymous_limit.positive?
23
+ raise AddAuth::Error, "passkeys.anonymous_limit must be a positive integer"
24
+ end
25
+ raise AddAuth::Error, "support_url must be a safe local support page" if support_url && !Sessions.safe_return(support_url)
26
+ @binding = BrowserBinding.new(digest: digest)
27
+ validate_origins!(rp_id, origins, allow_localhost)
28
+ @rp = WebAuthn::RelyingParty.new(id: rp_id, name: name, allowed_origins: origins.dup,
29
+ encoding: :base64url, acceptable_attestation_types: ["None"], credential_options_timeout: 120_000)
30
+ @configuration_digest = digest.digest(JSON.generate([rp_id, origins.sort]))
31
+ end
32
+
33
+ def registration_options(user:, session:, browser_secret:)
34
+ @store.with_user(id: user&.id) do |account|
35
+ proof = management_proof(account, session, allow_recovery: true)
36
+ next failure(:elevation_required) unless proof.success? && @binding.valid?(browser_secret)
37
+ @store.update(account, webauthn_id: @binding.generate) unless account.webauthn_id
38
+ options = @rp.options_for_registration(user: {id: account.webauthn_id, name: account.email_address},
39
+ exclude: @store.credentials(user: account).map(&:external_id), attestation: "none",
40
+ authenticator_selection: {residentKey: "required", requireResidentKey: true, userVerification: "required"},
41
+ extensions: {credProps: true})
42
+ create_options(options, kind: "registration", user: account, session: session,
43
+ purpose: proof.credential.purpose, browser_secret: browser_secret)
44
+ end
45
+ end
46
+
47
+ def authentication_options(browser_secret:, user: nil, session: nil, purpose: nil)
48
+ return failure unless @binding.valid?(browser_secret)
49
+ if user || session || purpose
50
+ @store.with_user(id: user&.id) do |account|
51
+ next failure(:elevation_required) unless account && @sessions.current_in_transaction(user: account, session: session) &&
52
+ @policy.reauthentication_rule_for(purpose) && @policy.methods_for(user: account, purpose: purpose).include?(:passkey)
53
+ options = @rp.options_for_authentication(allow: @store.credentials(user: account).map(&:external_id), user_verification: "required")
54
+ create_options(options, kind: "assertion", user: account, session: session, purpose: purpose, browser_secret: browser_secret)
55
+ end
56
+ else
57
+ return failure(:rate_limited) unless @limiter.call(key: @digest.digest("passkey:anonymous-ceremonies"), limit: @anonymous_limit)
58
+ options = @rp.options_for_authentication(user_verification: "required")
59
+ create_options(options, kind: "assertion", browser_secret: browser_secret)
60
+ end
61
+ end
62
+
63
+ def register(transaction:, credential_response:, user:, session:, browser_secret:, nickname: nil)
64
+ payload = parse_payload(credential_response)
65
+ return failure unless payload && @binding.valid?(transaction)
66
+ @store.with_ceremony(digest: @digest.digest(transaction), user_id: user&.id) do |account, ceremony|
67
+ next failure unless valid_ceremony?(account, ceremony, "registration", browser_secret) &&
68
+ bound_session?(ceremony, session) && ceremony.user_id == account.id
69
+ proof = management_proof(account, session, purpose: ceremony.authentication_purpose)
70
+ next failure(:elevation_required) unless proof.success?
71
+ verified = verify { @rp.verify_registration(payload, ceremony.challenge, user_verification: true, user_presence: true) }
72
+ next verified if verified.is_a?(Result)
73
+ next failure unless valid_backup?(verified) && verified.raw_id == verified.response.authenticator_data.credential.id
74
+ label = normalized_label(nickname || "Passkey")
75
+ next failure unless label
76
+ credential = @store.create_credential(user: account, external_id: verified.id,
77
+ public_key: verified.public_key, sign_count: verified.sign_count,
78
+ backup_eligible: verified.backup_eligible?, backup_state: verified.backed_up?, nickname: label,
79
+ aaguid: verified.response.authenticator_data.aaguid, transports: transports(payload))
80
+ next failure unless credential
81
+ @store.update(ceremony, consumed_at: @clock.now)
82
+ grant = nil
83
+ if proof.credential.purpose == :recover_passkeys
84
+ @store.revoke_other_sessions(user: account, except: session, at: @clock.now)
85
+ @store.invalidate_proofs(user: account, at: @clock.now)
86
+ grant = @sessions.rotate_in_transaction(user: account, session: session, grant: proof.credential)
87
+ raise AddAuth::Error, "recovery replacement could not finalize" unless grant
88
+ @store.update(grant.session, elevated_at: nil, elevation_version: nil, elevation_expires_at: nil)
89
+ @notify.call(user: account, kind: :recovery_completed, at: @clock.now)
90
+ else
91
+ @notify.call(user: account, kind: :passkey_added, at: @clock.now)
92
+ end
93
+ Result.success(user: account, strategy: :passkey, credential: credential, session: grant&.session, grant: grant)
94
+ end
95
+ end
96
+
97
+ def authenticate(transaction:, credential_response:, browser_secret:, session: nil, replacing: nil, **hints)
98
+ payload = parse_payload(credential_response)
99
+ return failure unless payload && @binding.valid?(transaction)
100
+ locator = @store.credential(id: payload["id"])
101
+ return failure unless locator
102
+ @store.with_ceremony(digest: @digest.digest(transaction), user_id: locator.user_id, replacing: replacing) do |account, ceremony|
103
+ next failure unless valid_ceremony?(account, ceremony, "assertion", browser_secret)
104
+ next failure unless ceremony.user_id.nil? == session.nil?
105
+ credential = @store.credential(id: payload["id"])
106
+ next failure unless credential && credential.user_id == account.id && !credential.revoked_at
107
+ if ceremony.user_id
108
+ next failure(:elevation_required) unless ceremony.user_id == account.id && bound_session?(ceremony, session) &&
109
+ @sessions.current_in_transaction(user: account, session: session)
110
+ else
111
+ next failure unless @access.sign_in_allowed?(account, :passkey)
112
+ end
113
+ verified = verify do
114
+ @rp.verify_authentication(payload, ceremony.challenge, public_key: credential.public_key,
115
+ sign_count: credential.sign_count, user_verification: true, user_presence: true)
116
+ end
117
+ next verified if verified.is_a?(Result)
118
+ next failure unless verified.user_handle == account.webauthn_id && valid_backup?(verified) &&
119
+ verified.backup_eligible? == credential.backup_eligible
120
+ evidence = StepUp::Evidence.new(user_id: account.id, session_id: session&.id, method: :passkey,
121
+ verified_at: @clock.now, session_digest: session&.token_digest, credential_id: credential.external_id,
122
+ credential_version: credential.public_key, user_verification: true)
123
+ grant = if ceremony.user_id
124
+ authorization = @policy.authorize(user: account, session_id: session.id,
125
+ purpose: ceremony.authentication_purpose, evidence: evidence)
126
+ next authorization unless authorization.success?
127
+ @sessions.rotate_in_transaction(user: account, session: session, grant: authorization.credential)
128
+ else
129
+ @sessions.create_in_transaction(user: account, method: :passkey, proof: evidence, replacing: replacing, **hints)
130
+ end
131
+ next failure(:elevation_required) unless grant
132
+ @store.update(credential, sign_count: verified.sign_count, backup_state: verified.backed_up?, last_used_at: @clock.now)
133
+ @store.update(ceremony, consumed_at: @clock.now)
134
+ Result.success(user: account, strategy: :passkey, credential: grant, session: grant.session)
135
+ end
136
+ end
137
+
138
+ def list(user:, session:)
139
+ @store.with_user(id: user&.id) do |account|
140
+ next [] unless account && @sessions.current_in_transaction(user: account, session: session)
141
+ @store.credentials(user: account).map do |row|
142
+ Entry.new(id: row.id, nickname: row.nickname, created_at: row.created_at, last_used_at: row.last_used_at,
143
+ backup_eligible: row.backup_eligible, backup_state: row.backup_state)
144
+ end
145
+ end
146
+ end
147
+
148
+ def rename(user:, session:, id:, nickname:)
149
+ label = normalized_label(nickname)
150
+ return failure unless label
151
+ @store.with_user(id: user&.id) do |account|
152
+ next failure unless account && @sessions.current_in_transaction(user: account, session: session)
153
+ row = @store.credentials(user: account).find { |item| item.id.to_s == id.to_s }
154
+ next failure unless row
155
+ @store.update(row, nickname: label)
156
+ Result.success(user: account, strategy: :passkey)
157
+ end
158
+ end
159
+
160
+ def remove(user:, session:, id:)
161
+ @store.with_user(id: user&.id) do |account|
162
+ next failure(:elevation_required) unless management_proof(account, session).success?
163
+ rows = @store.credentials(user: account)
164
+ row = rows.find { |item| item.id.to_s == id.to_s }
165
+ next failure unless row
166
+ next failure unless rows.length > 1 || @access.fallback?(account)
167
+ @store.update(row, revoked_at: @clock.now)
168
+ @notify.call(user: account, kind: :passkey_removed, at: @clock.now)
169
+ Result.success(user: account, strategy: :passkey)
170
+ end
171
+ end
172
+
173
+ def change_policy(user:, session:, strict:, acknowledged:)
174
+ return failure unless [true, false].include?(strict)
175
+ return failure if strict && (acknowledged != true || @support_url.to_s.empty?)
176
+ @store.with_user(id: user&.id) do |account|
177
+ proof = @sessions.elevation_in_transaction(user: account, session: session, purpose: :manage_policy, policy: @policy)
178
+ next failure(:elevation_required) unless proof.success? && proof.credential.method == :passkey && proof.credential.user_verification
179
+ next failure unless @store.credentials(user: account).any?
180
+ row = @sessions.current_in_transaction(user: account, session: session)
181
+ @store.update(account, add_auth_strict: strict, add_auth_policy_version: @access.version(account) + 1)
182
+ @store.revoke_other_sessions(user: account, except: row, at: @clock.now)
183
+ @store.invalidate_proofs(user: account, at: @clock.now)
184
+ @store.update(row, authentication_policy_version: @access.version(account), authenticated_with: "passkey",
185
+ authenticated_at: proof.credential.verified_at, authentication_credential_id: proof.credential.credential_id,
186
+ authentication_uv: true)
187
+ grant = @sessions.rotate_in_transaction(user: account, session: row, grant: proof.credential)
188
+ raise AddAuth::Error, "policy transition could not finalize" unless grant
189
+ @notify.call(user: account, kind: :policy_changed, at: @clock.now)
190
+ Result.success(user: account, strategy: :passkey, session: grant.session, credential: grant)
191
+ end
192
+ end
193
+
194
+ def cancel(transaction:, browser_secret:)
195
+ return false unless @binding.valid?(transaction)
196
+ @store.cancel(digest: @digest.digest(transaction)) do |record|
197
+ next false unless record && @binding.matches?(record.browser_digest, browser_secret)
198
+ @store.update(record, consumed_at: @clock.now) unless record.consumed_at
199
+ true
200
+ end
201
+ end
202
+
203
+ private
204
+
205
+ def management_proof(account, session, purpose: PURPOSE, allow_recovery: false)
206
+ return failure(:elevation_required) unless account
207
+ return failure(:elevation_required) if purpose.to_sym == :recover_passkeys && !@access.recoverable?(account)
208
+ proof = @sessions.elevation_in_transaction(user: account, session: session, purpose: purpose, policy: @policy)
209
+ return proof if proof.success? || !allow_recovery || purpose.to_sym != PURPOSE || !@access.recoverable?(account)
210
+ @sessions.elevation_in_transaction(user: account, session: session, purpose: :recover_passkeys, policy: @policy)
211
+ end
212
+
213
+ def create_options(options, kind:, browser_secret:, user: nil, session: nil, purpose: nil)
214
+ identifier = @binding.generate
215
+ @store.create_ceremony(digest: @digest.digest(identifier), challenge: options.challenge, kind: kind,
216
+ browser_digest: @binding.digest(browser_secret), configuration_digest: @configuration_digest,
217
+ user_id: user&.id, session_id: session&.id, session_digest: session&.token_digest,
218
+ authentication_purpose: purpose&.to_s, expires_at: @clock.now + 300)
219
+ Result.success(user: user, strategy: :passkey, credential: {transaction: identifier, publicKey: options.as_json})
220
+ end
221
+
222
+ def valid_ceremony?(account, row, kind, browser_secret)
223
+ account && @eligible.call(account) == true && row && row.kind == kind && !row.consumed_at &&
224
+ row.expires_at > @clock.now && row.configuration_digest == @configuration_digest &&
225
+ @binding.matches?(row.browser_digest, browser_secret)
226
+ end
227
+
228
+ def bound_session?(ceremony, session)
229
+ session && ceremony.session_id == session.id && ceremony.session_digest == session.token_digest
230
+ end
231
+
232
+ def valid_backup?(credential) = !credential.backed_up? || credential.backup_eligible?
233
+
234
+ def normalized_label(label)
235
+ label.strip if label.is_a?(String) && label.valid_encoding? && label.bytesize <= 240 &&
236
+ label.strip.length.between?(1, 60) && !label.match?(/[\x00-\x1f\x7f]/)
237
+ end
238
+
239
+ def transports(payload)
240
+ value = payload.dig("response", "transports")
241
+ value.is_a?(Array) ? value & %w[usb nfc ble internal hybrid] : []
242
+ end
243
+
244
+ def parse_payload(value)
245
+ return unless value.is_a?(Hash) && JSON.generate(value).bytesize <= PAYLOAD_LIMIT
246
+ id = value["id"]
247
+ return unless id.is_a?(String) && id.bytesize.between?(1, 2048) && /\A[A-Za-z0-9_-]+\z/.match?(id) && value["rawId"] == id
248
+ client = JSON.parse(Base64.urlsafe_decode64(value.fetch("response").fetch("clientDataJSON")))
249
+ return unless client.is_a?(Hash) && [nil, false].include?(client["crossOrigin"]) && !client.key?("topOrigin")
250
+ value
251
+ rescue ArgumentError, TypeError, KeyError, NoMethodError, JSON::ParserError
252
+ nil
253
+ end
254
+
255
+ def verify
256
+ yield
257
+ rescue WebAuthn::SignCountVerificationError
258
+ failure(:counter_regression)
259
+ rescue
260
+ # Library parsing/verification handles attacker-controlled binary data.
261
+ # This rescue surrounds verification only, never persistence or finalization.
262
+ failure
263
+ end
264
+
265
+ def validate_origins!(rp_id, origins, allow_localhost)
266
+ valid_id = rp_id.is_a?(String) && /\A[a-z0-9]+(?:[.-][a-z0-9]+)*\z/.match?(rp_id) &&
267
+ (PublicSuffix.domain(rp_id) || (allow_localhost && rp_id == "localhost"))
268
+ valid = valid_id && origins.is_a?(Array) && origins.any? && origins.all? do |origin|
269
+ uri = URI.parse(origin)
270
+ secure = uri.scheme == "https" || (allow_localhost && uri.scheme == "http" && %w[localhost 127.0.0.1].include?(uri.host))
271
+ secure && uri.host && (uri.host == rp_id || uri.host.end_with?(".#{rp_id}")) &&
272
+ !uri.userinfo && !uri.query && !uri.fragment && uri.path.empty? && uri.to_s == origin
273
+ end
274
+ raise AddAuth::Error, "configure a stable RP ID and exact HTTPS origins" unless valid
275
+ rescue URI::InvalidURIError, TypeError
276
+ raise AddAuth::Error, "configure a stable RP ID and exact HTTPS origins"
277
+ end
278
+
279
+ def failure(reason = :invalid_credentials)
280
+ @on_failure.call(reason)
281
+ Result.failure(reason: reason)
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/runtime"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ # Included AFTER the host Authentication concern. No parallel session model.
8
+ module Authentication
9
+ private
10
+
11
+ # Authentication management always requires a named session, even if
12
+ # the host also serves public pages through a permissive general guard.
13
+ def require_add_auth_authentication
14
+ request_authentication unless resume_session
15
+ end
16
+
17
+ def find_session_by_cookie
18
+ grant = Runtime.sessions.resume(signed_value: cookies.signed[:session_id])
19
+ add_auth_write_cookie(grant) if grant&.bearer
20
+ grant&.session
21
+ end
22
+
23
+ def start_new_session_for(user)
24
+ grant = Runtime.sessions.start(user: user, method: :password, replacing: add_auth_replacement_session, **add_auth_session_hints)
25
+ raise AddAuth::Error, "account is not eligible to sign in" unless grant
26
+ add_auth_accept(grant)
27
+ end
28
+
29
+ def add_auth_session_hints
30
+ {user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip}
31
+ end
32
+
33
+ def add_auth_replacement_session
34
+ Runtime.sessions.replacement_for(signed_value: cookies.signed[:session_id])
35
+ end
36
+
37
+ def add_auth_accept(grant)
38
+ destination = Core::Sessions.safe_return(session[:return_to_after_authenticating])
39
+ reset_session
40
+ session[:return_to_after_authenticating] = destination if destination
41
+ add_auth_write_cookie(grant)
42
+ Current.session = grant.session
43
+ end
44
+
45
+ def add_auth_write_cookie(grant)
46
+ cookies.signed[:session_id] = {value: grant.bearer, expires: grant.session.expires_at,
47
+ httponly: true, secure: request.ssl? || ::Rails.env.production?, same_site: :lax, path: "/"}
48
+ end
49
+
50
+ def terminate_session
51
+ Runtime.sessions.revoke(session: Current.session) if Current.session
52
+ Current.reset
53
+ reset_session
54
+ cookies.delete(:session_id, path: "/")
55
+ end
56
+
57
+ def request_authentication
58
+ session[:return_to_after_authenticating] = Core::Sessions.safe_return(request.fullpath) if request.get?
59
+ redirect_to Runtime.sign_in_path, status: request.get? ? :found : :see_other
60
+ end
61
+
62
+ def after_authentication_url
63
+ Core::Sessions.safe_return(session.delete(:return_to_after_authenticating)) || "/"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module AuthenticationPages
6
+ def add_auth_password_sign_in
7
+ return head :not_found unless Runtime.config.passwords_enabled
8
+ intake = Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
9
+ action: :sign_in, challenge_token: challenge_token)
10
+ return intake_failure(intake) if intake.is_a?(Symbol)
11
+ grant = Runtime.sessions.authenticate(identifier: intake, password: params[:password], replacing: add_auth_replacement_session, **add_auth_session_hints) do
12
+ Runtime.authenticate_password(identifier: intake, password: params[:password])
13
+ end
14
+ if grant
15
+ destination = after_authentication_url
16
+ add_auth_accept(grant)
17
+ redirect_to destination, status: :see_other
18
+ else
19
+ @error = "Email or password is incorrect. Try again or request a sign-in link."
20
+ page("form", status: :unprocessable_entity)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def valid_request_origin?
27
+ Core::Intake.origin_allowed?(origin: request.origin, base_url: request.base_url)
28
+ end
29
+
30
+ def challenge_token
31
+ params[:challenge_token].presence || params["cf-turnstile-response"].presence || params["g-recaptcha-response"].presence
32
+ end
33
+
34
+ def service_unavailable
35
+ @error = "Sign-in is temporarily unavailable. Please try again shortly."
36
+ response.set_header("Retry-After", "60")
37
+ page("form", status: :service_unavailable)
38
+ end
39
+
40
+ def private_response
41
+ response.set_header("Cache-Control", "no-store")
42
+ response.set_header("Referrer-Policy", "no-referrer")
43
+ end
44
+
45
+ def page(partial, status: :ok)
46
+ @page_partial = authentication_partial(partial)
47
+ respond_to do |format|
48
+ format.html { render "add_auth/sign_ins/show", layout: "add_auth/authentication", status: status }
49
+ format.turbo_stream { render turbo_stream: turbo_stream.update("add_auth-content", partial: @page_partial), status: status }
50
+ end
51
+ end
52
+
53
+ def authentication_partial(partial) = "add_auth/sign_ins/#{partial}"
54
+
55
+ def intake_failure(reason)
56
+ @error, status = {
57
+ rate_limited: ["Too many attempts. Try again in five minutes.", :too_many_requests],
58
+ challenge_unavailable: ["Verification is temporarily unavailable. Please try again shortly.", :service_unavailable],
59
+ challenge_rejected: ["Verification failed. Please try again.", :unprocessable_entity]
60
+ }.fetch(reason, ["Check your credentials and try again.", :unprocessable_entity])
61
+ response.set_header("Retry-After", "300") if %i[too_many_requests service_unavailable].include?(status)
62
+ page("form", status: status)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_encryptor"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ # A pending delivery contains a bearer: encrypt it with a separate key and
8
+ # bind it to its token digest. Never enqueue or log the plaintext argument.
9
+ class DeliveryCipher
10
+ def initialize(key:)
11
+ raise ArgumentError, "delivery key must be 32 bytes" unless key.is_a?(String) && key.bytesize == 32
12
+ @encryptor = ActiveSupport::MessageEncryptor.new(key.dup.freeze,
13
+ cipher: "aes-256-gcm", serializer: :json)
14
+ end
15
+
16
+ def encrypt(token:, digest:, expires_at:)
17
+ @encryptor.encrypt_and_sign(token, purpose: purpose(digest), expires_at: expires_at)
18
+ end
19
+
20
+ def decrypt(payload:, digest:)
21
+ @encryptor.decrypt_and_verify(payload, purpose: purpose(digest))
22
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
23
+ nil
24
+ end
25
+
26
+ def inspect = "#<#{self.class} [FILTERED]>"
27
+
28
+ private
29
+
30
+ def purpose(digest) = "add_auth.email-delivery.v1:#{digest}"
31
+ end
32
+ end
33
+ end