add_auth 0.2.2 → 0.4.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +64 -17
  4. data/ROADMAP.md +98 -43
  5. data/SECURITY.md +8 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +146 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  12. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  13. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  14. data/app/jobs/add_auth/account_request_job.rb +17 -0
  15. data/app/mailers/add_auth/account_mailer.rb +12 -0
  16. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  17. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  18. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  19. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  20. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  21. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  22. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  23. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  24. data/app/views/add_auth/accounts/_request.html.erb +17 -0
  25. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  26. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  27. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  28. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  29. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  30. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  31. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  32. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  33. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  34. data/app/views/add_auth/sessions/index.html.erb +7 -0
  35. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  36. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  37. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  38. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  39. data/exe/add_auth-preflight +26 -0
  40. data/lib/add_auth/configuration.rb +78 -4
  41. data/lib/add_auth/core/access_policy.rb +11 -1
  42. data/lib/add_auth/core/account_lifecycle.rb +295 -0
  43. data/lib/add_auth/core/account_policy.rb +68 -0
  44. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  45. data/lib/add_auth/core/external_identities.rb +278 -0
  46. data/lib/add_auth/core/intake.rb +6 -1
  47. data/lib/add_auth/core/maintenance.rb +5 -3
  48. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  49. data/lib/add_auth/core/migration/readiness.rb +41 -0
  50. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  51. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  52. data/lib/add_auth/core/mobile_profile.rb +46 -0
  53. data/lib/add_auth/core/mobile_response.rb +12 -0
  54. data/lib/add_auth/core/native_authentication.rb +77 -0
  55. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  56. data/lib/add_auth/core/passwords/credential.rb +49 -0
  57. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  58. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  59. data/lib/add_auth/core/remaining_factors.rb +24 -0
  60. data/lib/add_auth/core/sessions.rb +102 -17
  61. data/lib/add_auth/core/step_up.rb +13 -1
  62. data/lib/add_auth/migration/static_inventory.rb +116 -0
  63. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  64. data/lib/add_auth/rails/authentication.rb +1 -1
  65. data/lib/add_auth/rails/authentication_pages.rb +6 -1
  66. data/lib/add_auth/rails/doctor.rb +80 -6
  67. data/lib/add_auth/rails/ejection.rb +1 -1
  68. data/lib/add_auth/rails/engine.rb +6 -1
  69. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  70. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  71. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  72. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  73. data/lib/add_auth/rails/password_adoption.rb +37 -0
  74. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  77. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  78. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  80. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  81. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  82. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  83. data/lib/add_auth/rails/runtime.rb +179 -8
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  101. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  102. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  103. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  104. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  105. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  106. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  107. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  110. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  111. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  112. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  113. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  114. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  115. data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
  116. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  117. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  119. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  120. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  121. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  122. data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
  123. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  124. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
  125. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  126. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
  127. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  128. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  129. data/lib/tasks/add_auth.rake +18 -1
  130. metadata +101 -2
@@ -104,6 +104,7 @@ class ChallengeController extends Controller {
104
104
  }
105
105
 
106
106
  beforeCache() { this.teardown() }
107
+ restore(event) { if (event.persisted) this.connect() }
107
108
  disconnect() { this.teardown() }
108
109
  teardown() {
109
110
  this.active = false
@@ -1,7 +1,9 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
1
3
  class CreateAddAuthSignInTokens < ActiveRecord::Migration[8.0]
2
4
  def change
3
5
  create_table :add_auth_sign_in_tokens do |t|
4
- t.references :user, null: false, foreign_key: true
6
+ t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), null: false, foreign_key: true
5
7
  t.string :digest, limit: 64, null: false
6
8
  t.string :identifier_digest, limit: 64, null: false
7
9
  t.string :purpose, null: false, default: "sign_in"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ # Optional persistence and routes; provider strategies remain disabled until configured.
