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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
# Host-selected finite API sessions. Client identifiers select configuration,
|
|
8
|
+
# never prove application identity or authorize an account.
|
|
9
|
+
class MobileProfile
|
|
10
|
+
PATTERN = /\Aam1:[A-Za-z0-9_-]{43}\z/
|
|
11
|
+
attr_reader :lifetime, :idle_timeout, :clients
|
|
12
|
+
|
|
13
|
+
def initialize(lifetime:, idle_timeout:, clients:, callbacks: {})
|
|
14
|
+
unless [lifetime, idle_timeout].all? { |value| value.is_a?(Integer) && value.between?(60, 90 * 86_400) } && idle_timeout <= lifetime
|
|
15
|
+
raise ArgumentError, "mobile timeouts must be explicit, finite and at most 90 days"
|
|
16
|
+
end
|
|
17
|
+
unless clients.is_a?(Array) && clients.size.between?(1, 32) && clients.uniq == clients &&
|
|
18
|
+
clients.all? { |value| value.is_a?(String) && value.match?(/\A[a-z][a-z0-9_-]{0,63}\z/) }
|
|
19
|
+
raise ArgumentError, "register bounded mobile client identifiers"
|
|
20
|
+
end
|
|
21
|
+
@lifetime, @idle_timeout = lifetime, idle_timeout
|
|
22
|
+
@clients = clients.map { |client| client.dup.freeze }.freeze
|
|
23
|
+
unless callbacks.is_a?(Hash) && callbacks.all? { |client, callback| client?(client) && valid_callback?(callback) }
|
|
24
|
+
raise ArgumentError, "register exact mobile callbacks without query, fragment or credentials"
|
|
25
|
+
end
|
|
26
|
+
@callbacks = callbacks.to_h { |client, callback| [client.dup.freeze, callback.dup.freeze] }.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def client?(id) = id.is_a?(String) && clients.include?(id)
|
|
31
|
+
def callback(client_id) = @callbacks[client_id]
|
|
32
|
+
def bearer = "am1:#{SecureRandom.urlsafe_base64(32)}"
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def valid_callback?(value)
|
|
37
|
+
return false unless value.is_a?(String) && value.ascii_only? && value.bytesize.between?(10, 2048) && !value.match?(/[\x00-\x20\x7f\\%]/)
|
|
38
|
+
uri = URI.parse(value)
|
|
39
|
+
uri.scheme && !%w[http javascript data file].include?(uri.scheme.downcase) &&
|
|
40
|
+
uri.host && !uri.host.empty? && uri.path && !uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module MobileResponse
|
|
6
|
+
def self.status(reason)
|
|
7
|
+
{rate_limited: 429, challenge_unavailable: 503, challenge_rejected: 422, unconfirmed: 403, locked: 423, disabled: 403,
|
|
8
|
+
elevation_required: 403}.fetch(reason, 401)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
class NativeAuthentication
|
|
8
|
+
Challenge = Struct.new(:id, :nonce, :expires_at) do
|
|
9
|
+
def inspect = "#<AddAuth::Core::NativeAuthentication::Challenge [FILTERED]>"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(external_identities:, profile:, providers:, intake:, verify:, accounts: nil)
|
|
13
|
+
@external, @profile, @providers, @intake, @verify = external_identities, profile, providers, intake, verify
|
|
14
|
+
unless providers.is_a?(Hash) && providers.all? { |client_id, provider| profile&.client?(client_id) && provider.is_a?(ExternalIdentities::Configuration) }
|
|
15
|
+
raise ArgumentError, "register a native provider configuration for each supported client"
|
|
16
|
+
end
|
|
17
|
+
@providers = providers.dup.freeze
|
|
18
|
+
@accounts = accounts
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start(client_id:, ip:, challenge_token: nil, intent: "sign_in")
|
|
22
|
+
admitted = @intake.anonymous(ip: ip, action: (intent == "enroll") ? :register : :provider, challenge_token: challenge_token)
|
|
23
|
+
return failure(admitted) if admitted.is_a?(Symbol)
|
|
24
|
+
provider = @providers[client_id]
|
|
25
|
+
return failure unless @profile&.client?(client_id) && provider
|
|
26
|
+
purpose = {"sign_in" => ExternalIdentities::NATIVE, "enroll" => ExternalIdentities::NATIVE_ENROLL}[intent]
|
|
27
|
+
return failure unless purpose
|
|
28
|
+
return failure if intent == "enroll" && !@accounts
|
|
29
|
+
nonce = SecureRandom.urlsafe_base64(32)
|
|
30
|
+
result = @external.begin_transaction(configuration_id: provider.id, browser_secret: nonce, purpose: purpose,
|
|
31
|
+
binding_context: context(client_id))
|
|
32
|
+
return result unless result.success?
|
|
33
|
+
Result.success(user: nil, strategy: :external_identity,
|
|
34
|
+
credential: Challenge.new(id: result.credential.id, nonce: nonce, expires_at: result.credential.expires_at))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def complete(client_id:, challenge_id:, nonce:, identity_token:, ip:, user_agent: nil)
|
|
38
|
+
checked = verified(client_id: client_id, challenge_id: challenge_id, nonce: nonce, identity_token: identity_token,
|
|
39
|
+
ip: ip, purpose: ExternalIdentities::NATIVE)
|
|
40
|
+
return checked unless checked.success?
|
|
41
|
+
@external.native_sign_in(evidence: checked.credential, client_id: client_id, ip_address: hint(ip, 128), user_agent: hint(user_agent, 512))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enroll(client_id:, challenge_id:, nonce:, identity_token:, ip:, identifier:, profile: {})
|
|
45
|
+
return failure(:disabled) unless @accounts
|
|
46
|
+
checked = verified(client_id: client_id, challenge_id: challenge_id, nonce: nonce, identity_token: identity_token,
|
|
47
|
+
ip: ip, purpose: ExternalIdentities::NATIVE_ENROLL)
|
|
48
|
+
return checked unless checked.success?
|
|
49
|
+
@accounts.call.register_external(identifier: identifier, evidence: checked.credential, profile: profile)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def verified(client_id:, challenge_id:, nonce:, identity_token:, ip:, purpose:)
|
|
55
|
+
admitted = @intake.anonymous(ip: ip, action: :native_exchange)
|
|
56
|
+
return failure(admitted) if admitted.is_a?(Symbol)
|
|
57
|
+
provider = @providers[client_id]
|
|
58
|
+
return failure unless @profile&.client?(client_id) && provider && identity_token.is_a?(String) &&
|
|
59
|
+
identity_token.ascii_only? && identity_token.bytesize.between?(1, 16_384)
|
|
60
|
+
pending = @external.pending(transaction: challenge_id, browser_secret: nonce, binding_context: context(client_id))
|
|
61
|
+
return failure unless pending && pending.purpose == purpose && pending.configuration.equal?(provider)
|
|
62
|
+
result = @verify.call(identity_token: identity_token, pending: pending, nonce: nonce)
|
|
63
|
+
evidence = provider.verify(server_result: result, transaction: pending)
|
|
64
|
+
return failure unless evidence
|
|
65
|
+
Result.success(user: nil, strategy: :external_identity, credential: evidence)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def context(client_id) = "native:#{client_id}"
|
|
69
|
+
|
|
70
|
+
def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
|
|
71
|
+
|
|
72
|
+
def hint(value, limit)
|
|
73
|
+
value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Passwords
|
|
8
|
+
# Explicit metadata support for the stock Rails bcrypt verifier, not an
|
|
9
|
+
# algorithm detector or authentication proof. Override verifiers supply
|
|
10
|
+
# their own available?(digest:) contract instead of using this adapter.
|
|
11
|
+
class BcryptSupport
|
|
12
|
+
PATTERN = /\A\$2[aby]\$(\d{2})\$[.\/A-Za-z0-9]{53}\z/
|
|
13
|
+
|
|
14
|
+
def initialize(maximum_cost: BCrypt::Engine::MAX_COST)
|
|
15
|
+
@maximum_cost = maximum_cost
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def available?(digest:)
|
|
19
|
+
return false unless digest.is_a?(String) && digest.valid_encoding? && digest.bytesize == 60
|
|
20
|
+
match = PATTERN.match(digest)
|
|
21
|
+
!!(match && match[1].to_i.between?(BCrypt::Engine::MIN_COST, @maximum_cost))
|
|
22
|
+
rescue ArgumentError, EncodingError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Passwords
|
|
8
|
+
class Credential
|
|
9
|
+
def self.scheme_after_assignment(scheme:, password:, digest_changed:)
|
|
10
|
+
(digest_changed && password.is_a?(String) && !password.empty?) ? "rails" : scheme
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(legacy_verifier:)
|
|
14
|
+
@legacy = legacy_verifier
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authenticate(user:, password:, current:)
|
|
18
|
+
return Result.failure(reason: :invalid_credentials) unless password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 1024)
|
|
19
|
+
verified = case scheme(user)
|
|
20
|
+
when nil, "rails" then current.call(password)
|
|
21
|
+
when "devise_bcrypt" then @legacy&.verify(digest: user.password_digest, password: password) == true
|
|
22
|
+
else false
|
|
23
|
+
end
|
|
24
|
+
verified ? Result.success(user: user, strategy: :password) : Result.failure(reason: :invalid_credentials)
|
|
25
|
+
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSecret, ArgumentError, EncodingError
|
|
26
|
+
Result.failure(reason: :invalid_credentials)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Availability is not authentication. It asks the explicitly selected
|
|
30
|
+
# verifier whether it supports this persisted credential with its current
|
|
31
|
+
# configuration. Older/custom adapters without that contract fail closed.
|
|
32
|
+
def available?(user:, current:)
|
|
33
|
+
return false unless user.respond_to?(:password_digest)
|
|
34
|
+
digest = user.password_digest
|
|
35
|
+
return false unless digest.is_a?(String) && digest.valid_encoding? && !digest.empty?
|
|
36
|
+
verifier = case scheme(user)
|
|
37
|
+
when nil, "rails" then current
|
|
38
|
+
when "devise_bcrypt" then @legacy
|
|
39
|
+
end
|
|
40
|
+
verifier.respond_to?(:available?) && verifier.available?(digest: digest) == true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def scheme(user) = user.respond_to?(:add_auth_password_scheme) ? user.add_auth_password_scheme : nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
require "add_auth/core/passwords/bcrypt_support"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
module Passwords
|
|
9
|
+
# Verification only. New password assignment remains the Rails host's job.
|
|
10
|
+
# The source profile chooses this adapter explicitly; no digest guessing.
|
|
11
|
+
class LegacyBcrypt
|
|
12
|
+
PATTERN = BcryptSupport::PATTERN
|
|
13
|
+
|
|
14
|
+
def initialize(pepper: -> {}, maximum_cost: 14)
|
|
15
|
+
raise ArgumentError, "pepper must be callable" unless pepper.respond_to?(:call)
|
|
16
|
+
raise ArgumentError, "maximum_cost must be between 4 and 20" unless maximum_cost.is_a?(Integer) && maximum_cost.between?(4, 20)
|
|
17
|
+
@pepper, @maximum_cost = pepper, maximum_cost
|
|
18
|
+
@support = BcryptSupport.new(maximum_cost: maximum_cost)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def available?(digest:)
|
|
22
|
+
@support.available?(digest: digest) && valid_pepper?(@pepper.call)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verify(digest:, password:)
|
|
26
|
+
return false unless password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 1024)
|
|
27
|
+
return false unless @support.available?(digest: digest)
|
|
28
|
+
pepper = @pepper.call
|
|
29
|
+
return false unless valid_pepper?(pepper)
|
|
30
|
+
BCrypt::Password.new(digest).is_password?((pepper.nil? || pepper.empty?) ? password : "#{password}#{pepper}")
|
|
31
|
+
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSecret, ArgumentError, EncodingError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inspect = "#<AddAuth::Core::Passwords::LegacyBcrypt [FILTERED]>"
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def valid_pepper?(pepper) = pepper.nil? || (pepper.is_a?(String) && pepper.valid_encoding? && pepper.bytesize <= 1024)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Passwords
|
|
6
|
+
# Called only inside the owning account transaction. The adapter supplies
|
|
7
|
+
# Rails hashing and persistence; these decisions are shared by entry paths.
|
|
8
|
+
class Lifecycle
|
|
9
|
+
def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {})
|
|
10
|
+
unless maximum_attempts.is_a?(Integer) && maximum_attempts.between?(1, 100) &&
|
|
11
|
+
unlock_in.is_a?(Numeric) && unlock_in.finite? && unlock_in.between?(60, 86_400)
|
|
12
|
+
raise ArgumentError, "configure bounded password attempts and unlock duration"
|
|
13
|
+
end
|
|
14
|
+
@store, @policy, @enabled, @maximum, @unlock_in, @clock, @issue_unlock = store, policy, enabled, maximum_attempts, unlock_in, clock, issue_unlock
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def prepare(user:)
|
|
18
|
+
return unless @enabled && user && !user.add_auth_manual_lock && user.locked_at &&
|
|
19
|
+
user.add_auth_locked_until && user.add_auth_locked_until <= @clock.now
|
|
20
|
+
@store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verified(user:, password:, valid:, rehash: true)
|
|
24
|
+
return false unless user && @policy.call(user) == true
|
|
25
|
+
if valid
|
|
26
|
+
@store.update_account(user: user, failed_attempts: 0) if @enabled && user.failed_attempts != 0
|
|
27
|
+
if rehash && user.respond_to?(:add_auth_password_scheme) && user.add_auth_password_scheme == "devise_bcrypt" && assignable?(password)
|
|
28
|
+
@store.rehash_password(user: user, password: password)
|
|
29
|
+
@store.revoke_authority(user: user, at: @clock.now)
|
|
30
|
+
end
|
|
31
|
+
true
|
|
32
|
+
else
|
|
33
|
+
record_failure(user) if @enabled
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def assignable?(password)
|
|
41
|
+
password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 72) && !password.include?("\0")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record_failure(user)
|
|
45
|
+
attempts = [user.failed_attempts.to_i + 1, @maximum].min
|
|
46
|
+
changes = {failed_attempts: attempts}
|
|
47
|
+
if attempts >= @maximum
|
|
48
|
+
changes[:locked_at] = @clock.now
|
|
49
|
+
changes[:add_auth_locked_until] = @clock.now + @unlock_in
|
|
50
|
+
end
|
|
51
|
+
@store.update_account(user: user, **changes)
|
|
52
|
+
if attempts >= @maximum
|
|
53
|
+
@store.revoke_authority(user: user, at: @clock.now)
|
|
54
|
+
@issue_unlock.call(user)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
# Evaluate against a reloaded account under the credential mutation lock.
|
|
6
|
+
# Counts must include only this account's live credentials. Feature flags
|
|
7
|
+
# and method labels alone do not establish a usable remaining factor.
|
|
8
|
+
class RemainingFactors
|
|
9
|
+
def initialize(access_policy:, passkey_count:, password_available:)
|
|
10
|
+
@access, @passkey_count, @password_available = access_policy, passkey_count, password_available
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(user)
|
|
14
|
+
return true if @access.sign_in_allowed?(user, :passkey) && positive_count?(@passkey_count.call(user))
|
|
15
|
+
return true if @access.recoverable?(user)
|
|
16
|
+
@access.sign_in_allowed?(user, :password) && @password_available.call(user) == true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def positive_count?(value) = value.is_a?(Integer) && value.positive?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "add_auth/core/mobile_profile"
|
|
4
5
|
|
|
5
6
|
module AddAuth
|
|
6
7
|
module Core
|
|
@@ -10,14 +11,15 @@ module AddAuth
|
|
|
10
11
|
end
|
|
11
12
|
class Entry
|
|
12
13
|
attr_reader :id, :current, :authenticated_with, :authenticated_at, :created_at,
|
|
13
|
-
:last_seen_at, :expires_at, :ip_address, :user_agent
|
|
14
|
+
:last_seen_at, :expires_at, :ip_address, :user_agent, :transport, :client_id
|
|
14
15
|
|
|
15
16
|
def initialize(id:, current:, authenticated_with:, authenticated_at:, created_at:,
|
|
16
|
-
last_seen_at:, expires_at:, ip_address:, user_agent:)
|
|
17
|
+
last_seen_at:, expires_at:, ip_address:, user_agent:, transport: "browser", client_id: nil)
|
|
17
18
|
@id, @current, @authenticated_with, @authenticated_at, @created_at = id, current,
|
|
18
19
|
authenticated_with, authenticated_at, created_at
|
|
19
20
|
@last_seen_at, @expires_at, @ip_address, @user_agent = last_seen_at, expires_at,
|
|
20
21
|
ip_address, user_agent
|
|
22
|
+
@transport, @client_id = transport, client_id
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def inspect = "#<AddAuth::Core::Sessions::Entry id=#{id} current=#{current.inspect}>"
|
|
@@ -27,7 +29,7 @@ module AddAuth
|
|
|
27
29
|
Page = Struct.new(:entries, :next_cursor)
|
|
28
30
|
|
|
29
31
|
def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
|
|
30
|
-
legacy_bridge_until: nil, clock: Time, access_policy: nil)
|
|
32
|
+
legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil)
|
|
31
33
|
unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
|
|
32
34
|
raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
|
|
33
35
|
end
|
|
@@ -36,7 +38,19 @@ module AddAuth
|
|
|
36
38
|
end
|
|
37
39
|
@store, @digest, @eligible, @clock = store, digest, eligible, clock
|
|
38
40
|
@access_policy = access_policy
|
|
41
|
+
@password_lifecycle = password_lifecycle
|
|
42
|
+
@on_sign_in = on_sign_in
|
|
43
|
+
@verified_denial = verified_denial
|
|
44
|
+
raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
|
|
45
|
+
@mobile = mobile_profile
|
|
39
46
|
@lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
|
|
47
|
+
if remembered_profile
|
|
48
|
+
values = remembered_profile.values_at(:lifetime, :idle_timeout)
|
|
49
|
+
unless values.all? { |n| n.is_a?(Numeric) && n.finite? && n.between?(60, 90 * 86_400) } && values.last <= values.first
|
|
50
|
+
raise ArgumentError, "remembered session timeouts must be bounded and idle_timeout <= lifetime"
|
|
51
|
+
end
|
|
52
|
+
@remembered = remembered_profile.slice(:lifetime, :idle_timeout).freeze
|
|
53
|
+
end
|
|
40
54
|
end
|
|
41
55
|
|
|
42
56
|
def start(user:, method:, replacing: nil, **hints)
|
|
@@ -50,36 +64,72 @@ module AddAuth
|
|
|
50
64
|
end
|
|
51
65
|
end
|
|
52
66
|
|
|
53
|
-
def authenticate(
|
|
67
|
+
def authenticate(**arguments, &verify)
|
|
68
|
+
result = authenticate_result(**arguments, &verify)
|
|
69
|
+
result.grant if result.success?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def authenticate_result(identifier:, password:, replacing: nil, disclose_policy: false, **hints)
|
|
54
73
|
@store.with_identifier(identifier: identifier, replacing: replacing) do |user|
|
|
74
|
+
@password_lifecycle&.prepare(user: user)
|
|
55
75
|
verified = yield
|
|
56
|
-
|
|
76
|
+
valid = user && verified && verified.id == user.id && verified.password_digest == user.password_digest
|
|
77
|
+
denial = @verified_denial&.call(user) if valid && disclose_policy
|
|
78
|
+
valid = @password_lifecycle.verified(user: user, password: password, valid: !!valid) if @password_lifecycle
|
|
79
|
+
grant = create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if valid
|
|
80
|
+
if grant
|
|
81
|
+
Result.success(user: user, strategy: :password, session: grant.session, grant: grant)
|
|
82
|
+
else
|
|
83
|
+
Result.failure(reason: %i[unconfirmed locked disabled].include?(denial) ? denial : :invalid_credentials)
|
|
84
|
+
end
|
|
57
85
|
end
|
|
58
86
|
end
|
|
59
87
|
|
|
60
88
|
# Runs inside the store's account transaction. When replacing a browser,
|
|
61
89
|
# that session's account must also be locked on the same connection.
|
|
62
|
-
def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, **hints)
|
|
90
|
+
def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, remember: false, transport: :browser, client_id: nil, **hints)
|
|
63
91
|
return unless user && @eligible.call(user) == true
|
|
64
|
-
|
|
92
|
+
supported_transport = transport == :browser || (transport == :mobile && @mobile&.client?(client_id))
|
|
93
|
+
return unless supported_transport
|
|
94
|
+
return if transport == :mobile && replacing
|
|
95
|
+
raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
|
|
65
96
|
return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
|
|
66
97
|
if method.to_s == "passkey"
|
|
67
98
|
return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
|
|
68
99
|
proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
|
|
69
100
|
end
|
|
101
|
+
if method.to_s == "external_identity"
|
|
102
|
+
return unless @access_policy && proof.is_a?(ExternalIdentities::SessionProof) && proof.user_id == user.id &&
|
|
103
|
+
proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300 &&
|
|
104
|
+
@access_policy.external_credential_current?(user: user, id: proof.credential_id, version: proof.credential_version)
|
|
105
|
+
hints = hints.merge(authentication_external_id: proof.credential_id,
|
|
106
|
+
authentication_external_version: proof.credential_version)
|
|
107
|
+
end
|
|
70
108
|
if user.respond_to?(:add_auth_policy_version)
|
|
71
109
|
hints = hints.merge(authentication_policy_version: @access_policy ? @access_policy.version(user) : user.add_auth_policy_version,
|
|
72
110
|
authentication_credential_id: proof&.credential_id, authentication_uv: proof&.user_verification || false)
|
|
73
111
|
end
|
|
74
|
-
raw = bearer
|
|
112
|
+
raw = (transport == :mobile) ? @mobile.bearer : bearer
|
|
75
113
|
now = @clock.now
|
|
76
114
|
if replacing
|
|
77
115
|
prior = @store.find_for_user_in_transaction(user_id: replacing.user_id, session_id: replacing.id)
|
|
78
116
|
@store.update(prior, revoked_at: now) if prior && !prior.revoked_at && prior.token_digest == replacing.token_digest
|
|
79
117
|
end
|
|
80
118
|
writer = persist || ->(**attributes) { @store.create(user: user, **attributes) }
|
|
119
|
+
duration = @lifetime
|
|
120
|
+
if @remembered
|
|
121
|
+
remembered = remember == true
|
|
122
|
+
duration = @remembered.fetch(:lifetime) if remembered
|
|
123
|
+
hints = hints.merge(remembered: remembered, idle_timeout: remembered ? @remembered.fetch(:idle_timeout) : @idle)
|
|
124
|
+
end
|
|
125
|
+
if transport == :mobile
|
|
126
|
+
duration = @mobile.lifetime
|
|
127
|
+
hints = hints.merge(transport: "mobile", client_id: client_id, mobile_idle_timeout: @mobile.idle_timeout)
|
|
128
|
+
hints[:remembered] = false if @remembered
|
|
129
|
+
end
|
|
81
130
|
row = writer.call(token_digest: @digest.digest(raw), authenticated_with: method.to_s,
|
|
82
|
-
authenticated_at: now, expires_at: now +
|
|
131
|
+
authenticated_at: now, expires_at: now + duration, last_seen_at: now, **hints)
|
|
132
|
+
@on_sign_in&.call(user: user, session: row, at: now)
|
|
83
133
|
Grant.new(session: row, bearer: raw)
|
|
84
134
|
end
|
|
85
135
|
|
|
@@ -90,7 +140,7 @@ module AddAuth
|
|
|
90
140
|
legacy = lookup.key?(:id)
|
|
91
141
|
@store.with_session(**lookup) do |user, row|
|
|
92
142
|
now = @clock.now
|
|
93
|
-
next unless live?(user, row, now: now)
|
|
143
|
+
next unless transport_for(row) == "browser" && live?(user, row, now: now)
|
|
94
144
|
if legacy
|
|
95
145
|
next if row.token_digest || now >= @deadline
|
|
96
146
|
raw = bearer
|
|
@@ -104,13 +154,25 @@ module AddAuth
|
|
|
104
154
|
end
|
|
105
155
|
end
|
|
106
156
|
|
|
157
|
+
# Only the exact Authorization bearer value enters this parser. Cookies,
|
|
158
|
+
# query parameters and legacy host API tokens are not alternate inputs.
|
|
159
|
+
def resume_mobile(bearer:)
|
|
160
|
+
return unless @mobile && bearer.is_a?(String) && MobileProfile::PATTERN.match?(bearer)
|
|
161
|
+
@store.with_session(digest: @digest.digest(bearer)) do |user, row|
|
|
162
|
+
now = @clock.now
|
|
163
|
+
next unless transport_for(row) == "mobile" && live?(user, row, now: now)
|
|
164
|
+
@store.update(row, last_seen_at: now) if row.last_seen_at <= now - 60
|
|
165
|
+
Grant.new(session: row)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
107
169
|
# A trusted cookie identifies what to retire, even if that account is now
|
|
108
170
|
# ineligible. This snapshot is replacement input, never authentication.
|
|
109
171
|
def replacement_for(signed_value:)
|
|
110
172
|
lookup = cookie_lookup(signed_value)
|
|
111
173
|
return unless lookup
|
|
112
174
|
@store.with_session(**lookup) do |_user, row|
|
|
113
|
-
row
|
|
175
|
+
row if transport_for(row) == "browser" && !(lookup.key?(:id) && row&.token_digest)
|
|
114
176
|
end
|
|
115
177
|
end
|
|
116
178
|
|
|
@@ -134,7 +196,7 @@ module AddAuth
|
|
|
134
196
|
now = @clock.now
|
|
135
197
|
candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
|
|
136
198
|
excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
|
|
137
|
-
active_after: now - @idle, legacy: !!(@deadline && now < @deadline))
|
|
199
|
+
active_after: now - [@idle, @remembered&.fetch(:idle_timeout) || @idle, @mobile&.idle_timeout || @idle].max, legacy: !!(@deadline && now < @deadline))
|
|
138
200
|
cursor = candidates[PAGE_SIZE - 1].id if candidates.length > PAGE_SIZE
|
|
139
201
|
rows = candidates.first(PAGE_SIZE)
|
|
140
202
|
if before.nil? && current_session_id
|
|
@@ -147,7 +209,8 @@ module AddAuth
|
|
|
147
209
|
Entry.new(id: row.id, current: row.id == current_session_id,
|
|
148
210
|
authenticated_with: row.authenticated_with, authenticated_at: row.authenticated_at,
|
|
149
211
|
created_at: row.created_at, last_seen_at: row.last_seen_at, expires_at: row.expires_at,
|
|
150
|
-
ip_address: row.ip_address, user_agent: row.user_agent
|
|
212
|
+
ip_address: row.ip_address, user_agent: row.user_agent,
|
|
213
|
+
transport: transport_for(row), client_id: row.respond_to?(:client_id) ? row.client_id : nil)
|
|
151
214
|
end
|
|
152
215
|
Page.new(entries: entries, next_cursor: cursor)
|
|
153
216
|
end
|
|
@@ -169,8 +232,11 @@ module AddAuth
|
|
|
169
232
|
def reauthenticate(user:, session:, purpose:, policy:)
|
|
170
233
|
with_live_session(user: user, session: session) do |account, row, _now|
|
|
171
234
|
next Result.failure(reason: :elevation_required) if @access_policy && !@access_policy.sign_in_allowed?(account, :password)
|
|
235
|
+
@password_lifecycle&.prepare(user: account)
|
|
172
236
|
verified = yield account
|
|
173
|
-
|
|
237
|
+
valid = verified && verified.id == account.id && verified.password_digest == account.password_digest
|
|
238
|
+
valid = @password_lifecycle.verified(user: account, password: nil, valid: !!valid, rehash: false) if @password_lifecycle
|
|
239
|
+
next Result.failure(reason: :invalid_credentials) unless valid
|
|
174
240
|
evidence = StepUp::Evidence.new(user_id: account.id, session_id: row.id,
|
|
175
241
|
method: :password, verified_at: @clock.now, session_digest: row.token_digest,
|
|
176
242
|
credential_version: policy.password_version(account))
|
|
@@ -202,7 +268,7 @@ module AddAuth
|
|
|
202
268
|
row = current_in_transaction(user: user, session: session)
|
|
203
269
|
return unless row && grant.is_a?(StepUp::Grant) && grant.valid_for?(user: user,
|
|
204
270
|
user_id: user.id, session_id: row.id, session_digest: row.token_digest, purpose: grant.purpose, now: @clock.now)
|
|
205
|
-
raw = bearer
|
|
271
|
+
raw = (transport_for(row) == "mobile") ? @mobile.bearer : bearer
|
|
206
272
|
attributes = {token_digest: @digest.digest(raw), elevated_at: grant.verified_at,
|
|
207
273
|
elevated_with: grant.method.to_s, elevation_purpose: grant.purpose.to_s,
|
|
208
274
|
elevation_credential_id: grant.credential_id, elevation_uv: grant.user_verification,
|
|
@@ -232,7 +298,9 @@ module AddAuth
|
|
|
232
298
|
now = @clock.now
|
|
233
299
|
return Result.failure(reason: :elevation_required) unless row&.respond_to?(:elevation_version) && row.elevated_at &&
|
|
234
300
|
row.elevation_purpose == purpose.to_s && row.elevation_expires_at && now < row.elevation_expires_at
|
|
235
|
-
|
|
301
|
+
evidence_class = (row.elevated_with == "external_identity") ? StepUp::ExternalEvidence : StepUp::Evidence
|
|
302
|
+
extra = (row.elevated_with == "external_identity") ? {purpose: purpose} : {}
|
|
303
|
+
evidence = evidence_class.send(:new, **extra, user_id: user.id, session_id: row.id,
|
|
236
304
|
method: row.elevated_with, verified_at: row.elevated_at, session_digest: row.token_digest,
|
|
237
305
|
credential_id: row.elevation_credential_id, user_verification: row.elevation_uv,
|
|
238
306
|
credential_version: row.elevation_version)
|
|
@@ -277,10 +345,27 @@ module AddAuth
|
|
|
277
345
|
return false unless user && row && @eligible.call(user) == true && !row.revoked_at
|
|
278
346
|
return false if @access_policy && !@access_policy.session_allowed?(user, row)
|
|
279
347
|
legacy = !row.token_digest && @deadline && now < @deadline
|
|
348
|
+
idle = @idle
|
|
349
|
+
if transport_for(row) == "mobile"
|
|
350
|
+
return false unless @mobile&.client?(row.client_id) && row.token_digest
|
|
351
|
+
stored_idle = row.mobile_idle_timeout
|
|
352
|
+
return false unless stored_idle.is_a?(Integer) && stored_idle.between?(60, 90 * 86_400)
|
|
353
|
+
idle = [stored_idle, @mobile.idle_timeout].min
|
|
354
|
+
return false unless row.authenticated_at && (row.authenticated_at + @mobile.lifetime > now)
|
|
355
|
+
elsif transport_for(row) != "browser"
|
|
356
|
+
return false
|
|
357
|
+
elsif @remembered && row.respond_to?(:remembered) && row.remembered == true
|
|
358
|
+
stored_idle = row.idle_timeout
|
|
359
|
+
return false unless stored_idle.is_a?(Numeric) && stored_idle.finite? && stored_idle.positive?
|
|
360
|
+
idle = [stored_idle, @remembered.fetch(:idle_timeout)].min
|
|
361
|
+
return false unless row.authenticated_at && row.authenticated_at + @remembered.fetch(:lifetime) > now
|
|
362
|
+
end
|
|
280
363
|
(legacy || (row.expires_at && row.last_seen_at)) &&
|
|
281
|
-
(!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now -
|
|
364
|
+
(!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - idle)
|
|
282
365
|
end
|
|
283
366
|
|
|
367
|
+
def transport_for(row) = row.respond_to?(:transport) ? row.transport : "browser"
|
|
368
|
+
|
|
284
369
|
def bearer = "lk1:#{SecureRandom.urlsafe_base64(32)}"
|
|
285
370
|
end
|
|
286
371
|
end
|
|
@@ -5,7 +5,7 @@ module AddAuth
|
|
|
5
5
|
# Evidence is issued by a trusted verifier, never deserialized from a client.
|
|
6
6
|
# A grant retains that verifier's current policy and the proof's original age.
|
|
7
7
|
class StepUp
|
|
8
|
-
METHODS = %i[password email_link passkey].freeze
|
|
8
|
+
METHODS = %i[password email_link passkey external_identity].freeze
|
|
9
9
|
|
|
10
10
|
class Evidence
|
|
11
11
|
attr_reader :user_id, :session_id, :method, :verified_at, :credential_id,
|
|
@@ -28,6 +28,15 @@ module AddAuth
|
|
|
28
28
|
def inspect = "#<AddAuth::Core::StepUp::Evidence [FILTERED]>"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
class ExternalEvidence < Evidence
|
|
32
|
+
attr_reader :purpose
|
|
33
|
+
def initialize(purpose:, **attributes)
|
|
34
|
+
@purpose = purpose.to_sym
|
|
35
|
+
super(**attributes)
|
|
36
|
+
end
|
|
37
|
+
private_class_method :new
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
class Grant
|
|
32
41
|
attr_reader :purpose, :expires_at
|
|
33
42
|
def initialize(evidence:, purpose:, expires_at:, policy:)
|
|
@@ -117,6 +126,9 @@ module AddAuth
|
|
|
117
126
|
return false if rule.require_passkey && !evidence.strong?
|
|
118
127
|
return false unless evidence.session_digest.is_a?(String) && !evidence.session_digest.empty?
|
|
119
128
|
return false if evidence.method == :passkey && !evidence.strong?
|
|
129
|
+
if evidence.method == :external_identity
|
|
130
|
+
return false unless evidence.is_a?(ExternalEvidence) && evidence.purpose == purpose.to_sym
|
|
131
|
+
end
|
|
120
132
|
if evidence.method == :password
|
|
121
133
|
return false unless evidence.credential_version.is_a?(String) && !evidence.credential_version.empty?
|
|
122
134
|
return false unless user.respond_to?(:password_digest) && password_version(user) == evidence.credential_version
|