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,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
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/external_identities/evidence"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
class ExternalIdentities
|
|
9
|
+
LINK = :link_external_identity
|
|
10
|
+
UNLINK = :unlink_external_identity
|
|
11
|
+
ENROLL = :enroll_external_identity
|
|
12
|
+
MOBILE = :mobile_sign_in
|
|
13
|
+
NATIVE = :native_sign_in
|
|
14
|
+
NATIVE_ENROLL = :native_enroll_external_identity
|
|
15
|
+
ENROLLMENT_PURPOSES = [ENROLL, NATIVE_ENROLL].freeze
|
|
16
|
+
|
|
17
|
+
# G3 rescues this outside its fresh-account transaction. Returning a
|
|
18
|
+
# failure Result here would commit a partially registered account.
|
|
19
|
+
class EnrollmentRejected < StandardError
|
|
20
|
+
attr_reader :reason
|
|
21
|
+
def initialize(reason:)
|
|
22
|
+
@reason = reason
|
|
23
|
+
super("external identity enrollment rejected")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# remaining_method is a Core policy port evaluated under the account lock;
|
|
28
|
+
# it must check actual enabled/usable password, passkey or trusted recovery
|
|
29
|
+
# credentials. Authority invalidation uses the shared lifecycle transaction.
|
|
30
|
+
def initialize(store:, configurations:, sessions:, access_policy:, policy:, eligible:,
|
|
31
|
+
digest:, revoke_authority:, remaining_method:, enrollment_eligible: ->(_user) { false }, clock: Time, lifetime: 300, enabled: false, mobile_enabled: false)
|
|
32
|
+
raise ArgumentError, "invalid transaction lifetime" unless lifetime.is_a?(Integer) && lifetime.between?(1, 600)
|
|
33
|
+
@store, @sessions, @access, @policy, @eligible = store, sessions, access_policy, policy, eligible
|
|
34
|
+
@configurations = configurations.to_h { |config| [config.id, config] }.freeze
|
|
35
|
+
raise ArgumentError, "duplicate provider configuration" unless @configurations.size == configurations.size
|
|
36
|
+
@digest, @revoke, @remaining, @clock, @lifetime, @enabled = digest, revoke_authority, remaining_method, clock, lifetime, enabled == true
|
|
37
|
+
@enrollment_eligible = enrollment_eligible
|
|
38
|
+
@mobile_enabled = mobile_enabled == true
|
|
39
|
+
@binding = BrowserBinding.new(digest: digest)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def begin_transaction(configuration_id:, browser_secret:, user: nil, session: nil, purpose: :sign_in, binding_context: nil)
|
|
43
|
+
return failure(:disabled) unless @enabled
|
|
44
|
+
config = @configurations[configuration_id]
|
|
45
|
+
return failure unless config && @binding.valid?(browser_secret) && valid_context?(binding_context)
|
|
46
|
+
return failure(:disabled) if [MOBILE.to_s, NATIVE.to_s, NATIVE_ENROLL.to_s].include?(purpose.to_s) && !@mobile_enabled
|
|
47
|
+
if ["sign_in", ENROLL.to_s, MOBILE.to_s, NATIVE.to_s, NATIVE_ENROLL.to_s].include?(purpose.to_s)
|
|
48
|
+
return failure if user || session
|
|
49
|
+
return issue(config, browser_secret, purpose, binding_context: binding_context)
|
|
50
|
+
end
|
|
51
|
+
@store.with_user(id: user&.id) do |account|
|
|
52
|
+
row = account && @sessions.current_in_transaction(user: account, session: session)
|
|
53
|
+
allowed = purpose.to_s == LINK.to_s || @policy.reauthentication_rule_for(purpose)
|
|
54
|
+
next failure(:elevation_required) unless row && @eligible.call(account) == true && allowed
|
|
55
|
+
issue(config, browser_secret, purpose, account, row, binding_context: binding_context)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def pending(transaction:, browser_secret:, binding_context: nil)
|
|
60
|
+
return unless @enabled && @binding.valid?(transaction) && @binding.valid?(browser_secret) && valid_context?(binding_context)
|
|
61
|
+
row = @store.transaction_by_digest(digest: @digest.digest(transaction))
|
|
62
|
+
return unless row && row.browser_digest == binding_digest(browser_secret, binding_context) && !row.consumed_at &&
|
|
63
|
+
row.expires_at > @clock.now && row.issued_at <= @clock.now
|
|
64
|
+
config = @configurations[row.provider_id]
|
|
65
|
+
return unless config && row.issuer == config.issuer && row.audience == config.audience
|
|
66
|
+
Pending.send(:new, configuration: config, id: transaction, purpose: row.purpose,
|
|
67
|
+
user_id: row.user_id, session_id: row.session_id, session_digest: row.session_digest,
|
|
68
|
+
policy_version: row.policy_version, issued_at: row.issued_at, expires_at: row.expires_at)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Cancellation is correlation-bound, idempotent and grants no authority.
|
|
72
|
+
def cancel(transaction:, browser_secret:, binding_context: nil)
|
|
73
|
+
proof = pending(transaction: transaction, browser_secret: browser_secret, binding_context: binding_context)
|
|
74
|
+
return false unless proof
|
|
75
|
+
@store.consume_transaction(digest: @digest.digest(proof.id), at: @clock.now)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sign_in(evidence:, replacing: nil, **hints)
|
|
79
|
+
finalize_sign_in(evidence: evidence, purpose: :sign_in, replacing: replacing) do |account, identity, proof|
|
|
80
|
+
grant = @sessions.create_in_transaction(user: account, method: :external_identity,
|
|
81
|
+
proof: proof, replacing: replacing, **hints)
|
|
82
|
+
raise AddAuth::Error, "external identity session could not finalize" unless grant
|
|
83
|
+
Result.success(user: account, strategy: :external_identity, credential: identity, session: grant.session, grant: grant)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def mobile_handoff(evidence:, handoffs:)
|
|
88
|
+
return failure(:disabled) unless @mobile_enabled
|
|
89
|
+
finalize_sign_in(evidence: evidence, purpose: MOBILE) do |account, _identity, proof|
|
|
90
|
+
handoffs.issue_in_transaction(user: account, proof: proof, pending: evidence.transaction)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def native_sign_in(evidence:, client_id:, **hints)
|
|
95
|
+
return failure(:disabled) unless @mobile_enabled
|
|
96
|
+
finalize_sign_in(evidence: evidence, purpose: NATIVE) do |account, identity, proof|
|
|
97
|
+
grant = @sessions.create_in_transaction(user: account, method: :external_identity,
|
|
98
|
+
proof: proof, transport: :mobile, client_id: client_id, **hints)
|
|
99
|
+
raise AddAuth::Error, "native identity session could not finalize" unless grant
|
|
100
|
+
Result.success(user: account, strategy: :external_identity, credential: identity, session: grant.session, grant: grant)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def link(user:, session:, evidence:, grant:)
|
|
105
|
+
return failure(:disabled) unless @enabled
|
|
106
|
+
return failure unless trusted?(evidence) && evidence.purpose == LINK
|
|
107
|
+
@store.with_user(id: user&.id) do |account|
|
|
108
|
+
next failure(:elevation_required) unless management?(account, session, grant, LINK) && bound?(evidence, account, session)
|
|
109
|
+
finalize(evidence, account) do
|
|
110
|
+
identity = @store.binding(namespace: evidence.namespace)
|
|
111
|
+
if identity && (identity.user_id != account.id || !matches?(identity, evidence))
|
|
112
|
+
next failure(:identity_conflict)
|
|
113
|
+
end
|
|
114
|
+
identity = @store.bind(user: account, evidence: evidence, at: @clock.now)
|
|
115
|
+
next failure(:identity_conflict) unless identity
|
|
116
|
+
Result.success(user: account, strategy: :external_identity, credential: identity)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Registration capability is minted by G3 only in create_account's fresh
|
|
122
|
+
# user block. This joins that transaction; it never authenticates the user.
|
|
123
|
+
def bind_new_account_in_transaction(registration:, evidence:)
|
|
124
|
+
reject_enrollment(:disabled) unless @enabled
|
|
125
|
+
valid_registration = defined?(AccountLifecycle::NewAccount) && registration.instance_of?(AccountLifecycle::NewAccount)
|
|
126
|
+
reject_enrollment unless valid_registration && trusted?(evidence) && ENROLLMENT_PURPOSES.include?(evidence.purpose)
|
|
127
|
+
reject_enrollment(:disabled) if evidence.purpose == NATIVE_ENROLL && !@mobile_enabled
|
|
128
|
+
tx = evidence.transaction
|
|
129
|
+
reject_enrollment unless tx.user_id.nil? && tx.session_id.nil? && tx.session_digest.nil? && tx.policy_version.nil?
|
|
130
|
+
account = @store.registration_account(user: registration.user)
|
|
131
|
+
reject_enrollment unless account && account.id == registration.user_id && @enrollment_eligible.call(account) == true
|
|
132
|
+
reject_enrollment(:expired_token) unless tx.issued_at <= @clock.now && tx.expires_at > @clock.now
|
|
133
|
+
reject_enrollment(:identity_conflict) if @store.binding(namespace: evidence.namespace)
|
|
134
|
+
reject_enrollment(:consumed_token) unless @store.consume_transaction(digest: @digest.digest(tx.id), at: @clock.now)
|
|
135
|
+
identity = @store.bind(user: account, evidence: evidence, at: @clock.now)
|
|
136
|
+
reject_enrollment(:identity_conflict) unless identity
|
|
137
|
+
Result.success(user: account, strategy: :external_identity, credential: identity)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unlink(user:, session:, identity_id:, grant:)
|
|
141
|
+
return failure(:disabled) unless @enabled
|
|
142
|
+
@store.with_user(id: user&.id) do |account|
|
|
143
|
+
next failure(:elevation_required) unless management?(account, session, grant, UNLINK)
|
|
144
|
+
identity = @store.binding_for_user(user_id: account.id, id: identity_id)
|
|
145
|
+
next failure unless identity && !identity.revoked_at
|
|
146
|
+
remaining = @store.bindings(user_id: account.id).any? { |other| other.id != identity.id && configured?(other) } &&
|
|
147
|
+
@access.sign_in_allowed?(account, :external_identity)
|
|
148
|
+
next failure(:last_credential) unless remaining || @remaining.call(account) == true
|
|
149
|
+
@store.revoke(identity: identity, at: @clock.now)
|
|
150
|
+
@revoke.call(user_id: account.id, at: @clock.now)
|
|
151
|
+
Result.success(user: account, strategy: :external_identity)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def reauthenticate(user:, session:, evidence:, policy: @policy)
|
|
156
|
+
return failure(:disabled) unless @enabled
|
|
157
|
+
return failure(:elevation_required) unless trusted?(evidence) && ![:sign_in, LINK, ENROLL].include?(evidence.purpose)
|
|
158
|
+
@store.with_user(id: user&.id) do |account|
|
|
159
|
+
row = account && @sessions.current_in_transaction(user: account, session: session)
|
|
160
|
+
next failure(:elevation_required) unless row && @eligible.call(account) == true && bound?(evidence, account, row) &&
|
|
161
|
+
@access.sign_in_allowed?(account, :external_identity)
|
|
162
|
+
identity = @store.binding(namespace: evidence.namespace)
|
|
163
|
+
next failure unless current_identity?(identity, evidence) && identity.user_id == account.id
|
|
164
|
+
# A callback timestamp is never substituted for provider auth_time.
|
|
165
|
+
time = evidence.authenticated_at
|
|
166
|
+
next failure(:elevation_required) unless time && time >= Time.at(evidence.transaction.issued_at.to_i) && time <= @clock.now
|
|
167
|
+
proof = StepUp::ExternalEvidence.send(:new, user_id: account.id, session_id: row.id,
|
|
168
|
+
method: :external_identity, verified_at: time, credential_id: identity.id.to_s,
|
|
169
|
+
credential_version: identity.credential_version, session_digest: row.token_digest, purpose: evidence.purpose)
|
|
170
|
+
authorized = policy.authorize(user: account, session_id: row.id, purpose: evidence.purpose, evidence: proof)
|
|
171
|
+
next authorized unless authorized.success?
|
|
172
|
+
finalize(evidence, account) { authorized }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Called by the shared authority-revocation transaction even while provider
|
|
177
|
+
# sign-in is disabled. A binding survives the event, but older anonymous
|
|
178
|
+
# transactions and previously issued session/elevation proofs do not.
|
|
179
|
+
def self.invalidate_credentials_in_transaction(store:, user_id:, at:)
|
|
180
|
+
raise ArgumentError, "invalidation requires an occurrence time" unless at.is_a?(Time)
|
|
181
|
+
store.invalidate_credentials(user_id: user_id, at: at, credential_version: SecureRandom.hex(16))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def invalidate_credentials_in_transaction(user_id:, at:)
|
|
185
|
+
self.class.invalidate_credentials_in_transaction(store: @store, user_id: user_id, at: at)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def credential_current?(user:, id:, version:)
|
|
189
|
+
return false unless @enabled
|
|
190
|
+
identity = @store.binding_for_user(user_id: user.id, id: id)
|
|
191
|
+
!!(identity && !identity.revoked_at && identity.credential_version == version && configured?(identity))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def finalize_sign_in(evidence:, purpose:, replacing: nil)
|
|
197
|
+
return failure(:disabled) unless @enabled
|
|
198
|
+
return failure unless trusted?(evidence) && evidence.purpose == purpose
|
|
199
|
+
@store.with_identity(namespace: evidence.namespace, replacing: replacing) do |account, identity|
|
|
200
|
+
next failure(:identity_unbound) unless bound_identity?(identity, evidence)
|
|
201
|
+
next failure unless current_identity?(identity, evidence)
|
|
202
|
+
next failure unless account && @eligible.call(account) == true && @access.sign_in_allowed?(account, :external_identity)
|
|
203
|
+
finalize(evidence, account) do
|
|
204
|
+
proof = SessionProof.send(:new, user_id: account.id, credential_id: identity.id,
|
|
205
|
+
credential_version: identity.credential_version, verified_at: @clock.now)
|
|
206
|
+
yield account, identity, proof
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def issue(config, browser_secret, purpose, account = nil, session = nil, binding_context: nil)
|
|
212
|
+
raw = @binding.generate
|
|
213
|
+
now = @clock.now
|
|
214
|
+
@store.create_transaction(digest: @digest.digest(raw), browser_digest: binding_digest(browser_secret, binding_context),
|
|
215
|
+
provider_id: config.id, issuer: config.issuer, audience: config.audience, purpose: purpose.to_s,
|
|
216
|
+
user_id: account&.id, session_id: session&.id, session_digest: session&.token_digest,
|
|
217
|
+
policy_version: account && @access.version(account), issued_at: now, expires_at: now + @lifetime)
|
|
218
|
+
Result.success(user: account, strategy: :external_identity, credential: pending(transaction: raw, browser_secret: browser_secret, binding_context: binding_context))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def valid_context?(context)
|
|
222
|
+
context.nil? || (context.is_a?(String) && context.ascii_only? && /\A[a-z][a-z0-9_:-]{0,127}\z/.match?(context))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def binding_digest(secret, context)
|
|
226
|
+
@digest.digest(context ? JSON.generate(["context", context, secret]) : secret)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def trusted?(evidence)
|
|
230
|
+
evidence.is_a?(VerifiedIdentity) && @configurations[evidence.provider_id].equal?(evidence.configuration)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def configured?(identity)
|
|
234
|
+
config = @configurations[identity.provider_id]
|
|
235
|
+
config && config.issuer == identity.issuer && config.audience == identity.audience
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def matches?(identity, evidence)
|
|
239
|
+
identity.provider_id == evidence.provider_id && identity.issuer == evidence.issuer &&
|
|
240
|
+
identity.audience == evidence.audience && identity.subject == evidence.subject
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def bound_identity?(identity, evidence) = identity && !identity.revoked_at && matches?(identity, evidence)
|
|
244
|
+
|
|
245
|
+
def current_identity?(identity, evidence)
|
|
246
|
+
bound_identity?(identity, evidence) && identity.linked_at <= evidence.transaction.issued_at &&
|
|
247
|
+
(!identity.invalidated_at || evidence.transaction.issued_at > identity.invalidated_at)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def bound?(evidence, account, session)
|
|
251
|
+
tx = evidence.transaction
|
|
252
|
+
tx.user_id == account.id && tx.session_id == session.id && tx.session_digest == session.token_digest &&
|
|
253
|
+
tx.policy_version == @access.version(account)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def management?(account, session, grant, purpose)
|
|
257
|
+
row = account && @sessions.current_in_transaction(user: account, session: session)
|
|
258
|
+
row && @eligible.call(account) == true && grant.is_a?(StepUp::Grant) &&
|
|
259
|
+
grant.valid_for?(user: account, user_id: account.id, session_id: row.id,
|
|
260
|
+
session_digest: row.token_digest, purpose: purpose, now: @clock.now)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def finalize(evidence, account)
|
|
264
|
+
tx = evidence.transaction
|
|
265
|
+
return failure(:expired_token) unless tx.issued_at <= @clock.now && tx.expires_at > @clock.now
|
|
266
|
+
return failure unless @eligible.call(account) == true
|
|
267
|
+
return failure(:consumed_token) unless @store.consume_transaction(digest: @digest.digest(tx.id), at: @clock.now)
|
|
268
|
+
yield
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def reject_enrollment(reason = :invalid_credentials)
|
|
272
|
+
raise EnrollmentRejected.new(reason: reason)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
data/lib/add_auth/core/intake.rb
CHANGED
|
@@ -52,7 +52,12 @@ module AddAuth
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def self.revoke_after_change?(changes)
|
|
55
|
-
(changes.keys.map(&:to_s) & %w[password_digest email_address]).any?
|
|
55
|
+
(changes.keys.map(&:to_s) & %w[password_digest email_address confirmed_at locked_at disabled_at deleted_at add_auth_authority add_auth_manual_lock]).any?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.notification_after_change(changes)
|
|
59
|
+
return :password_changed if changes.key?("password_digest")
|
|
60
|
+
:email_changed if changes.key?("email_address")
|
|
56
61
|
end
|
|
57
62
|
end
|
|
58
63
|
end
|
|
@@ -5,12 +5,14 @@ module AddAuth
|
|
|
5
5
|
# One bounded, replayable pass. Stores repeat cutoffs at mutation time;
|
|
6
6
|
# returning counts records committed work, never a claim of an empty backlog.
|
|
7
7
|
class Maintenance
|
|
8
|
+
EXPIRING = {ceremony: :ceremonies_deleted, external_transaction: :external_transactions_deleted,
|
|
9
|
+
mobile_handoff: :mobile_handoffs_deleted}.freeze
|
|
8
10
|
def initialize(stores:, options:, enqueue:, clock: Time)
|
|
9
11
|
@stores, @options, @enqueue, @clock = stores, options, enqueue, clock
|
|
10
12
|
unless options.batch_size.is_a?(Integer) && (1..1000).cover?(options.batch_size)
|
|
11
13
|
raise ArgumentError, "maintenance.batch_size must be an integer from 1 to 1000"
|
|
12
14
|
end
|
|
13
|
-
%i[session email notification].each do |kind|
|
|
15
|
+
%i[session email notification account].each do |kind|
|
|
14
16
|
value = options.public_send(:"#{kind}_retention")
|
|
15
17
|
valid = value.nil? || (value.is_a?(Numeric) && value.finite? && value >= 0)
|
|
16
18
|
unless valid
|
|
@@ -23,8 +25,8 @@ module AddAuth
|
|
|
23
25
|
now, limit = @clock.now, @options.batch_size
|
|
24
26
|
counts = {}
|
|
25
27
|
@stores.each do |kind, store|
|
|
26
|
-
if kind
|
|
27
|
-
counts[
|
|
28
|
+
if EXPIRING.key?(kind)
|
|
29
|
+
counts[EXPIRING.fetch(kind)] = store.purge_expired(before: now, now: now, limit: limit)
|
|
28
30
|
next
|
|
29
31
|
end
|
|
30
32
|
retention = @options.public_send(:"#{kind}_retention")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "add_auth/core/passwords/legacy_bcrypt"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
module Migration
|
|
9
|
+
class AccountAdoption
|
|
10
|
+
# A retired source may still save harmless profile/tracking fields, but
|
|
11
|
+
# every credential, lifecycle flag and outstanding Devise proof remains
|
|
12
|
+
# owned by its selected authority. The bridge supplies dirty field names;
|
|
13
|
+
# only Core classifies their security meaning.
|
|
14
|
+
SOURCE_AUTHORITY_FIELDS = %w[email encrypted_password email_address password_digest confirmed_at unconfirmed_email
|
|
15
|
+
confirmation_token confirmation_sent_at reset_password_token reset_password_sent_at
|
|
16
|
+
locked_at failed_attempts unlock_token remember_created_at
|
|
17
|
+
add_auth_locked_until add_auth_manual_lock add_auth_password_scheme add_auth_migration_stamp
|
|
18
|
+
add_auth_strict add_auth_policy_version webauthn_id disabled_at deleted_at].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(store:)
|
|
21
|
+
@store = store
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(after: nil, limit: 100)
|
|
25
|
+
raise ArgumentError, "batch size must be between 1 and 1000" unless limit.is_a?(Integer) && limit.between?(1, 1000)
|
|
26
|
+
page = @store.source_page(after: after, limit: limit + 1)
|
|
27
|
+
counts = {inspected: 0, prepared: 0, unchanged: 0, conflicts: 0, changed_source: 0, active_destination: 0}
|
|
28
|
+
page.first(limit).each do |snapshot|
|
|
29
|
+
counts[:inspected] += 1
|
|
30
|
+
outcome = @store.with_account(snapshot.fetch(:id)) do |source|
|
|
31
|
+
next :changed_source unless source && stamp(source) == stamp(snapshot)
|
|
32
|
+
next :active_destination unless source[:authority] == "devise"
|
|
33
|
+
attributes = self.class.projection(email: source[:email], encrypted_password: source[:encrypted_password])
|
|
34
|
+
next :conflicts unless attributes
|
|
35
|
+
next :unchanged if source[:migration_stamp] == attributes[:add_auth_migration_stamp] && source[:email_address] == attributes[:email_address] && source[:password_digest] == attributes[:password_digest] && source[:scheme] == "devise_bcrypt"
|
|
36
|
+
@store.prepare(id: source[:id], **attributes)
|
|
37
|
+
:prepared
|
|
38
|
+
end
|
|
39
|
+
counts[outcome || :conflicts] += 1
|
|
40
|
+
end
|
|
41
|
+
counts.merge(next_cursor: (page.length > limit) ? page[limit - 1][:id] : nil)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.stamp(email:, encrypted_password:)
|
|
45
|
+
values = [email, encrypted_password].map { |value| value.nil? ? "nil:" : "#{value.bytesize}:#{value}" }
|
|
46
|
+
::Digest::SHA256.hexdigest(values.join("|"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.source_authority?(value) = value == "devise"
|
|
50
|
+
|
|
51
|
+
def self.authorize_source_write!(authority:, changed_fields:, authority_changed: false)
|
|
52
|
+
raise AddAuth::Error, "switch account authority through the reviewed migration procedure" if authority_changed
|
|
53
|
+
return if source_authority?(authority)
|
|
54
|
+
return if authority == "add_auth" && (changed_fields.map(&:to_s) & SOURCE_AUTHORITY_FIELDS).empty?
|
|
55
|
+
raise AddAuth::Error, "source authentication state is no longer authoritative"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.projection_for_write(authority:, authority_changed:, changed_fields:, **source)
|
|
59
|
+
authorize_source_write!(authority: authority, authority_changed: authority_changed, changed_fields: changed_fields)
|
|
60
|
+
source_authority?(authority) ? projection(**source) || raise(AddAuth::Error, "unsupported source credential profile; reconcile before writing") : {}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.projection(email:, encrypted_password:)
|
|
64
|
+
return unless email.is_a?(String) && email.valid_encoding? && email.bytesize.between?(1, 254)
|
|
65
|
+
identifier = email.strip.downcase
|
|
66
|
+
return unless identifier.match?(/\A[^\s@]+@[^\s@]+\z/) && identifier.bytesize <= 254
|
|
67
|
+
match = encrypted_password.is_a?(String) && Passwords::LegacyBcrypt::PATTERN.match(encrypted_password)
|
|
68
|
+
return unless match && match[1].to_i.between?(4, 14)
|
|
69
|
+
{email_address: identifier, password_digest: encrypted_password, add_auth_password_scheme: "devise_bcrypt",
|
|
70
|
+
add_auth_migration_stamp: stamp(email: email, encrypted_password: encrypted_password)}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def stamp(source) = self.class.stamp(email: source[:email], encrypted_password: source[:encrypted_password])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Migration
|
|
6
|
+
# A census is evidence for planning, never permission to change authority.
|
|
7
|
+
class Readiness
|
|
8
|
+
MODULES = %w[database_authenticatable registerable recoverable rememberable validatable confirmable lockable trackable omniauthable timeoutable].freeze
|
|
9
|
+
EXTENSIONS = %w[devise-jwt devise_token_auth devise-two-factor devise-security devise-encryptable devise-passkeys devise-webauthn mongoid].freeze
|
|
10
|
+
TARGETS = %w[app/models/user.rb app/models/session.rb app/models/current.rb app/controllers/concerns/authentication.rb].freeze
|
|
11
|
+
ACTIONS = {
|
|
12
|
+
source_version: "Use a separately tested source profile; the initial fixture target is Devise 5.0.4.",
|
|
13
|
+
effective_inventory: "Run the effective inspection in an isolated local copy to verify schema, options and account counts.",
|
|
14
|
+
incomplete_inventory: "Resolve unreadable, oversized, dynamic or truncated input and repeat inspection.",
|
|
15
|
+
unsupported_extension: "Review each extension's credential and policy semantics before conversion.",
|
|
16
|
+
multiple_scopes: "Use an explicitly reviewed identity-scope conversion; do not merge scopes.",
|
|
17
|
+
account_contract: "Prepare additive Rails User/Session/Authentication contracts and review existing files.",
|
|
18
|
+
password_profile: "Prove the effective legacy verifier and a reset route for unsupported credentials.",
|
|
19
|
+
identifier_conflicts: "Resolve missing or normalization-colliding identifiers without merging accounts.",
|
|
20
|
+
policy_mapping: "Map every active account restriction and lifecycle operation before switch.",
|
|
21
|
+
migration_acceptance: "Complete account, lifecycle, provider, client and rollback rehearsals before switching authority."
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def call(facts)
|
|
25
|
+
codes = [:migration_acceptance]
|
|
26
|
+
codes << :source_version unless facts.dig(:versions, :devise) == "5.0.4"
|
|
27
|
+
codes << :effective_inventory unless facts[:mode] == "effective"
|
|
28
|
+
codes << :incomplete_inventory unless facts[:complete]
|
|
29
|
+
codes << :unsupported_extension if facts.fetch(:extensions, []).any? || facts.fetch(:unknown_modules, 0).positive?
|
|
30
|
+
codes << :multiple_scopes if facts.fetch(:scope_count, 0) > 1
|
|
31
|
+
codes << :account_contract unless facts[:rails_contract]
|
|
32
|
+
codes << :password_profile if facts[:mode] != "effective" || facts.fetch(:accounts, []).any? { |a| a[:custom_verifier] || a[:pepper_configured] || a.dig(:counts, :unknown_password).to_i.positive? }
|
|
33
|
+
codes << :identifier_conflicts if facts.fetch(:accounts, []).any? { |a| a.dig(:counts, :missing_identifier).to_i.positive? || a.dig(:counts, :identifier_collisions).to_i.positive? }
|
|
34
|
+
codes << :policy_mapping if facts.fetch(:modules, []).any? { |name| name != "database_authenticatable" }
|
|
35
|
+
{schema_version: 1, status: "inventory", migration_ready: false,
|
|
36
|
+
facts: facts, blockers: codes.uniq.map { |code| {code: code.to_s, action: ACTIONS.fetch(code)} }}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
class MobileAuthentication
|
|
6
|
+
def initialize(sessions:, profile:, intake:, verify_password:)
|
|
7
|
+
@sessions, @profile, @intake, @verify_password = sessions, profile, intake, verify_password
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def password(identifier:, password:, client_id:, ip:, user_agent: nil, challenge_token: nil)
|
|
11
|
+
admitted = @intake.call(identifier: identifier, ip: ip, action: :sign_in, challenge_token: challenge_token)
|
|
12
|
+
return Result.failure(reason: admitted) if admitted.is_a?(Symbol)
|
|
13
|
+
return Result.failure(reason: :invalid_credentials) unless @profile&.client?(client_id) && password.is_a?(String) &&
|
|
14
|
+
password.valid_encoding? && password.bytesize.between?(1, 1024) && !password.include?("\0")
|
|
15
|
+
|
|
16
|
+
@sessions.authenticate_result(identifier: admitted, password: password, transport: :mobile, client_id: client_id, disclose_policy: true,
|
|
17
|
+
ip_address: hint(ip, 128), user_agent: hint(user_agent, 512)) do
|
|
18
|
+
@verify_password.call(identifier: admitted, password: password)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.bearer(header)
|
|
23
|
+
return unless header.is_a?(String) && header.bytesize == 54
|
|
24
|
+
match = /\ABearer (am1:[A-Za-z0-9_-]{43})\z/i.match(header)
|
|
25
|
+
match[1] if match && MobileProfile::PATTERN.match?(match[1])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def hint(value, limit)
|
|
31
|
+
value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
# The native verifier protects this application handoff. It does not replace
|
|
9
|
+
# upstream provider state/nonce/PKCE, which remain the library's responsibility.
|
|
10
|
+
class MobileHandoffs
|
|
11
|
+
PURPOSE = ExternalIdentities::MOBILE
|
|
12
|
+
CODE = /\Aah1:[A-Za-z0-9_-]{43}\z/
|
|
13
|
+
STATE = /\A[A-Za-z0-9_-]{43,128}\z/
|
|
14
|
+
VERIFIER = /\A[A-Za-z0-9._~-]{43,128}\z/
|
|
15
|
+
CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/
|
|
16
|
+
Handoff = Struct.new(:code, :state, :callback) do
|
|
17
|
+
def inspect = "#<AddAuth::Core::MobileHandoffs::Handoff [FILTERED]>"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(store:, profile:, sessions:, access_policy:, digest:, clock: Time, lifetime: 60)
|
|
21
|
+
raise ArgumentError, "handoff lifetime must be between 1 and 120 seconds" unless lifetime.is_a?(Integer) && lifetime.between?(1, 120)
|
|
22
|
+
@store, @profile, @sessions, @access, @digest, @clock, @lifetime = store, profile, sessions, access_policy, digest, clock, lifetime
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def begin_transaction(pending:, client_id:, callback:, state:, code_challenge:, code_challenge_method:)
|
|
26
|
+
return failure unless pending.is_a?(ExternalIdentities::Pending) && pending.purpose == PURPOSE &&
|
|
27
|
+
@profile&.client?(client_id) && @profile.callback(client_id) == callback && callback &&
|
|
28
|
+
text?(state, STATE) && text?(code_challenge, CHALLENGE) && code_challenge_method == "S256" &&
|
|
29
|
+
pending.issued_at <= @clock.now && pending.expires_at > @clock.now
|
|
30
|
+
row = @store.create_pending(external_digest: @digest.digest(pending.id), client_id: client_id, callback: callback,
|
|
31
|
+
state: state, challenge_digest: @digest.digest(code_challenge), issued_at: pending.issued_at, expires_at: pending.expires_at)
|
|
32
|
+
row ? Result.success(user: nil, strategy: :external_identity) : failure
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called only from ExternalIdentities' verified binding finalizer, inside
|
|
36
|
+
# the owning account transaction. No Session or bearer exists at this point.
|
|
37
|
+
def issue_in_transaction(user:, proof:, pending:)
|
|
38
|
+
return failure unless proof.is_a?(ExternalIdentities::SessionProof) && proof.user_id == user.id && pending.purpose == PURPOSE
|
|
39
|
+
row = @store.by_external_digest(digest: @digest.digest(pending.id))
|
|
40
|
+
return failure unless row && current_profile?(row) && !row.digest && !row.consumed_at && row.expires_at > @clock.now
|
|
41
|
+
code = "ah1:#{SecureRandom.urlsafe_base64(32)}"
|
|
42
|
+
issued = @store.issue(row: row, user_id: user.id, digest: @digest.digest(code),
|
|
43
|
+
credential_id: proof.credential_id, credential_version: proof.credential_version,
|
|
44
|
+
policy_version: @access.version(user), authenticated_at: proof.verified_at,
|
|
45
|
+
expires_at: [row.expires_at, @clock.now + @lifetime].min)
|
|
46
|
+
return failure unless issued
|
|
47
|
+
Result.success(user: user, strategy: :external_identity, credential: Handoff.new(code: code, state: row.state, callback: row.callback))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exchange(code:, state:, code_verifier:, client_id:, ip: nil, user_agent: nil)
|
|
51
|
+
return failure unless @profile&.client?(client_id) && text?(code, CODE) && text?(state, STATE) && text?(code_verifier, VERIFIER)
|
|
52
|
+
challenge = Base64.urlsafe_encode64(::Digest::SHA256.digest(code_verifier), padding: false)
|
|
53
|
+
@store.with_code(digest: @digest.digest(code)) do |user, row|
|
|
54
|
+
now = @clock.now
|
|
55
|
+
next failure unless user && row && current_profile?(row) && row.client_id == client_id &&
|
|
56
|
+
@digest.matches?(row.challenge_digest, challenge) && @digest.matches?(@digest.digest(row.state), state)
|
|
57
|
+
next failure(:consumed_token) if row.consumed_at
|
|
58
|
+
next failure(:expired_token) unless row.issued_at <= now && row.expires_at > now
|
|
59
|
+
next failure unless row.policy_version == @access.version(user)
|
|
60
|
+
proof = ExternalIdentities::SessionProof.send(:new, user_id: user.id, credential_id: row.credential_id,
|
|
61
|
+
credential_version: row.credential_version, verified_at: row.authenticated_at)
|
|
62
|
+
grant = @sessions.create_in_transaction(user: user, method: :external_identity, proof: proof,
|
|
63
|
+
transport: :mobile, client_id: row.client_id, ip_address: hint(ip, 128), user_agent: hint(user_agent, 512))
|
|
64
|
+
next failure unless grant
|
|
65
|
+
# Session creation and consume commit together. An unexpected lost CAS
|
|
66
|
+
# rolls back issuance; it must never return a credential for this request.
|
|
67
|
+
raise AddAuth::Error, "mobile handoff could not finalize" unless @store.consume(row: row, at: now)
|
|
68
|
+
Result.success(user: user, strategy: :external_identity, session: grant.session, grant: grant)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def current_profile?(row) = @profile&.client?(row.client_id) && @profile.callback(row.client_id) == row.callback
|
|
75
|
+
def text?(value, pattern) = value.is_a?(String) && value.ascii_only? && pattern.match?(value)
|
|
76
|
+
def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
|
|
77
|
+
|
|
78
|
+
def hint(value, limit)
|
|
79
|
+
value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|