authnz_eleven 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +384 -0
  5. data/lib/authnz_eleven/identity.rb +210 -0
  6. data/lib/authnz_eleven/principal.rb +201 -0
  7. data/lib/authnz_eleven/principals.rb +111 -0
  8. data/lib/authnz_eleven/version.rb +5 -0
  9. data/lib/authnz_eleven/wizard.rb +570 -0
  10. data/lib/authnz_eleven.rb +15 -0
  11. data/lib/generators/authnz_eleven/USAGE +26 -0
  12. data/lib/generators/authnz_eleven/authnz_eleven_generator.rb +3040 -0
  13. data/lib/generators/authnz_eleven/templates/config/forbidden_usernames.LICENSE +22 -0
  14. data/lib/generators/authnz_eleven/templates/config/forbidden_usernames.txt +558 -0
  15. data/lib/generators/authnz_eleven/templates/config/initializers/authentication.rb.tt +275 -0
  16. data/lib/generators/authnz_eleven/templates/config/initializers/omniauth.rb.tt +9 -0
  17. data/lib/generators/authnz_eleven/templates/config/initializers/team_slug.rb.tt +24 -0
  18. data/lib/generators/authnz_eleven/templates/config/initializers/webauthn.rb.tt +14 -0
  19. data/lib/generators/authnz_eleven/templates/config/locales/authentication.en.yml.tt +11 -0
  20. data/lib/generators/authnz_eleven/templates/controllers/active_teams_controller.rb.tt +6 -0
  21. data/lib/generators/authnz_eleven/templates/controllers/admin/dashboard_controller.rb.tt +4 -0
  22. data/lib/generators/authnz_eleven/templates/controllers/admin/sessions_controller.rb.tt +17 -0
  23. data/lib/generators/authnz_eleven/templates/controllers/admin/users_controller.rb.tt +91 -0
  24. data/lib/generators/authnz_eleven/templates/controllers/admin_controller.rb.tt +6 -0
  25. data/lib/generators/authnz_eleven/templates/controllers/base_controller.rb.tt +11 -0
  26. data/lib/generators/authnz_eleven/templates/controllers/concerns/api_token_authentication.rb.tt +21 -0
  27. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/easy_dev_login.rb.tt +47 -0
  28. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/registering.rb.tt +25 -0
  29. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/sudoable.rb.tt +117 -0
  30. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/webauthn_ceremony.rb.tt +51 -0
  31. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication.rb.tt +421 -0
  32. data/lib/generators/authnz_eleven/templates/controllers/concerns/authorization.rb.tt +48 -0
  33. data/lib/generators/authnz_eleven/templates/controllers/concerns/captchable.rb.tt +11 -0
  34. data/lib/generators/authnz_eleven/templates/controllers/impersonations_controller.rb.tt +8 -0
  35. data/lib/generators/authnz_eleven/templates/controllers/invitations_controller.rb.tt +65 -0
  36. data/lib/generators/authnz_eleven/templates/controllers/settings/api_tokens_controller.rb.tt +54 -0
  37. data/lib/generators/authnz_eleven/templates/controllers/settings/authentications/events_controller.rb.tt +5 -0
  38. data/lib/generators/authnz_eleven/templates/controllers/settings/dashboard_controller.rb.tt +4 -0
  39. data/lib/generators/authnz_eleven/templates/controllers/settings/email_verifications_controller.rb.tt +15 -0
  40. data/lib/generators/authnz_eleven/templates/controllers/settings/emails_controller.rb.tt +93 -0
  41. data/lib/generators/authnz_eleven/templates/controllers/settings/invitations_controller.rb.tt +61 -0
  42. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/authenticators_controller.rb.tt +87 -0
  43. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/recovery_codes_controller.rb.tt +27 -0
  44. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/security_keys_controller.rb.tt +86 -0
  45. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/sms_controller.rb.tt +84 -0
  46. data/lib/generators/authnz_eleven/templates/controllers/settings/omniauth_identities_controller.rb.tt +27 -0
  47. data/lib/generators/authnz_eleven/templates/controllers/settings/passkeys_controller.rb.tt +70 -0
  48. data/lib/generators/authnz_eleven/templates/controllers/settings/passwords_controller.rb.tt +74 -0
  49. data/lib/generators/authnz_eleven/templates/controllers/settings/phone_verifications_controller.rb.tt +39 -0
  50. data/lib/generators/authnz_eleven/templates/controllers/settings/phones_controller.rb.tt +88 -0
  51. data/lib/generators/authnz_eleven/templates/controllers/settings/sessions_controller.rb.tt +29 -0
  52. data/lib/generators/authnz_eleven/templates/controllers/settings/users_controller.rb.tt +42 -0
  53. data/lib/generators/authnz_eleven/templates/controllers/settings_controller.rb.tt +5 -0
  54. data/lib/generators/authnz_eleven/templates/controllers/teams_controller.rb.tt +38 -0
  55. data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments/passkeys_controller.rb.tt +36 -0
  56. data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments/passwords_controller.rb.tt +28 -0
  57. data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments_controller.rb.tt +14 -0
  58. data/lib/generators/authnz_eleven/templates/controllers/users/easy_dev_logins_controller.rb.tt +19 -0
  59. data/lib/generators/authnz_eleven/templates/controllers/users/email_verifications_controller.rb.tt +36 -0
  60. data/lib/generators/authnz_eleven/templates/controllers/users/magic_link_controller.rb.tt +53 -0
  61. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/recovery_codes_controller.rb.tt +30 -0
  62. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/security_keys_controller.rb.tt +34 -0
  63. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/sms_controller.rb.tt +45 -0
  64. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/totps_controller.rb.tt +42 -0
  65. data/lib/generators/authnz_eleven/templates/controllers/users/omniauth/registrations_controller.rb.tt +26 -0
  66. data/lib/generators/authnz_eleven/templates/controllers/users/omniauth_controller.rb.tt +139 -0
  67. data/lib/generators/authnz_eleven/templates/controllers/users/passkeys/sessions_controller.rb.tt +18 -0
  68. data/lib/generators/authnz_eleven/templates/controllers/users/password_resets_controller.rb.tt +64 -0
  69. data/lib/generators/authnz_eleven/templates/controllers/users/registrations/email_verifications_controller.rb.tt +53 -0
  70. data/lib/generators/authnz_eleven/templates/controllers/users/registrations/phone_verifications_controller.rb.tt +37 -0
  71. data/lib/generators/authnz_eleven/templates/controllers/users/registrations_controller.rb.tt +112 -0
  72. data/lib/generators/authnz_eleven/templates/controllers/users/sessions_controller.rb.tt +52 -0
  73. data/lib/generators/authnz_eleven/templates/controllers/users/sms_sessions_controller.rb.tt +53 -0
  74. data/lib/generators/authnz_eleven/templates/controllers/users/sudos_controller.rb.tt +42 -0
  75. data/lib/generators/authnz_eleven/templates/erb/admin/dashboard/index.html.erb.tt +9 -0
  76. data/lib/generators/authnz_eleven/templates/erb/admin/sessions/index.html.erb.tt +27 -0
  77. data/lib/generators/authnz_eleven/templates/erb/admin/users/index.html.erb.tt +32 -0
  78. data/lib/generators/authnz_eleven/templates/erb/admin/users/show.html.erb.tt +70 -0
  79. data/lib/generators/authnz_eleven/templates/erb/invitations/show.html.erb.tt +9 -0
  80. data/lib/generators/authnz_eleven/templates/erb/settings/api_tokens/create.html.erb.tt +7 -0
  81. data/lib/generators/authnz_eleven/templates/erb/settings/api_tokens/index.html.erb.tt +29 -0
  82. data/lib/generators/authnz_eleven/templates/erb/settings/authentications/events/index.html.erb.tt +18 -0
  83. data/lib/generators/authnz_eleven/templates/erb/settings/dashboard/index.html.erb.tt +75 -0
  84. data/lib/generators/authnz_eleven/templates/erb/settings/emails/edit.html.erb.tt +68 -0
  85. data/lib/generators/authnz_eleven/templates/erb/settings/invitations/new.html.erb.tt +31 -0
  86. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/authenticators/new.html.erb.tt +57 -0
  87. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/recovery_codes/create.html.erb.tt +14 -0
  88. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/recovery_codes/index.html.erb.tt +13 -0
  89. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/edit.html.erb.tt +14 -0
  90. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/index.html.erb.tt +20 -0
  91. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/new.html.erb.tt +17 -0
  92. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/sms/new.html.erb.tt +35 -0
  93. data/lib/generators/authnz_eleven/templates/erb/settings/omniauth_identities/index.html.erb.tt +28 -0
  94. data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/edit.html.erb.tt +14 -0
  95. data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/index.html.erb.tt +21 -0
  96. data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/new.html.erb.tt +13 -0
  97. data/lib/generators/authnz_eleven/templates/erb/settings/passwords/edit.html.erb.tt +68 -0
  98. data/lib/generators/authnz_eleven/templates/erb/settings/phone_verifications/new.html.erb.tt +22 -0
  99. data/lib/generators/authnz_eleven/templates/erb/settings/phones/edit.html.erb.tt +58 -0
  100. data/lib/generators/authnz_eleven/templates/erb/settings/sessions/index.html.erb.tt +29 -0
  101. data/lib/generators/authnz_eleven/templates/erb/settings/users/show.html.erb.tt +28 -0
  102. data/lib/generators/authnz_eleven/templates/erb/shared/_authnz_eleven_nav.html.erb.tt +24 -0
  103. data/lib/generators/authnz_eleven/templates/erb/shared/_authnz_eleven_sudo_challenge.html.erb.tt +43 -0
  104. data/lib/generators/authnz_eleven/templates/erb/shared/_captcha.html.erb.tt +8 -0
  105. data/lib/generators/authnz_eleven/templates/erb/teams/index.html.erb.tt +34 -0
  106. data/lib/generators/authnz_eleven/templates/erb/teams/new.html.erb.tt +22 -0
  107. data/lib/generators/authnz_eleven/templates/erb/teams/show.html.erb.tt +5 -0
  108. data/lib/generators/authnz_eleven/templates/erb/user_mailer/email_change_verification.html.erb.tt +7 -0
  109. data/lib/generators/authnz_eleven/templates/erb/user_mailer/invitation.html.erb.tt +11 -0
  110. data/lib/generators/authnz_eleven/templates/erb/user_mailer/magic_link.html.erb.tt +5 -0
  111. data/lib/generators/authnz_eleven/templates/erb/user_mailer/password_reset.html.erb.tt +6 -0
  112. data/lib/generators/authnz_eleven/templates/erb/user_mailer/registration_email_verification.html.erb.tt +7 -0
  113. data/lib/generators/authnz_eleven/templates/erb/user_mailer/security_notification.html.erb.tt +15 -0
  114. data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/passkeys/new.html.erb.tt +24 -0
  115. data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/passwords/new.html.erb.tt +21 -0
  116. data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/show.html.erb.tt +21 -0
  117. data/lib/generators/authnz_eleven/templates/erb/users/email_verifications/show.html.erb.tt +9 -0
  118. data/lib/generators/authnz_eleven/templates/erb/users/magic_link/edit.html.erb.tt +9 -0
  119. data/lib/generators/authnz_eleven/templates/erb/users/magic_link/new.html.erb.tt +22 -0
  120. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/recovery_codes/new.html.erb.tt +20 -0
  121. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/security_keys/new.html.erb.tt +17 -0
  122. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/sms/new.html.erb.tt +25 -0
  123. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/totps/new.html.erb.tt +21 -0
  124. data/lib/generators/authnz_eleven/templates/erb/users/omniauth/registrations/new.html.erb.tt +28 -0
  125. data/lib/generators/authnz_eleven/templates/erb/users/password_resets/edit.html.erb.tt +26 -0
  126. data/lib/generators/authnz_eleven/templates/erb/users/password_resets/new.html.erb.tt +20 -0
  127. data/lib/generators/authnz_eleven/templates/erb/users/registrations/email_verifications/new.html.erb.tt +11 -0
  128. data/lib/generators/authnz_eleven/templates/erb/users/registrations/email_verifications/show.html.erb.tt +34 -0
  129. data/lib/generators/authnz_eleven/templates/erb/users/registrations/new.html.erb.tt +87 -0
  130. data/lib/generators/authnz_eleven/templates/erb/users/registrations/phone_verifications/new.html.erb.tt +46 -0
  131. data/lib/generators/authnz_eleven/templates/erb/users/sessions/new.html.erb.tt +88 -0
  132. data/lib/generators/authnz_eleven/templates/erb/users/sms_sessions/edit.html.erb.tt +30 -0
  133. data/lib/generators/authnz_eleven/templates/erb/users/sms_sessions/new.html.erb.tt +22 -0
  134. data/lib/generators/authnz_eleven/templates/erb/users/sudos/new.html.erb.tt +9 -0
  135. data/lib/generators/authnz_eleven/templates/javascript/controllers/webauthn_controller.js.tt +142 -0
  136. data/lib/generators/authnz_eleven/templates/jobs/authnz_eleven_deferred_job.rb.tt +9 -0
  137. data/lib/generators/authnz_eleven/templates/jobs/authnz_eleven_mail_delivery_job.rb.tt +3 -0
  138. data/lib/generators/authnz_eleven/templates/jobs/sms_delivery_job.rb.tt +11 -0
  139. data/lib/generators/authnz_eleven/templates/lib/captcha/null.rb.tt +9 -0
  140. data/lib/generators/authnz_eleven/templates/lib/captcha/turnstile.rb.tt +39 -0
  141. data/lib/generators/authnz_eleven/templates/lib/captcha.rb.tt +15 -0
  142. data/lib/generators/authnz_eleven/templates/lib/sms/log.rb.tt +8 -0
  143. data/lib/generators/authnz_eleven/templates/lib/sms/twilio.rb.tt +70 -0
  144. data/lib/generators/authnz_eleven/templates/lib/sms.rb.tt +15 -0
  145. data/lib/generators/authnz_eleven/templates/mailers/user_mailer.rb.tt +98 -0
  146. data/lib/generators/authnz_eleven/templates/migrations/add_guest_to_users_migration.rb.tt +6 -0
  147. data/lib/generators/authnz_eleven/templates/migrations/create_api_tokens_migration.rb.tt +14 -0
  148. data/lib/generators/authnz_eleven/templates/migrations/create_events_migration.rb.tt +26 -0
  149. data/lib/generators/authnz_eleven/templates/migrations/create_invitations_migration.rb.tt +20 -0
  150. data/lib/generators/authnz_eleven/templates/migrations/create_memberships_migration.rb.tt +12 -0
  151. data/lib/generators/authnz_eleven/templates/migrations/create_omniauth_identities_migration.rb.tt +16 -0
  152. data/lib/generators/authnz_eleven/templates/migrations/create_password_histories_migration.rb.tt +11 -0
  153. data/lib/generators/authnz_eleven/templates/migrations/create_pending_registrations_migration.rb.tt +31 -0
  154. data/lib/generators/authnz_eleven/templates/migrations/create_recovery_codes_migration.rb.tt +11 -0
  155. data/lib/generators/authnz_eleven/templates/migrations/create_sessions_migration.rb.tt +19 -0
  156. data/lib/generators/authnz_eleven/templates/migrations/create_teams_migration.rb.tt +9 -0
  157. data/lib/generators/authnz_eleven/templates/migrations/create_users_migration.rb.tt +70 -0
  158. data/lib/generators/authnz_eleven/templates/migrations/create_webauthn_credentials_migration.rb.tt +16 -0
  159. data/lib/generators/authnz_eleven/templates/models/api_token.rb.tt +24 -0
  160. data/lib/generators/authnz_eleven/templates/models/concerns/adminable.rb.tt +15 -0
  161. data/lib/generators/authnz_eleven/templates/models/concerns/authenticatable.rb.tt +151 -0
  162. data/lib/generators/authnz_eleven/templates/models/concerns/bannable.rb.tt +24 -0
  163. data/lib/generators/authnz_eleven/templates/models/concerns/deadboltable.rb.tt +39 -0
  164. data/lib/generators/authnz_eleven/templates/models/concerns/email_verifiable.rb.tt +52 -0
  165. data/lib/generators/authnz_eleven/templates/models/concerns/eventable.rb.tt +22 -0
  166. data/lib/generators/authnz_eleven/templates/models/concerns/guestable.rb.tt +30 -0
  167. data/lib/generators/authnz_eleven/templates/models/concerns/invitable.rb.tt +12 -0
  168. data/lib/generators/authnz_eleven/templates/models/concerns/magic_linkable.rb.tt +34 -0
  169. data/lib/generators/authnz_eleven/templates/models/concerns/password_historical.rb.tt +28 -0
  170. data/lib/generators/authnz_eleven/templates/models/concerns/password_policy.rb.tt +38 -0
  171. data/lib/generators/authnz_eleven/templates/models/concerns/password_rotatable.rb.tt +15 -0
  172. data/lib/generators/authnz_eleven/templates/models/concerns/phone_verifiable.rb.tt +50 -0
  173. data/lib/generators/authnz_eleven/templates/models/concerns/principals.rb.tt +67 -0
  174. data/lib/generators/authnz_eleven/templates/models/concerns/recoverable.rb.tt +19 -0
  175. data/lib/generators/authnz_eleven/templates/models/concerns/second_factor.rb.tt +52 -0
  176. data/lib/generators/authnz_eleven/templates/models/concerns/sms_challengeable.rb.tt +38 -0
  177. data/lib/generators/authnz_eleven/templates/models/concerns/team_member.rb.tt +20 -0
  178. data/lib/generators/authnz_eleven/templates/models/current.rb.tt +28 -0
  179. data/lib/generators/authnz_eleven/templates/models/event.rb.tt +32 -0
  180. data/lib/generators/authnz_eleven/templates/models/invitation.rb.tt +122 -0
  181. data/lib/generators/authnz_eleven/templates/models/membership.rb.tt +16 -0
  182. data/lib/generators/authnz_eleven/templates/models/omniauth_identity.rb.tt +12 -0
  183. data/lib/generators/authnz_eleven/templates/models/password_history.rb.tt +3 -0
  184. data/lib/generators/authnz_eleven/templates/models/pending_registration.rb.tt +216 -0
  185. data/lib/generators/authnz_eleven/templates/models/recovery_code.rb.tt +41 -0
  186. data/lib/generators/authnz_eleven/templates/models/session.rb.tt +46 -0
  187. data/lib/generators/authnz_eleven/templates/models/team.rb.tt +12 -0
  188. data/lib/generators/authnz_eleven/templates/models/user.rb.tt +59 -0
  189. data/lib/generators/authnz_eleven/templates/models/webauthn_credential.rb.tt +12 -0
  190. data/lib/generators/authnz_eleven/templates/tasks/authnz_eleven.rake.tt +41 -0
  191. data/lib/generators/authnz_eleven/templates/test_unit/controllers/account_existence_parity_test.rb.tt +158 -0
  192. data/lib/generators/authnz_eleven/templates/test_unit/controllers/admin/access_test.rb.tt +139 -0
  193. data/lib/generators/authnz_eleven/templates/test_unit/controllers/admin/multi_factor_authentication_test.rb.tt +65 -0
  194. data/lib/generators/authnz_eleven/templates/test_unit/controllers/api_token_authentication_test.rb.tt +45 -0
  195. data/lib/generators/authnz_eleven/templates/test_unit/controllers/credential_enrollment_test.rb.tt +84 -0
  196. data/lib/generators/authnz_eleven/templates/test_unit/controllers/guest_access_test.rb.tt +113 -0
  197. data/lib/generators/authnz_eleven/templates/test_unit/controllers/invitations_controller_test.rb.tt +230 -0
  198. data/lib/generators/authnz_eleven/templates/test_unit/controllers/passkey_ceremony_test.rb.tt +126 -0
  199. data/lib/generators/authnz_eleven/templates/test_unit/controllers/password_rotation_test.rb.tt +67 -0
  200. data/lib/generators/authnz_eleven/templates/test_unit/controllers/security_key_ceremony_test.rb.tt +218 -0
  201. data/lib/generators/authnz_eleven/templates/test_unit/controllers/security_notifications_test.rb.tt +39 -0
  202. data/lib/generators/authnz_eleven/templates/test_unit/controllers/session_lifecycle_test.rb.tt +102 -0
  203. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/api_tokens_controller_test.rb.tt +61 -0
  204. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/authentications/events_controller_test.rb.tt +17 -0
  205. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/email_verifications_controller_test.rb.tt +39 -0
  206. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/invitations_controller_test.rb.tt +157 -0
  207. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/management_test.rb.tt +256 -0
  208. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/multi_factor_authentication/management_test.rb.tt +222 -0
  209. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/omniauth_identities_controller_test.rb.tt +57 -0
  210. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/phone_verifications_controller_test.rb.tt +146 -0
  211. data/lib/generators/authnz_eleven/templates/test_unit/controllers/teams_controller_test.rb.tt +83 -0
  212. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/email_verifications_controller_test.rb.tt +74 -0
  213. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/magic_link_controller_test.rb.tt +90 -0
  214. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/multi_factor_authentication/challenge_test.rb.tt +143 -0
  215. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/omniauth/registrations_controller_test.rb.tt +138 -0
  216. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/omniauth_controller_test.rb.tt +97 -0
  217. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/password_resets_controller_test.rb.tt +58 -0
  218. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations/email_verifications_controller_test.rb.tt +102 -0
  219. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations/phone_verifications_controller_test.rb.tt +69 -0
  220. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations_controller_test.rb.tt +180 -0
  221. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sessions_controller_test.rb.tt +124 -0
  222. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sms_sessions_controller_test.rb.tt +111 -0
  223. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sudos_controller_test.rb.tt +110 -0
  224. data/lib/generators/authnz_eleven/templates/test_unit/fixtures/memberships.yml.tt +13 -0
  225. data/lib/generators/authnz_eleven/templates/test_unit/fixtures/teams.yml.tt +2 -0
  226. data/lib/generators/authnz_eleven/templates/test_unit/fixtures/users.yml.tt +68 -0
  227. data/lib/generators/authnz_eleven/templates/test_unit/mailers/user_mailer_test.rb.tt +88 -0
  228. data/lib/generators/authnz_eleven/templates/test_unit/models/api_token_test.rb.tt +38 -0
  229. data/lib/generators/authnz_eleven/templates/test_unit/models/password_history_test.rb.tt +51 -0
  230. data/lib/generators/authnz_eleven/templates/test_unit/models/session_test.rb.tt +16 -0
  231. data/lib/generators/authnz_eleven/templates/test_unit/models/sms_challengeable_test.rb.tt +108 -0
  232. data/lib/generators/authnz_eleven/templates/test_unit/models/user/guestable_test.rb.tt +78 -0
  233. data/lib/generators/authnz_eleven/templates/test_unit/models/user/recovery_codes_test.rb.tt +32 -0
  234. data/lib/generators/authnz_eleven/templates/test_unit/models/user/sign_in_methods_test.rb.tt +56 -0
  235. data/lib/generators/authnz_eleven/templates/test_unit/models/user_test.rb.tt +145 -0
  236. data/lib/generators/authnz_eleven/wizard_generator.rb +52 -0
  237. metadata +309 -0
