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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +64 -17
- data/ROADMAP.md +98 -43
- data/SECURITY.md +8 -0
- data/app/controllers/add_auth/accounts_controller.rb +146 -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/controllers/add_auth/reauthentications_controller.rb +5 -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 +17 -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 +295 -0
- data/lib/add_auth/core/account_policy.rb +68 -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 +6 -1
- data/lib/add_auth/rails/doctor.rb +80 -6
- 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 +179 -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 +104 -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 +56 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -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/email_link_generator.rb +4 -1
- 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/feature_configuration.rb +21 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
- 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/notifications_generator.rb +3 -4
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
- data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
- 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 +101 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Migration
|
|
8
|
+
class AccountStore
|
|
9
|
+
FIELDS = {id: :id, email: :email, encrypted_password: :encrypted_password,
|
|
10
|
+
authority: :add_auth_authority, migration_stamp: :add_auth_migration_stamp,
|
|
11
|
+
email_address: :email_address, password_digest: :password_digest, scheme: :add_auth_password_scheme}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(user_model:)
|
|
14
|
+
@users = user_model
|
|
15
|
+
@lock = Stores::AccountLock.new(user_model)
|
|
16
|
+
unless @users.primary_key == "id" && (FIELDS.values.map(&:to_s) - @users.column_names).empty?
|
|
17
|
+
raise ArgumentError, "prepare the reviewed User account schema before conversion"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def source_page(after:, limit:)
|
|
22
|
+
scope = @users.unscoped.order(:id)
|
|
23
|
+
scope = scope.where(@users.arel_table[:id].gt(after)) if after
|
|
24
|
+
scope.limit(limit).pluck(*FIELDS.values).map { |row| FIELDS.keys.zip(row).to_h }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_account(id)
|
|
28
|
+
@lock.call(id: id) { |user| yield(user && FIELDS.to_h { |name, field| [name, user.read_attribute(field)] }) }
|
|
29
|
+
rescue ActiveRecord::RecordNotUnique
|
|
30
|
+
:conflicts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def prepare(id:, **attributes)
|
|
34
|
+
raise AddAuth::Error, "conversion requires an account transaction" unless @users.connection.transaction_open?
|
|
35
|
+
# Source authority is retained. No callbacks, mail or host provisioning.
|
|
36
|
+
@users.unscoped.where(id: id).update_all(attributes)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/migration/readiness"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Migration
|
|
8
|
+
class EffectiveInventory
|
|
9
|
+
LIMIT = 10_000
|
|
10
|
+
ACCOUNT_FIELDS = %w[email email_address encrypted_password password_digest confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at remember_created_at reset_password_token confirmation_token unlock_token authentication_token provider uid].freeze
|
|
11
|
+
OPTIONS = %i[authentication_keys case_insensitive_keys strip_whitespace_keys stretches password_length reconfirmable allow_unconfirmed_access_for confirm_within reset_password_within maximum_attempts unlock_strategy unlock_in remember_for sign_in_after_reset_password].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(limit: LIMIT)
|
|
14
|
+
raise ArgumentError, "limit must be between 1 and 100000" unless limit.is_a?(Integer) && limit.between?(1, 100_000)
|
|
15
|
+
@limit = limit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
unless %w[test development].include?(::Rails.env.to_s) && defined?(::Devise)
|
|
20
|
+
raise ArgumentError, "effective preflight requires a local test/development Devise host"
|
|
21
|
+
end
|
|
22
|
+
@complete = true
|
|
23
|
+
mappings = ::Devise.mappings.values
|
|
24
|
+
models = mappings.map(&:to).uniq
|
|
25
|
+
connections = models.map(&:connection_pool).uniq
|
|
26
|
+
raise ArgumentError, "inspect one Active Record connection pool at a time" unless connections.length == 1
|
|
27
|
+
accounts = read_only(connections.first) { models.map { |model| account(model) } }
|
|
28
|
+
all_modules = accounts.flat_map { |entry| entry[:modules] }.uniq
|
|
29
|
+
facts = {mode: "effective", complete: @complete, row_limit: @limit,
|
|
30
|
+
versions: %w[devise rails bcrypt].to_h { |name| [name.to_sym, Gem.loaded_specs[name]&.version&.to_s] }.merge(ruby: RUBY_VERSION),
|
|
31
|
+
scope_count: mappings.length, modules: all_modules & Core::Migration::Readiness::MODULES,
|
|
32
|
+
unknown_modules: accounts.sum { |entry| entry[:unknown_modules] },
|
|
33
|
+
extensions: Core::Migration::Readiness::EXTENSIONS.select { |name| Gem.loaded_specs.key?(name) },
|
|
34
|
+
rails_contract: accounts.length == 1 && accounts.first[:rails_contract], accounts: accounts}
|
|
35
|
+
Core::Migration::Readiness.new.call(facts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_only(pool)
|
|
41
|
+
pool.with_connection do |connection|
|
|
42
|
+
raise ArgumentError, "effective preflight requires its own transaction" if connection.transaction_open?
|
|
43
|
+
case connection.adapter_name
|
|
44
|
+
when "PostgreSQL"
|
|
45
|
+
connection.transaction(requires_new: true) do
|
|
46
|
+
connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
|
47
|
+
connection.execute("SET LOCAL statement_timeout = '10s'")
|
|
48
|
+
yield
|
|
49
|
+
end
|
|
50
|
+
when "SQLite"
|
|
51
|
+
previous = connection.select_value("PRAGMA query_only").to_i
|
|
52
|
+
begin
|
|
53
|
+
connection.execute("PRAGMA query_only = ON")
|
|
54
|
+
# Rails 8.1 starts ordinary SQLite transactions with BEGIN
|
|
55
|
+
# IMMEDIATE, which requests write authority even for SELECTs.
|
|
56
|
+
connection.execute("BEGIN DEFERRED TRANSACTION")
|
|
57
|
+
begin
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
connection.execute("ROLLBACK")
|
|
61
|
+
end
|
|
62
|
+
ensure
|
|
63
|
+
connection.execute("PRAGMA query_only = #{previous}")
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError, "effective preflight supports SQLite and PostgreSQL only"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def account(model)
|
|
72
|
+
columns = model.columns_hash
|
|
73
|
+
fields = ACCOUNT_FIELDS & columns.keys
|
|
74
|
+
# No model instances, callbacks, password verification or normalization hooks.
|
|
75
|
+
rows = model.unscoped.limit(@limit + 1).pluck(*fields).map { |values| fields.zip(Array(values)).to_h }
|
|
76
|
+
@complete = false if rows.length > @limit
|
|
77
|
+
rows = rows.first(@limit)
|
|
78
|
+
modules = model.devise_modules.map(&:to_s)
|
|
79
|
+
lookup_keys = Array(model.authentication_keys)
|
|
80
|
+
identifier = (lookup_keys.length == 1 && fields.include?(lookup_keys.first.to_s)) ? lookup_keys.first.to_s : "email"
|
|
81
|
+
@complete = false unless lookup_keys == [:email] || lookup_keys == [:email_address]
|
|
82
|
+
password = fields.include?("encrypted_password") ? "encrypted_password" : "password_digest"
|
|
83
|
+
keys = rows.map { |row| row[identifier].to_s.strip.downcase }
|
|
84
|
+
nonblank = keys.reject(&:empty?)
|
|
85
|
+
options = OPTIONS.filter_map { |name| [name, safe_option(model.public_send(name))] if model.respond_to?(name) }.to_h
|
|
86
|
+
verifier = model.instance_method(:valid_password?).source_location&.first
|
|
87
|
+
devise_root = Gem.loaded_specs.fetch("devise").full_gem_path + "/"
|
|
88
|
+
indexes = model.connection.indexes(model.table_name)
|
|
89
|
+
{model: safe_name(model.name), table: safe_name(model.table_name),
|
|
90
|
+
primary_key_type: columns[model.primary_key]&.type&.to_s,
|
|
91
|
+
database: model.connection.adapter_name, identifier: identifier,
|
|
92
|
+
identifier_type: columns[identifier]&.type&.to_s,
|
|
93
|
+
identifier_collation: columns[identifier]&.respond_to?(:collation) ? safe_name(columns[identifier].collation) : nil,
|
|
94
|
+
unique_identifier_index: indexes.any? { |index| index.unique && index.columns == [identifier] && !index.where },
|
|
95
|
+
modules: modules & Core::Migration::Readiness::MODULES,
|
|
96
|
+
unknown_modules: (modules - Core::Migration::Readiness::MODULES).length,
|
|
97
|
+
options: options, pepper_configured: model.respond_to?(:pepper) && !model.pepper.to_s.empty?,
|
|
98
|
+
custom_verifier: !verifier&.start_with?(devise_root) || !model.instance_method(:password=).source_location&.first&.start_with?(devise_root),
|
|
99
|
+
rails_contract: (model.name == "User" && fields.include?("email_address") && fields.include?("password_digest") && model.respond_to?(:authenticate_by) && defined?(::Session) && defined?(::Authentication)) ? true : false,
|
|
100
|
+
related_tables: related_tables(model),
|
|
101
|
+
counts: {inspected: rows.length, missing_identifier: keys.count(&:empty?), identifier_collisions: nonblank.length - nonblank.uniq.length,
|
|
102
|
+
unknown_password: rows.count { |row| !row[password].to_s.match?(/\A\$2[aby]\$\d{2}\$[.\/A-Za-z0-9]{53}\z/) },
|
|
103
|
+
unconfirmed: fields.include?("confirmed_at") ? rows.count { |row| row["confirmed_at"].nil? } : 0,
|
|
104
|
+
reconfirming: rows.count { |row| !row["unconfirmed_email"].to_s.empty? },
|
|
105
|
+
locked: rows.count { |row| row["locked_at"] }, disabled: rows.count { |row| row["disabled_at"] },
|
|
106
|
+
deleted: rows.count { |row| row["deleted_at"] },
|
|
107
|
+
outstanding_authority: rows.count { |row| %w[remember_created_at reset_password_token confirmation_token unlock_token authentication_token].any? { |key| !row[key].to_s.empty? } }}}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def related_tables(model)
|
|
111
|
+
connection = model.connection
|
|
112
|
+
tables = connection.tables.sort
|
|
113
|
+
@complete = false if tables.length > 200
|
|
114
|
+
tables.first(200).filter_map do |table|
|
|
115
|
+
next if table == model.table_name
|
|
116
|
+
columns = connection.columns(table).to_h { |column| [column.name, column] }
|
|
117
|
+
foreign_keys = connection.foreign_keys(table).select { |key| key.to_table == model.table_name }
|
|
118
|
+
reference = foreign_keys.first&.column || ("user_id" if model.name == "User" && columns.key?("user_id"))
|
|
119
|
+
next unless reference && columns.key?(reference)
|
|
120
|
+
quoted_table = connection.quote_table_name(table)
|
|
121
|
+
quote = ->(name) { connection.quote_column_name(name) }
|
|
122
|
+
identity_fields = %w[provider issuer subject uid] & columns.keys
|
|
123
|
+
selected = [reference, *identity_fields]
|
|
124
|
+
records = connection.select_rows("SELECT #{selected.map { |field| quote.call(field) }.join(", ")} FROM #{quoted_table} LIMIT #{@limit + 1}")
|
|
125
|
+
@complete = false if records.length > @limit
|
|
126
|
+
records = records.first(@limit)
|
|
127
|
+
identities = identity_fields.empty? ? [] : records.map { |row| row.drop(1) }
|
|
128
|
+
account_table = connection.quote_table_name(model.table_name)
|
|
129
|
+
key = quote.call(model.primary_key)
|
|
130
|
+
reference_column = quote.call(reference)
|
|
131
|
+
orphan_count = connection.select_value(<<~SQL).to_i
|
|
132
|
+
SELECT COUNT(*) FROM
|
|
133
|
+
(SELECT #{reference_column} FROM #{quoted_table} LIMIT #{@limit + 1}) source
|
|
134
|
+
LEFT JOIN #{account_table} account ON account.#{key} = source.#{reference_column}
|
|
135
|
+
WHERE source.#{reference_column} IS NOT NULL AND account.#{key} IS NULL
|
|
136
|
+
SQL
|
|
137
|
+
{table: safe_name(table), reference: safe_name(reference), key_type: columns[reference].type.to_s,
|
|
138
|
+
foreign_key_enforced: foreign_keys.any?, inspected: records.length,
|
|
139
|
+
orphaned_references: orphan_count, duplicate_identities: identities.length - identities.uniq.length}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def safe_name(value)
|
|
144
|
+
value.to_s.match?(/\A[A-Za-z_]\w*(?:::\w+)*\z/) ? value.to_s : nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def safe_option(value)
|
|
148
|
+
case value
|
|
149
|
+
when nil, true, false, Numeric then value
|
|
150
|
+
when Symbol then safe_name(value)
|
|
151
|
+
when Range then [safe_option(value.begin), safe_option(value.end), value.exclude_end?]
|
|
152
|
+
when Array then value.map { |item| safe_option(item) }
|
|
153
|
+
when Hash then value.to_h { |key, item| [safe_name(key), safe_option(item)] }
|
|
154
|
+
else
|
|
155
|
+
# ActiveSupport::Duration exposes only a numeric duration.
|
|
156
|
+
value.is_a?(ActiveSupport::Duration) ? value.to_i : "custom"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Migration
|
|
6
|
+
module KeyType
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Resolve when the migration runs, after the host's account table exists.
|
|
10
|
+
# Session IDs remain integer until opaque cursor support is established.
|
|
11
|
+
def for(connection, table, session: false)
|
|
12
|
+
key = connection.primary_key(table)
|
|
13
|
+
column = connection.columns(table).find { |entry| entry.name == key }
|
|
14
|
+
raise ArgumentError, "AddAuth requires a single integer or UUID primary key on #{table}" unless column
|
|
15
|
+
return :bigint if column.type == :integer && column.limit == 8
|
|
16
|
+
return :integer if column.type == :integer
|
|
17
|
+
return :uuid if column.type == :uuid && !session
|
|
18
|
+
raise ArgumentError, session ? "AddAuth requires integer Session IDs; UUID accounts can use integer Sessions" : "AddAuth requires an integer or UUID account primary key"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/migration/account_adoption"
|
|
4
|
+
require "add_auth/rails/stores/account_lock"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
module Migration
|
|
9
|
+
# Include after Devise in an expanded, reviewed source model. This does
|
|
10
|
+
# not select routes or retire other host credential readers at cutover.
|
|
11
|
+
module SourceBridge
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
before_save :add_auth_project_source_credentials
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def valid_password?(password)
|
|
19
|
+
Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def active_for_authentication?
|
|
23
|
+
Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Devise Lockable increments this counter through update_all before its
|
|
27
|
+
# later save callback. Fence that bypass on the same account row lock.
|
|
28
|
+
def increment_failed_attempts
|
|
29
|
+
self.class.transaction do
|
|
30
|
+
current = Stores::AccountLock.new(self.class).current_in_transaction(id: id)
|
|
31
|
+
raise AddAuth::Error, "source account no longer exists" unless current
|
|
32
|
+
Core::Migration::AccountAdoption.authorize_source_write!(authority: current[:add_auth_authority], changed_fields: [:failed_attempts])
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def add_auth_project_source_credentials
|
|
40
|
+
current = persisted? ? Stores::AccountLock.new(self.class).current_in_transaction(id: id) : self
|
|
41
|
+
raise AddAuth::Error, "source account no longer exists" unless current
|
|
42
|
+
source = %i[email encrypted_password].to_h do |name|
|
|
43
|
+
[name, will_save_change_to_attribute?(name) ? self[name] : current[name]]
|
|
44
|
+
end
|
|
45
|
+
attributes = Core::Migration::AccountAdoption.projection_for_write(**source,
|
|
46
|
+
authority: current[:add_auth_authority], authority_changed: persisted? && will_save_change_to_add_auth_authority?,
|
|
47
|
+
changed_fields: changed_attribute_names_to_save)
|
|
48
|
+
attributes.each { |name, value| self[name] = value }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/passwords/legacy_bcrypt"
|
|
4
|
+
require "add_auth/core/passwords/credential"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
# Include after has_secure_password. Rails owns new assignment and lookup;
|
|
9
|
+
# Core chooses exactly one verifier for the persisted credential profile.
|
|
10
|
+
module PasswordAdoption
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
self.filter_attributes += [:add_auth_password_scheme]
|
|
15
|
+
before_save :add_auth_retire_legacy_password
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def authenticate_password(password)
|
|
19
|
+
result = Core::Passwords::Credential.new(legacy_verifier: AddAuth.configuration.legacy_password_verifier)
|
|
20
|
+
.authenticate(user: self, password: password, current: ->(value) { super(value) })
|
|
21
|
+
result.success? ? result.user : false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def authenticate(password) = authenticate_password(password)
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def add_auth_retire_legacy_password
|
|
29
|
+
# A host's ordinary password assignment replaces the legacy credential.
|
|
30
|
+
# Direct migration copies do not set the virtual plaintext attribute.
|
|
31
|
+
self.add_auth_password_scheme = Core::Passwords::Credential.scheme_after_assignment(
|
|
32
|
+
scheme: add_auth_password_scheme, password: password, digest_changed: will_save_change_to_password_digest?
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "active_support/message_encryptor"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
# Bounded transport storage for the form collected before provider consent.
|
|
9
|
+
# This grants no identity/account authority: G3/G4 still consume the exact
|
|
10
|
+
# verified enrollment transaction inside the new-account transaction.
|
|
11
|
+
class ProviderEnrollment
|
|
12
|
+
MAX_BYTES = 4096
|
|
13
|
+
|
|
14
|
+
def initialize(model:, digest:, key:)
|
|
15
|
+
@model, @digest = model, digest
|
|
16
|
+
@cipher = ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: :json)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(pending:, identifier:, profile:)
|
|
20
|
+
return false unless enrollment?(pending) && bounded_profile?(profile) && identifier.is_a?(String)
|
|
21
|
+
payload = {"identifier" => identifier, "profile" => profile}
|
|
22
|
+
return false unless JSON.generate(payload).bytesize <= MAX_BYTES
|
|
23
|
+
ciphertext = @cipher.encrypt_and_sign(payload, purpose: purpose(pending), expires_at: pending.expires_at)
|
|
24
|
+
row(pending).where(enrollment_payload: nil).update_all(enrollment_payload: ciphertext) == 1
|
|
25
|
+
rescue JSON::GeneratorError, EncodingError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read(pending:)
|
|
30
|
+
return unless enrollment?(pending)
|
|
31
|
+
ciphertext = row(pending).pick(:enrollment_payload)
|
|
32
|
+
return unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_BYTES * 3
|
|
33
|
+
payload = @cipher.decrypt_and_verify(ciphertext, purpose: purpose(pending))
|
|
34
|
+
return unless payload.is_a?(Hash) && payload["identifier"].is_a?(String) && bounded_profile?(payload["profile"])
|
|
35
|
+
{identifier: payload.fetch("identifier"), profile: payload.fetch("profile").symbolize_keys}
|
|
36
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def erase(pending:)
|
|
41
|
+
row(pending).update_all(enrollment_payload: nil) if enrollment?(pending)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def enrollment?(pending)
|
|
47
|
+
pending.is_a?(Core::ExternalIdentities::Pending) && pending.purpose == Core::ExternalIdentities::ENROLL
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bounded_profile?(profile)
|
|
51
|
+
profile.is_a?(Hash) && profile.size <= 20 && profile.all? do |name, value|
|
|
52
|
+
(name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.valid_encoding? && name.to_s.bytesize.between?(1, 64) &&
|
|
53
|
+
(value.nil? || value == true || value == false || (value.is_a?(String) && value.valid_encoding? && value.bytesize <= 2048))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def purpose(pending) = "add_auth.external-enrollment:#{@digest.digest(pending.id)}"
|
|
58
|
+
def row(pending) = @model.where(digest: @digest.digest(pending.id))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/message_encryptor"
|
|
4
|
+
require "rack/request"
|
|
5
|
+
require "rack/utils"
|
|
6
|
+
|
|
7
|
+
module AddAuth
|
|
8
|
+
module Rails
|
|
9
|
+
module ProviderLibraries
|
|
10
|
+
# OmniAuth's Apple strategy binds state and nonce to rack.session. Apple
|
|
11
|
+
# posts its callback cross-site, where a normal SameSite=Lax Rails session
|
|
12
|
+
# is intentionally absent. This tiny Rack wrapper copies only those
|
|
13
|
+
# verifier inputs and the Core browser correlation into a separately
|
|
14
|
+
# encrypted, HttpOnly, Secure, SameSite=None cookie scoped to Apple's
|
|
15
|
+
# callback path. It never changes the application's normal session cookie.
|
|
16
|
+
class AppleFormPostCorrelation
|
|
17
|
+
COOKIE = "add_auth_apple_callback"
|
|
18
|
+
PENDING_KEY = "add_auth.apple_pending"
|
|
19
|
+
START_PATH = "/auth/apple"
|
|
20
|
+
CALLBACK_PATH = "/auth/apple/callback"
|
|
21
|
+
PURPOSE = "add_auth.apple-form-post.v1"
|
|
22
|
+
|
|
23
|
+
def self.remember!(session:, transaction:, browser_secret:, configuration_id:, purpose:, remember: false)
|
|
24
|
+
raise ArgumentError, "Apple provider transaction is invalid" unless text?(transaction)
|
|
25
|
+
raise ArgumentError, "Apple provider browser secret is invalid" unless text?(browser_secret)
|
|
26
|
+
raise ArgumentError, "Apple provider configuration is invalid" unless text?(configuration_id)
|
|
27
|
+
raise ArgumentError, "Apple provider purpose is invalid" unless text?(purpose.to_s)
|
|
28
|
+
|
|
29
|
+
session[PENDING_KEY] = {"transaction" => transaction, "browser_secret" => browser_secret,
|
|
30
|
+
"configuration_id" => configuration_id, "purpose" => purpose.to_s, "remember" => remember == true}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.text?(value)
|
|
34
|
+
value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
|
|
35
|
+
end
|
|
36
|
+
private_class_method :text?
|
|
37
|
+
|
|
38
|
+
def initialize(app, key:, lifetime: 600, clock: -> { Time.now })
|
|
39
|
+
raise ArgumentError, "Apple callback key must be 32 bytes" unless key.is_a?(String) && key.bytesize == 32
|
|
40
|
+
raise ArgumentError, "Apple callback lifetime must be positive" unless lifetime.is_a?(Integer) && lifetime.positive?
|
|
41
|
+
|
|
42
|
+
@app = app
|
|
43
|
+
@cipher = ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: :json)
|
|
44
|
+
@lifetime, @clock = lifetime, clock
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call(env)
|
|
48
|
+
request = Rack::Request.new(env)
|
|
49
|
+
return request.post? ? start(env) : @app.call(env) if request.path_info == START_PATH
|
|
50
|
+
return callback(env, request) if request.path_info == CALLBACK_PATH
|
|
51
|
+
|
|
52
|
+
@app.call(env)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def start(env)
|
|
58
|
+
pending = env.fetch("rack.session", {}).delete(PENDING_KEY)
|
|
59
|
+
return @app.call(env) if pending.nil?
|
|
60
|
+
return failure unless pending.is_a?(Hash) && valid_pending?(pending)
|
|
61
|
+
|
|
62
|
+
status, headers, body = @app.call(env)
|
|
63
|
+
return [status, headers, body] unless status.between?(300, 399)
|
|
64
|
+
|
|
65
|
+
payload = pending.merge("state" => env.fetch("rack.session", {})["omniauth.state"],
|
|
66
|
+
"nonce" => env.fetch("rack.session", {})["omniauth.nonce"])
|
|
67
|
+
return [status, headers, body] unless valid_payload?(payload)
|
|
68
|
+
|
|
69
|
+
write_cookie(headers, encrypt(payload))
|
|
70
|
+
[status, headers, body]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def callback(env, request)
|
|
74
|
+
# Do not claim a host-owned Apple callback without our capsule. A
|
|
75
|
+
# request reaching AddAuth's controller still needs valid correlation.
|
|
76
|
+
return @app.call(env) unless request.cookies.key?(COOKIE)
|
|
77
|
+
payload = decrypt(request.cookies[COOKIE])
|
|
78
|
+
return failure unless valid_payload?(payload)
|
|
79
|
+
|
|
80
|
+
session = env.fetch("rack.session", {})
|
|
81
|
+
session["omniauth.state"] = payload.fetch("state")
|
|
82
|
+
session["omniauth.nonce"] = payload.fetch("nonce")
|
|
83
|
+
env["add_auth.apple_correlation"] = payload.slice("transaction", "browser_secret", "configuration_id", "purpose", "remember")
|
|
84
|
+
status, headers, body = @app.call(env)
|
|
85
|
+
clear_cookie(headers)
|
|
86
|
+
[status, headers, body]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def valid_pending?(payload)
|
|
90
|
+
self.class.send(:text?, payload["transaction"]) && self.class.send(:text?, payload["browser_secret"]) &&
|
|
91
|
+
self.class.send(:text?, payload["configuration_id"]) && self.class.send(:text?, payload["purpose"]) &&
|
|
92
|
+
[true, false].include?(payload["remember"])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def valid_payload?(payload)
|
|
96
|
+
payload.is_a?(Hash) && valid_pending?(payload) && self.class.send(:text?, payload["state"]) && self.class.send(:text?, payload["nonce"])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def encrypt(payload)
|
|
100
|
+
@cipher.encrypt_and_sign(payload, purpose: PURPOSE, expires_in: @lifetime)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def decrypt(value)
|
|
104
|
+
return unless self.class.send(:text?, value)
|
|
105
|
+
|
|
106
|
+
@cipher.decrypt_and_verify(value, purpose: PURPOSE)
|
|
107
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_cookie(headers, value)
|
|
112
|
+
Rack::Utils.set_cookie_header!(headers, COOKIE, value: value, path: CALLBACK_PATH, secure: true,
|
|
113
|
+
httponly: true, same_site: :none, max_age: @lifetime)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def clear_cookie(headers)
|
|
117
|
+
Rack::Utils.set_cookie_header!(headers, COOKIE, value: "", path: CALLBACK_PATH, secure: true,
|
|
118
|
+
httponly: true, same_site: :none, max_age: 0)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def failure
|
|
122
|
+
[422, {"content-type" => "text/plain", "cache-control" => "no-store"}, ["Provider sign-in could not be verified."]]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
module ProviderLibraries
|
|
9
|
+
class AppleJwks
|
|
10
|
+
URL = URI("https://appleid.apple.com/auth/keys").freeze
|
|
11
|
+
CACHE_KEY = "add_auth:apple:jwks:v1"
|
|
12
|
+
MAX_BYTES = 65_536
|
|
13
|
+
|
|
14
|
+
def initialize(cache:)
|
|
15
|
+
@cache = cache
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# ruby-jwt's key finder requests one invalidation on an unknown kid.
|
|
19
|
+
# No request/header claim selects a URL or changes TLS verification.
|
|
20
|
+
def call(options = {})
|
|
21
|
+
cached = cache_operation { @cache.read(CACHE_KEY) } unless options[:invalidate]
|
|
22
|
+
return cached if valid?(cached)
|
|
23
|
+
keys = fetch
|
|
24
|
+
cache_operation { @cache.write(CACHE_KEY, keys, expires_in: 3600) }
|
|
25
|
+
keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cache_operation
|
|
31
|
+
yield
|
|
32
|
+
rescue
|
|
33
|
+
raise AddAuth::Error, "Apple signing keys unavailable", cause: nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch
|
|
37
|
+
body = +""
|
|
38
|
+
Timeout.timeout(5) do
|
|
39
|
+
Net::HTTP.start(URL.host, URL.port, use_ssl: true, open_timeout: 2, read_timeout: 3, write_timeout: 3) do |http|
|
|
40
|
+
http.max_retries = 0
|
|
41
|
+
http.request(Net::HTTP::Get.new(URL.request_uri)) do |response|
|
|
42
|
+
raise AddAuth::Error, "Apple signing keys unavailable" unless response.code == "200"
|
|
43
|
+
response.read_body do |part|
|
|
44
|
+
raise AddAuth::Error, "Apple signing keys unavailable" if body.bytesize + part.bytesize > MAX_BYTES
|
|
45
|
+
body << part
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
keys = JSON.parse(body)
|
|
51
|
+
raise AddAuth::Error, "Apple signing keys unavailable" unless valid?(keys)
|
|
52
|
+
keys
|
|
53
|
+
rescue JSON::ParserError, Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError
|
|
54
|
+
raise AddAuth::Error, "Apple signing keys unavailable", cause: nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def valid?(value)
|
|
58
|
+
value.is_a?(Hash) && value["keys"].is_a?(Array) && value["keys"].size.between?(1, 16) &&
|
|
59
|
+
value["keys"].all? { |key|
|
|
60
|
+
key.is_a?(Hash) && key["kty"] == "RSA" && key["alg"] == "RS256" &&
|
|
61
|
+
key["kid"].is_a?(String) && key["kid"].bytesize.between?(1, 128) && key["n"].is_a?(String) && key["n"].bytesize.between?(1, 1024) &&
|
|
62
|
+
key["e"].is_a?(String) && key["e"].bytesize.between?(1, 16)
|
|
63
|
+
} && value["keys"].map { |key| key["kid"] }.uniq.length == value["keys"].length
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional adapter: the host installs and requires ruby-jwt. No OAuth strategy,
|
|
4
|
+
# global library setting or provider credential storage is introduced.
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module ProviderLibraries
|
|
8
|
+
module AppleNative
|
|
9
|
+
ISSUER = "https://appleid.apple.com"
|
|
10
|
+
PURPOSES = [Core::ExternalIdentities::NATIVE, Core::ExternalIdentities::NATIVE_ENROLL].freeze
|
|
11
|
+
|
|
12
|
+
class CallbackResult
|
|
13
|
+
def self.capture(identity_token:, pending:, nonce:, jwks:)
|
|
14
|
+
return unless defined?(::JWT) && pending.is_a?(Core::ExternalIdentities::Pending) &&
|
|
15
|
+
PURPOSES.include?(pending.purpose) && pending.configuration.issuer == ISSUER &&
|
|
16
|
+
identity_token.is_a?(String) && identity_token.bytesize.between?(1, 16_384) && nonce.is_a?(String)
|
|
17
|
+
claims, = ::JWT.decode(identity_token, nil, true, algorithms: ["RS256"], jwks: jwks, allow_nil_kid: false,
|
|
18
|
+
iss: ISSUER, verify_iss: true, aud: pending.configuration.audience, verify_aud: true, verify_iat: true,
|
|
19
|
+
required_claims: %w[iss aud sub exp iat nonce])
|
|
20
|
+
signed_nonce = claims["nonce"]
|
|
21
|
+
return unless signed_nonce.is_a?(String) && signed_nonce.bytesize == nonce.bytesize &&
|
|
22
|
+
OpenSSL.fixed_length_secure_compare(signed_nonce, nonce) && claims["iat"].is_a?(Integer) && claims["exp"].is_a?(Integer) &&
|
|
23
|
+
claims["iat"] >= pending.issued_at.to_i && claims["aud"] == pending.configuration.audience
|
|
24
|
+
new(pending: pending, subject: claims["sub"])
|
|
25
|
+
rescue ::JWT::DecodeError, ArgumentError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(pending:, subject:)
|
|
30
|
+
@transaction_id = pending.id
|
|
31
|
+
@configuration = pending.configuration
|
|
32
|
+
@purpose = pending.purpose
|
|
33
|
+
@subject = Core::ExternalIdentities::Configuration.text(subject)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
private_class_method :new
|
|
37
|
+
|
|
38
|
+
def verified_claims(transaction:)
|
|
39
|
+
return unless transaction.is_a?(Core::ExternalIdentities::Pending) && transaction.id == @transaction_id &&
|
|
40
|
+
transaction.configuration.equal?(@configuration) && transaction.purpose == @purpose
|
|
41
|
+
{issuer: ISSUER, audience: @configuration.audience, subject: @subject, provenance: "ruby-jwt/apple-native-v1"}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect = "#<AddAuth::Rails::ProviderLibraries::AppleNative::CallbackResult [FILTERED]>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Verifier
|
|
48
|
+
def call(server_result:, transaction:)
|
|
49
|
+
server_result.verified_claims(transaction: transaction) if server_result.is_a?(CallbackResult)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module ProviderLibraries
|
|
6
|
+
# Routing selects only host-registered AddAuth providers. Authentication
|
|
7
|
+
# and transaction ownership are checked later by the library and Core.
|
|
8
|
+
class CallbackRoute
|
|
9
|
+
def self.matches?(request)
|
|
10
|
+
options = AddAuth.configuration.external_identities
|
|
11
|
+
options.enabled && options.providers.any? { |provider| provider.middleware_name == request.path_parameters[:provider] }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|