add_auth 0.2.1 → 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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +57 -18
  4. data/ROADMAP.md +260 -242
  5. data/SECURITY.md +6 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +138 -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/helpers/add_auth/sign_ins_helper.rb +2 -1
  12. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  13. data/app/jobs/add_auth/account_request_job.rb +17 -0
  14. data/app/mailers/add_auth/account_mailer.rb +12 -0
  15. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  16. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  17. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  18. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  19. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  20. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  21. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  22. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  23. data/app/views/add_auth/accounts/_request.html.erb +14 -0
  24. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  25. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  26. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  27. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  28. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  29. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  30. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  31. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  32. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  33. data/app/views/add_auth/sessions/index.html.erb +7 -0
  34. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  35. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  36. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  37. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  38. data/exe/add_auth-preflight +26 -0
  39. data/lib/add_auth/configuration.rb +78 -4
  40. data/lib/add_auth/core/access_policy.rb +11 -1
  41. data/lib/add_auth/core/account_lifecycle.rb +251 -0
  42. data/lib/add_auth/core/account_policy.rb +50 -0
  43. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  44. data/lib/add_auth/core/external_identities.rb +278 -0
  45. data/lib/add_auth/core/intake.rb +6 -1
  46. data/lib/add_auth/core/maintenance.rb +5 -3
  47. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  48. data/lib/add_auth/core/migration/readiness.rb +41 -0
  49. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  50. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  51. data/lib/add_auth/core/mobile_profile.rb +46 -0
  52. data/lib/add_auth/core/mobile_response.rb +12 -0
  53. data/lib/add_auth/core/native_authentication.rb +77 -0
  54. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  55. data/lib/add_auth/core/passwords/credential.rb +49 -0
  56. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  57. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  58. data/lib/add_auth/core/remaining_factors.rb +24 -0
  59. data/lib/add_auth/core/sessions.rb +102 -17
  60. data/lib/add_auth/core/step_up.rb +13 -1
  61. data/lib/add_auth/migration/static_inventory.rb +116 -0
  62. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  63. data/lib/add_auth/rails/authentication.rb +1 -1
  64. data/lib/add_auth/rails/authentication_pages.rb +5 -0
  65. data/lib/add_auth/rails/doctor.rb +73 -3
  66. data/lib/add_auth/rails/ejection.rb +1 -1
  67. data/lib/add_auth/rails/engine.rb +6 -1
  68. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  69. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  70. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  71. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  72. data/lib/add_auth/rails/password_adoption.rb +37 -0
  73. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  74. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  77. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  78. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  80. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  81. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  82. data/lib/add_auth/rails/rate_limit_cache.rb +17 -0
  83. data/lib/add_auth/rails/runtime.rb +173 -9
  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 +96 -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 +52 -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_auth_account_token.rb +6 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  101. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  102. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  103. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  104. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  105. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  110. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  111. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  112. data/lib/generators/add_auth/install/templates/initializer.rb +7 -1
  113. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  114. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  119. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  120. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  121. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  122. data/lib/tasks/add_auth.rake +18 -1
  123. metadata +86 -2
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module AddAuth
7
+ module Core
8
+ class AccountLifecycle
9
+ include Delivery
10
+
11
+ PURPOSES = %w[confirm reset_password unlock].freeze
12
+ TOKEN = /\Aac1:[A-Za-z0-9_-]{43}\z/
13
+ SECURITY_ATTRIBUTES = %w[id email email_address password password_confirmation password_digest encrypted_password
14
+ confirmed_at unconfirmed_email locked_at disabled_at deleted_at failed_attempts confirmation_token
15
+ reset_password_token unlock_token authentication_token].freeze
16
+ class Conflict < StandardError; end
17
+ class InvalidPassword < StandardError; end
18
+ class DeletionRejected < StandardError; end
19
+
20
+ class NewAccount
21
+ attr_reader :user, :user_id
22
+ def initialize(user:)
23
+ @user, @user_id = user, user.id
24
+ freeze
25
+ end
26
+ private_class_method :new
27
+ def inspect = "#<AddAuth::Core::AccountLifecycle::NewAccount [FILTERED]>"
28
+ end
29
+
30
+ def self.validate_host_write!(changes:, enabled:)
31
+ if enabled && (changes.keys.map(&:to_s) & %w[email_address unconfirmed_email]).any?
32
+ raise AddAuth::Error, "change account email through the account lifecycle command"
33
+ end
34
+ end
35
+
36
+ def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
37
+ lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil)
38
+ raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
39
+ @store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
40
+ @clock, @random, @lifetime, @notify = clock, random, lifetime, notify
41
+ @sessions, @step_up_policy = sessions, step_up_policy
42
+ @profile_attributes = profile_attributes
43
+ @deletion_allowed = deletion_allowed
44
+ @external_identities = external_identities
45
+ end
46
+
47
+ def register(identifier:, password:, profile: {})
48
+ return failure unless valid_new_password?(password)
49
+ register_account(identifier: identifier, password: password, profile: profile)
50
+ end
51
+
52
+ def register_external(identifier:, evidence:, profile: {})
53
+ return failure unless @external_identities
54
+ external = @external_identities.call
55
+ register_account(identifier: identifier, password: nil, profile: profile) do |user|
56
+ external.bind_new_account_in_transaction(registration: NewAccount.send(:new, user: user), evidence: evidence)
57
+ end
58
+ rescue ExternalIdentities::EnrollmentRejected
59
+ failure
60
+ end
61
+
62
+ def issue(identifier:, purpose:, request_id: @random.uuid)
63
+ return failure unless PURPOSES.include?(purpose.to_s)
64
+ email = normalize(identifier)
65
+ return accepted unless email
66
+ @store.with_user(identifier: email) do |user|
67
+ next if @store.issued?(request_id: request_id)
68
+ next unless @policy.allowed?(user, purpose: purpose)
69
+ recipient = recipient_for(user, purpose.to_s)
70
+ next unless recipient
71
+ issue_in_transaction(user: user, purpose: purpose.to_s, recipient: recipient, request_id: request_id)
72
+ end
73
+ accepted
74
+ end
75
+
76
+ def preview(token:, purpose:)
77
+ return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
78
+ user, record = @store.inspect_token(digest: @digest.digest(token))
79
+ reason = rejection(user, record, purpose: purpose.to_s)
80
+ reason ? Result.failure(reason: reason) : Result.success(user: user, strategy: purpose.to_sym)
81
+ end
82
+
83
+ def consume(token:, purpose:, password: nil)
84
+ return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
85
+ @store.with_token(digest: @digest.digest(token)) do |user, record|
86
+ reason = rejection(user, record, purpose: purpose.to_s)
87
+ next Result.failure(reason: reason) if reason
88
+ now = @clock.now
89
+ case purpose.to_s
90
+ when "confirm"
91
+ pending = user.unconfirmed_email
92
+ if pending && !pending.empty?
93
+ old_address = user.email_address
94
+ @store.promote_address(user: user, digest: address_digest(pending))
95
+ @store.update_account(user: user, email_address: pending, unconfirmed_email: nil, confirmed_at: now)
96
+ @store.revoke_authority(user: user, at: now)
97
+ @notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
98
+ @notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
99
+ else
100
+ @store.update_account(user: user, confirmed_at: now)
101
+ @store.provision(user: user)
102
+ end
103
+ when "reset_password"
104
+ next failure unless valid_new_password?(password)
105
+ @store.replace_password(user: user, password: password)
106
+ @store.revoke_authority(user: user, at: now)
107
+ @notify.call(user: user, kind: :password_changed, at: now)
108
+ when "unlock"
109
+ @store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
110
+ end
111
+ @store.consume(record: record, at: now)
112
+ Result.success(user: user, strategy: purpose.to_sym)
113
+ end
114
+ rescue Conflict, InvalidPassword
115
+ failure
116
+ end
117
+
118
+ def change_email(user:, session:, identifier:)
119
+ email = normalize(identifier)
120
+ return failure unless email
121
+ with_account_change(user: user, session: session, purpose: :change_email) do |account|
122
+ next accepted if email == normalize(account.email_address)
123
+ @store.claim_address(user: account, digest: address_digest(email), address: email, state: "pending")
124
+ @store.update_account(user: account, unconfirmed_email: email)
125
+ issue_in_transaction(user: account, purpose: "confirm", recipient: email)
126
+ accepted
127
+ end
128
+ rescue Conflict
129
+ failure
130
+ end
131
+
132
+ def change_password(user:, session:, password:)
133
+ return failure unless valid_new_password?(password)
134
+ with_account_change(user: user, session: session, purpose: :change_password) do |account|
135
+ @store.replace_password(user: account, password: password)
136
+ @store.revoke_authority(user: account, at: @clock.now)
137
+ @notify.call(user: account, kind: :password_changed, at: @clock.now)
138
+ accepted
139
+ end
140
+ rescue InvalidPassword
141
+ failure
142
+ end
143
+
144
+ def valid_new_password?(password)
145
+ password.is_a?(String) && password.valid_encoding? && !password.include?("\0") && password.bytesize.between?(1, 1024) && @password_policy.call(password) == true
146
+ end
147
+
148
+ def delete_account(user:, session:)
149
+ with_account_change(user: user, session: session, purpose: :delete_account) do |account|
150
+ next failure unless @deletion_allowed.call(account) == true
151
+ @store.revoke_authority(user: account, at: @clock.now)
152
+ @store.delete_account(user: account)
153
+ accepted
154
+ end
155
+ rescue DeletionRejected
156
+ failure
157
+ end
158
+
159
+ # The password lifecycle already owns the account transaction and lock.
160
+ def issue_unlock_in_transaction(user:)
161
+ return unless @policy.allowed?(user, purpose: :unlock)
162
+ recipient = recipient_for(user, "unlock")
163
+ issue_in_transaction(user: user, purpose: "unlock", recipient: recipient) if recipient
164
+ end
165
+
166
+ private
167
+
168
+ def register_account(identifier:, password:, profile:)
169
+ email = normalize(identifier)
170
+ return failure unless email && profile.is_a?(Hash) && profile.length <= 20
171
+ attributes = @profile_attributes.call(profile)
172
+ profile_valid = attributes.is_a?(Hash) && attributes.keys.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && !SECURITY_ATTRIBUTES.include?(name.to_s) && !name.to_s.start_with?("add_auth_") }
173
+ raise AddAuth::Error, "registration profile must contain host profile fields only" unless profile_valid
174
+ outcome = @store.create_account(email: email, password: password, profile: attributes) do |user|
175
+ raise Conflict unless @policy.allowed?(user, purpose: :confirm)
176
+ yield user if block_given?
177
+ @store.claim_address(user: user, digest: address_digest(email), address: email, state: "current")
178
+ issue_in_transaction(user: user, purpose: "confirm", recipient: email)
179
+ end
180
+ (outcome == :invalid) ? failure : accepted
181
+ end
182
+
183
+ def with_account_change(user:, session:, purpose:)
184
+ outcome = Result.failure(reason: :elevation_required)
185
+ return outcome unless @sessions && @step_up_policy
186
+ authorization = @sessions.with_elevation(user: user, session: session, purpose: purpose, policy: @step_up_policy) do |account|
187
+ outcome = @policy.allowed?(account, purpose: :manage) ? yield(account) : failure
188
+ end
189
+ authorization.success? ? outcome : authorization
190
+ end
191
+
192
+ def accepted = Result.success(user: nil, strategy: :account_request)
193
+ def failure = Result.failure(reason: :invalid_credentials)
194
+
195
+ def normalize(value)
196
+ return unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
197
+ value = value.strip.downcase
198
+ value if value.match?(/\A[^\s@]+@[^\s@]+\z/) && value.bytesize <= 254
199
+ end
200
+
201
+ def address_digest(email) = @digest.digest("account-address:#{normalize(email)}")
202
+
203
+ def recipient_for(user, purpose)
204
+ if purpose == "confirm"
205
+ normalize(user.unconfirmed_email.to_s.empty? ? user.email_address : user.unconfirmed_email)
206
+ else
207
+ address = normalize(@trusted_address.call(user))
208
+ address if address == normalize(user.email_address)
209
+ end
210
+ end
211
+
212
+ def version(user, purpose)
213
+ @digest.digest(JSON.generate([purpose, user.id, user.email_address, user.unconfirmed_email,
214
+ user.password_digest, user.confirmed_at&.to_f, user.locked_at&.to_f, user.disabled_at&.to_f,
215
+ user.deleted_at&.to_f, user.add_auth_manual_lock]))
216
+ end
217
+
218
+ def issue_in_transaction(user:, purpose:, recipient:, request_id: @random.uuid)
219
+ token = "ac1:#{@random.urlsafe_base64(32)}"
220
+ digest = @digest.digest(token)
221
+ expires_at = @clock.now + @lifetime
222
+ payload = @cipher.encrypt(token: JSON.generate(token: token, recipient: recipient, purpose: purpose), digest: digest, expires_at: expires_at)
223
+ @store.replace_pending(user: user, purpose: purpose, digest: digest, request_id: request_id,
224
+ address_digest: address_digest(recipient), account_version: version(user, purpose),
225
+ expires_at: expires_at, delivery_payload: payload, created_at: @clock.now)
226
+ end
227
+
228
+ def rejection(user, record, purpose: record&.purpose)
229
+ return :invalid_credentials unless user && record && record.purpose == purpose && PURPOSES.include?(purpose)
230
+ return :consumed_token if record.consumed_at
231
+ return :revoked_token if record.revoked_at
232
+ return :expired_token if record.expires_at <= @clock.now
233
+ return :invalid_credentials unless @policy.allowed?(user, purpose: purpose)
234
+ recipient = recipient_for(user, purpose)
235
+ return :invalid_credentials unless recipient && record.address_digest == address_digest(recipient) && record.account_version == version(user, purpose)
236
+ nil
237
+ end
238
+
239
+ def delivery_details(user, record)
240
+ raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
241
+ return unless raw
242
+ payload = JSON.parse(raw)
243
+ return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
244
+ payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
245
+ {recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose}
246
+ rescue JSON::ParserError, KeyError
247
+ nil
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ class AccountPolicy
6
+ def initialize(enabled:, eligible: ->(_user) { true }, clock: Time)
7
+ @enabled, @eligible, @clock = enabled, eligible, clock
8
+ end
9
+
10
+ def allowed?(user, purpose: :sign_in)
11
+ return denial(user).nil? if %i[sign_in manage].include?(purpose.to_sym)
12
+ return false unless available?(user)
13
+ return true unless @enabled
14
+ case purpose.to_sym
15
+ when :confirm
16
+ !value(user, :confirmed_at) || !value(user, :unconfirmed_email).to_s.empty?
17
+ when :unlock
18
+ locked?(user) && !value(user, :add_auth_manual_lock)
19
+ when :reset_password
20
+ !!value(user, :confirmed_at) && !value(user, :add_auth_strict)
21
+ else false
22
+ end
23
+ end
24
+
25
+ # Only a verified-password result may disclose this to an anonymous client.
26
+ def denial(user)
27
+ return :disabled unless available?(user)
28
+ return unless @enabled
29
+ return :unconfirmed unless value(user, :confirmed_at)
30
+ :locked if locked?(user)
31
+ end
32
+
33
+ def locked?(user)
34
+ value(user, :locked_at) && (value(user, :add_auth_manual_lock) || !value(user, :add_auth_locked_until) || value(user, :add_auth_locked_until) > @clock.now)
35
+ end
36
+
37
+ private
38
+
39
+ def available?(user)
40
+ user && @eligible.call(user) == true &&
41
+ (!user.respond_to?(:add_auth_authority) || user.add_auth_authority == "add_auth") &&
42
+ (!@enabled || !(value(user, :disabled_at) || value(user, :deleted_at)))
43
+ end
44
+
45
+ def value(user, name)
46
+ user.public_send(name) if user.respond_to?(name)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module AddAuth
7
+ module Core
8
+ class ExternalIdentities
9
+ # Only configured server code may supply this verifier. It must validate a
10
+ # live middleware/library result, including issuer, audience, state/nonce
11
+ # and (for reauthentication) auth_time/max_age. A request auth hash is not
12
+ # such a result. Core deliberately contains no protocol or token decoder.
13
+ class Configuration
14
+ attr_reader :id, :issuer, :audience
15
+
16
+ def initialize(id:, issuer:, audience:, verifier:, clock: Time)
17
+ @id, @issuer, @audience = [id, issuer, audience].map { |value| self.class.text(value) }
18
+ raise ArgumentError, "a trusted server verifier is required" unless verifier.respond_to?(:call)
19
+ @verifier, @clock = verifier, clock
20
+ freeze
21
+ end
22
+
23
+ def self.text(value)
24
+ raise ArgumentError, "invalid external identity field" unless value.is_a?(String) && value.valid_encoding? &&
25
+ value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
26
+ value.dup.freeze
27
+ end
28
+
29
+ def verify(server_result:, transaction:)
30
+ return unless transaction.is_a?(Pending) && transaction.configuration.equal?(self)
31
+ claims = @verifier.call(server_result: server_result, transaction: transaction)
32
+ return unless claims.is_a?(Hash) && claims[:issuer] == issuer && claims[:audience] == audience
33
+ VerifiedIdentity.send(:new, configuration: self, transaction: transaction,
34
+ subject: self.class.text(claims[:subject]), provenance: self.class.text(claims[:provenance]),
35
+ authenticated_at: claims[:authenticated_at], verified_at: @clock.now)
36
+ rescue ArgumentError
37
+ nil
38
+ end
39
+
40
+ def namespace(subject) = ::Digest::SHA256.hexdigest(JSON.generate([id, issuer, audience, subject]))
41
+ def inspect = "#<AddAuth::Core::ExternalIdentities::Configuration [FILTERED]>"
42
+ end
43
+
44
+ class Pending
45
+ attr_reader :configuration, :id, :purpose, :user_id, :session_id, :session_digest,
46
+ :policy_version, :issued_at, :expires_at
47
+
48
+ def initialize(configuration:, id:, purpose:, user_id:, session_id:, session_digest:, policy_version:, issued_at:, expires_at:)
49
+ @configuration = configuration
50
+ @id, @purpose = id.dup.freeze, purpose.to_sym
51
+ @user_id, @session_id = user_id&.dup&.freeze, session_id&.dup&.freeze
52
+ @session_digest = session_digest&.dup&.freeze
53
+ @policy_version = policy_version
54
+ @issued_at, @expires_at = issued_at.dup.freeze, expires_at.dup.freeze
55
+ freeze
56
+ end
57
+ private_class_method :new
58
+
59
+ def inspect = "#<AddAuth::Core::ExternalIdentities::Pending [FILTERED]>"
60
+ end
61
+
62
+ class VerifiedIdentity
63
+ attr_reader :configuration, :transaction, :subject, :provenance, :authenticated_at, :verified_at
64
+
65
+ def initialize(configuration:, transaction:, subject:, provenance:, authenticated_at:, verified_at:)
66
+ raise ArgumentError, "invalid authentication occurrence time" unless authenticated_at.nil? || authenticated_at.is_a?(Time)
67
+ @configuration, @transaction, @subject, @provenance = configuration, transaction, subject, provenance
68
+ @authenticated_at = authenticated_at&.dup&.freeze
69
+ @verified_at = verified_at.dup.freeze
70
+ freeze
71
+ end
72
+ private_class_method :new
73
+
74
+ def provider_id = configuration.id
75
+ def issuer = configuration.issuer
76
+ def audience = configuration.audience
77
+ def transaction_id = transaction.id
78
+ def purpose = transaction.purpose
79
+ def namespace = configuration.namespace(subject)
80
+ def inspect = "#<AddAuth::Core::ExternalIdentities::VerifiedIdentity [FILTERED]>"
81
+ end
82
+
83
+ # Session evidence is issued only after locked binding resolution. It is
84
+ # never a StepUp::Evidence merely because the callback was just received.
85
+ class SessionProof
86
+ attr_reader :user_id, :credential_id, :credential_version, :verified_at
87
+ def initialize(user_id:, credential_id:, credential_version:, verified_at:)
88
+ @user_id, @credential_id = user_id, credential_id.to_s.dup.freeze
89
+ @credential_version, @verified_at = credential_version.dup.freeze, verified_at.dup.freeze
90
+ freeze
91
+ end
92
+ private_class_method :new
93
+ def user_verification = false
94
+ def inspect = "#<AddAuth::Core::ExternalIdentities::SessionProof [FILTERED]>"
95
+ end
96
+ end
97
+ end
98
+ end