@@ -0,0 +1,3040 @@
1
+ # frozen_string_literal: false
2
+
3
+ require "rails/generators/active_record"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/string/filters"
6
+ require_relative "../../authnz_eleven/version"
7
+ require_relative "../../authnz_eleven/identity"
8
+ require_relative "../../authnz_eleven/principals"
9
+
10
+ # Generates a pre-built authentication system into a Rails application.
11
+ #
12
+ # The core is DB-backed sessions, channel verification and /settings. Doors —
13
+ # --password, --magic-link, --sms-code, --omniauth, --passkey — and everything
14
+ # else is selected through flags; at least one principal and one door are required.
15
+ #
16
+ # Behavior is split into concerns rather than a fat User, and tunables live in
17
+ # the generated initializer rather than in flags.
18
+ class AuthnzElevenGenerator < Rails::Generators::Base
19
+ include ActiveRecord::Generators::Migration
20
+
21
+ # With this many flags, a misspelling is the likeliest way to get a build that is
22
+ # quietly missing a feature: Thor's default is to ignore what it doesn't
23
+ # recognise, so `--password-rotateable` would generate an app with no rotation and say
24
+ # nothing. Refuse instead.
25
+ check_unknown_options!
26
+
27
+ class_option :email, type: :string, lazy_default: "required", group: "Identifier", desc: "Sign in with an email address: required (default) or optional. Add ,permanent to stop users changing it"
28
+ class_option :phone, type: :string, lazy_default: "required", group: "Identifier", desc: "Sign in with a phone number: required (default) or optional. Add ,permanent to stop users changing it"
29
+ class_option :username, type: :boolean, group: "Identifier", desc: "Sign in with a unique, unchangeable username"
30
+ class_option :registration, type: :string, default: "open", enum: %w[open open-and-invites invite-only closed], group: "Identifier", desc: "Who can create an account"
31
+ class_option :contactable, type: :boolean, default: true, group: "Identifier", desc: "Every account must have an email or phone, so you can reach them"
32
+ class_option :verifiable, type: :boolean, default: true, group: "Identifier", desc: "Verify emails by link and phones by texted code"
33
+ class_option :coy, type: :boolean, default: false, group: "Identifier", desc: "Respond the same whether or not an account exists for an email or phone"
34
+ class_option :encrypted_pii, type: :boolean, group: "Identifier", desc: "Encrypt emails and phone numbers at rest"
35
+ class_option :guestable, type: :boolean, group: "Identifier", desc: "Anonymous guest users, absorbed into the account when they sign up"
36
+
37
+ class_option :password, type: :string, lazy_default: "required", enum: %w[required optional deferred], group: "Sign-in", desc: "Password sign-in. deferred asks for it after verification"
38
+ class_option :magic_link, type: :boolean, group: "Sign-in", desc: "Sign in by emailed link"
39
+ class_option :sms_code, type: :boolean, group: "Sign-in", desc: "Sign in by texted six-digit code. Needs --phone"
40
+ class_option :passkey, type: :boolean, group: "Sign-in", desc: "Sign in with a passkey"
41
+ class_option :omniauth, type: :boolean, group: "Sign-in", desc: "Social / SSO login via OmniAuth"
42
+
43
+ class_option :recoverable, type: :boolean, group: "Password", desc: "Password reset by emailed link"
44
+ class_option :pwned, type: :boolean, group: "Password", desc: "Reject passwords found in known data breaches"
45
+ class_option :strong_passwords, type: :boolean, group: "Password", desc: "Reject weak passwords by zxcvbn score"
46
+ class_option :deadboltable, type: :boolean, group: "Password", desc: "Shut password sign-in for a while after too many failures. Needs --password"
47
+ class_option :password_rotatable, type: :boolean, group: "Password", desc: "Make users change their password every 90 days"
48
+ class_option :password_historical, type: :boolean, group: "Password", desc: "Reject reuse of the last 5 passwords"
49
+
50
+ class_option :rememberable, type: :boolean, group: "Session", desc: "A 'remember me' checkbox on password sign-in"
51
+ class_option :timeoutable, type: :boolean, group: "Session", desc: "Sign users out after a period of inactivity"
52
+ class_option :max_sessionable, type: :string, lazy_default: "evict", enum: %w[evict prompt], group: "Session", desc: "Cap concurrent sessions: evict the oldest (default), or prompt the user to end one"
53
+ class_option :last_seenable, type: :boolean, group: "Session", desc: "Record when each user was last seen"
54
+ class_option :trackable, type: :boolean, group: "Session", desc: "An audit trail of authentication activity"
55
+
56
+ class_option :api_tokens, type: :boolean, group: "API", desc: "Personal access tokens for your own API endpoints"
57
+
58
+ class_option :second_factor, type: :string, lazy_default: "totp", group: "Hardening", desc: "Two-factor auth: totp (default), webauthn, sms, or a comma-separated mix"
59
+ class_option :sudoable, type: :boolean, group: "Hardening", desc: "Ask users to authenticate again before dangerous actions"
60
+ class_option :captchable, type: :boolean, group: "Hardening", desc: "A Cloudflare Turnstile captcha on the signed-out forms"
61
+ class_option :security_notifications, type: :boolean, group: "Hardening", desc: "Email users when security settings change. Needs --email"
62
+
63
+ class_option :adminable, type: :boolean, group: "Admin", desc: "Users can be site admins, set in the database"
64
+ class_option :admin_dashboard, type: :boolean, group: "Admin", desc: "An /admin area for users and sessions"
65
+ class_option :bannable, type: :boolean, group: "Admin", desc: "Admins can ban users, temporarily or permanently"
66
+ class_option :impersonatable, type: :boolean, group: "Admin", desc: "Admins can impersonate other users"
67
+ class_option :easy_dev_login, type: :boolean, group: "Admin", desc: "Development only: sign in as anyone"
68
+
69
+ class_option :teams, type: :string, lazy_default: "scope", enum: %w[scope middleware session], group: "Team", desc: "Teams and memberships. The current team lives in the URL (scope, middleware) or the session"
70
+
71
+ class_option :user_class, type: :string, group: "Identity class", desc: "Name of the identity class (default User)"
72
+ class_option :namespaced, type: :boolean, group: "Identity class", desc: "Its own URL prefix and namespace. Required for a second identity"
73
+ class_option :primary_key_type, type: :string, group: "Identity class", desc: "Primary key type for the migrations, e.g. uuid"
74
+
75
+ source_root File.expand_path("templates", __dir__)
76
+
77
+ def validate_principal!
78
+ return if principals.any?
79
+
80
+ raise Rails::Generators::Error, <<~MSG.squish
81
+ No identifier was selected. Add --email, --phone or --username.
82
+ MSG
83
+ end
84
+
85
+ # Fail fast when a generated app would have no way to sign in.
86
+ def validate_authentication_strategy!
87
+ return if password? || magic_link? || omniauth? || passkey? || sms_code?
88
+
89
+ raise Rails::Generators::Error, <<~MSG.squish
90
+ No sign-in method was selected. Add --password, --magic-link, --sms-code,
91
+ --passkey or --omniauth.
92
+ MSG
93
+ end
94
+
95
+ # --second-factor names the factors an account may enroll, so an unknown one is a
96
+ # typo that would otherwise generate a build quietly missing the factor asked for.
97
+ def validate_second_factor!
98
+ unknown = second_factors - %i[totp webauthn sms]
99
+
100
+ unless unknown.empty?
101
+ raise Rails::Generators::Error, <<~MSG.squish
102
+ Unknown second factor#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")}.
103
+ Use totp, webauthn, sms, or a comma-separated mix like totp,webauthn.
104
+ For passkey sign-in, use --passkey.
105
+ MSG
106
+ end
107
+
108
+ return unless sms_second_factor?
109
+
110
+ if principals.phone.nil?
111
+ raise Rails::Generators::Error, <<~MSG.squish
112
+ --second-factor=sms needs --phone.
113
+ MSG
114
+ end
115
+
116
+ return unless sms_code?
117
+
118
+ raise Rails::Generators::Error, <<~MSG.squish
119
+ --sms-code and --second-factor=sms both text a code to the same phone, so
120
+ the second text adds no security. Use one or the other.
121
+ MSG
122
+ end
123
+
124
+ # A deadbolt counts failed guesses at a password, so without one there is nothing
125
+ # to count and nothing to bar. Refused rather than ignored: silently generating
126
+ # no deadbolt for a build that asked for one is how you end up believing you have a
127
+ # brute-force defence that was never emitted.
128
+ def validate_deadboltable!
129
+ return unless options.deadboltable?
130
+ return if password?
131
+
132
+ raise Rails::Generators::Error, <<~MSG.squish
133
+ --deadboltable needs --password. The other sign-in methods can't be
134
+ guessed. To block an account entirely, use --bannable.
135
+ MSG
136
+ end
137
+
138
+ def validate_coy!
139
+ return unless coy? && !options.verifiable?
140
+
141
+ raise Rails::Generators::Error, <<~MSG.squish
142
+ --coy needs verification. Without it, sign-up says when an email or phone
143
+ number is already taken. Remove --no-verifiable, or remove --coy.
144
+ MSG
145
+ end
146
+
147
+ def validate_sudoable!
148
+ return unless sudoable?
149
+ return if sudo_bars.any?
150
+
151
+ raise Rails::Generators::Error, <<~MSG.squish
152
+ --sudoable needs a password, a passkey, or a totp or webauthn second
153
+ factor to ask for. Add --password, --passkey or --second-factor.
154
+ MSG
155
+ end
156
+
157
+ def validate_security_notifications!
158
+ return unless security_notifications? && principals.email.nil?
159
+
160
+ raise Rails::Generators::Error, <<~MSG.squish
161
+ --security-notifications needs --email so there is somewhere to send them.
162
+ MSG
163
+ end
164
+
165
+ # --password=optional means "optional because another door covers you" — the
166
+ # sense --contactable gives --email=optional. With no other door there is
167
+ # nothing to be optional against, and a blank field would mint an account that
168
+ # can never sign in. Same shape as validate_email_none!, and the same reason
169
+ # --contactable refuses a lone optional channel.
170
+ def validate_password_optional!
171
+ return unless password_form_optional?
172
+ return if non_password_doors?
173
+
174
+ raise Rails::Generators::Error, <<~MSG.squish
175
+ --password=optional needs another sign-in method, or users who skip the
176
+ password can't sign in. Add --passkey, --omniauth, --magic-link or
177
+ --sms-code, or use --password.
178
+ MSG
179
+ end
180
+
181
+ # --password=deferred moves the password off the sign-up form onto a second
182
+ # page. That page must earn its place for every sign-up: it either follows a
183
+ # channel verification, or offers a choice between a password and a passkey.
184
+ # Otherwise it is one page's work split across two.
185
+ def validate_password_deferred!
186
+ return unless password_deferred?
187
+ return if passkey?
188
+ return if contactable? && (email_verifiable? || phone_verifiable?)
189
+
190
+ raise Rails::Generators::Error, <<~MSG.squish
191
+ --password=deferred asks for the password after the user verifies their
192
+ email or phone, and with these flags not every sign-up does. Add --passkey,
193
+ or use --password or --password=optional.
194
+ MSG
195
+ end
196
+
197
+ # --sms-code texts the code to the number on the account.
198
+ def validate_sms_code_option!
199
+ return unless options.sms_code?
200
+
201
+ if principals.phone.nil?
202
+ raise Rails::Generators::Error, <<~MSG.squish
203
+ --sms-code needs --phone.
204
+ MSG
205
+ end
206
+
207
+ return unless principals.phone.optional?
208
+ return if password? || magic_link? || omniauth? || passkey?
209
+
210
+ raise Rails::Generators::Error, <<~MSG.squish
211
+ --sms-code is the only sign-in method, so every account needs a phone
212
+ number. Use --phone, or add another sign-in method.
213
+ MSG
214
+ end
215
+
216
+ # --email takes a requiredness word, optionally followed by modifiers.
217
+ def validate_email_option!
218
+ return unless options[:email]
219
+
220
+ return if principal_mode_valid?(options[:email])
221
+
222
+ raise Rails::Generators::Error, <<~MSG.squish
223
+ --email=#{options[:email]} isn't valid. Use --email, --email=optional or
224
+ --email=required,permanent.
225
+ MSG
226
+ end
227
+
228
+ # --phone mirrors --email's value guard. Omission means no phone column.
229
+ def validate_phone_option!
230
+ return unless options[:phone]
231
+
232
+ return if principal_mode_valid?(options[:phone])
233
+
234
+ raise Rails::Generators::Error, <<~MSG.squish
235
+ --phone=#{options[:phone]} isn't valid. Use --phone, --phone=optional or
236
+ --phone=required,permanent.
237
+ MSG
238
+ end
239
+
240
+ # "permanent" removes the settings surface for a principal, so the value a row
241
+ # is created with is the one it keeps. That only holds when the value is there
242
+ # from the start. An optional one may arrive later, which needs the settings form.
243
+ def validate_permanent!
244
+ principal = principals.select(&:permanent?).find(&:optional?)
245
+ return unless principal
246
+
247
+ raise Rails::Generators::Error, <<~MSG.squish
248
+ --#{principal.column}=optional,permanent isn't supported. A permanent value
249
+ has to be given at sign-up. Use --#{principal.column}=required,permanent.
250
+ MSG
251
+ end
252
+
253
+ # --no-verifiable leaves nobody having proved the address, so permanent freezes
254
+ # whatever was typed, typo included: no reset, no magic link, no settings form.
255
+ # Warn rather than refuse — the column stays writable, so an admin or a console
256
+ # can still fix it, and that is the host's call to make.
257
+ def warn_about_unverified_permanent!
258
+ permanent = principals.select { |p| p.permanent? && !channel_verifiable?(p) }
259
+ return if permanent.empty?
260
+
261
+ say_status :warning, <<~MSG.squish, :yellow
262
+ --#{permanent.map(&:column).join(" and --")}=required,permanent with --no-verifiable:
263
+ the value is never verified, so users can't fix a typo from sign-up. Only an
264
+ admin can.
265
+ MSG
266
+ end
267
+
268
+ def validate_channel_dependencies!
269
+ conflicts = []
270
+ conflicts << "--recoverable needs --email." if recoverable? && principals.email.nil?
271
+ conflicts << "--magic-link needs --email." if magic_link? && principals.email.nil?
272
+ conflicts << "--registration=#{options[:registration]} needs --email or --phone." if invitable? && principals.channels.empty?
273
+ return if conflicts.empty?
274
+
275
+ raise Rails::Generators::Error, conflicts.join(" ")
276
+ end
277
+
278
+ # --contactable promises every account holds a channel. With TWO optional channels
279
+ # that promise is a real record-level rule (Principals#must_be_contactable): give a
280
+ # number or an address, either will do. With only ONE channel there is nothing for
281
+ # "optional" to be optional against, and the promise and the flag disagree.
282
+ #
283
+ # Refuse rather than resolve it. Promoting the column to required would override
284
+ # what the author wrote without saying so; leaving it alone would let the flag be
285
+ # on while accounts hold no channel at all. Making them say which they meant is
286
+ # the only option honest in both directions.
287
+ def validate_contactable!
288
+ return unless contactable?
289
+
290
+ channels = principals.select(&:channel?)
291
+ return if channels.size > 1 || channels.any?(&:required?)
292
+ return if channels.empty? # A username-only build has no channel to require.
293
+
294
+ only = channels.sole
295
+ raise Rails::Generators::Error, <<~MSG.squish
296
+ With --#{only.column}=optional and no #{only.type == :email ? "phone" : "email"},
297
+ an account could have no way to be contacted. Use --#{only.column},
298
+ add --#{only.type == :email ? "phone=optional" : "email=optional"}, or allow it
299
+ with --no-contactable.
300
+ MSG
301
+ end
302
+
303
+ # A generated identity claims a top-level Ruby constant for its model (e.g.
304
+ # --user-class=Admin -> class Admin, app/models/admin.rb). If that constant is
305
+ # already a namespace on disk (e.g. app/controllers/admin/ from --admin-dashboard's
306
+ # Admin::* controllers), Zeitwerk raises at boot — far from where the mistake
307
+ # was made. Catch it here instead. Checks what is actually on disk rather than
308
+ # hardcoding "admin" as a reserved word, so it also catches collisions with any
309
+ # other existing app/controllers namespace.
310
+ def validate_no_constant_collision!
311
+ if admin_dashboard? && identity.singular == "admin" && !identity.namespaced?
312
+ raise Rails::Generators::Error, <<~MSG.squish
313
+ --admin-dashboard and --user-class=Admin can't be combined: both define
314
+ Admin. Choose a different --user-class.
315
+ MSG
316
+ end
317
+
318
+ namespace_dir = in_destination("app/controllers/#{identity.singular}")
319
+ # An empty (or nonexistent) directory defines no autoload entry, so it can't
320
+ # actually collide — only files inside it would. Check for those, not just
321
+ # the directory's existence, to avoid flagging harmless leftover/stray dirs.
322
+ return if Dir.glob("#{namespace_dir}/**/*.rb").none?
323
+
324
+ raise Rails::Generators::Error, <<~MSG.squish
325
+ --user-class=#{identity.class_name} can't be used: app/controllers/#{identity.singular}/
326
+ already defines a #{identity.class_name} module, which would clash with the
327
+ #{identity.class_name} model. Choose a different --user-class.
328
+ MSG
329
+ end
330
+
331
+ # A second (or later) identity must be namespaced — a run without --namespaced
332
+ # always writes to the same shared paths (app/models/session.rb, app/models/current.rb,
333
+ # app/controllers/application_controller.rb, ...) regardless of --user-class, so
334
+ # a second such run collides with the incumbent identity. Catch it up front
335
+ # with a clear message instead of Rails' per-file overwrite prompt.
336
+ def validate_second_identity_namespaced!
337
+ return if identity.namespaced?
338
+ return unless File.exist?(in_destination("app/models/session.rb"))
339
+ return if File.read(in_destination("app/models/session.rb")).include?("belongs_to :#{identity.singular}")
340
+
341
+ raise Rails::Generators::Error, <<~MSG.squish
342
+ This app already has an identity, so a second one must be generated
343
+ with --namespaced. Re-run with --user-class=#{identity.class_name} --namespaced.
344
+ MSG
345
+ end
346
+
347
+ def validate_middleware_teams!
348
+ return unless middleware_teams? && identity.namespaced?
349
+
350
+ raise Rails::Generators::Error, <<~MSG.squish
351
+ --teams=middleware can't be used with --namespaced. Use --teams or
352
+ --teams=session.
353
+ MSG
354
+ end
355
+
356
+ def add_gems
357
+ if password?
358
+ if bcrypt_present?
359
+ uncomment_lines "Gemfile", /gem "bcrypt"/
360
+ else
361
+ gem "bcrypt", "~> 3.1.7", comment: "Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]"
362
+ end
363
+ end
364
+
365
+ if principals.email
366
+ gem "valid_email2", comment: "Use valid_email2 for well-tested email syntax validation [https://github.com/micke/valid_email2]"
367
+ end
368
+
369
+ if phone?
370
+ gem "phonelib", comment: "Use phonelib to parse, validate, and normalize phone numbers to E.164 [https://github.com/daddyz/phonelib]"
371
+ end
372
+
373
+ if omniauth?
374
+ gem "omniauth", ">= 2.0", comment: "Use OmniAuth to support multi-provider authentication [https://github.com/omniauth/omniauth]"
375
+ end
376
+
377
+ if second_factor?
378
+ gem "rotp", comment: "Use rotp for generating and validating one time passwords [https://github.com/mdp/rotp]"
379
+ gem "rqrcode", comment: "Use rqrcode for creating and rendering QR codes into various formats [https://github.com/whomwah/rqrcode]"
380
+ end
381
+
382
+ if webauthn_credentials?
383
+ gem "webauthn", comment: "Use webauthn to make Rails a conformant WebAuthn relying party [https://github.com/cedarcode/webauthn-ruby]"
384
+ end
385
+
386
+ if pwned?
387
+ gem "pwned", comment: "Use Pwned to check if a password has been found in any data breach [https://github.com/philnash/pwned]"
388
+ end
389
+
390
+ if strong_passwords?
391
+ gem "zxcvbn", comment: "Use zxcvbn to score password strength and reject weak passwords [https://github.com/envato/zxcvbn-ruby]"
392
+ end
393
+ end
394
+
395
+ def create_configuration_files
396
+ template "config/initializers/authentication.rb.tt", "config/initializers/#{identity.initializer_file}.rb"
397
+ template "config/locales/authentication.en.yml.tt", "config/locales/#{identity.initializer_file}.en.yml"
398
+ install_omniauth_initializer
399
+ template "config/initializers/webauthn.rb.tt", "config/initializers/webauthn.rb" if webauthn_credentials? && !File.exist?(in_destination("config/initializers/webauthn.rb"))
400
+ template "config/initializers/team_slug.rb.tt", "config/initializers/team_slug.rb" if middleware_teams?
401
+
402
+ if principals.username
403
+ # The forbidden-username blocklist is plain data read by the user model's
404
+ # validation, and it's identity-free — a second --username identity reuses
405
+ # the first writer's copy (first-writer-wins, like the captcha lib).
406
+ copy_file "config/forbidden_usernames.txt", "config/forbidden_usernames.txt" unless File.exist?(in_destination("config/forbidden_usernames.txt"))
407
+ copy_file "config/forbidden_usernames.LICENSE", "config/forbidden_usernames.LICENSE" unless File.exist?(in_destination("config/forbidden_usernames.LICENSE"))
408
+ end
409
+ end
410
+
411
+ # Rails' stock filter list doesn't reach the columns this generator adds, so
412
+ # they log in the clear — and since ActiveRecord::Base.filter_attributes reads
413
+ # the same list, they print from `inspect` too.
414
+ #
415
+ # They go in config/initializers/filter_parameter_logging.rb rather than our own
416
+ # initializer: that file is where someone auditing "what does this app redact?"
417
+ # looks. It belongs to the host app, so this only appends, and only what isn't
418
+ # already there (a re-run, or a second identity sharing a filter with the first).
419
+ def install_parameter_filters
420
+ names = parameter_filters
421
+ initializer = "config/initializers/filter_parameter_logging.rb"
422
+ return if names.empty?
423
+
424
+ unless File.exist?(in_destination(initializer))
425
+ @parameter_filter_line = filter_parameters_line(names)
426
+ return
427
+ end
428
+
429
+ contents = File.read(in_destination(initializer))
430
+ names = names.reject { |name| contents.include?(filter_regexp(name)) }
431
+ return if names.empty?
432
+
433
+ append_to_file initializer, <<~RUBY
434
+
435
+ # Added by authnz_eleven: identifiers and secrets this authentication system
436
+ # stores or receives that the list above doesn't reach. Anchored rather than
437
+ # substring-matched (the notation the list above uses) so that, for example,
438
+ # `uid` doesn't also swallow `uuid`, and `code` leaves unrelated names that
439
+ # merely contain the same substring readable.
440
+ #{filter_parameters_line(names)}
441
+ RUBY
442
+ end
443
+
444
+ # One builder per identity, in its own file: a strategy's path_prefix must match
445
+ # the prefix its callbacks are routed under, and each identity picks its providers.
446
+ def install_omniauth_initializer
447
+ return unless omniauth?
448
+
449
+ template "config/initializers/omniauth.rb.tt", "config/initializers/#{identity.namespaced? ? "#{identity.singular}_omniauth" : "omniauth"}.rb"
450
+ end
451
+
452
+ def omniauth_path_prefix
453
+ identity.namespaced? ? "/#{identity.path_prefix}/auth" : "/auth"
454
+ end
455
+
456
+ # Rails ships :null_store in test. Texted codes and magic links live in the cache
457
+ # and fail closed on a miss, so a build with either needs a real store for its
458
+ # own suite to pass. Throttles fail open and aren't listed.
459
+ def configure_test_cache
460
+ environment = "config/environments/test.rb"
461
+ return unless sms? || magic_link?
462
+ return unless File.read(in_destination(environment)).include?("config.cache_store = :null_store")
463
+
464
+ gsub_file environment, "config.cache_store = :null_store", "config.cache_store = :memory_store"
465
+
466
+ test_helper = "test/test_helper.rb"
467
+ reset = "ActiveSupport::TestCase.setup { Rails.cache.clear }"
468
+ return unless File.exist?(in_destination(test_helper)) && !File.read(in_destination(test_helper)).include?(reset)
469
+
470
+ append_to_file test_helper, "\n#{reset}\n"
471
+ end
472
+
473
+ # Behaviour tests see deferred work done by the time the request returns. The
474
+ # emitted account-existence parity test swaps the adapter back to prove it left.
475
+ def configure_test_deferred_jobs
476
+ test_helper = "test/test_helper.rb"
477
+ inline = "AuthnzElevenDeferredJob.queue_adapter = :inline"
478
+ return unless coy? && File.exist?(in_destination(test_helper))
479
+ return if File.read(in_destination(test_helper)).include?(inline)
480
+
481
+ append_to_file test_helper, "\n#{inline}\n"
482
+ end
483
+
484
+ # Tests answer a WebAuthn sudo bar by tapping whichever fake key @client holds.
485
+ def configure_test_webauthn
486
+ test_helper = "test/test_helper.rb"
487
+ return unless webauthn_credentials? && File.exist?(in_destination(test_helper))
488
+
489
+ require_line = %(require "webauthn/fake_client")
490
+ append_to_file test_helper, "\n#{require_line}\n" unless File.read(in_destination(test_helper)).include?(require_line)
491
+ assertion = "#{identity.route_scope}_sudo_assertion"
492
+ return if !sudoable? || File.read(in_destination(test_helper)).include?("def #{assertion}")
493
+
494
+ append_to_file test_helper, <<~RUBY
495
+
496
+ class ActionDispatch::IntegrationTest
497
+ def #{assertion}
498
+ get new_#{identity.route_scope}_sudo_path(format: :json)
499
+ @client.get(challenge: response.parsed_body.fetch("challenge"), rp_id: WebAuthn.configuration.rp_id,
500
+ user_verified: true).to_json
501
+ end
502
+ end
503
+ RUBY
504
+ end
505
+
506
+ # Fixtures are written in the clear, so without this the suite stores plaintext into
507
+ # an encrypted column and every read of it raises. Rails defaults it off, and the
508
+ # file belongs to the host app, so only append when it isn't already set.
509
+ def configure_fixture_encryption
510
+ environment = "config/environments/test.rb"
511
+ setting = "config.active_record.encryption.encrypt_fixtures = true"
512
+ return unless encryption? && File.exist?(in_destination(environment))
513
+ return if File.read(in_destination(environment)).include?(setting)
514
+
515
+ inject_into_file environment, "\n #{setting}\n", before: /^end\b/
516
+ end
517
+
518
+ def create_migrations
519
+ migration_template "migrations/create_users_migration.rb.tt", "#{db_migrate_path}/create_#{identity.table}.rb"
520
+ migration_template "migrations/create_teams_migration.rb.tt", "#{db_migrate_path}/create_#{identity.teams_table}.rb" if teams?
521
+ migration_template "migrations/create_memberships_migration.rb.tt", "#{db_migrate_path}/create_#{identity.memberships_table}.rb" if teams?
522
+ migration_template "migrations/create_sessions_migration.rb.tt", "#{db_migrate_path}/create_#{identity.sessions_table}.rb"
523
+ migration_template "migrations/create_api_tokens_migration.rb.tt", "#{db_migrate_path}/create_#{identity.api_tokens_table}.rb" if api_tokens?
524
+ migration_template "migrations/create_events_migration.rb.tt", "#{db_migrate_path}/create_#{identity.events_table}.rb" if trackable?
525
+ migration_template "migrations/create_recovery_codes_migration.rb.tt", "#{db_migrate_path}/create_#{identity.recovery_codes_table}.rb" if second_factor?
526
+ migration_template "migrations/create_webauthn_credentials_migration.rb.tt", "#{db_migrate_path}/create_#{identity.webauthn_credentials_table}.rb" if webauthn_credentials?
527
+ migration_template "migrations/create_password_histories_migration.rb.tt", "#{db_migrate_path}/create_#{identity.password_histories_table}.rb" if password_historical?
528
+ migration_template "migrations/create_invitations_migration.rb.tt", "#{db_migrate_path}/create_#{identity.invitations_table}.rb" if invitable?
529
+ migration_template "migrations/create_pending_registrations_migration.rb.tt", "#{db_migrate_path}/create_#{identity.pending_registrations_table}.rb" if pending_registration_model?
530
+ migration_template "migrations/create_omniauth_identities_migration.rb.tt", "#{db_migrate_path}/create_#{identity.omniauth_identities_table}.rb" if omniauth?
531
+ migration_template "migrations/add_guest_to_users_migration.rb.tt", "#{db_migrate_path}/add_guest_to_#{identity.table}.rb" if guestable?
532
+ end
533
+
534
+ def create_models
535
+ template "models/current.rb.tt", "app/models/#{identity.current_file}.rb"
536
+ template "models/user.rb.tt", "app/models/#{identity.singular}.rb"
537
+ template "models/session.rb.tt", "app/models/#{identity.session_file}.rb"
538
+ template "models/api_token.rb.tt", "app/models/#{identity.api_token_file}.rb" if api_tokens?
539
+
540
+ template "models/concerns/principals.rb.tt", shared_concern_path(identity.principals_concern_file)
541
+ template "models/concerns/password_policy.rb.tt", shared_concern_path(identity.password_policy_concern_file) if password?
542
+ template "models/concerns/authenticatable.rb.tt", "app/models/#{identity.singular}/authenticatable.rb"
543
+ template "models/concerns/password_rotatable.rb.tt", "app/models/#{identity.singular}/password_rotatable.rb" if password_rotatable?
544
+ template "models/concerns/password_historical.rb.tt", "app/models/#{identity.singular}/password_historical.rb" if password_historical?
545
+ template "models/concerns/recoverable.rb.tt", "app/models/#{identity.singular}/recoverable.rb" if recoverable?
546
+ template "models/concerns/magic_linkable.rb.tt", "app/models/#{identity.singular}/magic_linkable.rb" if magic_link?
547
+ template "models/concerns/second_factor.rb.tt", "app/models/#{identity.singular}/second_factor.rb" if second_factor?
548
+ template "models/concerns/deadboltable.rb.tt", "app/models/#{identity.singular}/deadboltable.rb" if deadboltable?
549
+ template "models/concerns/bannable.rb.tt", "app/models/#{identity.singular}/bannable.rb" if bannable?
550
+ template "models/concerns/email_verifiable.rb.tt", "app/models/#{identity.singular}/email_verifiable.rb" if email_change_verification?
551
+ template "models/concerns/phone_verifiable.rb.tt", "app/models/#{identity.singular}/phone_verifiable.rb" if phone_change_verification?
552
+ template "models/concerns/sms_challengeable.rb.tt", "app/models/#{identity.singular}/sms_challengeable.rb" if sms?
553
+
554
+ if trackable?
555
+ template "models/event.rb.tt", "app/models/#{identity.event_file}.rb"
556
+ template "models/concerns/eventable.rb.tt", shared_concern_path(identity.namespaced_class("Eventable").underscore)
557
+ end
558
+
559
+ template "models/recovery_code.rb.tt", "app/models/#{identity.recovery_code_file}.rb" if second_factor?
560
+ template "models/webauthn_credential.rb.tt", "app/models/#{identity.webauthn_credential_file}.rb" if webauthn_credentials?
561
+ template "models/password_history.rb.tt", "app/models/#{identity.password_history_file}.rb" if password_historical?
562
+
563
+ template "models/pending_registration.rb.tt", "app/models/#{identity.pending_registration_file}.rb" if pending_registration_model?
564
+ template "models/omniauth_identity.rb.tt", "app/models/#{identity.omniauth_identity_file}.rb" if omniauth?
565
+
566
+ template "models/concerns/adminable.rb.tt", "app/models/#{identity.singular}/adminable.rb" if adminable?
567
+
568
+ if teams?
569
+ template "models/team.rb.tt", "app/models/#{identity.team_file}.rb"
570
+ template "models/membership.rb.tt", "app/models/#{identity.membership_file}.rb"
571
+ template "models/concerns/team_member.rb.tt", "app/models/#{identity.singular}/team_member.rb"
572
+ end
573
+
574
+ if invitable?
575
+ template "models/invitation.rb.tt", "app/models/#{identity.invitation_file}.rb"
576
+ template "models/concerns/invitable.rb.tt", "app/models/#{identity.singular}/invitable.rb"
577
+ end
578
+
579
+ template "models/concerns/guestable.rb.tt", "app/models/#{identity.singular}/guestable.rb" if guestable?
580
+ end
581
+
582
+ def create_controllers
583
+ template "controllers/concerns/authentication.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}.rb"
584
+ template "controllers/concerns/api_token_authentication.rb.tt", "app/controllers/concerns/#{identity.nested_path("api_token_authentication")}.rb" if api_tokens?
585
+ # Nested under the identity's concern rather than a flat app/controllers/concerns/
586
+ # because it reads that identity's config, routes and sudo subject — a second
587
+ # identity would otherwise clobber the first's copy (the EasyDevLogin shape).
588
+ template "controllers/concerns/authentication/sudoable.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/sudoable.rb" if sudoable?
589
+ template "controllers/concerns/authentication/webauthn_ceremony.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/webauthn_ceremony.rb" if webauthn_credentials?
590
+ template "controllers/concerns/authentication/registering.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/registering.rb" if pending_registration_model?
591
+ template "controllers/concerns/authorization.rb.tt", "app/controllers/concerns/#{identity.authorization_concern_file}.rb" if authorization?
592
+
593
+ if captchable?
594
+ template "controllers/concerns/captchable.rb.tt", "app/controllers/concerns/captchable.rb" unless File.exist?(in_destination("app/controllers/concerns/captchable.rb"))
595
+ # Provider dispatch + the Turnstile strategy. app/lib is autoloaded, and the
596
+ # Captcha namespace is where a second provider (Recaptcha, Hcaptcha) drops in.
597
+ template "lib/captcha.rb.tt", "app/lib/captcha.rb" unless File.exist?(in_destination("app/lib/captcha.rb"))
598
+ template "lib/captcha/turnstile.rb.tt", "app/lib/captcha/turnstile.rb" unless File.exist?(in_destination("app/lib/captcha/turnstile.rb"))
599
+ template "lib/captcha/null.rb.tt", "app/lib/captcha/null.rb" unless File.exist?(in_destination("app/lib/captcha/null.rb"))
600
+ end
601
+
602
+ if sms_delivery?
603
+ # The SMS seam, identity-free and shared: an SMS provider is app-global in
604
+ # practice, so the first identity to need one writes these and later ones
605
+ # reuse them. Same first-writer-wins shape as app/lib/captcha.rb, and the
606
+ # same caveat — sms.rb bakes in its writer's config namespace. Keep the two
607
+ # seams consistent if that coupling ever gets a cleaner resolution.
608
+ template "lib/sms.rb.tt", "app/lib/sms.rb" unless File.exist?(in_destination("app/lib/sms.rb"))
609
+ template "lib/sms/twilio.rb.tt", "app/lib/sms/twilio.rb" unless File.exist?(in_destination("app/lib/sms/twilio.rb"))
610
+ template "lib/sms/log.rb.tt", "app/lib/sms/log.rb" unless File.exist?(in_destination("app/lib/sms/log.rb"))
611
+ template "jobs/sms_delivery_job.rb.tt", "app/jobs/sms_delivery_job.rb" unless File.exist?(in_destination("app/jobs/sms_delivery_job.rb"))
612
+ end
613
+
614
+ if easy_dev_login?
615
+ if password?
616
+ # A password form already collects an email, so the shortcut piggybacks
617
+ # on it (type any email, password ignored) — no dedicated endpoint needed.
618
+ template "controllers/concerns/authentication/easy_dev_login.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/easy_dev_login.rb"
619
+ else
620
+ # Passwordless: there's no credential form to ride, so ship an explicit
621
+ # dev-only endpoint the email field on the sign-in page posts to.
622
+ template "controllers/users/easy_dev_logins_controller.rb.tt", "app/controllers/#{identity.controller_module}/easy_dev_logins_controller.rb"
623
+ end
624
+ end
625
+
626
+ if identity.namespaced?
627
+ # A namespaced identity never touches the shared ApplicationController — its
628
+ # authentication concern (and Authorization/Captchable, if present) is
629
+ # wired into its own BaseController instead, so a second identity's
630
+ # inclusion can never collide with the first's.
631
+ template "controllers/base_controller.rb.tt", "app/controllers/#{identity.base_controller_file}.rb"
632
+ else
633
+ inject_into_class "app/controllers/application_controller.rb", "ApplicationController" do
634
+ # Authentication first so the session is resumed before Authorization's
635
+ # gates run; Authorization's before_actions register after and run after.
636
+ includes = " include #{identity.authentication_concern}\n"
637
+ includes += " include #{identity.authorization_concern}\n" if authorization?
638
+ includes += " include Captchable\n" if captchable?
639
+ "#{includes}\n"
640
+ end
641
+ end
642
+
643
+ template "controllers/users/sessions_controller.rb.tt", "app/controllers/#{identity.controller_module}/sessions_controller.rb"
644
+ template "controllers/users/registrations_controller.rb.tt", "app/controllers/#{identity.controller_module}/registrations_controller.rb" if typed_registration?
645
+ template "controllers/users/password_resets_controller.rb.tt", "app/controllers/#{identity.controller_module}/password_resets_controller.rb" if recoverable?
646
+ template "controllers/users/magic_link_controller.rb.tt", "app/controllers/#{identity.controller_module}/magic_link_controller.rb" if magic_link?
647
+ template "controllers/users/sudos_controller.rb.tt", "app/controllers/#{identity.controller_module}/sudos_controller.rb" if sudoable?
648
+ template "controllers/users/omniauth_controller.rb.tt", "app/controllers/#{identity.controller_module}/omniauth_controller.rb" if omniauth?
649
+ template "controllers/users/omniauth/registrations_controller.rb.tt", "app/controllers/#{identity.controller_module}/omniauth/registrations_controller.rb" if omniauth_registration?
650
+ template "controllers/users/passkeys/sessions_controller.rb.tt", "app/controllers/#{identity.controller_module}/passkeys/sessions_controller.rb" if passkey?
651
+ template "controllers/users/sms_sessions_controller.rb.tt", "app/controllers/#{identity.controller_module}/sms_sessions_controller.rb" if sms_code?
652
+ template "controllers/users/registrations/phone_verifications_controller.rb.tt", "app/controllers/#{identity.controller_module}/registrations/phone_verifications_controller.rb" if phone_gated_registration?
653
+
654
+ if second_factor?
655
+ # Recovery codes back whichever factors the build has, so they are the one
656
+ # piece that follows second_factor? rather than a particular factor.
657
+ template "controllers/users/multi_factor_authentication/challenge/recovery_codes_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/recovery_codes_controller.rb"
658
+ template "controllers/users/multi_factor_authentication/challenge/totps_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/totps_controller.rb" if totp?
659
+ template "controllers/users/multi_factor_authentication/challenge/security_keys_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/security_keys_controller.rb" if security_keys?
660
+ template "controllers/users/multi_factor_authentication/challenge/sms_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/sms_controller.rb" if sms_second_factor?
661
+ end
662
+
663
+ template "controllers/settings_controller.rb.tt", "app/controllers/#{identity.settings_controller_file}.rb"
664
+ template "controllers/settings/dashboard_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/dashboard_controller.rb"
665
+ template "controllers/settings/passwords_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/passwords_controller.rb" if password?
666
+ template "controllers/settings/emails_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/emails_controller.rb" if email_changeable?
667
+ template "controllers/settings/phones_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/phones_controller.rb" if phone_changeable?
668
+ template "controllers/settings/email_verifications_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/email_verifications_controller.rb" if email_change_verification?
669
+ template "controllers/users/email_verifications_controller.rb.tt", "app/controllers/#{identity.controller_module}/email_verifications_controller.rb" if email_change_verification?
670
+ template "erb/users/email_verifications/show.html.erb.tt", "app/views/#{identity.controller_module}/email_verifications/show.html.erb" if email_change_verification?
671
+ template "controllers/users/registrations/email_verifications_controller.rb.tt", "app/controllers/#{identity.controller_module}/registrations/email_verifications_controller.rb" if email_gated_registration?
672
+ template "controllers/settings/phone_verifications_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/phone_verifications_controller.rb" if phone_change_verification?
673
+ template "controllers/settings/sessions_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/sessions_controller.rb"
674
+ template "controllers/settings/api_tokens_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/api_tokens_controller.rb" if api_tokens?
675
+ template "controllers/settings/users_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/users_controller.rb"
676
+ template "controllers/settings/passkeys_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/passkeys_controller.rb" if passkey?
677
+ template "controllers/settings/omniauth_identities_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/omniauth_identities_controller.rb" if omniauth?
678
+ template "controllers/users/credential_enrollments_controller.rb.tt", "app/controllers/#{identity.controller_module}/credential_enrollments_controller.rb" if registration_credential_page?
679
+ template "controllers/users/credential_enrollments/passwords_controller.rb.tt", "app/controllers/#{identity.controller_module}/credential_enrollments/passwords_controller.rb" if pre_account_password_enrollment?
680
+ template "controllers/users/credential_enrollments/passkeys_controller.rb.tt", "app/controllers/#{identity.controller_module}/credential_enrollments/passkeys_controller.rb" if pre_account_passkey_enrollment?
681
+
682
+ if second_factor?
683
+ template "controllers/settings/multi_factor_authentication/recovery_codes_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/recovery_codes_controller.rb"
684
+ template "controllers/settings/multi_factor_authentication/authenticators_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/authenticators_controller.rb" if totp?
685
+ template "controllers/settings/multi_factor_authentication/security_keys_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys_controller.rb" if security_keys?
686
+ template "controllers/settings/multi_factor_authentication/sms_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/sms_controller.rb" if sms_second_factor?
687
+ end
688
+
689
+ if trackable?
690
+ template "controllers/settings/authentications/events_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/authentications/events_controller.rb"
691
+ end
692
+
693
+ if admin_dashboard?
694
+ template "controllers/admin_controller.rb.tt", "app/controllers/#{identity.nested_path("admin_controller")}.rb"
695
+ template "controllers/admin/dashboard_controller.rb.tt", "app/controllers/#{identity.nested_path("admin")}/dashboard_controller.rb"
696
+ template "controllers/admin/users_controller.rb.tt", "app/controllers/#{identity.nested_path("admin")}/users_controller.rb"
697
+ template "controllers/admin/sessions_controller.rb.tt", "app/controllers/#{identity.nested_path("admin")}/sessions_controller.rb"
698
+
699
+ # Deliberately outside the admin area: ending impersonation is the one part
700
+ # of the flow that must work while the admin area is shut.
701
+ template "controllers/impersonations_controller.rb.tt", "app/controllers/#{identity.nested_path("impersonations_controller")}.rb" if impersonatable?
702
+ end
703
+
704
+ if teams?
705
+ template "controllers/teams_controller.rb.tt", "app/controllers/#{identity.nested_path("teams_controller")}.rb"
706
+ template "controllers/active_teams_controller.rb.tt", "app/controllers/#{identity.nested_path("active_teams_controller")}.rb" if session_carries_team?
707
+ end
708
+
709
+ if invitable?
710
+ template "controllers/invitations_controller.rb.tt", "app/controllers/#{identity.nested_path("invitations_controller")}.rb"
711
+ template "erb/invitations/show.html.erb.tt", "app/views/#{identity.nested_path("invitations")}/show.html.erb" if teams?
712
+ template "controllers/settings/invitations_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/invitations_controller.rb"
713
+ end
714
+ end
715
+
716
+ def create_mailers
717
+ template "mailers/user_mailer.rb.tt", "app/mailers/#{identity.mailer_file}.rb" if sends_email?
718
+ return unless sends_email? && !File.exist?(in_destination("app/jobs/authnz_eleven_mail_delivery_job.rb"))
719
+
720
+ template "jobs/authnz_eleven_mail_delivery_job.rb.tt", "app/jobs/authnz_eleven_mail_delivery_job.rb"
721
+ end
722
+
723
+ def create_deferred_job
724
+ return unless coy? && !File.exist?(in_destination("app/jobs/authnz_eleven_deferred_job.rb"))
725
+
726
+ template "jobs/authnz_eleven_deferred_job.rb.tt", "app/jobs/authnz_eleven_deferred_job.rb"
727
+ end
728
+
729
+ def install_javascript
730
+ return unless webauthn_credentials?
731
+
732
+ template "javascript/controllers/webauthn_controller.js.tt", "app/javascript/controllers/webauthn_controller.js"
733
+ run "bin/importmap pin @rails/request.js" if importmaps?
734
+ end
735
+
736
+ def create_tasks
737
+ task_path = identity.namespaced? ? "lib/tasks/authnz_eleven_#{identity.singular}.rake" : "lib/tasks/authnz_eleven.rake"
738
+ template "tasks/authnz_eleven.rake.tt", task_path if guestable? || timeoutable? || pending_registration_model?
739
+ end
740
+
741
+ # Drop commented Solid Queue schedules for the sweeps next to whatever recurring
742
+ # tasks the app already has. Only touch the file if it exists (no Solid Queue,
743
+ # no file — the rake tasks + post-install note still cover it), and keep them
744
+ # commented so they never collide with the app's real schedule. Each block is
745
+ # guarded independently so re-running the generator, or adding a flag later,
746
+ # appends only what is missing.
747
+ def schedule_recurring_tasks
748
+ return unless timeoutable? || guestable? || pending_registration_model?
749
+ return unless File.exist?("config/recurring.yml")
750
+
751
+ existing = File.read("config/recurring.yml")
752
+
753
+ if pending_registration_model? && !existing.include?("delete_expired_pending_registrations")
754
+ append_to_file "config/recurring.yml", <<~YAML
755
+
756
+ # delete_expired_pending_registrations:
757
+ # command: "#{identity.pending_registration_class}.expired.destroy_all"
758
+ # schedule: every day at 2am
759
+ YAML
760
+ end
761
+
762
+ if timeoutable? && !existing.include?("delete_timed_out_sessions")
763
+ append_to_file "config/recurring.yml", <<~YAML
764
+
765
+ # delete_timed_out_sessions:
766
+ # command: "#{identity.session_class}.timed_out.delete_all"
767
+ # schedule: every day at 3am
768
+ YAML
769
+ end
770
+
771
+ return unless guestable?
772
+ return if existing.include?("delete_expired_guests")
773
+
774
+ append_to_file "config/recurring.yml", <<~YAML
775
+
776
+ # delete_expired_guests:
777
+ # command: "#{identity.class_name}.expired_guests.find_each(&:destroy)"
778
+ # schedule: every day at 4am
779
+ YAML
780
+ end
781
+
782
+ def create_tests
783
+ template "test_unit/fixtures/users.yml.tt", "test/fixtures/#{identity.table}.yml"
784
+ template "test_unit/mailers/user_mailer_test.rb.tt", "test/mailers/#{identity.mailer_file}_test.rb" if sends_email?
785
+
786
+ if teams?
787
+ template "test_unit/fixtures/teams.yml.tt", "test/fixtures/#{identity.team_file.pluralize}.yml"
788
+ template "test_unit/fixtures/memberships.yml.tt", "test/fixtures/#{identity.membership_file.pluralize}.yml"
789
+ end
790
+
791
+ template "test_unit/controllers/users/sessions_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/sessions_controller_test.rb"
792
+ template "test_unit/controllers/account_existence_parity_test.rb.tt", "test/controllers/#{identity.controller_module}/account_existence_parity_test.rb" if coy?
793
+ template "test_unit/controllers/session_lifecycle_test.rb.tt", "test/controllers/#{identity.controller_module}/session_lifecycle_test.rb" if timeoutable? || rememberable? || last_seenable? || max_sessionable?
794
+ template "test_unit/controllers/security_notifications_test.rb.tt", "test/controllers/#{identity.controller_module}/security_notifications_test.rb" if security_notifications?
795
+ template "test_unit/controllers/settings/api_tokens_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/api_tokens_controller_test.rb" if api_tokens?
796
+ template "test_unit/models/api_token_test.rb.tt", "test/models/#{identity.api_token_file}_test.rb" if api_tokens?
797
+ template "test_unit/controllers/api_token_authentication_test.rb.tt", "test/controllers/#{identity.controller_module}/api_token_authentication_test.rb" if api_tokens?
798
+ template "test_unit/controllers/credential_enrollment_test.rb.tt", "test/controllers/#{identity.controller_module}/credential_enrollment_test.rb" if credential_staged_registration?
799
+ template "test_unit/controllers/passkey_ceremony_test.rb.tt", "test/controllers/#{identity.controller_module}/passkey_ceremony_test.rb" if passkey?
800
+ template "test_unit/controllers/security_key_ceremony_test.rb.tt", "test/controllers/#{identity.controller_module}/security_key_ceremony_test.rb" if security_keys?
801
+ template "test_unit/controllers/password_rotation_test.rb.tt", "test/controllers/#{identity.controller_module}/password_rotation_test.rb" if password_rotatable?
802
+ template "test_unit/models/password_history_test.rb.tt", "test/models/#{identity.password_history_file}_reuse_test.rb" if password_historical?
803
+ template "test_unit/models/sms_challengeable_test.rb.tt", "test/models/#{identity.singular}/sms_challengeable_test.rb" if sms?
804
+ template "test_unit/models/session_test.rb.tt", "test/models/#{identity.session_file}_test.rb" if timeoutable?
805
+ template "test_unit/controllers/users/registrations_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/registrations_controller_test.rb" if typed_registration?
806
+ template "test_unit/controllers/users/password_resets_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/password_resets_controller_test.rb" if recoverable?
807
+ template "test_unit/controllers/users/magic_link_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/magic_link_controller_test.rb" if magic_link?
808
+ template "test_unit/controllers/users/sudos_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/sudos_controller_test.rb" if sudoable?
809
+ template "test_unit/controllers/users/omniauth_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/omniauth_controller_test.rb" if omniauth?
810
+ template "test_unit/controllers/settings/omniauth_identities_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/omniauth_identities_controller_test.rb" if omniauth?
811
+ template "test_unit/controllers/users/multi_factor_authentication/challenge_test.rb.tt", "test/controllers/#{identity.controller_module}/multi_factor_authentication/challenge_test.rb" if second_factor?
812
+ template "test_unit/controllers/settings/email_verifications_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/email_verifications_controller_test.rb" if email_change_verification?
813
+ template "test_unit/controllers/users/email_verifications_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/email_verifications_controller_test.rb" if email_change_verification?
814
+ template "test_unit/controllers/users/registrations/email_verifications_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/registrations/email_verifications_controller_test.rb" if email_gated_registration?
815
+ template "test_unit/controllers/settings/phone_verifications_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/phone_verifications_controller_test.rb" if phone_change_verification?
816
+ template "test_unit/controllers/users/sms_sessions_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/sms_sessions_controller_test.rb" if sms_code?
817
+ template "test_unit/controllers/users/registrations/phone_verifications_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/registrations/phone_verifications_controller_test.rb" if phone_gated_registration?
818
+
819
+ # Authenticated-area tests read Current.user, which now resolves for every
820
+ # identity (each namespaced session aliases its principal to :user — see
821
+ # models/session.rb.tt), so these ship for namespaced identities too.
822
+ template "test_unit/controllers/settings/management_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/management_test.rb"
823
+ template "test_unit/controllers/settings/authentications/events_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/authentications/events_controller_test.rb" if trackable?
824
+ template "test_unit/controllers/settings/multi_factor_authentication/management_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/management_test.rb" if second_factor?
825
+
826
+ if invitable?
827
+ template "test_unit/controllers/invitations_controller_test.rb.tt", "test/controllers/#{identity.nested_path("invitations_controller_test")}.rb"
828
+ template "test_unit/controllers/settings/invitations_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/invitations_controller_test.rb"
829
+ end
830
+
831
+ template "test_unit/controllers/teams_controller_test.rb.tt", "test/controllers/#{identity.nested_path("teams_controller_test")}.rb" if teams?
832
+
833
+ if admin_dashboard?
834
+ template "test_unit/controllers/admin/access_test.rb.tt", "test/controllers/#{identity.nested_path("admin")}/access_test.rb"
835
+ template "test_unit/controllers/admin/multi_factor_authentication_test.rb.tt", "test/controllers/#{identity.nested_path("admin")}/multi_factor_authentication_test.rb" if second_factor?
836
+ end
837
+
838
+ template "test_unit/models/user/guestable_test.rb.tt", "test/models/#{identity.singular}/guestable_test.rb" if guestable?
839
+ template "test_unit/models/user/sign_in_methods_test.rb.tt", "test/models/#{identity.singular}/sign_in_methods_test.rb" if removable_credentials?
840
+ template "test_unit/models/user/recovery_codes_test.rb.tt", "test/models/#{identity.singular}/recovery_codes_test.rb" if both_second_factors?
841
+ template "test_unit/controllers/guest_access_test.rb.tt", "test/controllers/#{identity.controller_module}/guest_access_test.rb" if guestable?
842
+ template "test_unit/models/user_test.rb.tt", "test/models/#{identity.singular}_test.rb"
843
+ template "test_unit/controllers/users/omniauth/registrations_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/omniauth/registrations_controller_test.rb" if omniauth_registration?
844
+ end
845
+
846
+ def create_views
847
+ template "erb/user_mailer/registration_email_verification.html.erb.tt", "app/views/#{identity.mailer_file}/registration_email_verification.html.erb" if email_gated_registration?
848
+ template "erb/user_mailer/email_change_verification.html.erb.tt", "app/views/#{identity.mailer_file}/email_change_verification.html.erb" if email_change_verification?
849
+ template "erb/user_mailer/password_reset.html.erb.tt", "app/views/#{identity.mailer_file}/password_reset.html.erb" if recoverable?
850
+ template "erb/user_mailer/magic_link.html.erb.tt", "app/views/#{identity.mailer_file}/magic_link.html.erb" if magic_link?
851
+ template "erb/user_mailer/invitation.html.erb.tt", "app/views/#{identity.mailer_file}/invitation.html.erb" if email_invitable?
852
+ template "erb/user_mailer/security_notification.html.erb.tt", "app/views/#{identity.mailer_file}/security_notification.html.erb" if security_notifications?
853
+
854
+ template "erb/shared/_captcha.html.erb.tt", "app/views/shared/_captcha.html.erb" if captchable? && !File.exist?(in_destination("app/views/shared/_captcha.html.erb"))
855
+ template "erb/shared/_authnz_eleven_sudo_challenge.html.erb.tt", partial_file(identity.sudo_partial) if sudoable?
856
+
857
+ template "erb/users/sessions/new.html.erb.tt", "app/views/#{identity.controller_module}/sessions/new.html.erb"
858
+ template "erb/users/registrations/new.html.erb.tt", "app/views/#{identity.controller_module}/registrations/new.html.erb" if typed_registration?
859
+ template "erb/users/omniauth/registrations/new.html.erb.tt", "app/views/#{identity.controller_module}/omniauth/registrations/new.html.erb" if omniauth_registration?
860
+ template "erb/users/password_resets/new.html.erb.tt", "app/views/#{identity.controller_module}/password_resets/new.html.erb" if recoverable?
861
+ template "erb/users/password_resets/edit.html.erb.tt", "app/views/#{identity.controller_module}/password_resets/edit.html.erb" if recoverable?
862
+ if magic_link?
863
+ template "erb/users/magic_link/new.html.erb.tt", "app/views/#{identity.controller_module}/magic_link/new.html.erb"
864
+ template "erb/users/magic_link/edit.html.erb.tt", "app/views/#{identity.controller_module}/magic_link/edit.html.erb"
865
+ end
866
+ if sms_code?
867
+ template "erb/users/sms_sessions/new.html.erb.tt", "app/views/#{identity.controller_module}/sms_sessions/new.html.erb"
868
+ template "erb/users/sms_sessions/edit.html.erb.tt", "app/views/#{identity.controller_module}/sms_sessions/edit.html.erb"
869
+ end
870
+ template "erb/users/registrations/phone_verifications/new.html.erb.tt", "app/views/#{identity.controller_module}/registrations/phone_verifications/new.html.erb" if phone_gated_registration?
871
+ if email_gated_registration?
872
+ template "erb/users/registrations/email_verifications/new.html.erb.tt", "app/views/#{identity.controller_module}/registrations/email_verifications/new.html.erb"
873
+ template "erb/users/registrations/email_verifications/show.html.erb.tt", "app/views/#{identity.controller_module}/registrations/email_verifications/show.html.erb"
874
+ end
875
+ template "erb/users/sudos/new.html.erb.tt", "app/views/#{identity.controller_module}/sudos/new.html.erb" if sudoable?
876
+
877
+ if second_factor?
878
+ template "erb/users/multi_factor_authentication/challenge/recovery_codes/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/recovery_codes/new.html.erb"
879
+ template "erb/users/multi_factor_authentication/challenge/totps/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/totps/new.html.erb" if totp?
880
+ template "erb/users/multi_factor_authentication/challenge/security_keys/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/security_keys/new.html.erb" if security_keys?
881
+ template "erb/users/multi_factor_authentication/challenge/sms/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/sms/new.html.erb" if sms_second_factor?
882
+ end
883
+
884
+ template "erb/settings/dashboard/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/dashboard/index.html.erb"
885
+ template "erb/settings/passwords/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passwords/edit.html.erb" if password?
886
+ template "erb/settings/emails/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/emails/edit.html.erb" if email_changeable?
887
+ template "erb/settings/phones/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/phones/edit.html.erb" if phone_changeable?
888
+ template "erb/settings/phone_verifications/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/phone_verifications/new.html.erb" if phone_change_verification?
889
+ template "erb/settings/sessions/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/sessions/index.html.erb"
890
+ template "erb/settings/api_tokens/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/api_tokens/index.html.erb" if api_tokens?
891
+ template "erb/settings/api_tokens/create.html.erb.tt", "app/views/#{identity.settings_path_prefix}/api_tokens/create.html.erb" if api_tokens?
892
+ template "erb/settings/users/show.html.erb.tt", "app/views/#{identity.settings_path_prefix}/users/show.html.erb"
893
+
894
+ if passkey?
895
+ template "erb/settings/passkeys/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passkeys/index.html.erb"
896
+ template "erb/settings/passkeys/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passkeys/new.html.erb"
897
+ template "erb/settings/passkeys/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passkeys/edit.html.erb"
898
+ end
899
+
900
+ if omniauth?
901
+ template "erb/settings/omniauth_identities/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/omniauth_identities/index.html.erb"
902
+ end
903
+
904
+ template "erb/users/credential_enrollments/show.html.erb.tt", "app/views/#{identity.controller_module}/credential_enrollments/show.html.erb" if registration_credential_page?
905
+ template "erb/users/credential_enrollments/passwords/new.html.erb.tt", "app/views/#{identity.controller_module}/credential_enrollments/passwords/new.html.erb" if pre_account_password_enrollment?
906
+ template "erb/users/credential_enrollments/passkeys/new.html.erb.tt", "app/views/#{identity.controller_module}/credential_enrollments/passkeys/new.html.erb" if pre_account_passkey_enrollment?
907
+
908
+ if second_factor?
909
+ template "erb/settings/multi_factor_authentication/recovery_codes/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/recovery_codes/index.html.erb"
910
+ template "erb/settings/multi_factor_authentication/recovery_codes/create.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/recovery_codes/create.html.erb"
911
+ template "erb/settings/multi_factor_authentication/authenticators/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/authenticators/new.html.erb" if totp?
912
+ template "erb/settings/multi_factor_authentication/sms/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/sms/new.html.erb" if sms_second_factor?
913
+
914
+ if security_keys?
915
+ template "erb/settings/multi_factor_authentication/security_keys/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys/index.html.erb"
916
+ template "erb/settings/multi_factor_authentication/security_keys/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys/new.html.erb"
917
+ template "erb/settings/multi_factor_authentication/security_keys/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys/edit.html.erb"
918
+ end
919
+ end
920
+
921
+ if trackable?
922
+ template "erb/settings/authentications/events/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/authentications/events/index.html.erb"
923
+ end
924
+
925
+ if admin_dashboard?
926
+ template "erb/admin/dashboard/index.html.erb.tt", "app/views/#{identity.nested_path("admin")}/dashboard/index.html.erb"
927
+ template "erb/admin/users/index.html.erb.tt", "app/views/#{identity.nested_path("admin")}/users/index.html.erb"
928
+ template "erb/admin/users/show.html.erb.tt", "app/views/#{identity.nested_path("admin")}/users/show.html.erb"
929
+ template "erb/admin/sessions/index.html.erb.tt", "app/views/#{identity.nested_path("admin")}/sessions/index.html.erb"
930
+ end
931
+
932
+ if teams?
933
+ template "erb/teams/index.html.erb.tt", "app/views/#{identity.nested_path("teams")}/index.html.erb"
934
+ template "erb/teams/new.html.erb.tt", "app/views/#{identity.nested_path("teams")}/new.html.erb"
935
+ template "erb/teams/show.html.erb.tt", "app/views/#{identity.nested_path("teams")}/show.html.erb" if scope_teams?
936
+ end
937
+
938
+ if invitable?
939
+ template "erb/settings/invitations/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/invitations/new.html.erb"
940
+ end
941
+ end
942
+
943
+ # The signed-in/out chrome (Settings, Sign out, the doors) so a fresh install is
944
+ # navigable without the host app writing a header first. Every identity gets its
945
+ # own partial. The default identity's goes into the application layout; a namespaced
946
+ # identity is a separate portal, like an engine, so it gets its own layout copied
947
+ # from the application one with its own nav in place of the default's. A layout
948
+ # is edited only when it's still the stock Rails default, so a customized one is
949
+ # never touched — we print a one-line note instead.
950
+ def install_navigation
951
+ partial = partial_file(identity.nav_partial)
952
+ template "erb/shared/_authnz_eleven_nav.html.erb.tt", partial unless File.exist?(in_destination(partial))
953
+
954
+ application_layout = in_destination("app/views/layouts/application.html.erb")
955
+ layout = "app/views/layouts/#{identity.namespaced? ? identity.controller_module : "application"}.html.erb"
956
+ render_line = %(<%= render "#{identity.nav_partial}" %>)
957
+
958
+ unless File.exist?(application_layout)
959
+ @navigation_render_line = render_line
960
+ return
961
+ end
962
+
963
+ unless File.exist?(in_destination(layout))
964
+ create_file layout, File.read(application_layout).sub(%(<%= render "shared/authnz_eleven_nav" %>), render_line)
965
+ end
966
+
967
+ contents = File.read(in_destination(layout))
968
+ if contents.include?(render_line)
969
+ # Already wired (a re-run); leave it be.
970
+ elsif contents.match?(%r{<body>\s*<%=\s*yield\s*%>\s*</body>}m)
971
+ # Stock Rails layout — safe to drop the render in right after <body>.
972
+ inject_into_file layout, "\n #{render_line}", after: "<body>"
973
+ else
974
+ # Customized layout: don't touch it, tell them where to add the line.
975
+ @navigation_render_line = render_line
976
+ end
977
+ end
978
+
979
+ def add_routes
980
+ route(identity.namespaced? ? namespaced_route_block : route_block)
981
+ end
982
+
983
+ # The first thing anyone sees, so it stays a checklist: what must happen before
984
+ # the app runs, then what to know before it ships. Everything explanatory — how
985
+ # the deadbolt behaves, what a guest is, which tunables exist — lives in the
986
+ # README and the initializer, which is where someone looks for it a week later.
987
+ def show_post_install
988
+ return unless behavior == :invoke
989
+
990
+ say "\nauthnz_eleven installed.", :green
991
+
992
+ say "\nNext steps:", :green
993
+ next_steps.each_with_index { |(text, *lines), index| say_item("#{index + 1}.", text, lines) }
994
+
995
+ warnings = ship_warnings
996
+ unless warnings.empty?
997
+ say "\nBefore you ship:", :yellow
998
+ warnings.each { |warning| say_item("*", warning, [], :yellow) }
999
+ end
1000
+
1001
+ say "\n Config: config/initializers/#{identity.initializer_file}.rb"
1002
+ say " Start reading: app/controllers/concerns/#{identity.authentication_concern_file}.rb\n"
1003
+ end
1004
+
1005
+ private
1006
+
1007
+ # ---------------------------------------------------------------------------
1008
+ # Post-install output
1009
+ #
1010
+ # The two lists show_post_install prints, and the wrapper that renders them.
1011
+ # ---------------------------------------------------------------------------
1012
+
1013
+ # Sentences are wrapped; the lines under them (commands, env vars, code) are
1014
+ # printed whole on their own rows so they can be found and copied.
1015
+ def say_item(marker, text, lines, color = nil)
1016
+ indent = " " * (marker.length + 3)
1017
+ rows = [+" #{marker} "]
1018
+
1019
+ text.split.each do |word|
1020
+ rows << indent.dup if rows.last.length + word.length > 78
1021
+ rows.last << word << " "
1022
+ end
1023
+
1024
+ rows.each { |row| say row.rstrip, color }
1025
+ lines.each { |line| say "#{indent} #{line}", :cyan }
1026
+ end
1027
+
1028
+ # What has to happen before the generated app runs, in the order you hit it.
1029
+ # Each step is a sentence followed by the lines to type or paste.
1030
+ def next_steps
1031
+ [
1032
+ (["Install the new gems.", "bundle install"] if gem_dependencies?),
1033
+ ["Run the migrations.", "bin/rails db:migrate"],
1034
+ ["Add a root route. Generated redirects use root_path."],
1035
+ (["Make sure Action Mailer can deliver in each environment, with default_url_options set."] if sends_email?),
1036
+ (["Use a shared production cache across all app processes. Missing cache entries invalidate sign-in links and texted codes."] if magic_link? || sms?),
1037
+ (encryption_step if encryption?),
1038
+ (["Set the WebAuthn origin and relying-party ID for each environment.", "config/initializers/webauthn.rb"] if webauthn_credentials?),
1039
+ (["Add your Cloudflare Turnstile keys to credentials for production (or set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY).", "turnstile:", " site_key: ...", " secret_key: ..."] if captchable?),
1040
+ (["Add your Twilio keys to credentials for production (or set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_MESSAGING_SERVICE_SID). Until then, texts go to the Rails log.", "twilio:", " account_sid: ...", " auth_token: ...", " messaging_service_sid: ..."] if sms_delivery?),
1041
+ (["Your layout isn't the stock one, so add the navigation partial yourself:", @navigation_render_line] if @navigation_render_line),
1042
+ (["Keep identifiers out of your logs. You have no filter_parameter_logging.rb, so add this to an initializer:", @parameter_filter_line] if @parameter_filter_line),
1043
+ (["Add allow_guest_access to the controllers guests may use, and fill in #{identity.class_name}::Guestable#absorb_guest."] if guestable?),
1044
+ (sweep_step if sweep_tasks.any?)
1045
+ ].compact
1046
+ end
1047
+
1048
+ def encryption_step
1049
+ ["Create encryption keys and paste them into each credentials file you deploy with. Losing them loses #{encrypted_columns_phrase}.",
1050
+ "bin/rails db:encryption:init",
1051
+ "bin/rails credentials:edit"]
1052
+ end
1053
+
1054
+ def sweep_tasks
1055
+ [
1056
+ ("delete_expired_pending_registrations" if pending_registration_model?),
1057
+ ("delete_timed_out_sessions" if timeoutable?),
1058
+ ("delete_expired_guests" if guestable?)
1059
+ ].compact
1060
+ end
1061
+
1062
+ def sweep_step
1063
+ scheduled = " Commented entries for them are in config/recurring.yml." if File.exist?("config/recurring.yml")
1064
+ ["Schedule these cleanup tasks. Nothing deletes expired rows for you.#{scheduled}",
1065
+ *sweep_tasks.map { |task| "bin/rails #{identity.rake_task(task)}" }]
1066
+ end
1067
+
1068
+ # Risks this build can actually produce, one sentence of consequence each.
1069
+ # A feature that merely deserves explaining doesn't belong here.
1070
+ def ship_warnings
1071
+ [
1072
+ ("There is no account recovery, so a user who loses their credential is locked out. Add --recoverable, --magic-link or --sms-code." unless recovery_path?),
1073
+ ("Only configure OmniAuth providers that verify email addresses. Their addresses are trusted, so one that doesn't verify lets a stranger claim someone else's address." if omniauth? && email_verifiable?),
1074
+ (email_optional_warning if principals.email&.optional?),
1075
+ *sms_warnings
1076
+ ].compact
1077
+ end
1078
+
1079
+ # Worth saying only where it bites: an account with no address either can't
1080
+ # sign in at all, or can't be recovered by the flows this build has.
1081
+ def email_optional_warning
1082
+ return "Email is optional and is your only identifier, so users who skip it can't sign in. Add --username or a required --phone." if login_principals.all? { |p| p.type == :email }
1083
+
1084
+ "Email is optional, so password reset and magic links can't reach some users." if recoverable? || magic_link?
1085
+ end
1086
+
1087
+ def sms_warnings
1088
+ return [] unless sms_delivery?
1089
+
1090
+ [
1091
+ ("Phone numbers aren't verified (--no-verifiable), so anyone can register a number they don't own and lock out its real owner." if sms_code? && !phone_verifiable?),
1092
+ ("There is no captcha. --captchable adds one to the signed-out forms, the main brake on bots." unless captchable?),
1093
+ ("Also offer an authenticator app or passkey if you have compliance requirements. NIST SP 800-63B restricts SMS." if sms_only_authenticator?)
1094
+ ].compact
1095
+ end
1096
+
1097
+ def sms_only_authenticator?
1098
+ (sms_code? || sms_second_factor?) && !(passkey? || totp? || security_keys?)
1099
+ end
1100
+
1101
+ # What the encryption step names as covered.
1102
+ def encrypted_columns_phrase
1103
+ columns = encrypted_pii? ? principals.select(&:channel?).map(&:column) : []
1104
+ columns.push("totp_secret") if totp?
1105
+ columns.to_sentence
1106
+ end
1107
+
1108
+ # Whether any flow in this build sends mail — the same set of conditions the
1109
+ # mailer template guards its methods with.
1110
+ def sends_email?
1111
+ email_gated_registration? || email_change_verification? || recoverable? ||
1112
+ magic_link? || email_invitable? || security_notifications?
1113
+ end
1114
+
1115
+ # ---------------------------------------------------------------------------
1116
+ # Generator plumbing
1117
+ # ---------------------------------------------------------------------------
1118
+
1119
+ # A --email / --phone value: one requiredness word, optionally followed by
1120
+ # "permanent". Principals.split_mode does the splitting; this only says whether
1121
+ # what it found is what a principal accepts.
1122
+ def principal_mode_valid?(value)
1123
+ requiredness, modifiers = AuthnzEleven::Principals.split_mode(value)
1124
+
1125
+ AuthnzEleven::Principals::REQUIREDNESS.include?(requiredness) &&
1126
+ (modifiers.empty? || AuthnzEleven::Principals.permanent?(modifiers))
1127
+ end
1128
+
1129
+ # The invocation that produced this build, stamped into the initializer so an
1130
+ # app can say what it was generated from years later. Rebuilt from the flags
1131
+ # rather than read off ARGV, so it is the same command whether the generator
1132
+ # was run from the command line or invoked in a test.
1133
+ #
1134
+ # Only flags that moved off their default are named: a default-valued flag
1135
+ # would be noise, and re-running the printed command has to mean the same
1136
+ # thing under the version it names.
1137
+ def generation_command
1138
+ flags = self.class.class_options.except(*Rails::Generators::Base.class_options.keys).filter_map do |name, option|
1139
+ value = options[name.to_s]
1140
+ generation_flag(name, value) unless value == option.default
1141
+ end
1142
+ ["bin/rails generate authnz_eleven", *flags]
1143
+ end
1144
+
1145
+ def generation_flag(name, value)
1146
+ dashed = name.to_s.tr("_", "-")
1147
+ case value
1148
+ when true then "--#{dashed}"
1149
+ when false then "--no-#{dashed}"
1150
+ else "--#{dashed}=#{value}"
1151
+ end
1152
+ end
1153
+
1154
+ # Wrapped with shell continuations so the comment stays readable and the
1155
+ # command stays runnable when a build carries a dozen flags.
1156
+ def generation_command_lines(width: 72)
1157
+ generation_command.each_with_object([]) do |word, lines|
1158
+ if lines.empty? || "#{lines.last} #{word}".length > width
1159
+ lines.last << " \\" unless lines.empty?
1160
+ lines << (lines.empty? ? +word : " #{word}")
1161
+ else
1162
+ lines.last << " #{word}"
1163
+ end
1164
+ end
1165
+ end
1166
+
1167
+ # A multi-line Ruby hash literal, punctuated here instead of in the template.
1168
+ #
1169
+ # Which entries a generated hash has depends on the flags, so a template that
1170
+ # writes its own commas is guessing which of its lines will end up last. In the
1171
+ # builds where it guesses wrong the literal trails a comma into its closing
1172
+ # brace (Style/TrailingCommaInHashLiteral), and where every entry happens to be
1173
+ # conditional it opens and closes over nothing at all. Hand over the entries
1174
+ # that actually exist and let this place the punctuation.
1175
+ #
1176
+ # +indent+ is the column the braces sit at; entries go two deeper. An entry may
1177
+ # be several lines (a comment above its pair), in which case the comma lands
1178
+ # after the last of them.
1179
+ def hash_literal(entries, indent:)
1180
+ return "{}" if entries.empty?
1181
+
1182
+ pad = " " * indent
1183
+ body = entries.map { |entry| entry.lines.map { |line| "#{pad} #{line.chomp}" }.join("\n") }
1184
+ "{\n#{body.join(",\n")}\n#{pad}}"
1185
+ end
1186
+
1187
+ # The attribute and param names install_parameter_filters redacts: the ones this
1188
+ # build introduces that Rails' stock list doesn't already reach. It reaches most
1189
+ # of them by substring — :passw covers password_digest, :otp and :secret cover
1190
+ # totp_secret, :token covers the emailed token, :email covers both the column and
1191
+ # pending_email — which is why none of those repeat here.
1192
+ def parameter_filters
1193
+ filters = []
1194
+ # The provider's identifier for the account. Not a credential, but a strategy
1195
+ # may put anything it likes in it, and several put the address or the handle
1196
+ # there (the developer strategy uses the email) — which quietly undoes the
1197
+ # redaction of the email column sitting one line above it in an `inspect`.
1198
+ filters << "uid" if omniauth?
1199
+ # The number itself. phone_verified_at stays readable: it's a timestamp, and
1200
+ # knowing when a number was proved is worth more in a log than it costs.
1201
+ filters << "phone" if phone?
1202
+ # An invitation address, under a column name :email can't see.
1203
+ filters << "sent_to" if invitable?
1204
+ # params[:code] on the way in: a recovery code, a texted one-time code, or an
1205
+ # OAuth authorization code on the callback. This also covers recovery codes
1206
+ # before they are digested.
1207
+ filters << "code" if second_factor? || sms? || omniauth?
1208
+ # The authenticator code answering a sudo bar. Its own entry because the filter
1209
+ # above is anchored (so unrelated names containing "code" stay readable), so it doesn't
1210
+ # reach a prefixed name. sudo_password needs no entry — Rails' stock list
1211
+ # matches "passw" as a substring.
1212
+ filters << "sudo_code" if sudoable? && sudo_bars.include?(:totp)
1213
+ # The bearer token every emailed link arrives under.
1214
+ filters << "sid" if email_verifiable? || recoverable? || magic_link?
1215
+ # The identifier typed at sign-in, and its copy in the bounce URL. `email_hint`
1216
+ # the stock :email already reaches, and a username is not a secret.
1217
+ filters.push("login", "login_hint") if multi_login?
1218
+ filters << "#{login_field}_hint" if !multi_login? && login_field.to_s == "phone"
1219
+ # The unproved number, under a column name /\Aphone\z/ can't see.
1220
+ filters << "pending_phone" if phone_verifiable?
1221
+ filters
1222
+ end
1223
+
1224
+ # Anchored, rather than the bare symbols the stock list is written in. Those
1225
+ # match on substrings, and this list is the host app's: `uid` alone would also
1226
+ # swallow its `uuid` and `guid`, while `code` would hide unrelated names that
1227
+ # merely contain the same substring.
1228
+ def filter_regexp(name) = "/\\A#{name}\\z/"
1229
+
1230
+ # ---- the destination app --------------------------------------------------
1231
+
1232
+ def in_destination(relative_path) = File.join(destination_root, relative_path)
1233
+
1234
+ # A concern with a generic name (Principals, Eventable) sits in app/models/concerns
1235
+ # for the default identity; a namespaced identity's copy joins its other models.
1236
+ def shared_concern_path(file) = "app/models/#{"concerns/" unless identity.namespaced?}#{file}.rb"
1237
+
1238
+ def partial_file(name) = "app/views/#{File.dirname(name)}/_#{File.basename(name)}.html.erb"
1239
+
1240
+ # A second identity asks for gems the first one already added.
1241
+ def gem(name, *, **)
1242
+ super unless File.read(in_destination("Gemfile")).match?(/^\s*gem "#{name}"/)
1243
+ end
1244
+
1245
+ def bcrypt_present?
1246
+ File.read(in_destination("Gemfile")).include?('gem "bcrypt"')
1247
+ rescue Errno::ENOENT
1248
+ false
1249
+ end
1250
+
1251
+ def importmaps?
1252
+ File.exist?("config/importmap.rb")
1253
+ end
1254
+
1255
+ # Every condition #add_gems writes a gem under.
1256
+ def gem_dependencies?
1257
+ password? || second_factor? || pwned? || strong_passwords? || omniauth? ||
1258
+ webauthn_credentials? || principals.email || phone?
1259
+ end
1260
+
1261
+ def filter_parameters_line(names)
1262
+ "Rails.application.config.filter_parameters += [ #{names.map { |name| filter_regexp(name) }.join(", ")} ]"
1263
+ end
1264
+
1265
+ # ---------------------------------------------------------------------------
1266
+ # What this build is
1267
+ #
1268
+ # The identity and principal sets, and the flag predicates derived from them.
1269
+ # Everything here answers a question about the build, and the expressions a
1270
+ # template needs sit beside the predicate that decides whether to emit them.
1271
+ # ---------------------------------------------------------------------------
1272
+
1273
+ # The identity being generated: derives the class name, table, sessions, route
1274
+ # and controller slugs, cookie, and path/account prefixes from --user-class
1275
+ # (default "User") and the scoping flags.
1276
+ def identity
1277
+ @identity ||= AuthnzEleven::Identity.new(
1278
+ class_name: options[:user_class],
1279
+ namespaced: options[:namespaced]
1280
+ )
1281
+ end
1282
+
1283
+ # The identifying attributes (principals) and their roles for this run — which
1284
+ # columns exist, which are login keys, which can receive messages. With no
1285
+ # principal flags this set is empty; validate_principal! rejects that shape.
1286
+ def principals
1287
+ @principals ||= AuthnzEleven::Principals.from_options(options)
1288
+ end
1289
+
1290
+ # The sign-in login key when the form posts a single field. A multi-login
1291
+ # build dispatches between several principals instead.
1292
+ def login_principal = principals.login_principals.first
1293
+ def login_field = login_principal.column
1294
+ def multi_login? = principals.multi_login?
1295
+
1296
+ # A phone principal is present (--phone=required|optional). Gates the phonelib
1297
+ # gem, the phone column's validation, the registration/settings phone field, and
1298
+ # (3b) the SMS verification flow.
1299
+ def phone? = !principals.phone.nil?
1300
+
1301
+ # Whether the phone column is NULL-able. An optional phone obviously is; a
1302
+ # *required* one is too under --guestable, because guests carry a nil number (no
1303
+ # synthesized numbers — that would risk texting a real stranger), so presence is
1304
+ # enforced by the model validation's `unless: :guest?` rather than by NOT NULL.
1305
+ def phone_column_nullable?
1306
+ principals.phone&.then { |p| p.optional? || guestable? } || false
1307
+ end
1308
+
1309
+ # The sign-in form's identifier field name. One login key → its own column
1310
+ # ("email"). Several → a single field-agnostic "login" the controller
1311
+ # dispatches by shape ("@" means email).
1312
+ def sign_in_field = multi_login? ? "login" : login_field
1313
+
1314
+ # The redirect hint param that carries the just-entered identifier back to the
1315
+ # form. Single-key builds name the field ("email_hint"); multi-key builds use
1316
+ # the field-agnostic "login_hint".
1317
+ def login_hint_param = multi_login? ? "login_hint" : "#{login_field}_hint"
1318
+
1319
+ # A no-password lookup by whichever login key was submitted, as a Ruby
1320
+ # expression for the templates. Single-key builds look the one column up
1321
+ # directly; multi-key builds fold the "@"-dispatch into the model's
1322
+ # find_by_login (generated only then, in Authenticatable).
1323
+ def find_by_login_expr(identifier_expr)
1324
+ if multi_login?
1325
+ "#{identity.class_name}.find_by_login(#{identifier_expr})"
1326
+ else
1327
+ "#{identity.class_name}.find_by(#{login_field}: #{identifier_expr})"
1328
+ end
1329
+ end
1330
+
1331
+ # Copy for a failed sign-in lookup — names the login key(s), stays paranoid.
1332
+ # Punctuation is added by each caller (HTML ends the sentence with a period,
1333
+ # the JSON API omits it). A multi-key build names both keys ("username/email").
1334
+ def incorrect_login_message
1335
+ keys = multi_login? ? login_principals.map(&:column).join("/") : login_field
1336
+ "That #{keys} or password is incorrect"
1337
+ end
1338
+
1339
+ # The two halves of the sign-in miss branch, which is now the only place a deadbolt
1340
+ # is spoken about: authenticate_with_password answers nil for a deadbolted account
1341
+ # exactly as it does for a wrong password, so a coy build has nothing to say and
1342
+ # nothing to decide.
1343
+ #
1344
+ # A --no-coy build is honest about account existence, so it keeps the record it
1345
+ # just looked up in order to tell a deadbolted visitor to come back later. The
1346
+ # window is real (config.deadbolt.duration), so "try again later" is true rather
1347
+ # than a brush-off, and it never says "deadbolted" — that is our word, not theirs.
1348
+ def failed_sign_in_lookup
1349
+ return deferred_call(identity.class_name, :register_failed_attempt, "#{login_identifier_expr}#{".to_s" unless multi_login?}") if coy?
1350
+
1351
+ "other = #{identity.class_name}.register_failed_attempt(#{login_identifier_expr})"
1352
+ end
1353
+
1354
+ # Work that happens only for some accounts leaves the request in a coy build, so
1355
+ # response timing can't tell which accounts exist.
1356
+ def deferred_call(receiver, method, *args)
1357
+ return "AuthnzElevenDeferredJob.perform_later(#{[receiver, ":#{method}", *args].join(", ")})" if coy?
1358
+
1359
+ "#{receiver}.#{method}#{"(#{args.join(", ")})" if args.any?}"
1360
+ end
1361
+
1362
+ # +period+ follows incorrect_login_message's rule: HTML ends the sentence, the
1363
+ # JSON API doesn't.
1364
+ def failed_sign_in_alert(period: true)
1365
+ stop = "." if period
1366
+ return %("#{incorrect_login_message}#{stop}") if coy?
1367
+
1368
+ %(other&.deadbolted? ? "Too many failed attempts. Please try again later#{stop}" : "#{incorrect_login_message}#{stop}")
1369
+ end
1370
+
1371
+ # The expression each sign-in shape holds its submitted login key in.
1372
+ def login_identifier_expr = multi_login? ? "identifier" : "params[:#{login_field}]"
1373
+
1374
+ def login_principals = principals.login_principals
1375
+
1376
+ # Whether a record can be saved with every login key blank — which is an account
1377
+ # nobody can ever sign in to again. It saves cleanly and even signs in once, and
1378
+ # is only discovered later at a sign-in form with nothing to type into it.
1379
+ #
1380
+ # No single presence rule can prevent it: each key is legitimately optional on its
1381
+ # own, and what must hold is that *some* one of them is filled. So the models get
1382
+ # a record-level check instead (the Principals concern's #at_least_one_login_key),
1383
+ # and only where it can fire — a build with any required login key already has its
1384
+ # guarantee, and a second rule there would be dead code.
1385
+ #
1386
+ # validate_email_none! is the same question one step earlier: it refuses a build
1387
+ # whose email COLUMN is missing with nothing to replace it, this catches the row
1388
+ # whose optional columns all came back blank.
1389
+ def login_key_may_be_missing? = login_principals.all?(&:optional?)
1390
+
1391
+ # Whether every account must hold a channel (--contactable, on by default).
1392
+ def contactable? = options.contactable?
1393
+
1394
+ # Whether that promise needs a record-level rule, or is already guaranteed.
1395
+ #
1396
+ # Emitted only where it can fire, so it is never dead code restating a rule
1397
+ # something else already enforces. Four ways it can't fire:
1398
+ #
1399
+ # * --no-contactable: no promise to keep
1400
+ # * fewer than two channels: validate_contactable! has already refused the
1401
+ # ambiguous shape, so a lone channel here is required and says so itself
1402
+ # * a required channel: its own presence rule IS the guarantee
1403
+ # * every login key is a channel: at_least_one_login_key already emits this
1404
+ # exact condition, character for character
1405
+ #
1406
+ # What's left is a non-channel login key (a username) beside two optional
1407
+ # channels — the account can sign in holding neither, and nothing else notices.
1408
+ def contactable_validation?
1409
+ return false unless contactable?
1410
+
1411
+ channels = principals.select(&:channel?)
1412
+ channels.size > 1 && channels.none?(&:required?) && !login_principals.all?(&:channel?)
1413
+ end
1414
+
1415
+ # "They submitted no channel at all", as a Ruby condition. Only asked in builds
1416
+ # where that is possible AND survivable, so the branch it guards is never dead.
1417
+ def channels_blank_condition
1418
+ principals.select(&:channel?).map { |principal| "@registration.#{principal.column}.blank?" }.join(" && ")
1419
+ end
1420
+
1421
+ # Natural-language list of the login keys for user-facing copy ("username, email,
1422
+ # or phone"). A single-key build reads as just the column ("email").
1423
+ def login_keys_phrase = or_phrase(login_principals.map(&:column))
1424
+
1425
+ # "a", "a or b", "a, b or c". Three callers below want this and none of them
1426
+ # want it to disagree with the others.
1427
+ def or_phrase(items)
1428
+ return items.first if items.one?
1429
+
1430
+ "#{items[0..-2].join(", ")} or #{items.last}"
1431
+ end
1432
+
1433
+ # The same list with articles, for a sentence addressed to somebody filling in a
1434
+ # form ("Enter an email or a phone number."). login_keys_phrase reads as a label;
1435
+ # this reads as an instruction.
1436
+ ARTICLED_PRINCIPAL_NOUNS = {
1437
+ email: "an email address", phone: "a phone number", username: "a username"
1438
+ }.freeze
1439
+
1440
+ def login_keys_article_phrase
1441
+ or_phrase(login_principals.map { |principal| ARTICLED_PRINCIPAL_NOUNS.fetch(principal.type) })
1442
+ end
1443
+
1444
+ # The same, for the channels — what must_be_contactable tells somebody to add.
1445
+ def channel_keys_article_phrase
1446
+ or_phrase(principals.select(&:channel?).map { |principal| ARTICLED_PRINCIPAL_NOUNS.fetch(principal.type) })
1447
+ end
1448
+
1449
+ # The argument list for a sign-up's params.permit — the principal columns this
1450
+ # form may submit, plus whatever else the call site needs, as one "permit"
1451
+ # fragment (e.g. ":email, :password, :password_confirmation").
1452
+ #
1453
+ # A single-channel invite-only build pins that channel to the invitation
1454
+ # (merged server-side by #user_params / #registration_params, never permitted).
1455
+ # A multi-channel build permits both and lets the final merge replace only the
1456
+ # addressed one. A username is still collected from the form.
1457
+ #
1458
+ # The extras are arguments rather than something a call site concatenates,
1459
+ # because the principal list can be *empty*: an invite-only build whose only
1460
+ # principal was the address has nothing left to permit. Joining here is what
1461
+ # renders that as `params.permit(:password, :password_confirmation)`, or a bare
1462
+ # `params.permit()`, rather than a stray leading comma.
1463
+ def permit_list(*extra)
1464
+ cols = principals.map(&:column)
1465
+ cols -= [principals.channels.first.column] if invitation_pins_only_channel?
1466
+ (cols + extra.map(&:to_s)).map { |c| ":#{c}" }.join(", ")
1467
+ end
1468
+
1469
+ # The whole right-hand side of a sign-up's params method, so the emitted method
1470
+ # is one line whether or not there's an invitation to pin the address to.
1471
+ def sign_up_params_expression
1472
+ extra = password_on_sign_up_form? ? %w[password password_confirmation] : []
1473
+ permitted = "params.permit(#{permit_list(*extra)})"
1474
+ invitable? ? "with_invitation(#{permitted})" : permitted
1475
+ end
1476
+
1477
+ # The whole pair, not just the key: a dynamic key needs a hash rocket, a
1478
+ # literal one must not have it (Style/HashSyntax).
1479
+ def invitation_channel_pair
1480
+ return "invitation.sent_to_channel => invitation.sent_to" if multi_channel_invitable?
1481
+
1482
+ "#{principals.channels.first.column}: invitation.sent_to"
1483
+ end
1484
+
1485
+ # An app with none of these leaves a locked-out user no way back in. Used only
1486
+ # to warn at install time; enforcing nothing (it's a legitimate choice).
1487
+ # recoverable? implies password?, so this can't advertise a password
1488
+ # reset for an app that has no password. --sms-code counts: someone who forgot
1489
+ # their password can still get in with a texted code.
1490
+ def recovery_path? = recoverable? || magic_link? || omniauth? || sms_code?
1491
+
1492
+ # A legal typed submission needs pre-account enrollment unless the form itself
1493
+ # guarantees a password or a required channel becomes a usable configured door.
1494
+ # This is deliberately a build capability; PendingRegistration#next_step answers
1495
+ # the corresponding question for one row.
1496
+ def typed_registration_guarantees_sign_in_method?
1497
+ password_required? ||
1498
+ (magic_link? && principals.email&.required?) ||
1499
+ (sms_code? && principals.phone&.required?)
1500
+ end
1501
+
1502
+ def credential_staged_registration?
1503
+ typed_registration? && !typed_registration_guarantees_sign_in_method?
1504
+ end
1505
+
1506
+ def pre_account_password_enrollment? = credential_staged_registration? && password?
1507
+ def pre_account_passkey_enrollment? = credential_staged_registration? && passkey?
1508
+
1509
+ # What the gate offers, which is not the same as what could clear it.
1510
+ #
1511
+ # A provider is deliberately absent. A provider sign-up arrives already holding
1512
+ # :omniauth and is never staged, so everybody here took the typed route and
1513
+ # declined the button on the sign-up form. Offering it back asks a question they
1514
+ # answered on the previous page. It returns below as the fallback for the build
1515
+ # where it is the only way out.
1516
+ def registration_credential_options
1517
+ [(:password if pre_account_password_enrollment?),
1518
+ (:passkey if pre_account_passkey_enrollment?)].compact
1519
+ end
1520
+
1521
+ # The build with nothing else to enroll. Here the provider button is not a
1522
+ # question already answered, it is the only door this sign-up can reach.
1523
+ def pre_account_provider_enrollment?
1524
+ credential_staged_registration? && omniauth? && registration_credential_options.empty?
1525
+ end
1526
+
1527
+ # Whether an invitation addressed to this channel becomes an account at the
1528
+ # sign-up POST. The invitation proves its own channel, but a second channel the
1529
+ # build requires is still unproved and holds the registration open — as does a
1530
+ # build whose form leaves the row with no way to sign in.
1531
+ def email_invitation_completes_registration? = invited_registration_completes?(:email)
1532
+ def phone_invitation_completes_registration? = invited_registration_completes?(:phone)
1533
+
1534
+ def invited_registration_completes?(channel)
1535
+ return true unless typed_staged_registration?
1536
+ return false unless typed_registration_guarantees_sign_in_method? || channel_door?(channel)
1537
+
1538
+ other = principals.select(&:channel?).find { |principal| principal.type != channel }
1539
+ !(other&.required? && channel_verifiable?(other))
1540
+ end
1541
+
1542
+ def channel_door?(channel) = channel == :email ? magic_link? : sms_code?
1543
+
1544
+ # The emitted sign-up fixture fills every channel, so once they are proven any
1545
+ # channel-backed door signs it in, whatever the build guarantees.
1546
+ def sign_up_fixture_can_sign_in? = password_required? || magic_link? || sms_code?
1547
+
1548
+ # An invite-only sign-up needs an accepted invitation in the session before it posts.
1549
+ def accept_test_invitation(sent_to)
1550
+ path = identity.nested_helper("accept_invitation_path")
1551
+ [
1552
+ %(invitation = #{identity.invitation_class}.create!(sent_to: #{sent_to}, inviter: #{identity.plural}(:alice))),
1553
+ %(get #{path}(token: invitation.generate_token_for(:invitation)))
1554
+ ].join("\n ")
1555
+ end
1556
+
1557
+ # An invite-only staged row belongs to its invitation, so a test that builds one
1558
+ # outside the sign-up flow has to supply one too. `save(validate: false)` skips
1559
+ # the association's presence rule but not the column's NOT NULL.
1560
+ def staged_registration_invitation(address)
1561
+ return "" unless invite_only?
1562
+
1563
+ channel = (%(, sent_to_channel: "email") if multi_channel_invitable?)
1564
+ %(, invitation: #{identity.invitation_class}.create!(sent_to: #{address}#{channel}, inviter: @user))
1565
+ end
1566
+
1567
+ # One step of the staged sign-up, wherever it is reached from. Emitted once, into
1568
+ # the Registering concern every registration controller includes.
1569
+ def advance_body
1570
+ lines = [" def advance(registration)", " case registration.next_step"]
1571
+ if omniauth_registration?
1572
+ lines << " when :complete_profile"
1573
+ lines << " redirect_to new_#{identity.route_scope}_omniauth_registration_path"
1574
+ end
1575
+ if email_gated_registration?
1576
+ lines << " when :verify_email"
1577
+ lines << " #{deferred_call("registration", :request_email_verification)}"
1578
+ lines << " redirect_to #{identity.route_scope}_new_verify_email_path"
1579
+ end
1580
+ if phone_gated_registration?
1581
+ lines << " when :verify_phone"
1582
+ lines << " #{deferred_call("registration", :request_phone_verification)}"
1583
+ lines << " redirect_to #{identity.route_scope}_verify_phone_path"
1584
+ end
1585
+ if credential_staged_registration?
1586
+ lines << " when :enroll_credential"
1587
+ lines << %( redirect_to #{credential_enrollment_path}, notice: "#{credential_enrollment_alert}")
1588
+ end
1589
+ lines += [" when :complete", " finish_registration(registration.complete)", " end", " end"]
1590
+ lines.join("\n")
1591
+ end
1592
+
1593
+ # Only worth a page of its own when there is an actual choice to put on it. One
1594
+ # option means one destination, and a chooser above a single button is worse
1595
+ # than the button. The provider fallback needs the page too: a button is all it
1596
+ # has, and there is nowhere else to put one.
1597
+ def registration_credential_chooser? = registration_credential_options.size > 1
1598
+ def registration_credential_page? = registration_credential_chooser? || pre_account_provider_enrollment?
1599
+
1600
+ def credential_enrollment_page_path(option)
1601
+ case option
1602
+ when :password then "#{identity.route_scope}_new_registration_password_path"
1603
+ when :passkey then "#{identity.route_scope}_new_registration_passkey_path"
1604
+ end
1605
+ end
1606
+
1607
+ def credential_enrollment_path
1608
+ return "#{identity.route_scope}_finish_setup_path" if registration_credential_page?
1609
+
1610
+ credential_enrollment_page_path(registration_credential_options.first) ||
1611
+ "#{identity.route_scope}_sign_up_path"
1612
+ end
1613
+
1614
+ def credential_enrollment_alert
1615
+ return "Choose how you'll sign in to finish setting up your account." if registration_credential_chooser?
1616
+
1617
+ case registration_credential_options.first
1618
+ when :passkey then "Create a passkey to finish setting up your account."
1619
+ when :password then "Choose a password to finish setting up your account."
1620
+ else "Connect an account to finish setting up your account."
1621
+ end
1622
+ end
1623
+
1624
+ # The before_actions that hold a signed-in account at a page until it fixes
1625
+ # something, in the order they run: the callback, the predicate that lifts it,
1626
+ # the page it holds you at, and what it says there.
1627
+ def impediments
1628
+ [
1629
+ [admin_mfa_gate?, "require_admin_mfa", "admin_mfa_enrolled?", admin_mfa_hold_path,
1630
+ "#{totp? ? "Set up a second factor" : "Enroll a security key"} to continue."],
1631
+ [max_sessions_prompt?, "require_session_within_limit", "within_session_limit?",
1632
+ "#{identity.settings_helper_prefix}_sessions_path", "Sign out of another session to continue."],
1633
+ [password_rotatable?, "require_fresh_password", "password_fresh?",
1634
+ "edit_#{identity.settings_helper_prefix}_password_path", "Your password has expired. Choose a new one to continue."],
1635
+ [teams?, "require_team", "on_a_team?", identity.nested_helper("teams_path"), "Create or join a team to continue."]
1636
+ ].select(&:first).map { |_in_this_build, *impediment| impediment }
1637
+ end
1638
+
1639
+ def impeded? = impediments.any?
1640
+
1641
+ def impediment_callbacks = impediments.map { |callback, *| ":#{callback}" }.join(", ")
1642
+
1643
+ def impediment_conditions = impersonatable? ? "if: :authenticated?, unless: :impersonating?" : "if: :authenticated?"
1644
+
1645
+ def pending_registration_session_key = "pending_#{identity.singular}_registration"
1646
+
1647
+ def interrupted_destination_key = session_key(:interrupted_destination)
1648
+
1649
+ # Every identity writes into the one Rails session, so a namespaced identity's keys
1650
+ # carry its name or two identities would read each other's.
1651
+ def session_key(name) = identity.namespaced? ? "#{identity.singular}_#{name}" : name.to_s
1652
+
1653
+ # Which second factors an account may enroll, from --second-factor=totp,webauthn.
1654
+ # Empty when the flag is absent, so second_factor? is "this build has any".
1655
+ def second_factors
1656
+ @second_factors ||= options.second_factor.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
1657
+ end
1658
+
1659
+ def second_factor? = second_factors.any?
1660
+ def totp? = second_factors.include?(:totp)
1661
+ def security_keys? = second_factors.include?(:webauthn)
1662
+ def sms_second_factor? = second_factors.include?(:sms)
1663
+
1664
+ # More than one enrolled means a sign-in has to choose where to land, and each
1665
+ # challenge page has somewhere to link. Neither question exists with one factor,
1666
+ # so the chooser and the cross-links are generated only here.
1667
+ def multiple_second_factors? = second_factors.size > 1
1668
+
1669
+ # The narrower pair: emitted tests that drive an authenticator and a security key
1670
+ # against each other need both to exist.
1671
+ def both_second_factors? = totp? && security_keys?
1672
+
1673
+ # How an account is asked whether it holds each factor, strongest first — the
1674
+ # order the challenge chooser and second_factor_enrolled? both read.
1675
+ def second_factor_enrolled_clauses
1676
+ clauses = []
1677
+ clauses << "#{second_factor_keys_expr}.any?" if security_keys?
1678
+ clauses << "totp_enrolled_at?" if totp?
1679
+ clauses << "sms_enrolled_at?" if sms_second_factor?
1680
+ clauses
1681
+ end
1682
+
1683
+ def second_factor_challenge_path_for(factor) = "new_#{identity.route_scope}_mfa_challenge_#{factor}_path"
1684
+
1685
+ # The challenge chooser's body. The strongest factor the account actually holds
1686
+ # wins; the weakest is the fallthrough, so the chain always lands somewhere. With
1687
+ # one factor this is that factor's path and nothing else, which is what
1688
+ # start_new_session_for redirects to directly.
1689
+ def second_factor_challenge_lines
1690
+ branches = []
1691
+ branches << ["#{second_factor_keys_expr("user")}.any?", "security_keys"] if security_keys?
1692
+ branches << ["user.totp_enrolled_at?", "totp"] if totp?
1693
+ branches << ["user.sms_enrolled_at?", "sms"] if sms_second_factor?
1694
+
1695
+ *guarded, (_, fallthrough) = branches
1696
+ guarded.map { |clause, factor| "return #{second_factor_challenge_path_for(factor)} if #{clause}" } <<
1697
+ second_factor_challenge_path_for(fallthrough)
1698
+ end
1699
+
1700
+ # The "use something else instead" links a challenge page offers: every other
1701
+ # factor this build has, each shown only when the account actually holds it.
1702
+ # Empty with one factor, which is how the links disappear from those builds.
1703
+ def second_factor_alternatives(except)
1704
+ factors = []
1705
+ factors << ["security_keys", "#{second_factor_keys_expr("@user")}.any?", "Use a security key instead"] if security_keys?
1706
+ factors << ["totp", "@user.totp_enrolled_at?", "Use your authenticator app instead"] if totp?
1707
+ factors << ["sms", "@user.sms_enrolled_at?", "Get a code by text instead"] if sms_second_factor?
1708
+
1709
+ factors.reject { |factor, _, _| factor == except.to_s }
1710
+ .map { |factor, clause, label| [clause, label, second_factor_challenge_path_for(factor)] }
1711
+ end
1712
+
1713
+ # --second-factor=webauthn is security keys as a *second* factor; --passkey is
1714
+ # passkeys as a *primary* passwordless door (a strategy of its own, no second
1715
+ # factor). Both are WebAuthn credentials stored in the same table, so
1716
+ # webauthn_credentials? gates the shared machinery (gem, RP initializer,
1717
+ # webauthn_credentials table + model, webauthn_handle, the JS controller).
1718
+ def passkey? = options.passkey?
1719
+ def webauthn_credentials? = security_keys? || passkey?
1720
+
1721
+ # Only when a build has both roles does the shared table need to tell them
1722
+ # apart (so the two management UIs don't list each other's credentials); the
1723
+ # authentication_factor column and the model scopes exist only in that case.
1724
+ def passkey_and_security_keys? = passkey? && security_keys?
1725
+
1726
+ # How the templates ask for an account's second-factor keys: plain in a build
1727
+ # where every credential is one, scoped where passkeys share the table. Pass no
1728
+ # receiver inside the model itself.
1729
+ def second_factor_keys_expr(receiver = nil)
1730
+ "#{receiver}#{"." if receiver}webauthn_credentials#{".second_factors" if passkey_and_security_keys?}"
1731
+ end
1732
+
1733
+ # The role a credential is created with, where the build has both.
1734
+ def factor_attr(role) = (", authentication_factor: :#{role}" if passkey_and_security_keys?)
1735
+
1736
+ # The sign-in doors a user's enrolled second factor is required at by default:
1737
+ # every knowledge/possession door this build actually has. A passkey door
1738
+ # (when it exists) is deliberately omitted — a passkey is itself a strong,
1739
+ # phishing-resistant factor, so it clears the bar on its own and is never
1740
+ # stepped up. Seeds config.second_factor.needed_after, which the app owner can
1741
+ # edit. Provider doors are never seeded: a provider names itself (:google, not
1742
+ # :omniauth) and only the app knows which ones it has configured.
1743
+ def default_needed_after
1744
+ strategies = []
1745
+ strategies << :password if password?
1746
+ strategies << :sms_code if sms_code?
1747
+ strategies
1748
+ end
1749
+
1750
+ # ---- sessions -------------------------------------------------------------
1751
+
1752
+ # Emit a sign-in call. Every one names the door it came through, so
1753
+ # config.second_factor.needed_after describes the whole policy and no site can
1754
+ # sidestep it, and sessions.via records it.
1755
+ # +subject+ is the local holding the user ("user", "@user"). +via+ is a Symbol
1756
+ # for a fixed door, or a String of Ruby for one only the request knows (the
1757
+ # omniauth provider).
1758
+ #
1759
+ # With two-factor the call can divert to the MFA challenge and answer false, so
1760
+ # a door must `return unless` it. Pass may_challenge: false where that can't
1761
+ # happen — an account created moments ago has no factor to be asked for — and
1762
+ # the guard is left off rather than emitted dead.
1763
+ def sign_in_call(subject, via:, remember_expr: nil, may_challenge: true)
1764
+ remember = ", remember: #{remember_expr}" if rememberable? && remember_expr
1765
+ door = via.is_a?(Symbol) ? ":#{via}" : via
1766
+ call = "start_new_session_for(#{subject}, via: #{door}#{remember})"
1767
+ may_challenge && second_factor? ? "return unless #{call}" : call
1768
+ end
1769
+
1770
+ def trackable? = options.trackable?
1771
+ def last_seenable? = options.last_seenable?
1772
+ def security_notifications? = options.security_notifications?
1773
+ def api_tokens? = options.api_tokens?
1774
+
1775
+ # "Remember me" is a cookie-lifetime policy on the browser session cookie, and
1776
+ # the idle timeout resolves the DB session per request.
1777
+ def rememberable? = options.rememberable?
1778
+ def timeoutable? = options.timeoutable?
1779
+
1780
+ # A concurrent-session cap enforced at the one choke point every door funnels
1781
+ # through (start_new_session_for).
1782
+ def max_sessionable? = options.max_sessionable.present?
1783
+
1784
+ # Which over-limit strategy the flag selected. "evict" (default) silently signs
1785
+ # out the least-recently-active session at sign-in; "prompt" holds the new
1786
+ # sign-in at a completion gate until the user trims a session themselves.
1787
+ def max_sessions_evict? = max_sessionable? && options.max_sessionable == "evict"
1788
+ def max_sessions_prompt? = max_sessionable? && options.max_sessionable == "prompt"
1789
+
1790
+ # Whether this build has a typed sign-up FORM — not whether accounts can be
1791
+ # created. The omniauth callback registers with no form at all, which is why
1792
+ # pending_registration? asks that separately.
1793
+ def typed_registration? = options[:registration] != "closed" && !social_login_only?
1794
+
1795
+ # Whether to advertise a *public* sign-up path in the UI. Invite-only keeps the
1796
+ # registration controller and route (reached only via an invitation link), but
1797
+ # there is no open sign-up — so the login page must not link to one.
1798
+ def public_registration? = typed_registration? && !invite_only?
1799
+
1800
+ # has_secure_password is opt-in. Any feature that is meaningless without a
1801
+ # password implies it — you can't reset or breach-check a password that doesn't
1802
+ # exist — so asking for one turns --password on (same shape as adminable? below).
1803
+ # Two flags are deliberately NOT here. sudoable? re-proves whatever factor the
1804
+ # user actually holds, so it never assumes a password exists. deadboltable?
1805
+ # needs one, but asking for a brute-force defence is not asking for the thing
1806
+ # being defended — validate_deadboltable! refuses that rather than invent a door.
1807
+ def password? = options.password? || recoverable? || pwned? || strong_passwords? || password_rotatable? || password_historical?
1808
+
1809
+ # Password reset ("forgot password"). Opt-in, and implies --password
1810
+ # (see above). Orthogonal to --magic-link: reset recovers a *passworded*
1811
+ # account, while magic-link is an additive way in that needs no password.
1812
+ def recoverable? = options.recoverable?
1813
+
1814
+ # Password expiry / rotation. Opt-in, implies --password (you can't
1815
+ # expire a password that doesn't exist). The maximum age itself lives in the
1816
+ # initializer (config.password.maximum_age, default 90.days; nil disables),
1817
+ # tunable without regenerating — the same config-not-flag shape as
1818
+ # timeoutable?'s idle_timeout and the session cap.
1819
+ def password_rotatable? = options.password_rotatable?
1820
+
1821
+ # Password-reuse history. Opt-in, implies --password. The depth lives in the
1822
+ # initializer (config.password.history_depth, default 5; nil keeps archiving
1823
+ # but stops rejecting).
1824
+ def password_historical? = options.password_historical?
1825
+
1826
+ # Whether a channel gets proved, asked one channel at a time. --no-verifiable is
1827
+ # a single opt-out that drops verification everywhere, but these predicates stay
1828
+ # per-channel so every template asks "does *this* channel verify?" rather than "is
1829
+ # the flag set?" — which is what would let a per-channel opt-out arrive without
1830
+ # touching a template.
1831
+ #
1832
+ # Each is keyed to its own principal, so a build without that principal forces it
1833
+ # false and emits none of its flow.
1834
+ #
1835
+ # Email: a signed link, clicked from the inbox. Dropping it takes the staged sign-up
1836
+ # and staged change, their tokens, mailers, controllers, routes and views; sign-up
1837
+ # then creates the account straight away and settings writes the column directly.
1838
+ def email_verifiable? = options.verifiable? && !principals.email.nil?
1839
+
1840
+ # Phone: a six-digit one-time code, typed into a form. Dropping it takes the staged
1841
+ # sign-up and staged change and their controllers, routes and views, leaving the
1842
+ # plain phone column and its login-key dispatch untouched. It is also what pulls in
1843
+ # the whole SMS delivery seam (see sms?), so a build that wants a phone column
1844
+ # without an SMS vendor is the case --no-verifiable serves.
1845
+ def phone_verifiable? = options.verifiable? && !principals.phone.nil?
1846
+
1847
+ def channel_verifiable?(principal) = principal.type == :email ? email_verifiable? : phone_verifiable?
1848
+
1849
+ # --encrypted-pii is a single boolean because invitations.sent_to holds either
1850
+ # channel in one column, so a per-channel split has no coherent answer there. The
1851
+ # predicates stay per-channel anyway, so every template asks about a channel rather
1852
+ # than about the flag.
1853
+ def email_encrypted? = options.encrypted_pii? && !principals.email.nil?
1854
+ def phone_encrypted? = options.encrypted_pii? && !principals.phone.nil?
1855
+
1856
+ # A TOTP secret is the one reversible credential in the schema — it can't be digested,
1857
+ # because verifying a code means recomputing it. So it is always encrypted, with no
1858
+ # flag: plaintext there means a database dump is a set of working second factors.
1859
+ # Nothing queries it, so it takes the random-IV mode and rotates freely.
1860
+ def encryption? = encrypted_pii? || totp?
1861
+
1862
+ def encrypted_pii? = options.encrypted_pii?
1863
+
1864
+ # Whether this build generates a way for the account holder to change the value
1865
+ # themselves: the /settings resource, its form, and — where the channel is
1866
+ # verified — the whole staged-change apparatus behind it (the pending_ column,
1867
+ # its token and mailer, the confirm and resend endpoints). ",permanent" is the
1868
+ # flag that turns these off.
1869
+ def email_changeable? = !principals.email.nil? && principals.email.changeable?
1870
+ def phone_changeable? = !principals.phone.nil? && principals.phone.changeable?
1871
+
1872
+ # A staged change, proved before it lands. Distinct from email_gated_registration?,
1873
+ # which proves the value an account is BORN with — a permanent build keeps that and
1874
+ # drops this.
1875
+ def email_change_verification? = email_verifiable? && email_changeable?
1876
+ def phone_change_verification? = phone_verifiable? && phone_changeable?
1877
+
1878
+ # Cache-backed six-digit challenges. A signed-link phone invitation needs the
1879
+ # provider and delivery job but none of this code-entry machinery.
1880
+ def sms? = phone_verifiable? || sms_code? || sms_second_factor?
1881
+
1882
+ # The app-global SMS provider seam, generated for challenges or invitations.
1883
+ def sms_delivery? = sms? || phone_invitable?
1884
+
1885
+ # Proof staging and method staging are independent capabilities. A provider
1886
+ # sign-up that still owes profile fields is the third reason the aggregate exists.
1887
+ def channel_staged_registration?
1888
+ typed_registration? && (email_verifiable? || phone_verifiable?)
1889
+ end
1890
+
1891
+ # Whether the typed sign-up form stages a row rather than creating the account.
1892
+ def typed_staged_registration? = typed_registration? && (channel_staged_registration? || credential_staged_registration?)
1893
+
1894
+ def pending_registration_model? = typed_staged_registration? || omniauth_registration?
1895
+
1896
+ # Where the row carries a provider login: either the provider sign-up itself, or
1897
+ # a typed sign-up whose only credential is connecting one.
1898
+ def pending_registration_holds_provider? = omniauth_registration? || pre_account_provider_enrollment?
1899
+
1900
+ # Compatibility for older, non-emission helpers while callers migrate to the
1901
+ # capability they actually need.
1902
+ alias pending_registration? pending_registration_model?
1903
+
1904
+ # What the sign-up form renders errors and values from. A staged build is filling
1905
+ # in a PendingRegistration, not a half-built account, and the view should say so.
1906
+ def registration_form_object = typed_staged_registration? ? "@registration" : "@user"
1907
+
1908
+ # Whether a sign-up can arrive with no channel to prove at all. Such a request has
1909
+ # nothing to wait for and nothing to hide, so it becomes an account immediately.
1910
+ #
1911
+ # Requires --no-contactable: with the default on, an account holding no channel is
1912
+ # refused either by must_be_contactable or by the column's own presence rule, so
1913
+ # this branch and everything it guards would be unreachable. Keyed to the flag
1914
+ # rather than to "every channel happens to be optional", so a build only has this
1915
+ # shape when it asked for it.
1916
+ def channel_less_registration?
1917
+ !contactable? && pending_registration_model? &&
1918
+ principals.select(&:channel?).all?(&:optional?) && !login_key_may_be_missing?
1919
+ end
1920
+
1921
+ # What a staged sign-up carries over to the account besides the channel that
1922
+ # just proved it: every principal that needs no proof, plus the password when
1923
+ # there is one.
1924
+ def staged_attributes_entries
1925
+ entries = principals.reject(&:channel?).map { |principal| "#{principal.column}: #{principal.column}" }
1926
+ entries << "password_digest: password_digest" if password_on_sign_up_form?
1927
+ entries
1928
+ end
1929
+
1930
+ # Empty in a build whose only principals are channels and whose door needs no
1931
+ # password — a magic-link-only sign-up carries nothing across. There the method
1932
+ # and its splat are both skipped rather than emitted as a `{}` nobody reads.
1933
+ def staged_attributes? = staged_attributes_entries.any?
1934
+
1935
+ def staged_attributes_literal(indent:) = hash_literal(staged_attributes_entries, indent: indent)
1936
+
1937
+ # How the carried values ride into a create! whose channel columns are already
1938
+ # spelled out literally. A trailing splat, or nothing at all.
1939
+ def staged_attributes_splat = (", **staged_attributes" if staged_attributes?)
1940
+
1941
+ # The channels a staged row can mint an account from. A deferred phone is absent
1942
+ # on purpose: it mints nothing, it is parked on an account the email already minted.
1943
+ def minting_channel_columns
1944
+ columns = []
1945
+ columns << principals.email.column if email_verifiable?
1946
+ columns << principals.phone.column if phone_registration_claim?
1947
+ columns
1948
+ end
1949
+
1950
+ # Whether PendingRegistration has a private section to open at all. A build whose
1951
+ # sign-up carries nothing, defers nothing and claims no username has every one of
1952
+ # its methods public, and a bare `private` before `end` would be the only thing
1953
+ # under it.
1954
+ def pending_registration_privates?
1955
+ staged_attributes? || omniauth? || !principals.username.nil?
1956
+ end
1957
+
1958
+ # Passwordless sign-in by texted code — a door, the phone sibling of --magic-link.
1959
+ # The phone principal is explicit; validate_sms_code_option! requires it.
1960
+ def sms_code? = options.sms_code?
1961
+
1962
+ # Whether an emailed link is the gate a staged sign-up hands off to — the sibling
1963
+ # of phone_gated_registration?. It decides everything that link touches: the
1964
+ # mailer method and its view, the token purpose and its expiry key, the "check
1965
+ # your email" page, and the controller that redeems it.
1966
+ #
1967
+ # Distinct from email_verifiable?, which only says there is an address worth
1968
+ # proving; an account can be asked to prove one long after sign-up.
1969
+ def email_gated_registration?
1970
+ email_verifiable? && typed_staged_registration? && typed_sign_up_may_owe?(principals.email)
1971
+ end
1972
+
1973
+ # An invite-only sign-up arrives with the invited channel proved and drops an optional
1974
+ # other one, so it can only owe a required channel the invitation might not have used.
1975
+ def typed_sign_up_may_owe?(principal)
1976
+ !invite_only? || (principal.required? && multi_channel_invitable?)
1977
+ end
1978
+
1979
+ # The sign-up form never collects the channel: it is the invitation's address.
1980
+ def invitation_pins_only_channel? = invite_only? && !multi_channel_invitable?
1981
+
1982
+ # Where to send someone whose staged sign-up is gone. The sign-up form, when this
1983
+ # build has one — an omniauth-only build stages from the callback and has no form
1984
+ # to go back to, so it offers the sign-in page and its provider buttons instead.
1985
+ def registration_entry_path
1986
+ return "#{identity.route_scope}_sign_up_path" if typed_registration?
1987
+
1988
+ "#{identity.route_scope}_sign_in_path"
1989
+ end
1990
+
1991
+ # Whether an SMS code is the gate a typed sign-up must clear — the phone sibling
1992
+ # of email_gated_registration?.
1993
+ def typed_phone_gated_registration?
1994
+ phone_verifiable? && typed_staged_registration? && typed_sign_up_may_owe?(principals.phone)
1995
+ end
1996
+
1997
+ # Either sign-up proves its number this way: the typed form, or a provider's profile form.
1998
+ def phone_gated_registration? = typed_phone_gated_registration? || omniauth_registration_verifies_phone?
1999
+
2000
+ # Non-disclosure of whether an account exists — Devise calls this "paranoid";
2001
+ # named --coy here instead, since that's closer to what it actually does (evasive,
2002
+ # not fearful). Off by default, for the better user experience; with --coy every
2003
+ # channel-principal flow that could confirm an address (password reset,
2004
+ # magic-link request, email change, the locked-account message) gives the same
2005
+ # response whether or not the address is on file. Responses only: the coy
2006
+ # branches still differ in timing. This is a *generation-time* choice, not a runtime
2007
+ # config, so the unchosen branch never ships to the app. Public principals
2008
+ # (username) are honest regardless (Principal#publicly_unique?), and
2009
+ # --no-verifiable independently forces honest *sign-up*, since the "check your
2010
+ # email" step was what covered the coy answer.
2011
+ def coy? = options.coy?
2012
+ def magic_link? = options.magic_link?
2013
+ # Teams are absent unless --teams is present. Each mode sets Current.team its own
2014
+ # way — the route, the middleware, or the session — and team_members_only then
2015
+ # checks membership without knowing which.
2016
+ def teams? = options[:teams].present?
2017
+ def scope_teams? = options[:teams] == "scope"
2018
+ def middleware_teams? = options[:teams] == "middleware"
2019
+ def session_carries_team? = options[:teams] == "session"
2020
+
2021
+ def team_root_helper = identity.nested_helper("team_root_path")
2022
+
2023
+ def team_foreign_key = identity.namespaced? ? "{ to_table: :#{identity.teams_table} }" : "true"
2024
+
2025
+ # A page an emitted test can visit that runs the team gate. Every generated page
2026
+ # is a remedy that skips it, so this is the team's own page or the host's root,
2027
+ # and a namespaced identity keeping its team in the session has neither.
2028
+ def team_gated_page
2029
+ return "#{team_root_helper}(team_id: #{identity.teams_table}(:acme))" if scope_teams?
2030
+
2031
+ "root_path" unless identity.namespaced?
2032
+ end
2033
+
2034
+ def admin_module = identity.nested_module("Admin")
2035
+ def admin_controller = identity.nested_module("AdminController")
2036
+ def admin_helper(name) = identity.nested_helper("admin_#{name}")
2037
+
2038
+ def team_id_constraint = primary_key_type == "uuid" ? '/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/' : '/\d+/'
2039
+ # Development-only "sign in as anyone" shortcut. What it *includes* depends on
2040
+ # the primary auth strategy: with a password it's the sign-in-form bypass
2041
+ # (the EasyDevLogin concern); passwordless it's an explicit dev-only email
2042
+ # field + endpoint (there's no credential form to piggyback). Both share the
2043
+ # "type an email, become that user" gesture.
2044
+ def easy_dev_login? = options.easy_dev_login?
2045
+ def pwned? = options.pwned?
2046
+ # Password strength scoring is a second, orthogonal password-quality validation
2047
+ # (see --pwned): both are `validates :password` lines, and either one implies
2048
+ # --password since there's no password to score without it.
2049
+ def strong_passwords? = options.strong_passwords?
2050
+ # A human/anti-abuse challenge on the *unauthenticated* write forms. It's a
2051
+ # property of the request (verified in a controller concern), not of the User
2052
+ # record — so unlike --pwned/--strong-passwords it never touches the model. Turnstile is
2053
+ # the shipped provider; Captcha.provider in app/lib/captcha.rb is the seam for others.
2054
+ def captchable? = options.captchable?
2055
+
2056
+ # The widget does not survive a Turbo body swap, and these forms re-render on failure.
2057
+ def captcha_form_options = captchable? ? ", data: {turbo: false}" : ""
2058
+
2059
+ # Re-prove before sensitive actions. Bar-aware: it clears the user's strongest
2060
+ # available factor, so (unlike before) it does NOT imply --password.
2061
+ def sudoable? = options.sudoable?
2062
+
2063
+ # The user a sudo re-prove must clear: always the signed-in admin, never the
2064
+ # person they're impersonating.
2065
+ def sudo_subject = "#{identity.current_class}.#{impersonatable? ? "true_user" : "user"}"
2066
+
2067
+ # The sudo bars this build can pose, in the order Sudoable#sudo_strategy tries
2068
+ # them. Ordered by what the person can actually produce at the keyboard right
2069
+ # now, not by cryptographic strength:
2070
+ #
2071
+ # :webauthn one touch, phishing-resistant, and on the device they're holding.
2072
+ # :password no second device involved, so it is the one bar that is always
2073
+ # available to whoever has one.
2074
+ # :totp last, because ranking it above the password would trap anyone who
2075
+ # signed in with a recovery code after losing the phone.
2076
+ #
2077
+ # :password is terminal when every user in the build has one, which is why :totp
2078
+ # drops out entirely unless --omniauth can produce a passwordless account. That
2079
+ # is also what keeps the emitted method free of unreachable branches.
2080
+ def sudo_bars
2081
+ bars = []
2082
+ bars << :webauthn if webauthn_credentials?
2083
+ bars << :password if password?
2084
+ bars << :totp if totp? && !unconditional_password_bar?
2085
+ bars
2086
+ end
2087
+
2088
+ # Whether an account that can reach a sudo bar might hold no password. Guests are
2089
+ # not in scope: they are never authenticated, so they never reach one, and their
2090
+ # NULL digest doesn't make the bar conditional for anyone who does — which is why
2091
+ # this is password_nullable? minus that clause rather than the same question.
2092
+ def passwordless_account_possible?
2093
+ !password? || password_form_optional? || password_deferred? || omniauth?
2094
+ end
2095
+
2096
+ # Every account in this build has a password it chose, so the password bar always
2097
+ # applies and nothing after it can be reached.
2098
+ def unconditional_password_bar? = password? && !passwordless_account_possible?
2099
+
2100
+ # One bar, and every account has it — so which bar to pose is known at generation
2101
+ # time. sudo_strategy would be a method returning a constant, every dispatch on it
2102
+ # a branch with one arm, and the helper_method that exposes it to the challenge
2103
+ # partial pure ceremony. All of it is emitted away.
2104
+ #
2105
+ # A passkey-only or authenticator-only build does NOT qualify: both fall through to
2106
+ # :open for an account that hasn't enrolled, so the branch is load-bearing there.
2107
+ def single_sudo_bar? = sudo_bars == [:password] && unconditional_password_bar?
2108
+
2109
+ # Sudo can hit the "no re-provable factor" case (a user whose only credential is
2110
+ # omniauth or a magic link) only when the build can produce such a user. There we
2111
+ # fail open rather than lock them out of the guarded action. Same question as the
2112
+ # password bar's, from the other side.
2113
+ def fail_open_sudo? = sudoable? && passwordless_account_possible?
2114
+
2115
+ # ---- credentials an account may remove ------------------------------------
2116
+
2117
+ # Credentials an account may hold and remove: several passkeys or provider
2118
+ # logins, or — where password_removable? below is true — the password itself. Their
2119
+ # settings pages carry a refusal rather than a sudo bar: a bar you can clear is
2120
+ # the wrong answer to an action nobody should be able to take, because the thing
2121
+ # being prevented is not impersonation but lockout, and proving who you are does
2122
+ # not make locking yourself out safe.
2123
+ #
2124
+ # Whether a removal would in fact lock the account is a question about the
2125
+ # ACCOUNT, and this predicate only says which builds need to ask it. The answer
2126
+ # itself is the model's #last_sign_in_method?, which can see the row. Deciding it
2127
+ # per door at generation time cannot: a build with two of them would guard
2128
+ # neither, and a provider-registered user could unlink into an account they can
2129
+ # no longer reach.
2130
+ def removable_credentials? = passkey? || omniauth? || password_removable?
2131
+
2132
+ # Doors that identify an account by something other than a password. Their
2133
+ # presence is what lets --password=optional be offered at all: an account can
2134
+ # decline a password and still have a way in.
2135
+ def non_password_doors? = passkey? || omniauth? || magic_link? || sms_code?
2136
+
2137
+ # What the SIGN-UP FORM does about a password — not what every account holds.
2138
+ # Nothing typed into a form can speak for an account minted somewhere else, so
2139
+ # password_nullable? below asks its own question.
2140
+ #
2141
+ # "optional" carries the sense --contactable gave --email=optional: optional
2142
+ # BECAUSE another door covers you, not "may be absent". WHICH door covers a
2143
+ # given account is a per-row fact. PendingRegistration answers it before account
2144
+ # creation; #last_sign_in_method?(:password) answers it later at removal time.
2145
+ def password_form_optional? = password? && options[:password] == "optional"
2146
+ def password_required? = password_on_sign_up_form? && !password_form_optional?
2147
+
2148
+ # Whether the password is enrolled after proof rather than typed on the initial
2149
+ # form. The unfinished attempt remains a PendingRegistration until the password
2150
+ # and account can commit together.
2151
+ #
2152
+ # The only credential here with a choice about when. A passkey is always enrolled
2153
+ # afterwards, a provider ceremony IS the arrival, and a magic link or SMS code is
2154
+ # not enrolled at all — a verified channel is already the door.
2155
+ def password_deferred? = password? && options[:password] == "deferred"
2156
+
2157
+ # Whether the sign-up form carries a password field at all. Sites that care about
2158
+ # the FORM ask this; sites that care about the BUILD ask #password?.
2159
+ def password_on_sign_up_form? = password? && !password_deferred?
2160
+
2161
+ # Whether any path in this build can mint an account with no password on file.
2162
+ # Decides the column's nullability, whether the presence rule can be lifted from
2163
+ # a row, and whether settings needs a way to SET a first password.
2164
+ #
2165
+ # Three paths can: a form that lets the field be blank, the OmniAuth callback (a
2166
+ # provider hands back no password and never will), and guest creation. The other
2167
+ # doors cannot — a --password --passkey build still collects a password from
2168
+ # everyone who signs up, and the passkey is an addition to it.
2169
+ #
2170
+ # Which ROWS may lack one, not which builds have another door. Where this is true
2171
+ # the excused rows hold a real NULL rather than a synthesized password nobody
2172
+ # can type.
2173
+ def password_nullable? = password? && (password_form_optional? || password_deferred? || omniauth? || guestable?)
2174
+
2175
+ # Whether settings offers to REMOVE a password. Only where the form called it
2176
+ # optional on the way in: a build that insists on one at sign-up does not then
2177
+ # offer a door out of it. An account minted without a password can still SET
2178
+ # one wherever password_nullable? holds — that is the add half, and it is not
2179
+ # this question.
2180
+ def password_removable? = password_form_optional? || password_deferred?
2181
+
2182
+ # Which ROWS may carry a NULL digest, as an expression the model can evaluate on
2183
+ # itself. Generated only where password_nullable? holds.
2184
+ #
2185
+ # Where the form called the password optional, that is every row and the answer
2186
+ # is a constant. Otherwise the form required one from everyone it collected, and
2187
+ # the rows excused are exactly the ones no form ever touched.
2188
+ def password_blank_rows_expression
2189
+ [("guest?" if guestable?), ("omniauth_identities.any?" if omniauth?)].compact.join(" || ")
2190
+ end
2191
+
2192
+ # The same question asked of a sign-up in progress. A typed row came through a form
2193
+ # that asked for a password; a provider row holds the provider login instead.
2194
+ def staged_password_blank_rows_expression = pending_registration_holds_provider? ? "provider.present?" : "false"
2195
+
2196
+ # Whether EVERY row may lack a password, on every model that includes the policy.
2197
+ # Where the form called the password optional or deferred it did so for everyone,
2198
+ # so no row is excused more than any other — and a predicate that cannot answer
2199
+ # anything but true is not a seam. PasswordPolicy drops the presence error flatly
2200
+ # and neither model generates #password_may_be_blank? at all.
2201
+ def password_blank_rows_all? = password_form_optional? || password_deferred?
2202
+
2203
+ # Whether a guest is excused the phone-presence rule — the only place a SHARED
2204
+ # validation asks a row whether it is a guest. PendingRegistration defines
2205
+ # `guest? = false` to answer this and nothing else, so the stub follows it.
2206
+ #
2207
+ # Not simply guestable?: an optional phone has no presence rule to excuse, and the
2208
+ # format rule excuses nobody — allow_blank already skips the nil phone every guest carries.
2209
+ def guest_excused_from_phone? = guestable? && principals.phone&.required? || false
2210
+
2211
+ # Too many failed sign-ins deadbolt the password door shut for a cooling-off window.
2212
+ #
2213
+ # The deadbolt defends the password and nothing else, so it bars the password door
2214
+ # and nothing else: a deadbolted account still signs in with its passkey, its magic
2215
+ # link, its provider. Barring those would buy nothing — the attacker never held
2216
+ # them — and would hand any stranger who knows an email address a switch for
2217
+ # locking its owner out of doors that were never under attack.
2218
+ #
2219
+ # It needs a guessable secret to count guesses against, so it needs a password:
2220
+ # a WebAuthn assertion either verifies or raises, and nobody brute-forces a
2221
+ # signed magic-link token. `--deadboltable` without `--password` is refused rather
2222
+ # than silently ignored.
2223
+ #
2224
+ # Enforced inside authenticate_with_password, where the password is checked, so
2225
+ # no sign-in door can forget it — no door does the checking. Not in the
2226
+ # start_new_session_for funnel: a deadbolt is not a fact about the account.
2227
+ def deadboltable? = options.deadboltable? && password?
2228
+
2229
+ # An account can be suspended by an admin: barred from every sign-in door this
2230
+ # build has, with its live sessions destroyed. Distinct from --deadboltable, which
2231
+ # is automatic, password-only, and clears itself — a ban is a deliberate policy
2232
+ # action, so it carries its own state (banned_at, banned_until), its own admin
2233
+ # control, and its own unban path. The two never touch each other's column.
2234
+ # Enforced at the choke point every door funnels through (start_new_session_for),
2235
+ # because unlike a deadbolt a ban *is* a fact about the account. Always named to the
2236
+ # user — it's a message you mean to deliver — so it ignores --coy.
2237
+ #
2238
+ # A ban may carry an expiry (banned_until); banned? reads it, so a temporary ban
2239
+ # lifts itself with nothing scheduled to sweep it.
2240
+ def bannable? = options.bannable?
2241
+
2242
+ def omniauth? = options.omniauth?
2243
+
2244
+ # Required principals an OmniAuth provider can't supply: a username, a phone. Where
2245
+ # any exist, a provider sign-up is staged and a form collects them before the
2246
+ # account exists. Where none do, the callback creates the account itself.
2247
+ def omniauth_registration_principals
2248
+ principals.required.select { |p| %i[username phone].include?(p.type) }
2249
+ end
2250
+
2251
+ def omniauth_registration? = omniauth? && omniauth_registration_principals.any?
2252
+ def omniauth_registration_collects_phone? = omniauth? && omniauth_registration_principals.any? { |p| p.type == :phone }
2253
+
2254
+ # "username.blank? || phone.blank?": the provider row still owes its profile form.
2255
+ def omniauth_profile_missing_condition = omniauth_registration_principals.map { |p| "#{p.column}.blank?" }.join(" || ")
2256
+
2257
+ # The omniauth registration form's permitted params — the columns the provider couldn't
2258
+ # supply (e.g. ":username"). Emitted into the registrations controller's permit.
2259
+ def omniauth_registration_permits
2260
+ omniauth_registration_principals.map { |p| ":#{p.column}" }.join(", ")
2261
+ end
2262
+
2263
+ # What an emitted test types into that form.
2264
+ def omniauth_profile_params_literal
2265
+ values = omniauth_registration_principals.map { |p| p.type == :phone ? %(phone: "+14158264003") : %(#{p.column}: "provideruser") }
2266
+ "{ #{values.join(", ")} }"
2267
+ end
2268
+
2269
+ # The form's number is proved by a texted code before the account exists.
2270
+ def omniauth_registration_verifies_phone? = omniauth_registration_collects_phone? && phone_verifiable?
2271
+
2272
+ # The only door is a provider, so the callback is the only way an account is ever
2273
+ # made. A typed form here would collect an address the provider supplies anyway
2274
+ # and end at "now connect an account".
2275
+ def social_login_only? = omniauth? && !password? && !passkey? && !magic_link? && !sms_code?
2276
+
2277
+ # The registration mode is one policy choice. Invitation modes generate the
2278
+ # invitation machinery; invite-only additionally gates every account-creating
2279
+ # flow on a still-pending invitation.
2280
+ def invitable? = %w[open-and-invites invite-only].include?(options[:registration])
2281
+ def invite_only? = options[:registration] == "invite-only"
2282
+ def email_invitable? = invitable? && !principals.email.nil?
2283
+ def phone_invitable? = invitable? && !principals.phone.nil?
2284
+ def multi_channel_invitable? = email_invitable? && phone_invitable?
2285
+
2286
+ def invitation_recipient_label
2287
+ return "Email or phone" if multi_channel_invitable?
2288
+
2289
+ email_invitable? ? "Email" : "Phone"
2290
+ end
2291
+
2292
+ # A staged flow can mint an account from a phone invitation even where required
2293
+ # email means phone would never be the ordinary sign-up verification gate.
2294
+ def phone_registration_claim? = phone_gated_registration? || (pending_registration_model? && phone_invitable?)
2295
+
2296
+ # Guests are anonymous, session-backed User rows.
2297
+ def guestable? = options.guestable?
2298
+
2299
+ # The admin boolean is its own concern; the admin UI and impersonation both need it.
2300
+ def adminable? = options.adminable? || admin_dashboard? || impersonatable?
2301
+ def admin_dashboard? = options.admin_dashboard?
2302
+ def impersonatable? = options.impersonatable?
2303
+
2304
+ # The enforced-MFA gate for admins only makes sense when both areas exist.
2305
+ def admin_mfa_gate? = admin_dashboard? && second_factor?
2306
+
2307
+ # Where an MFA-less admin is held. The settings enrollment page for the factor
2308
+ # they can reach with nothing but a phone — TOTP where this build has it — so a
2309
+ # locked-out admin is never asked for hardware they may not be holding. Read by
2310
+ # the concern and by the tests.
2311
+ def admin_mfa_hold_path
2312
+ return "new_#{identity.settings_helper_prefix}_mfa_authenticator_path" if totp?
2313
+ return "new_#{identity.settings_helper_prefix}_mfa_sms_path" if sms_second_factor?
2314
+
2315
+ "new_#{identity.settings_helper_prefix}_mfa_security_key_path"
2316
+ end
2317
+
2318
+ # Authorization — role gates (admin) and per-resource access checks (team
2319
+ # membership) — lives in its own controller concern, separate from proving
2320
+ # identity (Authentication). Generated only when there's a gate to house.
2321
+ def authorization? = adminable? || teams?
2322
+
2323
+ # ---------------------------------------------------------------------------
2324
+ # Ruby written into the generated TEST suite
2325
+ #
2326
+ # Nothing below reaches app code — every one of these is read only by a
2327
+ # template under templates/test_unit. Fixture passwords, sudo answers and the
2328
+ # sign-in gesture live here so a test template never has to ask which build
2329
+ # shape it is in.
2330
+ # ---------------------------------------------------------------------------
2331
+
2332
+ # The body of an emitted test's `clear_sudo_bar` helper. Enrolling a credential is
2333
+ # guarded by require_sudo_within, so a test that enrols has to stamp sudo_at first —
2334
+ # once, since the window then covers the rest of the test. Without a password the
2335
+ # bar is a key once the account holds one, and nothing before; fixtures never hold
2336
+ # an enrolled authenticator, so the TOTP bar never comes up.
2337
+ def test_clear_sudo_body
2338
+ return " # No bar to clear: this build has no sudo." unless sudoable?
2339
+
2340
+ if sudo_bars.include?(:password)
2341
+ %( post #{identity.route_scope}_sudo_path, params: { sudo_password: "quilted-lantern-moss-97" })
2342
+ else
2343
+ %( post #{identity.route_scope}_sudo_path, params: { sudo_credential: #{identity.route_scope}_sudo_assertion } if @user.webauthn_credentials.any?)
2344
+ end
2345
+ end
2346
+
2347
+ # The body of an emitted test's `sign_in_as(user)` helper, indented to sit inside
2348
+ # it. "Sign in" is not one gesture: a password build posts the form, a magic-link
2349
+ # build consumes a link, an --sms-code build asks for a code and types it back.
2350
+ # Every test template that needs a signed-in user shares this one choice.
2351
+ def test_sign_in_body
2352
+ if password?
2353
+ %( post #{identity.route_scope}_sign_in_path, params: ) +
2354
+ %({ #{sign_in_field}: user.#{login_field}, password: "quilted-lantern-moss-97" })
2355
+ elsif magic_link?
2356
+ <<~RUBY.gsub(/^(?=.)/, " ").chomp
2357
+ token = user.generate_magic_link
2358
+ patch #{identity.route_scope}_magic_link_path(sid: token)
2359
+ RUBY
2360
+ elsif sms_code?
2361
+ <<~RUBY.gsub(/^(?=.)/, " ").chomp
2362
+ post #{identity.route_scope}_sms_sign_in_path, params: { phone: user.phone }
2363
+ # The code exists only in the outgoing text, so read it back from there.
2364
+ code = ActiveJob::Base.queue_adapter.enqueued_jobs.reverse
2365
+ .filter_map { |job| job.to_s[/(\\d{6}) is your/, 1] }.first
2366
+ patch #{identity.route_scope}_sms_sign_in_code_path, params: { code: code }
2367
+ RUBY
2368
+ else
2369
+ # Passkey and omniauth have no gesture a hermetic test can perform, so this
2370
+ # build's door can't be driven at all. Mint the session directly instead and
2371
+ # hand the client the same signed cookie sign-in would have set, produced with
2372
+ # the app's own signing config. It is a stand-in for the door, not a test of it.
2373
+ #
2374
+ # Such a build also holds every request at the enrollment gate until a credential
2375
+ # exists, so the stand-in has to clear that too — a caller asking for a signed-in
2376
+ # user wants one who can actually reach a page.
2377
+ #
2378
+ # Deliberately ASCII-only. Thor reads templates with binread, so the ERB
2379
+ # buffer is BINARY; concatenating a UTF-8 string into one that already holds
2380
+ # a non-ASCII byte raises Encoding::CompatibilityError at generation time.
2381
+ #
2382
+ # The stand-in passkey is a real key held by @client, so the test can answer
2383
+ # a sudo bar with it (see configure_test_webauthn).
2384
+ lines =
2385
+ if stand_in_passkey?
2386
+ [
2387
+ "@client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)",
2388
+ "key = WebAuthn::Credential.from_create(@client.create(rp_id: WebAuthn.configuration.rp_id, user_verified: true))",
2389
+ %(user.webauthn_credentials.create!(name: "Test key", external_id: key.id, public_key: key.public_key#{factor_attr(:first_factor)}))
2390
+ ]
2391
+ else
2392
+ [%(user.omniauth_identities.create!(provider: "developer", uid: "uid-\#{user.id}"))]
2393
+ end
2394
+ lines += [
2395
+ "record = user.sessions.create!",
2396
+ %(env = Rails.application.env_config.merge("HTTP_HOST" => "www.example.com")),
2397
+ "jar = ActionDispatch::Request.new(env).cookie_jar",
2398
+ "jar.signed[:#{identity.cookie_name}] = record.id",
2399
+ "cookies[:#{identity.cookie_name}] = jar[:#{identity.cookie_name}]"
2400
+ ]
2401
+ lines.map { |line| " #{line}" }.join("\n")
2402
+ end
2403
+ end
2404
+
2405
+ # Whether a hermetic test can perform this build's sign-in gesture. Everything
2406
+ # else gets a minted session (see test_sign_in_body's last branch), which is
2407
+ # enough for tests ABOUT the signed-in area but not for tests about the act of
2408
+ # signing in — those assert on what the sign-in path itself does, and a stand-in
2409
+ # that never travels that path can't satisfy them.
2410
+ def test_drivable_door? = password? || magic_link? || sms_code?
2411
+
2412
+ def stand_in_passkey? = passkey? && !test_drivable_door?
2413
+
2414
+ # The principal half of a generated test's sign-up fixture. The interpolations
2415
+ # are escaped (`\#{`) so they survive into the emitted file intact: they belong
2416
+ # to the test, and run when it does, not while this generator writes it.
2417
+ def fixture_principal_entries(with_email:)
2418
+ entries = []
2419
+ entries << %(email: "new-\#{SecureRandom.hex(4)}@example.com") if with_email
2420
+ entries << %(username: "user_\#{SecureRandom.hex(4)}") if principals.username
2421
+ entries << %(phone: "+1415826\#{format('%04d', SecureRandom.random_number(10_000))}") if principals.phone
2422
+ entries
2423
+ end
2424
+
2425
+ # What the emitted model test's build_<user> helper starts from.
2426
+ def account_fixture_literal(indent:)
2427
+ entries = fixture_principal_entries(with_email: !principals.email.nil?)
2428
+ entries << %(password: "quilted-lantern-moss-97") if password?
2429
+ hash_literal(entries, indent: indent)
2430
+ end
2431
+
2432
+ # What the emitted controller test posts to sign up. Two differences from the
2433
+ # model fixture: an invite-only build's address comes from the invitation rather
2434
+ # than the form, and a form submits a confirmation beside the password.
2435
+ def sign_up_fixture_literal(indent:)
2436
+ entries = fixture_principal_entries(with_email: !principals.email.nil? && !invitation_pins_only_channel?)
2437
+ entries.concat([%(password: "quilted-lantern-moss-97"), %(password_confirmation: "quilted-lantern-moss-97")]) if password?
2438
+ hash_literal(entries, indent: indent)
2439
+ end
2440
+
2441
+ # How an emitted test gives the fixture a way in that isn't its password, so the
2442
+ # removal guard stands down. Whichever non-password door this build actually has.
2443
+ def password_optional_second_door_setup
2444
+ if omniauth?
2445
+ %(@user.omniauth_identities.create!(provider: "developer", uid: "a-second-way-in"))
2446
+ elsif passkey?
2447
+ %(@user.webauthn_credentials.create!(name: "Key", external_id: "ext-second-way-in", public_key: "pk"#{factor_attr(:first_factor)}))
2448
+ else
2449
+ # --magic-link and --sms-code ride a principal the fixture already holds, so
2450
+ # there is nothing to create: the door is open the moment the account exists.
2451
+ "# #{magic_link? ? "the address" : "the number"} on file is already the second way in"
2452
+ end
2453
+ end
2454
+
2455
+ # Put a second factor on a record directly, for tests that need one enrolled
2456
+ # rather than exercising the enrollment flow itself.
2457
+ # The challenge an emitted test lands on after the first factor: the one
2458
+ # enroll_second_factor_expr put on the fixture.
2459
+ def enrolled_second_factor_challenge_path
2460
+ return second_factor_challenge_path_for("totp") if totp?
2461
+ return second_factor_challenge_path_for("sms") if sms_second_factor?
2462
+
2463
+ second_factor_challenge_path_for("security_keys")
2464
+ end
2465
+
2466
+ def enroll_second_factor_expr(subject)
2467
+ return "#{subject}.update!(totp_enrolled_at: Time.current)" if totp?
2468
+ return "#{subject}.update!(sms_enrolled_at: Time.current)" if sms_second_factor?
2469
+
2470
+ %(#{subject}.webauthn_credentials.create!(name: "Key", external_id: "ext-\#{#{subject}.id}", public_key: "pk"#{factor_attr(:second_factor)}))
2471
+ end
2472
+
2473
+ # What an emitted *settings* test can expect when it hits a sudo-guarded action:
2474
+ # :answer when the fixture can meet the bar (see sudo_test_answer), :open when
2475
+ # there is no bar this account can be asked and sudo fails open.
2476
+ def sudo_test_mode = sudoable? && sudo_test_answer ? :answer : :open
2477
+
2478
+ # The sudo answer a generated test sends with a guarded request. Fixture accounts
2479
+ # hold no authenticator enrollment, so the bars they can meet are the password and
2480
+ # the stand-in passkey's assertion; without either, sudo fails open. A TOTP bar is
2481
+ # deliberately absent: sudo_strategy poses it only to an enrolled account, so a
2482
+ # fixture carrying a totp_secret still clears sudo without answering, and sending
2483
+ # a code would look like coverage that isn't there.
2484
+ def sudo_test_answer
2485
+ return %(sudo_password: "quilted-lantern-moss-97") if sudo_bars.include?(:password)
2486
+
2487
+ %(sudo_credential: #{identity.route_scope}_sudo_assertion) if stand_in_passkey?
2488
+ end
2489
+
2490
+ # The sudo answer spliced into a generated test's params hash. Empty when the build
2491
+ # needs no answer.
2492
+ def sudo_test_params_pair = sudo_test_mode == :answer ? ", #{sudo_test_answer}" : ""
2493
+
2494
+ # The credential fragment a guarded settings write (change your address, change
2495
+ # your number, delete your account) must carry: the sudo answer where those
2496
+ # controllers are guarded, or a password_challenge where they collect one
2497
+ # themselves.
2498
+ def settings_credential_pair
2499
+ return sudo_test_params_pair if sudoable?
2500
+
2501
+ password? ? %(, password_challenge: "quilted-lantern-moss-97") : ""
2502
+ end
2503
+
2504
+ # The OmniAuth callback path a generated test posts to (the route is unnamed, so
2505
+ # tests use the literal path). Namespaced identities carry it under their prefix, the
2506
+ # same place namespaced_omniauth_routes mounts the callbacks.
2507
+ def omniauth_callback_test_path
2508
+ prefix = "/#{identity.path_prefix}" if identity.namespaced?
2509
+ "#{prefix}/auth/developer/callback"
2510
+ end
2511
+
2512
+ # The OmniAuth failure path a generated test hits (unnamed route, like the
2513
+ # callback above). Namespaced identities carry it under their prefix.
2514
+ def omniauth_failure_test_path
2515
+ prefix = "/#{identity.path_prefix}" if identity.namespaced?
2516
+ "#{prefix}/auth/failure"
2517
+ end
2518
+
2519
+ # ---------------------------------------------------------------------------
2520
+ # Routes
2521
+ # ---------------------------------------------------------------------------
2522
+
2523
+ def identity_scope_args
2524
+ path = %("#{identity.path_prefix}", ) if identity.namespaced?
2525
+ "#{path}module: :#{identity.controller_module}, as: :#{identity.route_scope}"
2526
+ end
2527
+
2528
+ def route_block
2529
+ clean_route_block(<<~RUBY)
2530
+ scope #{identity_scope_args} do
2531
+ get "sign_in", to: "sessions#new"#{password? ? %(\n post "sign_in", to: "sessions#create") : ""}
2532
+ delete "sign_out", to: "sessions#destroy"
2533
+ #{user_scope_route_blocks}end
2534
+
2535
+ #{settings_namespace}
2536
+ #{top_level_route_blocks}
2537
+ RUBY
2538
+ end
2539
+
2540
+ # The namespaced shape: one outer scope, path-prefixed under the identity's own
2541
+ # name, wraps *everything* — doors, settings, teams, admin, omniauth callbacks —
2542
+ # plus a subroot (<identity>_root_path). That is what keeps a second identity
2543
+ # from colliding with the first on bare paths like /sign_in or /admin.
2544
+ def namespaced_route_block
2545
+ clean_route_block(<<~RUBY)
2546
+ scope #{identity_scope_args} do
2547
+ get "sign_in", to: "sessions#new"#{password? ? %(\n post "sign_in", to: "sessions#create") : ""}
2548
+ delete "sign_out", to: "sessions#destroy"
2549
+ #{user_scope_route_blocks}
2550
+ #{settings_namespace(indent: 2)}
2551
+ #{namespaced_omniauth_routes if omniauth?}
2552
+ #{area_route_blocks.gsub(/^(?=.)/, " ")}
2553
+ root to: "settings/dashboard#index"
2554
+ end
2555
+ #{namespaced_top_level_route_blocks}
2556
+ RUBY
2557
+ end
2558
+
2559
+ def area_route_blocks
2560
+ route_blocks(
2561
+ (team_routes if teams?),
2562
+ (admin_routes if admin_dashboard?)
2563
+ )
2564
+ end
2565
+
2566
+ def user_scope_route_blocks
2567
+ route_blocks(
2568
+ (registration_routes if typed_registration?),
2569
+ (email_verification_routes if email_verifiable?),
2570
+ (password_reset_routes if recoverable?),
2571
+ (magic_link_routes if magic_link?),
2572
+ (sms_code_routes if sms_code?),
2573
+ (phone_verification_routes if phone_gated_registration?),
2574
+ (passkey_routes if passkey?),
2575
+ (credential_enrollment_routes if credential_staged_registration?),
2576
+ (sudo_routes if sudoable?),
2577
+ (mfa_challenge_routes if second_factor?),
2578
+ trailing: true
2579
+ )
2580
+ end
2581
+
2582
+ # The settings namespace's optional resources, indented to sit inside it. The
2583
+ # individual builders emit at column 0 and are shifted here in one place,
2584
+ # because the namespace sits at a different depth in the two route shapes:
2585
+ # top level by default, one level in once namespaced.
2586
+ def settings_route_blocks(indent)
2587
+ blocks = route_blocks(
2588
+ (settings_invitation_route if invitable?),
2589
+ (mfa_profile_routes if second_factor?),
2590
+ (passkey_profile_routes if passkey?),
2591
+ (omniauth_profile_routes if omniauth?),
2592
+ (authentication_event_routes if trackable?),
2593
+ trailing: true
2594
+ )
2595
+ blocks.gsub(/^(?=.)/, " " * indent)
2596
+ end
2597
+
2598
+ def top_level_route_blocks
2599
+ route_blocks(
2600
+ area_route_blocks,
2601
+ (omniauth_routes if omniauth?),
2602
+ (invitation_routes if invitable?),
2603
+ (easy_dev_login_routes if easy_dev_login? && !password?)
2604
+ )
2605
+ end
2606
+
2607
+ # What a namespaced identity leaves outside its scope. Its omniauth callbacks, teams
2608
+ # and admin area move inside, where a second identity can't collide with them.
2609
+ def namespaced_top_level_route_blocks
2610
+ route_blocks(
2611
+ (namespaced_invitation_routes if invitable?),
2612
+ (easy_dev_login_routes if easy_dev_login? && !password?)
2613
+ )
2614
+ end
2615
+
2616
+ def route_blocks(*blocks, trailing: false)
2617
+ routes = blocks.compact.map(&:chomp).reject(&:empty?).join("\n")
2618
+ routes += "\n" if trailing && !routes.empty?
2619
+ routes
2620
+ end
2621
+
2622
+ # The self-service settings area: a landing page plus the :settings namespace
2623
+ # holding every account-management resource. One builder serves both route
2624
+ # shapes, since only its depth differs — +indent+ 0 for the default identity,
2625
+ # which mounts it at the top level (/settings -> Settings::...), and 2 once
2626
+ # namespaced, where it nests inside the identity's own outer scope, inheriting
2627
+ # that scope's module and as: (/realtors/settings -> Realtors::Settings::...).
2628
+ def settings_namespace(indent: 0)
2629
+ pad = " " * indent
2630
+ body = " " * (indent + 2)
2631
+
2632
+ lines = [%(#{pad}get "settings", to: "settings/dashboard#index"), "#{pad}namespace :settings do"]
2633
+ # :create sets a FIRST password on an account minted without one; :destroy
2634
+ # gives one up. Different questions — a required build still needs :create for
2635
+ # its provider and guest rows, and still offers no way out.
2636
+ password_actions = [":edit", ":update", (":create" if password_nullable?), (":destroy" if password_removable?)].compact
2637
+ lines << "#{body}resource :password, only: [ #{password_actions.join(", ")} ]" if password?
2638
+ lines << "#{body}resource :email, only: [ :edit, :update ]" if email_changeable?
2639
+ lines << "#{body}resource :phone, only: [ :edit, :update ]" if phone_changeable?
2640
+ lines << "#{body}resource :user, only: [ :show, :destroy ]"
2641
+ # Only the change resend lives under /settings. Its link is consumed by
2642
+ # the identity's own EmailVerificationsController at the top level, because the inbox may
2643
+ # be read on a different device with no session. Staged sign-up links use the
2644
+ # sibling Registrations::EmailVerificationsController for their different
2645
+ # collision rule.
2646
+ lines << "#{body}resource :email_verification, only: [ :create ]" if email_change_verification?
2647
+ lines << "#{body}resource :phone_verification, only: [ :new, :create, :update ]" if phone_change_verification?
2648
+ lines << "#{body}resources :sessions, only: [ :index, :destroy ]"
2649
+ lines << "#{body}resources :api_tokens, only: [ :index, :create, :destroy ]" if api_tokens?
2650
+
2651
+ extras = settings_route_blocks(indent + 2).chomp
2652
+ lines << extras unless extras.empty?
2653
+
2654
+ lines << "#{pad}end"
2655
+ lines.join("\n")
2656
+ end
2657
+
2658
+ def clean_route_block(routes)
2659
+ align_route_lines(routes.lines.map(&:rstrip)).join("\n")
2660
+ end
2661
+
2662
+ # Squares up the columns in a finished route block: the first argument, and
2663
+ # whatever follows the first comma (`to:`, `only:`, ...).
2664
+ #
2665
+ # It happens here, after assembly, because no builder above can do it. Which
2666
+ # routes a block contains depends on the flags, so padding written by hand is
2667
+ # padding aimed at a line that may not be generated — and when it isn't, the
2668
+ # leftover spaces align with nothing, which is exactly what RuboCop's
2669
+ # Layout/SpaceBeforeFirstArg objects to (`get "sign_in"` is fine beside a
2670
+ # `post "sign_in"` and a stray double space without it). Aligning once, when the
2671
+ # membership of each group is finally known, is the only way to be right in
2672
+ # every build.
2673
+ #
2674
+ # A group is a run of adjacent route calls sharing one indent. Anything else
2675
+ # breaks it — a blank line, a comment, an `end`, a block opener (`... do`, whose
2676
+ # body sits a level deeper and lines up on its own).
2677
+ def align_route_lines(lines)
2678
+ lines
2679
+ .slice_when { |before, after| !same_route_group?(before, after) }
2680
+ .flat_map { |group| align_route_group(group) }
2681
+ end
2682
+
2683
+ def same_route_group?(before, after)
2684
+ parts = route_line_parts(before)
2685
+ parts && route_line_parts(after)&.fetch(:indent) == parts[:indent]
2686
+ end
2687
+
2688
+ def align_route_group(group)
2689
+ parts = group.map { |line| route_line_parts(line) }
2690
+ return group unless parts.all?
2691
+
2692
+ method_width = parts.map { |part| part[:method].length }.max
2693
+ option_width = parts.filter_map { |part| part[:argument].length if part[:options] }.max || 0
2694
+
2695
+ parts.map do |part|
2696
+ line = "#{part[:indent]}#{part[:method].ljust(method_width)} #{part[:argument]}"
2697
+ line << "#{" " * (option_width - part[:argument].length + 1)}#{part[:options]}" if part[:options]
2698
+ line
2699
+ end
2700
+ end
2701
+
2702
+ # Splits one route call into the pieces the aligner pads between, or nil for a
2703
+ # line that isn't one (a comment, a blank, an `end`, a block opener).
2704
+ def route_line_parts(line)
2705
+ return nil if line.end_with?(" do")
2706
+
2707
+ match = /\A(?<indent> *)(?<method>[a-z_]+) +(?<rest>\S.*)\z/.match(line)
2708
+ return nil unless match
2709
+
2710
+ argument, separator, options = match[:rest].partition(", ")
2711
+ return { indent: match[:indent], method: match[:method], argument: argument, options: nil } if separator.empty?
2712
+
2713
+ { indent: match[:indent], method: match[:method], argument: "#{argument},", options: options.lstrip }
2714
+ end
2715
+
2716
+ def primary_key_options
2717
+ return "" unless primary_key_type
2718
+
2719
+ ", id: #{primary_key_type.to_sym.inspect}"
2720
+ end
2721
+
2722
+ def reference_type_options
2723
+ return "" unless primary_key_type
2724
+
2725
+ ", type: #{primary_key_type.to_sym.inspect}"
2726
+ end
2727
+
2728
+ def primary_key_type
2729
+ configured_primary_key_type =
2730
+ options[:primary_key_type] ||
2731
+ active_record_generator_options[:primary_key_type] ||
2732
+ active_record_generator_options["primary_key_type"]
2733
+
2734
+ configured_primary_key_type&.to_s
2735
+ end
2736
+
2737
+ def active_record_generator_options
2738
+ Rails::Generators.options[:active_record] || {}
2739
+ end
2740
+
2741
+ def registration_routes
2742
+ <<~RUBY.gsub(/^/, " ")
2743
+
2744
+ get "sign_up", to: "registrations#new"
2745
+ post "sign_up", to: "registrations#create"
2746
+ RUBY
2747
+ end
2748
+
2749
+ def sudo_routes
2750
+ <<~RUBY.gsub(/^/, " ")
2751
+
2752
+ resource :sudo, only: [ :new, :create ]
2753
+ RUBY
2754
+ end
2755
+
2756
+ def easy_dev_login_routes
2757
+ <<~RUBY
2758
+
2759
+ if Rails.env.development?
2760
+ scope #{identity_scope_args} do
2761
+ post "easy_dev_login", to: "easy_dev_logins#create"
2762
+ end
2763
+ end
2764
+ RUBY
2765
+ end
2766
+
2767
+ def settings_invitation_route
2768
+ <<~RUBY
2769
+ resource :invitation, only: [ :new, :create ]
2770
+ RUBY
2771
+ end
2772
+
2773
+ def authentication_event_routes
2774
+ <<~RUBY
2775
+ namespace :authentications do
2776
+ resources :events, only: :index
2777
+ end
2778
+ RUBY
2779
+ end
2780
+
2781
+ def invitation_routes
2782
+ routes = [%(get "invitations/accept", to: "invitations#show", as: :accept_invitation)]
2783
+ routes << %(patch "invitations/accept", to: "invitations#update") if teams?
2784
+ "\n#{routes.join("\n")}\n"
2785
+ end
2786
+
2787
+ # Routed at the top level (outside the identity's own scope) like
2788
+ # invitation_routes above, but path-prefixed and pointing at this identity's own
2789
+ # namespaced controller and route helper. Both must differ from the incumbent's:
2790
+ # the path so the accept link reaches this identity (a bare "invitations/accept"
2791
+ # collides with the incumbent's identical path and Rails dispatches the first),
2792
+ # and the controller must be named in full in `to:` — an inline `module:` option
2793
+ # alongside a string `to:` does NOT namespace it (it leaks in as a stray param
2794
+ # and the bare InvitationsController handles the request), so spell it out.
2795
+ def namespaced_invitation_routes
2796
+ path = "#{identity.path_prefix}/invitations/accept"
2797
+ routes = [%(get "#{path}", to: "#{identity.controller_module}/invitations#show", as: :#{identity.route_scope}_accept_invitation)]
2798
+ routes << %(patch "#{path}", to: "#{identity.controller_module}/invitations#update") if teams?
2799
+ "\n#{routes.join("\n")}\n"
2800
+ end
2801
+
2802
+ def omniauth_routes
2803
+ registration = <<~RUBY.chomp if omniauth_registration?
2804
+
2805
+ scope "auth", as: :#{identity.route_scope}_omniauth do
2806
+ resource :registration, only: [ :new, :create ], controller: "#{identity.controller_module}/omniauth/registrations"
2807
+ end
2808
+ RUBY
2809
+ <<~RUBY
2810
+
2811
+ post "/auth/:provider/callback", to: "#{identity.controller_module}/omniauth#create"
2812
+ get "/auth/:provider/callback", to: "#{identity.controller_module}/omniauth#create"
2813
+ get "/auth/failure", to: "#{identity.controller_module}/omniauth#failure"#{registration}
2814
+ RUBY
2815
+ end
2816
+
2817
+ # Same routes as omniauth_routes, but relative: this variant lives *inside*
2818
+ # namespaced_route_block's `scope "...", module: ... do`, which already supplies
2819
+ # both the path prefix and the controller module, so /realtor/auth/... is
2820
+ # free and "omniauth#create" (not "realtors/omniauth#create") resolves
2821
+ # correctly.
2822
+ def namespaced_omniauth_routes
2823
+ registration = <<~RUBY.chomp if omniauth_registration?
2824
+
2825
+ scope "auth", as: :omniauth do
2826
+ resource :registration, only: [ :new, :create ], controller: "omniauth/registrations"
2827
+ end
2828
+ RUBY
2829
+ <<~RUBY.gsub(/^/, " ")
2830
+
2831
+ post "auth/:provider/callback", to: "omniauth#create"
2832
+ get "auth/:provider/callback", to: "omniauth#create"
2833
+ get "auth/failure", to: "omniauth#failure"#{registration}
2834
+ RUBY
2835
+ end
2836
+
2837
+ def password_reset_routes
2838
+ <<~RUBY.gsub(/^/, " ")
2839
+
2840
+ get "forgot_password", to: "password_resets#new"
2841
+ post "reset_password", to: "password_resets#create"
2842
+ get "reset_password", to: "password_resets#edit"
2843
+ patch "reset_password", to: "password_resets#update"
2844
+ put "reset_password", to: "password_resets#update"
2845
+ RUBY
2846
+ end
2847
+
2848
+ # Email sign-up and account change have different collision rules, so their
2849
+ # links land at separate endpoints even though both remain reachable signed-out.
2850
+ # A permanent email keeps the sign-up half and drops the change half, which is
2851
+ # why the two are assembled separately rather than as one heredoc.
2852
+ def email_verification_routes
2853
+ lines = []
2854
+
2855
+ # The sign-up click carries its own proof at /verify_email, where GET only
2856
+ # renders the confirm button and PATCH does the work — a mail scanner's GET
2857
+ # must not be able to consume the link. /new remains the waiting page and
2858
+ # POST remains its resend action.
2859
+ if email_gated_registration?
2860
+ lines.push(
2861
+ %(get "verify_email", to: "registrations/email_verifications#show"),
2862
+ %(patch "verify_email", to: "registrations/email_verifications#update"),
2863
+ %(get "verify_email/new", to: "registrations/email_verifications#new", as: :new_verify_email),
2864
+ %(post "verify_email", to: "registrations/email_verifications#create")
2865
+ )
2866
+ end
2867
+ lines << %(get "verify_email_change", to: "email_verifications#show") if email_change_verification?
2868
+ lines << %(patch "verify_email_change", to: "email_verifications#update") if email_change_verification?
2869
+ return "" if lines.empty?
2870
+
2871
+ lines.map { " #{it}\n" }.join.prepend("\n")
2872
+ end
2873
+
2874
+ # Where a phone-gated sign-up finishes. Unauthenticated by construction — the
2875
+ # visitor has no session yet, which is the whole point.
2876
+ def phone_verification_routes
2877
+ <<~RUBY.gsub(/^/, " ")
2878
+
2879
+ get "verify_phone", to: "registrations/phone_verifications#new"
2880
+ post "verify_phone", to: "registrations/phone_verifications#create"
2881
+ patch "verify_phone", to: "registrations/phone_verifications#update"
2882
+ RUBY
2883
+ end
2884
+
2885
+ def magic_link_routes
2886
+ <<~RUBY.gsub(/^/, " ")
2887
+
2888
+ get "magic_link", to: "magic_link#edit"
2889
+ patch "magic_link", to: "magic_link#update"
2890
+ get "magic_link/new", to: "magic_link#new", as: :new_magic_link
2891
+ post "magic_link", to: "magic_link#create"
2892
+ RUBY
2893
+ end
2894
+
2895
+ # Passwordless sign-in by texted code. Two steps, two paths: the request form
2896
+ # takes a number, the code form takes the six digits. Both unauthenticated by
2897
+ # construction — there is no session until the code is redeemed — which is why
2898
+ # what links them is a signed token in the session rather than a URL param.
2899
+ def sms_code_routes
2900
+ <<~RUBY.gsub(/^/, " ")
2901
+
2902
+ get "sms_sign_in", to: "sms_sessions#new"
2903
+ post "sms_sign_in", to: "sms_sessions#create"
2904
+ get "sms_sign_in/code", to: "sms_sessions#edit", as: :sms_sign_in_code
2905
+ patch "sms_sign_in/code", to: "sms_sessions#update"
2906
+ RUBY
2907
+ end
2908
+
2909
+ # Passwordless passkey sign-in (the primary door). #new returns the assertion
2910
+ # challenge as JSON; #create verifies the signed assertion and starts the
2911
+ # session. Lives under the users scope like the other doors.
2912
+ def passkey_routes
2913
+ <<~RUBY.gsub(/^/, " ")
2914
+
2915
+ namespace :passkeys do
2916
+ resource :session, only: [ :new, :create ]
2917
+ end
2918
+ RUBY
2919
+ end
2920
+
2921
+ # Initial credentials belong to the pending registration, before any session
2922
+ # exists. Emit only the endpoints this build can actually reach.
2923
+ def credential_enrollment_routes
2924
+ lines = []
2925
+ lines << %(get "finish_setup", to: "credential_enrollments#show") if registration_credential_page?
2926
+ if pre_account_password_enrollment?
2927
+ lines << %(get "registration/password", to: "credential_enrollments/passwords#new", as: :new_registration_password)
2928
+ lines << %(post "registration/password", to: "credential_enrollments/passwords#create", as: :registration_password)
2929
+ end
2930
+ if pre_account_passkey_enrollment?
2931
+ lines << %(get "registration/passkey", to: "credential_enrollments/passkeys#new", as: :new_registration_passkey)
2932
+ lines << %(post "registration/passkey", to: "credential_enrollments/passkeys#create", as: :registration_passkey)
2933
+ end
2934
+ lines.map { " #{it}\n" }.join.prepend("\n")
2935
+ end
2936
+
2937
+ # Manage your own passkeys (list / enroll / rename / remove), separate from the
2938
+ # MFA security-keys page.
2939
+ def passkey_profile_routes
2940
+ <<~RUBY
2941
+ resources :passkeys, only: [ :index, :new, :create, :edit, :update, :destroy ]
2942
+ RUBY
2943
+ end
2944
+
2945
+ # List and unlink your provider logins. There is no :new and no :create — linking
2946
+ # runs the same OmniAuth ceremony signing in does, so it goes out through
2947
+ # /auth/:provider and comes back to the callback, which links it because a
2948
+ # session is already open. The index just renders the buttons that start it.
2949
+ def omniauth_profile_routes
2950
+ <<~RUBY
2951
+ resources :omniauth_identities, path: "connected-accounts", only: [ :index, :destroy ]
2952
+ RUBY
2953
+ end
2954
+
2955
+ def mfa_challenge_routes
2956
+ <<~RUBY.gsub(/^/, " ")
2957
+
2958
+ namespace :multi_factor_authentication, path: "mfa", as: :mfa do
2959
+ namespace :challenge do#{totp? ? "\n resource :totp, only: [ :new, :create ]" : ""}#{sms_second_factor? ? "\n resource :sms, only: [ :new, :create, :update ]" : ""}
2960
+ resource :recovery_codes, only: [ :new, :create ]#{security_keys? ? "\n resource :security_keys, only: [ :new, :create ]" : ""}
2961
+ end
2962
+ end
2963
+ RUBY
2964
+ end
2965
+
2966
+ def mfa_profile_routes
2967
+ <<~RUBY
2968
+ namespace :multi_factor_authentication, path: "mfa", as: :mfa do
2969
+ #{mfa_profile_authenticator_routes}#{mfa_profile_sms_routes} resources :recovery_codes, only: [ :index, :create ] do
2970
+ post :complete, on: :collection
2971
+ end
2972
+ #{mfa_profile_security_key_routes}end
2973
+ RUBY
2974
+ end
2975
+
2976
+ def mfa_profile_authenticator_routes
2977
+ return "" unless totp?
2978
+
2979
+ " resource :authenticator, only: [ :new, :show, :create, :destroy ]\n"
2980
+ end
2981
+
2982
+ def mfa_profile_sms_routes
2983
+ return "" unless sms_second_factor?
2984
+
2985
+ " resource :sms, only: [ :new, :show, :create, :update, :destroy ]\n"
2986
+ end
2987
+
2988
+ def mfa_profile_security_key_routes
2989
+ return "" unless security_keys?
2990
+
2991
+ " resources :security_keys\n"
2992
+ end
2993
+
2994
+ def team_routes
2995
+ routes = ["resources :teams, only: [ :index, :new, :create ]"]
2996
+ routes << "resource :active_team, only: :create" if session_carries_team?
2997
+ routes << <<~RUBY.chomp if scope_teams?
2998
+ scope ":team_id", as: :team, constraints: { team_id: #{team_id_constraint} } do
2999
+ root to: "teams#show"
3000
+ end
3001
+ RUBY
3002
+
3003
+ "\n#{routes.join("\n")}\n"
3004
+ end
3005
+
3006
+ def admin_routes
3007
+ impersonation_routes =
3008
+ if impersonatable?
3009
+ <<~RUBY.gsub(/^/, " ")
3010
+ post "users/:id/impersonate", to: "users#impersonate", as: "impersonate_user"
3011
+ RUBY
3012
+ else
3013
+ ""
3014
+ end
3015
+
3016
+ # Stopping sits outside the admin namespace on purpose — see
3017
+ # ImpersonationsController. Starting is an admin action; stopping isn't.
3018
+ stop_impersonating_route = impersonatable? ? %(\nresource :impersonation, only: [ :destroy ]\n) : ""
3019
+
3020
+ member_posts = []
3021
+ member_posts << "reset_second_factor" if second_factor?
3022
+ # Undeadbolt only: deadbolting is the failed-attempt counter's job, and an admin who
3023
+ # wants to shut an account off reaches for a ban.
3024
+ member_posts << "undeadbolt" if deadboltable?
3025
+ member_posts += %w[ban unban] if bannable?
3026
+ member_actions = member_posts.map { |a| "\n post :#{a}, on: :member" }.join
3027
+
3028
+ <<~RUBY
3029
+
3030
+ namespace :admin do
3031
+ resources :users, only: [ :index, :show, :destroy ] do
3032
+ delete :sessions, on: :member, action: :destroy_all_sessions#{member_actions}
3033
+ end
3034
+ resources :sessions, only: [ :index, :destroy ]
3035
+ #{impersonation_routes} root to: "dashboard#index"
3036
+ end
3037
+ RUBY
3038
+ .concat(stop_impersonating_route)
3039
+ end
3040
+ end