add_auth 0.2.2 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +38 -14
- data/ROADMAP.md +87 -38
- data/SECURITY.md +6 -0
- data/app/controllers/add_auth/accounts_controller.rb +138 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +14 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +251 -0
- data/lib/add_auth/core/account_policy.rb +50 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +5 -0
- data/lib/add_auth/rails/doctor.rb +70 -3
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +170 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- metadata +85 -2
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Stores
|
|
8
|
+
class MobileHandoffs
|
|
9
|
+
def initialize(user_model:, handoff_model:, session_model:)
|
|
10
|
+
@users, @handoffs = user_model, handoff_model
|
|
11
|
+
unless [handoff_model, session_model].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
|
|
12
|
+
raise ArgumentError, "mobile authentication models must share one connection pool"
|
|
13
|
+
end
|
|
14
|
+
@lock = AccountLock.new(@users)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_pending(**attributes)
|
|
18
|
+
@handoffs.transaction(requires_new: true) { @handoffs.create!(**attributes) }
|
|
19
|
+
rescue ActiveRecord::RecordNotUnique
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def by_external_digest(digest:) = @handoffs.uncached { @handoffs.find_by(external_digest: digest) }
|
|
24
|
+
|
|
25
|
+
def issue(row:, **attributes)
|
|
26
|
+
require_transaction!
|
|
27
|
+
@handoffs.where(id: row.id, digest: nil, consumed_at: nil, user_id: nil).update_all(**attributes) == 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_code(digest:)
|
|
31
|
+
@handoffs.uncached do
|
|
32
|
+
locator = @handoffs.find_by(digest: digest)
|
|
33
|
+
@lock.call(id: locator&.user_id) do |user|
|
|
34
|
+
yield user, user && @handoffs.find_by(digest: digest, user_id: user.id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consume(row:, at:)
|
|
40
|
+
require_transaction!
|
|
41
|
+
@handoffs.where(id: row.id, user_id: row.user_id, consumed_at: nil).where("expires_at > ?", at)
|
|
42
|
+
.update_all(consumed_at: at) == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def require_transaction!
|
|
48
|
+
raise AddAuth::Error, "mobile handoff requires an account transaction" unless @users.connection.transaction_open?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "add_auth/rails/stores/delivery_state"
|
|
4
|
+
require "add_auth/rails/stores/commit_dispatch"
|
|
4
5
|
|
|
5
6
|
module AddAuth
|
|
6
7
|
module Rails
|
|
@@ -16,12 +17,8 @@ module AddAuth
|
|
|
16
17
|
def append(user:, **attributes)
|
|
17
18
|
raise AddAuth::Error, "security notifications must join the account transaction" unless @users.current_transaction.open?
|
|
18
19
|
event = @events.create!(**attributes, user_id: user.id)
|
|
19
|
-
@users.current_transaction
|
|
20
|
-
|
|
21
|
-
raise AddAuth::Error unless job
|
|
22
|
-
rescue
|
|
23
|
-
ActiveSupport::Notifications.instrument("notification_enqueue_failed.add_auth", event_id: event.id)
|
|
24
|
-
end
|
|
20
|
+
CommitDispatch.enqueue(transaction: @users.current_transaction, job: ::AddAuth::SecurityNotificationJob,
|
|
21
|
+
id: event.id, failure_event: "notification_enqueue_failed.add_auth", payload: {event_id: event.id})
|
|
25
22
|
event
|
|
26
23
|
end
|
|
27
24
|
|
|
@@ -6,29 +6,27 @@ module AddAuth
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
|
+
before_update :add_auth_validate_account_write
|
|
9
10
|
after_update :add_auth_invalidate_proofs
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
private
|
|
13
14
|
|
|
15
|
+
def add_auth_validate_account_write
|
|
16
|
+
Core::AccountLifecycle.validate_host_write!(changes: changes_to_save, enabled: AddAuth.configuration.lifecycle.enabled)
|
|
17
|
+
end
|
|
18
|
+
|
|
14
19
|
def add_auth_invalidate_proofs
|
|
15
20
|
return unless Core::Intake.revoke_after_change?(saved_changes)
|
|
16
21
|
now = Time.current
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
kind = Core::Intake.notification_after_change(saved_changes)
|
|
23
|
+
if AddAuth.configuration.notifications.enabled && kind
|
|
19
24
|
Runtime.security_events.issue(user: self, kind: kind, at: now)
|
|
20
25
|
if saved_change_to_email_address? && email_address_before_last_save != email_address
|
|
21
26
|
Runtime.security_events.issue(user: self, kind: :email_changed, at: now, recipient: email_address_before_last_save)
|
|
22
27
|
end
|
|
23
28
|
end
|
|
24
|
-
|
|
25
|
-
::AddAuthCeremony.where(user_id: id, consumed_at: nil).update_all(consumed_at: now)
|
|
26
|
-
end
|
|
27
|
-
sessions.where(revoked_at: nil).update_all(revoked_at: now)
|
|
28
|
-
if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
|
|
29
|
-
::AddAuthSignInToken.where(user_id: id, consumed_at: nil, revoked_at: nil)
|
|
30
|
-
.update_all(revoked_at: now, delivery_payload: nil)
|
|
31
|
-
end
|
|
29
|
+
Runtime.authority.revoke(user_id: id, at: now)
|
|
32
30
|
end
|
|
33
31
|
end
|
|
34
32
|
end
|
data/lib/add_auth/result.rb
CHANGED
|
@@ -16,6 +16,7 @@ module AddAuth
|
|
|
16
16
|
consumed_token revoked_token challenge_rejected
|
|
17
17
|
challenge_unavailable rate_limited origin_mismatch
|
|
18
18
|
counter_regression elevation_required
|
|
19
|
+
identity_unbound identity_conflict last_credential
|
|
19
20
|
].freeze
|
|
20
21
|
|
|
21
22
|
attr_reader :user, :strategy, :credential, :reason, :session, :grant
|
data/lib/add_auth/version.rb
CHANGED
data/lib/add_auth.rb
CHANGED
|
@@ -13,11 +13,15 @@ require "add_auth/configuration"
|
|
|
13
13
|
# ejectable, disposable UI on top of it.
|
|
14
14
|
require "add_auth/core/browser_binding"
|
|
15
15
|
require "add_auth/core/access_policy"
|
|
16
|
+
require "add_auth/core/account_policy"
|
|
16
17
|
require "add_auth/core/sessions"
|
|
18
|
+
require "add_auth/core/mobile_response"
|
|
17
19
|
require "add_auth/core/step_up"
|
|
20
|
+
require "add_auth/core/external_identities"
|
|
18
21
|
require "add_auth/core/intake"
|
|
19
22
|
require "add_auth/core/rate_limit"
|
|
20
23
|
require "add_auth/core/delivery"
|
|
24
|
+
require "add_auth/core/account_lifecycle"
|
|
21
25
|
require "add_auth/core/maintenance"
|
|
22
26
|
require "add_auth/core/security_events"
|
|
23
27
|
require "add_auth/core/strategies/email_link"
|
|
@@ -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
|
+
class AccountsGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Prepare optional account lifecycle schema and pages; review existing account confirmation and policy before enabling"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
|
|
16
|
+
def prerequisites
|
|
17
|
+
invoke "add_auth:notifications"
|
|
18
|
+
invoke "add_auth:step_up"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def persistence
|
|
22
|
+
unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_accounts.rb")].any?
|
|
23
|
+
migration_template "add_add_auth_accounts.rb.tt", "db/migrate/add_add_auth_accounts.rb"
|
|
24
|
+
end
|
|
25
|
+
%w[add_auth_account_token add_auth_address_claim].each do |name|
|
|
26
|
+
template "#{name}.rb", "app/models/#{name}.rb" unless File.exist?(File.join(destination_root, "app/models/#{name}.rb"))
|
|
27
|
+
end
|
|
28
|
+
say "Account lifecycle is prepared but disabled. Map existing account policy and confirmation state, then set config.lifecycle.enabled = true."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def routes
|
|
32
|
+
return if File.read(File.join(destination_root, "config/routes.rb")).include?("# AddAuth account lifecycle")
|
|
33
|
+
route <<~ROUTES
|
|
34
|
+
# AddAuth account lifecycle
|
|
35
|
+
get "account/sign-up", to: "add_auth/accounts#new"
|
|
36
|
+
post "account/sign-up", to: "add_auth/accounts#create"
|
|
37
|
+
get "account/check-email", to: "add_auth/accounts#check_email"
|
|
38
|
+
get "account/requests/:purpose", to: "add_auth/accounts#request_form", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
39
|
+
post "account/requests/:purpose", to: "add_auth/accounts#request_proof", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
40
|
+
get "account/proofs/:purpose", to: "add_auth/accounts#proof", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
41
|
+
post "account/proofs/:purpose", to: "add_auth/accounts#consume", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
42
|
+
get "account/email", to: "add_auth/accounts#edit_email"
|
|
43
|
+
post "account/email", to: "add_auth/accounts#change_email"
|
|
44
|
+
get "account/password", to: "add_auth/accounts#edit_password"
|
|
45
|
+
post "account/password", to: "add_auth/accounts#change_password"
|
|
46
|
+
get "account/delete", to: "add_auth/accounts#confirm_deletion"
|
|
47
|
+
delete "account", to: "add_auth/accounts#destroy"
|
|
48
|
+
ROUTES
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "add_auth/rails/migration/key_type"
|
|
2
|
+
|
|
3
|
+
class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
|
|
4
|
+
def up
|
|
5
|
+
user_key_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
|
|
6
|
+
false_default = (connection.adapter_name == "SQLite") ? -> { "0" } : false
|
|
7
|
+
%i[confirmed_at locked_at disabled_at deleted_at add_auth_locked_until].each do |name|
|
|
8
|
+
add_column :users, name, :datetime unless column_exists?(:users, name)
|
|
9
|
+
end
|
|
10
|
+
add_column :users, :unconfirmed_email, :string unless column_exists?(:users, :unconfirmed_email)
|
|
11
|
+
add_column :users, :failed_attempts, :integer, default: 0, null: false unless column_exists?(:users, :failed_attempts)
|
|
12
|
+
add_column :users, :add_auth_manual_lock, :boolean, default: false_default, null: false unless column_exists?(:users, :add_auth_manual_lock)
|
|
13
|
+
add_column :users, :add_auth_password_scheme, :string unless column_exists?(:users, :add_auth_password_scheme)
|
|
14
|
+
add_column :users, :add_auth_authority, :string, default: "add_auth", null: false unless column_exists?(:users, :add_auth_authority)
|
|
15
|
+
add_column :sessions, :remembered, :boolean, default: false_default, null: false unless column_exists?(:sessions, :remembered)
|
|
16
|
+
add_column :sessions, :idle_timeout, :integer unless column_exists?(:sessions, :idle_timeout)
|
|
17
|
+
create_table :add_auth_address_claims do |t|
|
|
18
|
+
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
19
|
+
t.string :digest, null: false
|
|
20
|
+
t.string :state, null: false
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
add_index :add_auth_address_claims, :digest, unique: true
|
|
24
|
+
add_index :add_auth_address_claims, [:user_id, :state], unique: true
|
|
25
|
+
create_table :add_auth_account_tokens do |t|
|
|
26
|
+
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
27
|
+
t.string :digest, null: false
|
|
28
|
+
t.string :purpose, null: false
|
|
29
|
+
t.string :address_digest, null: false
|
|
30
|
+
t.string :account_version, null: false
|
|
31
|
+
t.string :request_id, null: false
|
|
32
|
+
t.datetime :expires_at, null: false
|
|
33
|
+
t.datetime :consumed_at
|
|
34
|
+
t.datetime :revoked_at
|
|
35
|
+
t.text :delivery_payload
|
|
36
|
+
t.string :delivery_lease_key
|
|
37
|
+
t.datetime :delivery_lease_until
|
|
38
|
+
t.datetime :delivered_at
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
add_index :add_auth_account_tokens, :digest, unique: true
|
|
42
|
+
add_index :add_auth_account_tokens, :request_id, unique: true
|
|
43
|
+
add_index :add_auth_account_tokens, :expires_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def down
|
|
47
|
+
raise ActiveRecord::IrreversibleMigration, "Retain account policy, address claims and spent proofs during rollback; retire them only after reviewed reconciliation"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
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 DeviseAccountsGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Prepare additive account columns for a reviewed Devise migration; does not convert accounts or switch authentication"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
|
|
16
|
+
def migration
|
|
17
|
+
return if Dir[File.join(destination_root, "db/migrate/*_prepare_add_auth_devise_accounts.rb")].any?
|
|
18
|
+
migration_template "prepare_add_auth_devise_accounts.rb.tt", "db/migrate/prepare_add_auth_devise_accounts.rb"
|
|
19
|
+
say "Review the generated migration and run preflight before conversion. Authentication and host models are unchanged."
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "add_auth/rails/migration/key_type"
|
|
2
|
+
|
|
3
|
+
class PrepareAddAuthDeviseAccounts < ActiveRecord::Migration[8.0]
|
|
4
|
+
def up
|
|
5
|
+
AddAuth::Rails::Migration::KeyType.for(connection, :users)
|
|
6
|
+
add_column :users, :email_address, :string unless column_exists?(:users, :email_address)
|
|
7
|
+
add_column :users, :password_digest, :string unless column_exists?(:users, :password_digest)
|
|
8
|
+
add_column :users, :add_auth_password_scheme, :string unless column_exists?(:users, :add_auth_password_scheme)
|
|
9
|
+
add_column :users, :add_auth_migration_stamp, :string unless column_exists?(:users, :add_auth_migration_stamp)
|
|
10
|
+
add_column :users, :add_auth_authority, :string, default: "devise", null: false unless column_exists?(:users, :add_auth_authority)
|
|
11
|
+
add_index :users, :email_address, unique: true unless index_exists?(:users, :email_address, unique: true)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
raise ActiveRecord::IrreversibleMigration, "Retain canonical credentials for the reviewed bridge rollback; retire columns only after reconciliation"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -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,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
|
data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt
ADDED
|
@@ -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)
|
|
@@ -4,6 +4,10 @@ AddAuth.configure do |config|
|
|
|
4
4
|
# routes remain guarded; the host owns password reset and account provisioning.
|
|
5
5
|
# config.passwords_enabled = true
|
|
6
6
|
|
|
7
|
+
# Ordinary Rails HTML navigation, including hosts without Hotwire:
|
|
8
|
+
# config.turbo_enabled = false
|
|
9
|
+
# Passkeys/captcha still use scoped gem JavaScript; no host JS build is needed.
|
|
10
|
+
|
|
7
11
|
# BEGIN add_auth session
|
|
8
12
|
# config.session.enabled = true
|
|
9
13
|
# END add_auth session
|
|
@@ -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
|
|
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
|