9
+ class ExternalIdentitiesGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
14
+
15
+ def persistence
16
+ unless Dir[File.join(destination_root, "db/migrate/*_create_add_auth_external_identities.rb")].any?
17
+ migration_template "create_add_auth_external_identities.rb.tt", "db/migrate/create_add_auth_external_identities.rb"
18
+ end
19
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_external_identity_validity.rb")].any?
20
+ migration_template "add_add_auth_external_identity_validity.rb.tt", "db/migrate/add_add_auth_external_identity_validity.rb"
21
+ end
22
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_external_enrollment.rb")].any?
23
+ migration_template "add_add_auth_external_enrollment.rb.tt", "db/migrate/add_add_auth_external_enrollment.rb"
24
+ end
25
+ %w[external_identity external_transaction].each do |name|
26
+ copy_file "add_auth_#{name}.rb", "app/models/add_auth_#{name}.rb", skip: true
27
+ end
28
+ end
29
+
30
+ def routes
31
+ source = File.read(File.join(destination_root, "config/routes.rb"))
32
+ return if source.include?("# AddAuth external provider callbacks")
33
+
34
+ route <<~ROUTES
35
+ # AddAuth external provider callbacks
36
+ get "account/sign-up/providers/:provider", to: "add_auth/provider_sign_ins#enrollment", defaults: {flow: "enroll"}
37
+ post "account/sign-up/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "enroll"}
38
+ post "sign-in/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "sign_in"}
39
+ post "account/external-identities/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "link"}
40
+ get "account/external-identities", to: "add_auth/external_identities#index"
41
+ delete "account/external-identities/:id", to: "add_auth/external_identities#destroy"
42
+ post "reauthenticate/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "reauthenticate"}
43
+ match "auth/:provider/callback", to: "add_auth/provider_sign_ins#callback", via: [:get, :post], constraints: AddAuth::Rails::ProviderLibraries::CallbackRoute
44
+ ROUTES
45
+ end
46
+
47
+ def provider_configuration
48
+ copy_file "providers.rb", "config/initializers/add_auth_providers.rb", skip: true
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ class AddAddAuthExternalEnrollment < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :add_auth_external_transactions, :enrollment_payload, :text
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddAddAuthExternalIdentityValidity < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :add_auth_external_identities, :invalidated_at, :datetime
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ class AddAuthExternalIdentity < ApplicationRecord
2
+ belongs_to :user
3
+ def inspect = "#<AddAuthExternalIdentity [FILTERED]>"
4
+ end
@@ -0,0 +1,6 @@
1
+ class AddAuthExternalTransaction < ApplicationRecord
2
+ self.filter_attributes += [:enrollment_payload, :digest, :browser_digest, :session_digest]
3
+ belongs_to :user, optional: true
4
+ belongs_to :session, optional: true
5
+ def inspect = "#<AddAuthExternalTransaction [FILTERED]>"
6
+ end
@@ -0,0 +1,50 @@
1
+ class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
2
+ def change
3
+ user_type = reference_type(:users)
4
+ session_type = reference_type(:sessions)
5
+ create_table :add_auth_external_identities do |t|
6
+ t.references :user, type: user_type, null: false, foreign_key: {on_delete: :cascade}
7
+ # SHA256 of the exact JSON [configuration, issuer, audience, subject].
8
+ # Indexing this key preserves case-sensitive subjects on either store.
9
+ t.string :namespace, limit: 64, null: false
10
+ t.string :provider_id, limit: 2048, null: false
11
+ t.string :issuer, limit: 2048, null: false
12
+ t.string :audience, limit: 2048, null: false
13
+ t.string :subject, limit: 2048, null: false
14
+ t.string :provenance, limit: 2048, null: false
15
+ t.string :credential_version, null: false
16
+ t.datetime :linked_at, null: false
17
+ t.datetime :revoked_at
18
+ t.timestamps
19
+ end
20
+ add_index :add_auth_external_identities, :namespace, unique: true
21
+ create_table :add_auth_external_transactions do |t|
22
+ t.string :digest, null: false
23
+ t.string :browser_digest, null: false
24
+ t.string :provider_id, limit: 2048, null: false
25
+ t.string :issuer, limit: 2048, null: false
26
+ t.string :audience, limit: 2048, null: false
27
+ t.string :purpose, null: false
28
+ t.references :user, type: user_type, foreign_key: {on_delete: :cascade}
29
+ t.references :session, type: session_type, foreign_key: {on_delete: :cascade}
30
+ t.string :session_digest
31
+ t.integer :policy_version
32
+ t.datetime :issued_at, null: false
33
+ t.datetime :expires_at, null: false
34
+ t.datetime :consumed_at
35
+ t.timestamps
36
+ end
37
+ add_index :add_auth_external_transactions, :digest, unique: true
38
+ add_index :add_auth_external_transactions, :expires_at
39
+ add_column :sessions, :authentication_external_id, :string
40
+ add_column :sessions, :authentication_external_version, :string
41
+ end
42
+
43
+ private
44
+
45
+ def reference_type(table)
46
+ column = connection.columns(table).find { |item| item.name == connection.primary_key(table) }
47
+ raise "Unsupported authentication primary key" unless column && [:integer, :uuid].include?(column.type)
48
+ (column.type == :integer && column.limit == 8) ? :bigint : column.type
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External provider support is opt-in. Add only the provider gems you use to
4
+ # the host application's Gemfile, then configure their OmniAuth middleware
5
+ # using their normal configuration. Reuse existing registrations rather than
6
+ # adding a second OmniAuth::Builder for the same provider. Keep the host's CSRF
7
+ # validator, scopes, request methods and failure handler. AddAuth changes none
8
+ # of them. Keep issuer/audience fixed; never derive them from a request, Host
9
+ # header, profile attribute, or callback parameter.
10
+ #
11
+ # AddAuth's correlation middleware goes before OmniAuth::Builder. It retains
12
+ # opaque Core transaction data only. OmniAuth (or the selected OIDC library)
13
+ # owns code exchange, state, nonce, JWKS/signature, issuer, audience and expiry.
14
+ # Requests without an AddAuth transaction continue through the existing stack.
15
+ # Generated callback routes select only enabled, explicitly registered providers.
16
+ # Review routing precedence before adopting a provider with an existing callback.
17
+ # Custom provider paths/callbacks require an explicit host integration; do not
18
+ # rewrite an existing library configuration to fit this conventional-path example.
19
+ #
20
+ # require "omniauth"
21
+ # require "omniauth-google-oauth2"
22
+ # require "add_auth/rails/provider_libraries/omniauth"
23
+ # require "add_auth/rails/provider_libraries/omniauth_correlation"
24
+ # Rails.application.config.middleware.use AddAuth::Rails::ProviderLibraries::OmniAuthCorrelation,
25
+ # providers: ["google_oauth2"]
26
+ # Rails.application.config.middleware.use OmniAuth::Builder do
27
+ # provider :google_oauth2, ENV.fetch("GOOGLE_CLIENT_ID"), ENV.fetch("GOOGLE_CLIENT_SECRET")
28
+ # end
29
+ #
30
+ # verifier = AddAuth::Rails::ProviderLibraries::OmniAuth::Verifier.new(
31
+ # provider: "google_oauth2", provenance: "omniauth-google-oauth2",
32
+ # mapping: {issuer: %i[extra id_info iss], audience: %i[extra id_info aud], subject: %i[extra id_info sub],
33
+ # authenticated_at: %i[extra id_info auth_time]}
34
+ # )
35
+ # AddAuth.configure do |config|
36
+ # config.external_identities.register(
37
+ # id: "google", label: "Google", middleware_name: "google_oauth2",
38
+ # configuration: AddAuth::Core::ExternalIdentities::Configuration.new(
39
+ # id: "google", issuer: "https://accounts.google.com", audience: ENV.fetch("GOOGLE_CLIENT_ID"), verifier: verifier
40
+ # )
41
+ # )
42
+ # config.external_identities.enabled = true
43
+ # end
44
+ #
45
+ # Apple form_post needs its own callback-only cookie wrapper before
46
+ # OmniAuth::Builder. Do not weaken your normal Rails session cookie:
47
+ #
48
+ # require "omniauth-apple"
49
+ # require "add_auth/rails/provider_libraries/apple_form_post_correlation"
50
+ # Rails.application.config.middleware.use AddAuth::Rails::ProviderLibraries::AppleFormPostCorrelation,
51
+ # key: Rails.application.key_generator.generate_key("add_auth.apple-form-post.v1", 32)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Generators
5
+ module FeatureConfiguration
6
+ private
7
+
8
+ def enable_feature(name)
9
+ path = "config/initializers/add_auth.rb"
10
+ source = File.read(File.join(destination_root, path))
11
+ setting = "config.#{name}.enabled"
12
+ pattern = /^(\s*)(?:# #{Regexp.escape(setting)} = true|#{Regexp.escape(setting)} = false)$/
13
+ if source.match?(pattern)
14
+ gsub_file path, pattern, "\\1#{setting} = true"
15
+ elsif !source.match?(/^\s*#{Regexp.escape(setting)}\s*=/)
16
+ append_to_file path, "\nAddAuth.configure do |config|\n #{setting} = true\nend\n"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,48 +1,156 @@
1
- # AddAuth configuration. Feature generators enable only their own block.
1
+ # frozen_string_literal: true
2
+
3
+ # AddAuth settings. Defaults are active; alternatives are commented out.
4
+ # Run the relevant generator and migrations before enabling a feature.
5
+ # Install alone enables no features. Existing initializers are never replaced.
2
6
  AddAuth.configure do |config|
3
- # Set false for email/passkey-only sign-in. Existing Rails password entry
4
- # routes remain guarded; the host owns password reset and account provisioning.
5
- # config.passwords_enabled = true
6
-
7
- # BEGIN add_auth session
8
- # config.session.enabled = true
9
- # END add_auth session
10
- # config.session.lifetime = 12.hours
11
- # config.session.idle_timeout = 30.minutes
12
- # Legacy cookies are rejected by default. Opt into a finite bridge explicitly:
13
- # config.session.legacy_bridge_until = Time.iso8601("2026-09-13T00:00:00Z")
14
-
15
- # BEGIN add_auth email_link
16
- # config.email_link.enabled = true
17
- # END add_auth email_link
18
- # config.email_link.token_lifetime = 20.minutes
19
- # config.email_link.same_browser = false # true requires the requesting browser
20
- # REQUIRED for email delivery: fixed, trusted origin; never derive it from Host.
21
- # config.base_url = "https://your-app.example"
22
- # config.mail_from = "Your app <sign-in@your-app.example>"
23
- # Configure a durable Active Job adapter and schedule add_auth:deliver_pending.
24
- # Rails.cache is suitable only with shared atomic increments and expiry.
25
- # Solid Cache is not suitable; configure a separate counter store:
26
- # https://addauthgem.com/production/#rate-limits
27
- # Each maintenance pass handles at most this many rows per operation/model:
28
- # config.maintenance.batch_size = 100 # 1..1000
29
- # History is retained until the host chooses a retention period (seconds):
30
- # config.maintenance.session_retention = 7.days
31
- # config.maintenance.email_retention = 7.days
32
- # config.maintenance.notification_retention = 30.days
33
-
34
- # Apply the host's current confirmed/locked/disabled policy on every resume:
35
- # config.eligible = ->(user) { user.confirmed? && !user.locked? && !user.disabled? }
36
-
37
- # Styling: nil disables CSS; a local path uses your own compiled stylesheet.
38
- # config.stylesheet = "/add_auth.css"
39
- # config.css_classes = { input: "form-control", button: "btn btn-primary" }
40
- # Eject editable templates with bin/rails generate add_auth:views.
41
-
42
- # Enable optional features with add_auth:passkeys, add_auth:step_up,
43
- # add_auth:notifications or add_auth:challenge. Install alone enables none.
44
- # Passkey recovery needs a host-verified recovery address; strict accounts
45
- # use another passkey or the host's support process, never email recovery.
46
- # config.challenge = AddAuth::Core::Challenge::Null.new
47
- # config.challenge_on = []
7
+ # Sign-in and rendering. HTML forms work in all browser modes.
8
+ config.passwords_enabled = true
9
+ # config.passwords_enabled = false # Use only other explicitly enabled methods.
10
+ config.turbo_enabled = true
11
+ # config.turbo_enabled = false # Ordinary JS navigation, with Turbo absent.
12
+
13
+ # Host business eligibility is checked alongside Core account policy.
14
+ config.eligible = ->(_user) { true }
15
+ # config.eligible = ->(user) { user.access_state == "active" }
16
+
17
+ # Browser sessions: bin/rails generate add_auth:session_upgrade
18
+ config.session.enabled = false
19
+ config.session.lifetime = 43_200 # 12 hours, absolute expiry.
20
+ config.session.idle_timeout = 1800 # 30 minutes; no greater than lifetime.
21
+ config.session.legacy_bridge_until = nil # Reject old Rails session-ID cookies.
22
+ # config.session.legacy_bridge_until = Time.iso8601("2030-01-01T00:00:00Z")
23
+ # Choose a short, fixed cutover deadline for a reviewed legacy-cookie bridge.
24
+
25
+ # Account lifecycle: bin/rails generate add_auth:accounts
26
+ # Password-only host: bin/rails generate add_auth:accounts --no-email-link
27
+ config.lifecycle.enabled = false
28
+ # config.lifecycle.enabled = true # After policy review and migrations.
29
+ config.lifecycle.confirmation_required = true
30
+ # config.lifecycle.confirmation_required = false
31
+ # false: password signup provisions and signs in immediately, without mail.
32
+ # confirmed_at stays nil. Requires the add_auth_provisioned_at migration.
33
+ config.lifecycle.reset_unconfirmed = false
34
+ # config.lifecycle.reset_unconfirmed = true # Requires optional confirmation.
35
+ # true: the stored mailbox holder may reset an unconfirmed password account
36
+ # after consuming an exact-address, single-use reset proof. No automatic login
37
+ # or confirmation. This does not trust the address for any other recovery.
38
+ # false: confirm the address before password reset, or use host support.
39
+ # Email changes always need fresh reauthentication and proof of the new address.
40
+ config.lifecycle.password_policy = ->(password) { password.length >= 12 && password.bytesize <= 72 }
41
+ config.lifecycle.eligible = ->(_user) { true } # Additional lifecycle eligibility.
42
+ config.lifecycle.profile_attributes = ->(_profile) { {} } # Accept no profile fields.
43
+ # config.lifecycle.profile_attributes = ->(profile) { profile.slice(:name, :consent) }
44
+ # Also allowlist those fields in the host registration controller. Never roles.
45
+ config.lifecycle.provision = ->(_user) {} # Local writes in the account transaction.
46
+ # config.lifecycle.provision = ->(user) { user.create_personal_workspace! }
47
+ # Raise on failure. Use the same DB pool; remote work needs a host outbox.
48
+ config.lifecycle.maximum_attempts = 20 # Failed-password threshold: 1..100.
49
+ config.lifecycle.unlock_in = 3600 # Timed lock: 60..86400 seconds.
50
+ config.lifecycle.proof_lifetime = 3600 # Account links: 60..86400 seconds.
51
+ config.lifecycle.remember_lifetime = 1_209_600 # 14 days, absolute expiry.
52
+ config.lifecycle.remember_idle_timeout = 604_800 # 7 days; <= remember_lifetime.
53
+ config.lifecycle.deletion_allowed = ->(_user) { true } # Host authorization/retention.
54
+ # config.lifecycle.deletion_allowed = ->(user) { user.invoices.none? }
55
+ config.lifecycle.delete_account = ->(user) { user.destroy! } # Same-DB local deletion.
56
+
57
+ # Email-link sign-in: bin/rails generate add_auth:email_link
58
+ # Account confirmation/reset mail does not require email-link sign-in.
59
+ config.email_link.enabled = false
60
+ config.email_link.token_lifetime = 1200 # 20 minutes.
61
+ config.email_link.same_browser = false
62
+ # config.email_link.same_browser = true # Require the requesting browser.
63
+
64
+ # Fresh proof for sensitive actions: bin/rails generate add_auth:step_up
65
+ # Use --no-email-link for password-only reauthentication.
66
+ config.step_up.enabled = false
67
+ config.step_up.fresh_for = 600 # Ordinary proof: 10 minutes.
68
+ config.step_up.strong_for = 300 # Strong proof: 5 minutes.
69
+ config.step_up.purposes = {} # Built-in feature purposes are supplied by Core.
70
+ # config.step_up.purposes = {
71
+ # export_data: {methods: [:password], return_to: "/exports/new", label: "export your data"}
72
+ # }
73
+ # Recheck elevation and host authorization inside the actual mutation.
74
+
75
+ # Passkeys: bin/rails generate add_auth:passkeys
76
+ config.passkeys.enabled = false
77
+ config.passkeys.rp_id = nil # Required: stable deployment RP ID.
78
+ # config.passkeys.rp_id = "example.com"
79
+ config.passkeys.origins = [] # Required: exact trusted origins, never request Host.
80
+ # config.passkeys.origins = ["https://app.example.com"]
81
+ config.passkeys.name = "Your account"
82
+ config.passkeys.anonymous_limit = 1000 # Shared ceremony budget per five minutes.
83
+ config.trusted_recovery_address = ->(_user) {} # No trusted recovery by default.
84
+ # config.trusted_recovery_address = ->(user) { user.email_address if user.confirmed_at }
85
+ # Lifecycle additionally rejects unconfirmed addresses as trusted recovery.
86
+ config.support_url = nil # Required before strict-policy activation.
87
+ # config.support_url = "/support" # Document your host-owned recovery process.
88
+
89
+ # Security notices: bin/rails generate add_auth:notifications
90
+ config.notifications.enabled = false
91
+ # Mail/queue settings also serve account proofs and optional email sign-in.
92
+ config.base_url = nil # Required for links: fixed trusted HTTPS origin.
93
+ # config.base_url = "https://app.example.com"
94
+ config.mail_from = nil # Required for mail; configure SMTP in Rails as usual.
95
+ # config.mail_from = "Your app <sign-in@example.com>"
96
+ # Configure a durable Active Job adapter and run add_auth:deliver_pending each minute.
97
+ config.rate_limit_store = nil # Defaults to Rails.cache; shared atomic counters required.
98
+ # config.rate_limit_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("AUTH_REDIS_URL"))
99
+ # Solid Cache can serve application caching, but cannot serve these counters.
100
+ config.maintenance.batch_size = 100 # Maximum rows per operation/model: 1..1000.
101
+ config.maintenance.session_retention = nil # Keep history until explicitly selected.
102
+ config.maintenance.email_retention = nil
103
+ config.maintenance.notification_retention = nil
104
+ config.maintenance.account_retention = nil
105
+ # config.maintenance.session_retention = 604_800 # 7 days, seconds.
106
+ # config.maintenance.email_retention = 604_800
107
+ # config.maintenance.notification_retention = 2_592_000 # 30 days.
108
+ # config.maintenance.account_retention = 604_800
109
+
110
+ # Captcha: bin/rails generate add_auth:challenge (provider-specific setup).
111
+ config.challenge = AddAuth::Core::Challenge::Null.new
112
+ config.challenge_on = [] # Available: sign_in, email_link, reauthenticate,
113
+ # passkey_enrollment, register, account_request, provider.
114
+ # config.challenge_on = [:sign_in, :register, :account_request]
115
+ config.challenge_when_unavailable = :closed
116
+ # config.challenge_when_unavailable = :open # Explicit outage bypass; emits an event.
117
+ # Replace challenge with a configured Turnstile or Recaptcha adapter. Keep
118
+ # secrets in Rails credentials/environment, and configure expected hostnames.
119
+
120
+ # Optional providers: bin/rails generate add_auth:external_identities
121
+ config.external_identities.enabled = false
122
+ # Register only reviewed Core provider configurations through
123
+ # config.external_identities.register(id:, label:, middleware_name:, configuration:,
124
+ # apple_form_post: false, reauthentication: false)
125
+ # or config.external_identities.register_native(configuration:).
126
+ # The generated add_auth_providers.rb explains provider-library middleware,
127
+ # verifier and credential setup. It remains commented out until reviewed.
128
+
129
+ # Native sessions: bin/rails generate add_auth:mobile_sessions
130
+ config.mobile.enabled = false
131
+ config.mobile.lifetime = nil # Required when enabled; choose finite seconds.
132
+ config.mobile.idle_timeout = nil # Required; <= lifetime.
133
+ config.mobile.clients = [] # Explicit first-party client identifiers.
134
+ config.mobile.callbacks = {} # Reviewed callbacks per client, when using handoffs.
135
+ config.mobile.apple_providers = {} # Native Apple provider IDs per client.
136
+ # config.mobile.lifetime = 2_592_000 # Example: 30 days.
137
+ # config.mobile.idle_timeout = 1_209_600 # Example: 14 days.
138
+ # config.mobile.clients = %w[android ios]
139
+
140
+ # Presentation: eject with bin/rails generate add_auth:views.
141
+ config.stylesheet = "/add_auth.css"
142
+ # config.stylesheet = nil # Supply your own styling.
143
+ config.css_classes = {}
144
+ # config.css_classes = {input: "form-control", button: "btn btn-primary"}
145
+
146
+ # Password migration and cryptography: retain Rails' verifier by default.
147
+ config.legacy_password_verifier = nil
148
+ config.current_password_support = AddAuth::Core::Passwords::BcryptSupport.new
149
+ # A custom verifier must also declare current_password_support.available?(digest:).
150
+ # Rails configures digest_secret from its key generator automatically. Preserve it.
151
+ # Outside Rails only:
152
+ # config.digest_secret = -> { ENV.fetch("ADD_AUTH_DIGEST_SECRET") }
153
+ # Optional adapter overrides must implement digest(token) and matches?(digest, token):
154
+ # config.session_token_digest = MySessionDigest.new
155
+ # config.sign_in_token_digest = MyProofDigest.new
48
156
  end
@@ -4,9 +4,9 @@ import { options, credential } from "/add_auth/codec.js"
4
4
 
5
5
  export class PasskeyController extends Controller {
6
6
  static targets = ["form", "controls", "unavailable", "status", "button"]
7
- static values = { optionsUrl: String, finishUrl: String, mode: String, purpose: String, csrf: String, conditional: Boolean }
7
+ static values = { optionsUrl: String, finishUrl: String, mode: String, purpose: String, csrf: String, conditional: Boolean, turbo: { type: Boolean, default: true } }
8
8
  connect() {
9
- this.generation = 0
9
+ this.generation = (this.generation || 0) + 1
10
10
  this.active = true
11
11
  if (!window.PublicKeyCredential || !navigator.credentials || !window.isSecureContext) return
12
12
  this.controlsTarget.hidden = false
@@ -25,13 +25,16 @@ export class PasskeyController extends Controller {
25
25
  otherSubmission(event) { if (event.target !== this.formTarget) this.stop() }
26
26
  disconnect() { this.active = false; this.stop() }
27
27
  beforeCache() { this.active = false; this.stop(); this.controlsTarget.hidden = true; this.unavailableTarget.hidden = false; this.message("") }
28
+ restore(event) { if (event.persisted) this.connect() }
28
29
  async run(conditional) {
29
30
  this.stop()
30
31
  const generation = this.generation
31
32
  this.abort = new AbortController()
32
33
  if (!conditional) { this.buttonTarget.disabled = true; this.message("Follow your browser’s passkey prompt.") }
33
34
  try {
34
- const challenge = new FormData(this.formTarget).get("challenge_token")
35
+ const form = new FormData(this.formTarget)
36
+ const challenge = form.get("challenge_token")
37
+ const remember = form.getAll("remember").at(-1)
35
38
  const start = await this.post(this.optionsUrlValue, { purpose: this.purposeValue || null, challenge_token: challenge }, this.abort.signal)
36
39
  this.formTarget.dispatchEvent(new CustomEvent("add_auth:proof-used"))
37
40
  if (!this.current(generation)) return
@@ -42,7 +45,7 @@ export class PasskeyController extends Controller {
42
45
  if (conditional) request.mediation = "conditional"
43
46
  const proof = await navigator.credentials[create ? "create" : "get"](request)
44
47
  if (!this.current(generation)) return
45
- const result = await this.post(this.finishUrlValue, { transaction: this.transaction, credential: credential(proof) }, this.abort.signal)
48
+ const result = await this.post(this.finishUrlValue, { transaction: this.transaction, credential: credential(proof), remember }, this.abort.signal)
46
49
  if (!this.current(generation)) return
47
50
  this.transaction = null
48
51
  this.navigate(result.redirect)
@@ -76,6 +79,6 @@ export class PasskeyController extends Controller {
76
79
  }
77
80
  current(generation) { return this.active && this.element.isConnected && this.generation === generation }
78
81
  message(text) { this.statusTarget.textContent = text; this.statusTarget.hidden = !text }
79
- navigate(path) { if (path) window.Turbo ? window.Turbo.visit(path) : window.location.assign(path) }
82
+ navigate(path) { if (path) (this.turboValue && window.Turbo) ? window.Turbo.visit(path) : window.location.assign(path) }
80
83
  }
81
84
  application.register("add-auth-passkey", PasskeyController)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class MobileSessionsGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+
14
+ def persistence
15
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_mobile_sessions.rb")].any?
16
+ migration_template "add_add_auth_mobile_sessions.rb.tt", "db/migrate/add_add_auth_mobile_sessions.rb"
17
+ end
18
+ unless Dir[File.join(destination_root, "db/migrate/*_create_add_auth_mobile_handoffs.rb")].any?
19
+ migration_template "create_add_auth_mobile_handoffs.rb.tt", "db/migrate/create_add_auth_mobile_handoffs.rb"
20
+ end
21
+ copy_file "add_auth_mobile_handoff.rb", "app/models/add_auth_mobile_handoff.rb", skip: true
22
+ end
23
+
24
+ def routes
25
+ source = File.read(File.join(destination_root, "config/routes.rb"))
26
+ return if source.include?("# AddAuth mobile sessions")
27
+ route <<~ROUTES
28
+ # AddAuth mobile sessions
29
+ post "mobile/session", to: "add_auth/mobile_sessions#create"
30
+ get "mobile/session", to: "add_auth/mobile_sessions#show"
31
+ delete "mobile/session", to: "add_auth/mobile_sessions#destroy"
32
+ get "mobile/sessions", to: "add_auth/mobile_sessions#index"
33
+ delete "mobile/sessions/:id", to: "add_auth/mobile_sessions#revoke"
34
+ post "mobile/sessions/revoke-all", to: "add_auth/mobile_sessions#revoke_all"
35
+ get "mobile/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "mobile"}
36
+ post "mobile/handoff", to: "add_auth/mobile_sessions#exchange"
37
+ post "mobile/apple/challenge", to: "add_auth/mobile_sessions#apple_challenge"
38
+ post "mobile/apple/session", to: "add_auth/mobile_sessions#apple"
39
+ post "mobile/apple/enrollment", to: "add_auth/mobile_sessions#apple_enroll"
40
+ ROUTES
41
+ end
42
+
43
+ def configuration
44
+ copy_file "mobile.rb", "config/initializers/add_auth_mobile.rb", skip: true
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ class AddAddAuthMobileSessions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :sessions, :transport, :string, null: false, default: "browser"
4
+ add_column :sessions, :client_id, :string
5
+ add_column :sessions, :mobile_idle_timeout, :integer
6
+ add_check_constraint :sessions, "transport IN ('browser', 'mobile')", name: "add_auth_session_transport"
7
+ add_check_constraint :sessions, "transport != 'mobile' OR (client_id IS NOT NULL AND mobile_idle_timeout IS NOT NULL AND mobile_idle_timeout BETWEEN 60 AND 7776000 AND token_digest IS NOT NULL AND authenticated_at IS NOT NULL AND expires_at IS NOT NULL AND last_seen_at IS NOT NULL)", name: "add_auth_mobile_profile"
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class AddAuthMobileHandoff < ApplicationRecord
2
+ self.filter_attributes += [:external_digest, :digest, :state, :challenge_digest]
3
+ belongs_to :user, optional: true
4
+ def inspect = "#<AddAuthMobileHandoff [FILTERED]>"
5
+ end
@@ -0,0 +1,27 @@
1
+ class CreateAddAuthMobileHandoffs < ActiveRecord::Migration[8.0]
2
+ def change
3
+ user_key = connection.columns(:users).find { |column| column.name == connection.primary_key(:users) }
4
+ raise "Unsupported authentication primary key" unless user_key && [:integer, :uuid].include?(user_key.type)
5
+ user_type = (user_key.type == :integer && user_key.limit == 8) ? :bigint : user_key.type
6
+ create_table :add_auth_mobile_handoffs do |t|
7
+ t.references :user, type: user_type, foreign_key: {on_delete: :cascade}
8
+ t.string :external_digest, null: false
9
+ t.string :digest
10
+ t.string :client_id, limit: 64, null: false
11
+ t.string :callback, limit: 2048, null: false
12
+ t.string :state, limit: 128, null: false
13
+ t.string :challenge_digest, null: false
14
+ t.string :credential_id
15
+ t.string :credential_version
16
+ t.integer :policy_version
17
+ t.datetime :authenticated_at
18
+ t.datetime :issued_at, null: false
19
+ t.datetime :expires_at, null: false
20
+ t.datetime :consumed_at
21
+ t.timestamps
22
+ end
23
+ add_index :add_auth_mobile_handoffs, :external_digest, unique: true
24
+ add_index :add_auth_mobile_handoffs, :digest, unique: true
25
+ add_index :add_auth_mobile_handoffs, :expires_at
26
+ end
27
+ end
@@ -0,0 +1,8 @@
1
+ # Mobile sessions are optional and remain disabled after generating persistence.
2
+ # Review the host/client timeout policy before enabling. These are example values.
3
+ # AddAuth.configure do |config|
4
+ # config.mobile.lifetime = 30 * 86_400
5
+ # config.mobile.idle_timeout = 14 * 86_400
6
+ # config.mobile.clients = %w[android ios]
7
+ # config.mobile.enabled = true
8
+ # end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class NotificationsGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
@@ -21,10 +23,7 @@ module AddAuth
21
23
  end
22
24
 
23
25
  def configuration
24
- path = "config/initializers/add_auth.rb"
25
- unless File.read(File.join(destination_root, path)).include?("config.notifications.enabled = true")
26
- append_to_file path, "\nAddAuth.configure do |config|\n config.notifications.enabled = true\nend\n"
27
- end
26
+ enable_feature(:notifications)
28
27
  say "Migrate, configure mail_from and a durable queue, and schedule add_auth:deliver_pending every minute."
29
28
  end
30
29
  end
@@ -1,7 +1,9 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
1
3
  class CreateAddAuthSecurityEvents < ActiveRecord::Migration[8.0]
2
4
  def change
3
5
  create_table :add_auth_security_events do |t|
4
- t.references :user, foreign_key: {on_delete: :nullify}
6
+ t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), foreign_key: {on_delete: :nullify}
5
7
  t.string :kind, null: false
6
8
  t.string :digest, null: false
7
9
  t.text :delivery_payload